比较前端框架ReactJs、SolidJS、Svelte和Lit底层逻辑 - Smashing


选择了四个框架来研究:React是当今占主导地位的框架,以及三个声称与React不同的新竞争者。

  • React“React 让创建交互式 UI 变得轻松。声明式视图使您的代码更可预测且更易于调试。”
  • SolidJS“Solid 遵循与 React 相同的理念......但是它有一个完全不同的实现,它放弃了使用虚拟 DOM。”
  • Svelte“Svelte 是一种构建用户界面的全新方法……在构建应用程序时发生的编译步骤。Svelte 没有使用虚拟 DOM 差异等技术,而是编写了在您的应用程序状态发生变化时通过手术更新 DOM 的代码。”
  • Lit“在 Web Components 标准之上构建,Lit 仅添加了……反应性、声明性模板和一些深思熟虑的功能。”

总结一下这些框架对它们的区别的看法:

  • React 使用声明式视图让构建 UI 变得更容易。
  • SolidJS 遵循 React 的理念,但使用了不同的技术。
  • Svelte 对 UI 使用compile-time方法。
  • Lit 使用现有标准,并添加了一些轻量级功能。

框架本身提到了声明性、反应性和虚拟 DOM 等词。让我们深入了解这些含义:
 
声明式编程
声明式编程是一种范式,在这种范式中,逻辑被定义,而没有指定控制流。
我们描述需要的结果是什么,而不是哪些步骤能让我们达到目的。

在声明式框架的早期,大约在2010年,DOM的API更加赤裸裸和冗长,用命令式的JavaScript编写Web应用程序需要大量的模板代码。这时,"模型-视图-视图模型"(MVVM)的概念开始盛行,当时具有划时代意义的Knockout和AngularJS框架,提供了一个JavaScript声明层,在库内处理这种复杂性。

今天,MVVM并不是一个广泛使用的术语,它在某种程度上是旧术语 "数据绑定 "的变种。
  
数据绑定
数据绑定是一种声明性的方式来表达数据如何在模型和用户界面之间进行同步。

所有流行的UI框架都提供了某种形式的数据绑定,它们的教程都以数据绑定的例子开始。

这里是JSX(SolidJS和React)中的数据绑定。

function HelloWorld() {
 const name = "Solid or React";

 return (
     <div>Hello {name}!</div>
 )
}

Lit中Data-binding数据绑定 :

class HelloWorld extends LitElement {
 @property()
 name = 'lit';

 render() {
   return html`<p>Hello ${this.name}!</p>`;
 }
}

Svelte中数据绑定:

<script>
  let name = 'world';
</script>

<h1>Hello {name}!</h1>

  
反应式
Reactivity是一种声明性的方式来表达数据改变的传播。
当我们有一种声明性地表达数据绑定的方式时,我们需要一种有效的方式来让框架传播数据改变。

  • React引擎将渲染的结果与之前的结果进行比较,并将差异应用于DOM本身。这种处理变化传播的方式被称为虚拟 DOM。
  • 在SolidJS中,这是以其存储和内置元素更明确地完成的。例如,"Show"元素将跟踪内部的变化,而不是虚拟 DOM。
  • 在Svelte中,"反应式 "代码被生成。Svelte知道哪些事件会导致变化,它会生成直接的代码,在事件和DOM变化之间划清界限。
  • 在Lit中,反应性是通过元素属性完成的,基本上是依靠HTML自定义元素的内置反应性。

 
逻辑
当一个框架为数据绑定提供了一个声明性的接口,并实现了反应性,它也需要提供一些方法来表达一些传统上必须写的逻辑。逻辑的基本构件是 "if "和 "for",所有主要的框架都提供了这些构件的一些表达。
  • 条件式conditional #

除了绑定基本数据如数字和字符串,每个框架都提供了一个 "conditional "语法。
在React中,它看起来像这样。
const [hasError, setHasError] = useState(false);  
return hasError ? <label>Message</label> : null;

