大家好,框架可脱我是究竟解决 ConardLi,相信各位在 Web 开发的啥问工作中已经离不开框架了,不知道有多少同学还用原生 JS 写代码呢?题们你有认真思考过框架究竟为我们解决了什么样的问题吗?脱离了这些框架,我们可以解决这些问题吗?离们我们来看看今天的文章: 最近,我对将框架与原生的框架可脱 JavaScript 进行对比非常感兴趣。我很想知道这些框架之间的究竟解决共性和差异是什么,Web 平台作为一个精简的啥问替代方案应该提供什么,以及它本身是题们否可以足够满足我们的需求。 我的离们目标不是要抨击这些框架,而是框架可脱想要了解使用框架的成本和收益,确定是究竟解决否存在某些替代方案,并看看即使我们决定使用框架,啥问是题们不是可以从中学到一些什么。 首先,离们我们先深入研究一些跨框架通用的技术特性,网站模板以及不同框架如何实现这些特性。 我选择了四个框架来研究:当今处于主导地位的框架 React ,以及其他三个声称与 React 工作方式不同的竞争者。 简单总结一下这些框架的区别: 声明式编程是一种在不指定控制流的情况下定义逻辑的范例。我们描述的是结果需要是什么,而不是我们需要采取什么步骤。 在声明式框架的早期,大约在 2010 年,DOM API 非常冗长,使用命令式 JavaScript 编写 Web 应用程序需要大量的样板代码。那时 “model-view-viewmodel” (MVVM) 的概念开始流行起来,当时开创性的 Knockout 和 AngularJS 框架提供了一个 JavaScript 声明层来处理库内部的复杂性。 数据绑定是一种声明性的方式,它用来表示数据如何在模型和用户界面之间同步。 所有流行的 UI 框架都提供了某种形式的数据绑定,它们的教程基本上都从一个数据绑定示例开始。 下面是 JSX 中的云服务器数据绑定(SolidJS 和 React): function HelloConardLi() { const name = "Solid or React; return ( ) Lit 中的数据绑定: class HelloConardLi extends LitElement { @property() name = lit; render() { return html` Hello ${ this.name}! } Svelte 中的数据绑定: let name = world; 响应式是一种表达变化和传递的声明性方式。 当我们有了一种声明式表达数据绑定的方法时,我们需要一种有效的方法让框架传递这个更改。 React 引擎会将渲染结果与之前的结果进行比较,并将差异应用到 DOM 本身。这种处理变更传播的方法称为虚拟 DOM。 在 SolidJS 中,这通过它的存储和内置元素更显式地完成。例如,Show 元素将跟踪内部发生的变化,而不是虚拟 DOM。 在 Svelte 中,会生成“响应式”代码。Svelte 知道哪些事件会导致更改,并生成简单的代码,在事件和 DOM 更改之间划清界限。 在 Lit 中,响应式是使用元素属性完成的,本质上依赖于 HTML 自定义元素的内置响应性。 当框架为数据绑定提供一个声明式接口,并实现响应式时,它还需要提供某种方式来表达一些传统上以命定方式编写的逻辑。比如传统的 “if” 和 “for” 语句,所有主要的框架都提供了这些逻辑的一些表达式。 除了绑定数字和字符串等基本数据外,每个框架都提供一个“条件”原语。在 React 中,它是这样的: const [hasError, setHasError] = useState(false); return hasError ? : null; … SolidJS 提供了一个内置的条件组件 Show : Svelte 提供了 #if 指令: { #if state.error} 在 Lit 中,你可以在 render 函数中使用三元运算: render() { return this.error ? html``: null; 还有一个比较常见的就是列表处理,它是 UI 里非常的关键部分,为了有效地工作,它们需要是响应式的,而不是在一个数据项发生变化时更新整个列表。 在 React 中,列表处理看起来像这样: contacts.map((contact, index) => { contact.name} React 使用特殊的 key 属性来区分列表中的每一项,确保整个列表不会全部重新渲染。 在 SolidJS 中,使用 for 和 index 内置元素: { contact => 在内部,SolidJS 使用它自己的内存与 for、index 决定状态更改时需要改动哪些元素。它比 React 更明确,而且避免了虚拟 DOM 的复杂性。 Svelte 使用 each 指令: { #each contacts as contact} Lit 提供了一个 repeat 函数,工作方式类似于 React 的 key repeat(contacts, contact => contact.id, 上面我们提到,框架提供声名式的数据绑定、条件和列表渲染、以及传递更改的响应式机制,另外还提供组件复用等能力。 这些能力虽然给我们带来了方便,但也额外增加了很多成本。 在查看捆绑依赖包的大小时,我习惯查看压缩后非 Gzip 的大小。这是与 JavaScript 执行的 CPU 成本最相关的大小。 似乎最新推出的框架在保持包大小方面都比 React 做得更好。虚拟 DOM 需要大量的 JavaScript 代码。 不知从何时开始,我们习惯了“构建”我们的 Web 应用程序。如果不设置 Node.js 和 Webpack 之类的打包器、处理 Babel-TypeScript 启动包中最近的一些配置更改等等,就不可能启动前端项目。 框架的表现力越强,包体积越小,同时构建工具和编译时间的负担就越大。 Svelte 声称虚拟 DOM 是纯粹的开销。我同意,但 “编译”(如 Svelte 和 SolidJS)和自定义客户端模板引擎(如 Lit)是不是也是一种不同类型的纯开销呢? 我们在使用或调试 Web 应用程序的时候,看到的代码和我们编写的代码是完全不同的。为了方便调试,我们一般需要依靠一些特殊调试工具来对网站上的代码进行逆向,并将其与我们自己代码中的错误联系起来。 在 React 中,调用堆栈永远不是你想象的那样,因为所有的更新都是 React 为你处理调度的。在没发生 bug 的情况下,这样挺好的。但是,比如你现在要尝试找到一个无限循环重新渲染的 bug,是非常痛苦的。 在 Svelte 中,库本身的包体积很小,但你需要发布和调试一大堆额外生成的代码,这些代码是用来实现 Svelte 响应式的,它们会据应用的需要进行定制。 使用 Lit 的话,它与构建无关,但如果想对它进行调试,你就必须了解它的模板引擎。这可能是我对这个框架持怀疑态度的最大原因。 在这篇文章中,我们介绍了4个框架,但还有很多框架 (AngularJS、Ember.js 和 Vue.js 等) 我们没提到。在这些框架的发展过程中,你能指望它的开发者、它的思想和它的生态系统能持续为你服务吗? 还有一件比修复自己的 bug 更麻烦的事,就是你需要持续考虑这些框架的 bug。另外你还要考虑是不是在没有修改代码的情况下,升级了一个框架的版本就引入一些新的 bug。 确实,这样的问题也存在于浏览器中,但是浏览器一旦有问题,每个人都跑不了。并且浏览器在大多数情况下,修复问题或发布解决方法都是非常迅速的。另外,本文中的大部分模式都基于成熟的 Web 平台 API,我们也并不是一直都要考虑升级。 在没有框架的情况下进行探索,似乎一个不可避免的结果就是实现一个自己的框架来进行响应式数据绑定。之前我也尝试过,但是看到它的成本有多大后,我决定在这次探索中遵循下面的原则: 不使用框架,也不是自己封装框架,而是想看看能不能直接使用 Web 原生的 API 实现。 Web 平台已经为我们提供了开箱即用的声明式编程机制:HTML 和 CSS。它们已经非常成熟、而且已经经过了非常广泛的测试。 但是,它们没有提供明确的数据绑定、条件渲染和列表渲染这样的概念,并且也没有跨平台响应式这样微妙的功能。 下面我将尝试整理一些关于如何在不借助框架的情况下,使用原生的 Web API 解决这些问题的指南。 我们回到前面提到的错误标签的示例。在 ReactJS 和 SolidJS 中,我们创建了可以转换为命令式代码的声明式代码,在 DOM 中添加或删除这个标签。在 Svelte 中,会直接编译生成这样的代码。 但是如果我们根本没有这样的代码,而是直接使用 CSS 来隐藏和显示错误标签呢? input[name="showErrors"]:not(:checked) ~ * output[name="error"] { display: none; } 注意,在这个例子中没有使用 class — 我们从表单的数据中开发 DOM 的行为和样式,而不是去手动更改元素类。 我不喜欢过度使用 CSS class 作为 JavaScript 选择器。我认为它们应该用于将类似样式的元素组合在一起,而不是作为一种改变组件样式的万能机制。 Changes Channel — 我们简称为 CHACHA,代表一个双向数据流,它可以通知 intent 方向和 observe 方向的变化,类似我们常说的双向绑定。 这是个挺有趣的名字,但它并不是一个很复杂或者很新颖的模式。双向数据流在 Web 或其他软件中都很常见(例如MessagePort)。 ChaCha 的界面通常可以从 App 的规范中衍生出来,而无需任何 UI 代码。 例如,一个应用程序允许你添加和删除联系人,并从服务器加载初始列表(可以刷新),它可以有这样一个 ChaCha: interface Contact { id: string; name: string; email: string; } // "Observe" Direction interface ContactListModelObserver { onAdd(contact: Contact); onRemove(contact: Contact); onUpdate(contact: Contact); } // "Intent" Direction interface ContactListModel { add(contact: Contact); remove(contact: Contact); reloadFromServer(); } 注意,这两个接口中的所有函数都是 void,并且只接收普通对象。这是故意这样做的,ChaCha 构建起来就像一个有两个端口的通道来发送消息,这允许它在 EventSource、HTML MessageChannel、Service Worker 或任何其他协议中工作。 ChaChas 的优点是它很方便测试:你可以发送动作并期待特定的调用返回给观察者。 HTML template 是存在于 DOM 中但不会显示的特殊元素,它们的目的是生成动态元素。 当我们使用一个 template 元素时,我们可以避免在渲染或更新列表的时候频繁操作DOM,下面是个例子: function addName(name) { const list = document.querySelector(#names); const item = list.querySelector(template).content.cloneNode(true).firstElementChild; item.querySelector(label).innerText = name; list.appendChild(item); } 通过使用列表项的 template 元素,我们可以在原始 HTML 中看到这些列表项 — 而不是用 JSX 或其他语言 “渲染” 出来的。你的 HTML 文件现在会包含应用程序的所有 HTML — 静态部分是渲染的 DOM 的一部分,而动态部分在 template 中表示,在一定时机会被克隆并 append 到文档中。 TodoMVC 是一个用于展示不同框架的 TODO LIST 的应用程序规范。TodoMVC 模板带有现成的 HTML 和 CSS,可帮助你专注于框架。 Github:https://github.com/tastejs/todomvc 我们将基于 TodoMVC 的规范来构建 ChaCha 接口: interface Task { title: string; completed: boolean; } interface TaskModelObserver { onAdd(key: number, value: Task); onUpdate(key: number, value: Task); onRemove(key: number); onCountChange(count: { active: number, completed: number}); } interface TaskModel { constructor(observer: TaskModelObserver); createTask(task: Task): void; updateTask(key: number, task: Task): void; deleteTask(key: number): void; clearCompleted(): void; markAll(completed: boolean): void; } 任务模型中的功能就来自于规范中描述的用户可以做什么样的事情(清除已完成的任务,将所有任务标记为已完成或未完成,获取未完成和已完成的任务数量)。 请注意,它遵循 ChaCha 的原则: 我们用 localStorage (https://github.com/noamr/todomvc-app-template/blob/main/js/model.js) 来模拟一下后端。 这个 Model 非常简单,与这次我们UI框架的讨论没有太大关系。当需要用到时,它将保存到 localStorage,并在一些变化时向观察者触发更改的回调。 接下来,我们将使用 TodoMVC 模板,并将它修改为基于表单的实现 — 表单的层次结构,输入和输出元素表示可以用 JavaScript 更改的数据。 我怎么知道某些东西是否需要成为一个表单元素?根据经验来看,如果它绑定到模型中的数据,那么它应该是一个表单元素。 下面是 HTML 的主要部分: All Active Completed 这个 HTML 包括下面的内容: 这个 DOM 是非常简洁的,它的元素中没有分散的类。它包含了应用程序所需的所有元素,以合理的层次结构排列。由于隐藏的 Input 元素,你已经可以很好地了解文档稍后可能发生的更改。 这个 HTML 不知道它将被设置什么样的样式,也不知道它将绑定到什么数据。让 CSS 和 JavaScript 为 HTML 工作,而不是让 HTML 为特定的样式机制工作。这将使更改设计变得更加容易。 现在我们在 CSS 中拥有了大部分的响应式,并且我们在模型中拥有了列表处理的功能,剩下的就是控制器代码了,在这个小应用程序中,控制器 JavaScript 大约有 40 行。 import TaskListModel from ./model.js; 上面,我们创建了一个新模型。 onAdd(key, value) { const newItem = document.querySelector(.todo-list template).content.cloneNode(true).firstElementChild; newItem.name = `task-${ key}`; const save = () => model.updateTask(key, Object.fromEntries(new FormData(newItem))); newItem.elements.completed.addEventListener(change, save); newItem.addEventListener(submit, save); newItem.elements.title.addEventListener(dblclick, ({ target}) => target.removeAttribute(readonly)); newItem.elements.title.addEventListener(blur, ({ target}) => target.setAttribute(readonly, )); newItem.elements.destroy.addEventListener(click, () => model.deleteTask(key)); this.onUpdate(key, value, newItem); document.querySelector(.todo-list).appendChild(newItem); } 当一个 item 被添加到 Model 中时,我们会在 UI 中创建相应的 item 项目。 在上面,我们克隆了 item 的内容,template 为特定的 item 分配了事件监听器,并将新 item 添加到列表中。 请注意,这个函数,连同 onUpdate、onRemove 和 onCountChange,都是从 Model 中调用的回调函数。 onUpdate(key, { title, completed}, form = document.forms[`task-${ key}`]) { form.elements.completed.checked = !!completed; form.elements.title.value = title; form.elements.title.blur(); 当一个项目被更新时,我们设置它的 completed 和 title 值,然后 blur(退出编辑模式)。 当从 Model 中删除一个 item,我们会从视图中删除其对应的列表项。 onCountChange({ active, completed}) { document.forms.main.elements.completedCount.value = completed; document.forms.main.elements.toggleAll.checked = active === 0; document.forms.main.elements.totalCount.value = active + completed; document.forms.main.elements.activeCount.innerHTML = `${ active}item${ active === 1 ? : s} left`; 在上面的代码中,当完成或未完成事项的数量发生变化时,我们设置适当的输入来触发 CSS 的响应,并格式化显示计数的输出。 const updateFilter = () => filter.value = location.hash.substr(2); window.addEventListener(hashchange, updateFilter); 然后我们从 hash fragment (以及在启动时)更新过滤器。上面我们所做的一切只是设置一个表单元素的值 — 其余的由 CSS 处理。 document.querySelector(.todoapp).addEventListener(submit, e => e.preventDefault(), { capture: true}); 这里,我们确保表单提交时不会重新加载页面。就是这几行代码把这个应用变成了 SPA 应用。 document.forms.newTask.addEventListener(submit, ({ target: { elements: { title}}}) => model.createTask({ title: title.value})); document.forms.main.elements.toggleAll.addEventListener(change, ({ target: { checked}})=> model.markAll(checked)); document.forms.main.elements.clearCompleted.addEventListener(click, () => 这里处理主要操作(创建、标记、清除)。 CSS 处理了规范中的很多要求,我们看几个例子: 根据规范,“X”(destroy) 按钮只会在鼠标悬停时显示。我还添加了一个可访问性位,让它在任务集中时可见: .task:not(:hover, :focus-within) button[name="destroy"] { opacity: 0 }当 filter 是当前链接时,会出现红色边框: .todoapp input[name="filter"][value=""] ~ footer a[href$="#/"], nav a:target { border-color: #CE4646; 注意,我们可以使用 link 元素的 href 作为部分属性选择器 — 而不需要 JavaScript 检查当前的过滤器,并在适当的元素上设置一个选定的类。 我们还使用 :target 选择器,这使我们不必担心是否要添加过滤器。 标题输入的视图和编辑样式会根据其只读模式而变化: .task input[name="title"]:read-only { … } .task input[name="title"]:not(:read-only) { … 过滤操作(即仅显示未完成和已完成的任务)是使用选择器完成的: input[name="filter"][value="active"] ~ * .task :is(input[name="completed"]:checked, input[name="completed"]:checked ~ *), input[name="filter"][value="completed"] ~ * .task :is(input[name="completed"]:not(:checked), input[name="completed"]:not(:checked) ~ *) { display: none; 上面的代码可能看起来有点冗长,使用 CSS 预处理器(如 Sass)可能可读性会更好。如果功能让这些样式代码变得越来越复杂,那么使用数据模型去实现会更好一点。 我相信框架为了实现复杂的任务提供了非常方便的方法,并且它们具有超越技术本身的好处,比如让一组开发人员遵循特定的风格和模式。Web 平台提供了许多选择,采用一个框架可以让每个人至少部分地在其中一些选择上达成一致。这是有价值的。另外,声明式编程的优雅也有值得说明的地方,而组件化的主要特性并不是这篇文章讨论的内容。 但是请记住,存在替代模式,通常成本更低,并不是说需要的开发经验就越少。让自己对这些模式时刻感到好奇,后续我们再做技术选型时也会更加简单。 原生实现的简单回顾: 本文译自:https://www.smashingmagazine.com/2022/02/web-frameworks-guide-part2/ 本文中的完整示例代码:https://github.com/noamr/todomvc-app-template/ 怎么样,这个的原生实现的 TodoList 你觉的怎么样?有解决框架给我们解决的问题吗?在实际开发里面,你会怎么选呢?框架
框架为我们解决什么问题?
声明式编程框架带来的成本
自己实现一个框架?
原生选择
TodoMvc
todos
总结