setHasError(true);

SolidJS提供了一个内置的条件组件,Show:

<Show when={state.error}>
  <label>Message</label>
</Show>

Svelte提供了if指令

{#if state.error}
  <label>Message</label>
{/if}

在Lit中,你会在渲染函数中使用明确的三元操作:

render() {
 return this.error ? html`<label>Message</label>`: null;
}

 
列表 
另一个常见的框架基元是列表处理。列表是用户界面的一个关键部分--联系人列表、通知等--为了有效地工作,它们需要是反应性的,而不是在一个数据项发生变化时更新整个列表。
在React中,列表处理看起来像这样:
contacts.map((contact, index) =>
 <li key={index}>
   {contact.name}
 </li>)

React使用特殊的key属性来区分列表项,它确保整个列表不会在每次渲染时被替换。

在SolidJS中,使用了for和index内置元素。

<For each={state.contacts}>
  {contact => <DIV>{contact.name}</DIV> }
</For>

在内部,SolidJS使用它自己的存储与for和index相结合,以决定当项目发生变化时要更新哪些元素。
它比React更明确,使我们能够避免虚拟DOM的复杂性。

Svelte使用each指令,根据其更新器进行转译:

{each contacts as contact}
  <div>{contact.name}</div>
{/each}

Lit提供了一个重复函数,它的工作原理类似于React的基于键的列表映射:

repeat(contacts, contact => contact.id,
    (contact, index) => html`<div>${contact.name}</div>`

 

组件模型 
有一件事超出了本文的范围,那就是不同框架中的组件模型,以及如何使用自定义HTML元素来处理它。
 
成本
框架提供了声明性的数据绑定,控制流原语(条件和列表),以及传播变化的反应式机制。
它们还提供了其他重要的东西,比如重用组件的方法,但这是另一篇文章的主题。
 

框架有用吗?是的。它们给了我们所有这些方便的功能。但这是一个正确的问题吗?使用框架是有代价的。让我们看看这些代价是什么。
 
捆绑包大小 
在查看bundle size时,我喜欢看非Gzip的minified size。这是与JavaScript执行的CPU成本最相关的大小。

  • ReactDOM 大约是 120 KB。
  • SolidJS大约是18KB。
  • Lit大约是16KB。
  • Svelte约为2KB,但生成的代码大小不一。

似乎今天的框架在保持捆绑大小方面做得比React更好。虚拟DOM需要大量的JavaScript。
 
构建
不知为何,我们习惯于 "构建 "我们的网络应用。如果不设置Node.js和Webpack这样的捆绑器,不处理Babel-TypeScript启动包中最近的一些配置变化,就不可能启动一个前端项目,以及所有这些事情。

框架的表现力越强,捆绑规模越小,构建工具和转译时间的负担就越大。

Svelte声称,虚拟DOM是纯粹的开销。
我同意,但也许 "构建"(如Svelte和SolidJS)和自定义客户端模板引擎(如Lit)也是纯粹的开销,是不同类型的?
 
调试
随着构建和转译的进行,会产生一种不同的代价。

当我们使用或调试网络应用时,我们看到的代码与我们写的完全不同。我们现在依靠不同质量的特殊调试工具来反向设计网站上发生的事情,并将其与我们自己代码中的错误联系起来。

  • 在React中,调用栈从来不是 "你的"--React为你处理调度。当没有bug的时候,这很好用。但如果你想找出无限循环重现的原因,你就会陷入痛苦的境地。
  • 在Svelte中,库本身的体积很小,但你要运送和调试一大堆神秘的生成代码,这些代码是Svelte对反应性的实现,根据你的应用需求定制。
  • 对于Lit来说,它不太需要构建,但为了有效地调试它,你必须了解它的模板引擎。这可能是我对框架持怀疑态度的最大原因。

当你寻找自定义的声明式解决方案时,你最终会面临更痛苦的命令式调试。本文中的例子使用Typescript来规范API,但代码本身并不要求转译。
 
升级
在本文中,我看了四个框架,但框架多得数不清(AngularJS、Ember.js和Vue.js,仅举几例)。你能指望框架、它的开发者、它的思想份额和它的生态系统在发展中为你工作吗?

有一件事比修复你自己的错误更令人沮丧,那就是必须为框架的错误找到变通办法。而比框架bug更令人沮丧的一件事是,当你把框架升级到新版本而不修改你的代码时,就会出现bug。

诚然,这个问题也存在于浏览器中,但当它发生时,它发生在每个人身上,而且在大多数情况下,修复或公布的变通方法是迫在眉睫的。另外,本文中的大多数模式都是基于成熟的网络平台API;并不总是需要走在流血的边缘。
  
总结
我们研究了使用框架的不同好处和成本,从他们试图解决的核心问题的角度出发,重点关注声明式编程、数据绑定、反应性、列表和条件。
Web 平台已经提供了开箱即用的声明式编程机制:HTML 和 CSS。这种机制是成熟的、经过良好测试的、流行的、广泛使用的和记录在案的。但是,它没有提供明确的数据绑定、条件渲染和列表同步的内置概念,并且反应性是跨多个平台功能传播的微妙细节。
 
保持DOM树的稳定实现反应性
在 ReactJS 和 SolidJS 中,我们创建了转换为命令式代码的声明性代码,将标签添加到 DOM 或删除它。在 Svelte 中,会生成该代码。
但是我们可以使用 CSS 来隐藏和显示错误标签:

<style>
    label.error { display: none; }
    .app.has-error label.error {display: block; }
</style>
<label class="error">Message</label>

<script>
   app.classList.toggle('has-error', true);
</script>

在这种情况下,反应是在浏览器中处理的——应用程序的类更改会传播到其后代,直到浏览器中的内部机制决定是否呈现标签。
这种技术有几个优点:

  • 捆绑包大小为零。
  • 构建步骤为零。
  • 更改传播在本机浏览器代码中经过优化和良好测试,并避免了不必要的昂贵 DOM 操作,如append和remove.
  • 选择器selector是稳定的。在这种情况下,您可以指望标签元素在那里。您可以对其应用动画,而无需依赖诸如“过渡组”之类的复杂结构。您可以在 JavaScript 中保存对它的引用。
  • 如果标签显示或隐藏,您可以在开发人员工具的样式面板中看到原因,该面板向您显示整个级联,最终在标签中显示(或隐藏)的规则链。

 
面向表单的“数据绑定”
在 JavaScript 繁重的单页应用程序 (SPA) 时代之前,表单是创建包含用户输入的 Web 应用程序的主要方式。传统上,用户填写表单并单击“提交”按钮,服务器端代码将处理响应。表单是数据绑定和交互的多页应用程序版本。
在上一节的错误标签示例中,我们展示了如何反应性地显示和隐藏错误消息。这就是我们在 React 中更新错误消息文本的方式(在 SolidJS 中也是如此):
const [errorMessage, setErrorMessage] = useState(null);
return <label className="error">{errorMessage}</label>

当我们拥有稳定的 DOM 和稳定的树形表单和表单元素时,我们可以执行以下代码替代使用这些框架:

<form name="contactForm">
  <fieldset name=
"email">
     <output name=
"error"></output>
  </fieldset>
</form>

<script>
  function setErrorMessage(message) {
  document.forms.contactForm.elements.email.elements.error.value = message;
  }
</script>

看起来非常冗长,但它也非常稳定、直接且非常高效。
  
输入数据的表单 
正确使用表格,有一个简洁的替代方案:
<form name="contactForm">
  <input name=
"id" type="hidden" value="136" />
  <input name=
"email" type="email"/>
  <input name=
"name" type="string" />
  <input name=
"subscriber" type="checkbox" />
</form>

<script>
   updateContact(Object.fromEntries(
       new FormData(document.forms.contactForm));
</script>

通过使用隐藏输入和有用的FormData类,我们可以在 DOM 输入和 JavaScript 函数之间无缝转换值。
 
结合表单和反应性 
通过结合表单的高性能选择器稳定性和 CSS 响应性,我们可以实现更复杂的 UI 逻辑:
<form name="contactForm">
  <input name=
"showErrors" type="checkbox" hidden />
  <fieldset name=
"names">
     <input name=
"name" />
     <output name=
"error"></output>
  </fieldset>
  <fieldset name=
"emails">
     <input name=
"email" />
     <output name=
"error"></output>
  </fieldset>
</form>

<script>
  function setErrorMessage(section, message) {
  document.forms.contactForm.elements[section].elements.error.value = message;
  }
  function setShowErrors(show) {
  document.forms.contactForm.elements.showErrors.checked = show;
  }
</script>

<style>
   input[name=
"showErrors"]:not(:checked) ~ * output[name="error"] {
      display: none;
   }
</style>

请注意,在此示例中,没有使用类——我们从表单的数据中开发 DOM 的行为和样式,而不是通过手动更改元素类。
 

表单的优势 :
  • 与层叠式一样,表单是内置于网络平台的,其大部分功能是稳定的。这意味着更少的JavaScript,更少的框架版本不匹配,而且没有 "构建"。
  • 默认情况下,表单是可访问的。如果你的应用程序正确地使用表单,就不需要ARIA属性、"可访问性插件 "和最后一分钟的审计。表单适合于键盘导航、屏幕阅读器和其他辅助技术。
  • 表单具有内置的输入验证功能:通过regex模式验证,在CSS中对无效和有效表单的反应性,处理必填与可选,等等。你不需要为了享受这些功能而使某些东西看起来像一个表单。
  • 表单的提交事件是非常有用的。例如,它允许在没有提交按钮的情况下捕获 "Enter "键,并允许通过提交者属性来区分多个提交按钮(正如我们将在后面的TODO例子中看到的)。
  • 默认情况下,元素与它们所包含的表单相关联,但也可以使用表单属性与文档中的任何其他表单相关联。这使我们能够在不对DOM树产生依赖的情况下玩转表单关联。
  • 使用稳定的选择器有助于实现UI测试自动化。我们可以使用嵌套的API作为一种稳定的方式来钩住DOM,而不管它的布局和层次结构如何。表单>(字段集)>元素的层次结构可以作为你的文档的互动骨架。

 
列表项的 HTML 模板元素
当我们使用一个template元素时,我们可以避免创建元素并在 JavaScript 中填充它们的所有样板代码。
<ul id="names">
  <template>
   <li><label class=
"name" /></li>
  </template>
</ul>
<script>
  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);
  }
</script>

通过使用template列表项的元素,我们可以在原始 HTML 中看到列表项——它不是使用 JSX 或其他语言“渲染”的。您的 HTML 文件现在包含应用程序的所有HTML — 静态部分是呈现的 DOM 的一部分,而动态部分在模板中表示,准备好在时机成熟时克隆并附加到文档中。

TodoMVC是用于展示不同框架的 TODO 列表的应用程序规范。TodoMVC 模板带有现成的 HTML 和 CSS,可帮助您专注于框架。
您可以在 GitHub 存储库中使用结果,并且可以使用[url=https://github.com/noamr/todomvc-app-template]完整的源代码[/url]。

最佳实践

  • 保持DOM树的稳定。它会启动了一个连锁反应,使事情变得简单。
  • 如果可以的话,依靠CSS的反应性而不是JavaScript。
  • 使用表单元素作为表示互动数据的主要方式。
  • 使用HTML template元素而不是JavaScript生成的模板。
  • 使用双向的变化流作为你的模型的接口。

 原文点击标题