在我的日常工作中,我致力于一个 JavaScript 框架(LWC)。尽管我已经在这个项目上工作了将近三年,但我仍然觉得自己是一个业余爱好者。当我阅读有关更大的框架世界的信息时,常常因为不了解的事情太多而感到不知所措。
然而,学习事物的最佳方法之一是亲自动手构建。而且,我们要继续保持那些 “距上一个 JavaScript 框架的天数” 模因的持续。因此,让我们来编写我们自己的现代 JavaScript 框架吧!
什么是“现代 JavaScript 框架”?
React 是一个出色的框架,我不是来贬低它的。但在这篇文章中,“现代 JavaScript 框架”指的是“React 时代后的框架” - 即 Lit、Solid、Svelte、Vue 等。
由于 React 在前端领域占据主导地位已久,每个更新的框架都在其阴影下成长。这些框架都受到了 React 的很大启发,但它们在演进中以出奇的相似方式远离了 React。尽管 React 本身一直在创新,但我发现后来的框架在现今更类似于彼此,而不是 React。
为了保持简单,我还将避免讨论像 Astro、Marko 和 Qwik 这样以服务器为先的框架。这些框架在其自身的领域表现出色,但与以客户端为重点的框架相比,它们来自稍微不同的思维传统。因此,在本文中,我们只讨论客户端渲染。
现代框架有哪些特点?
从我的角度来看,React 时代后的框架都集中在相同的基本理念上:
- 使用响应性(例如 signals)进行 DOM 更新。
- 使用克隆的模板进行 DOM 渲染。
- 使用像 <template> 和 Proxy 这样的现代 Web API,使上述所有工作更容易。
需要明确的是,这些框架在微观层面上差异很大,它们在处理诸如 Web 组件、编译和用户界面 API 等方面的方式也不同。并非所有的框架都使用 Proxy。但总体来说,大多数框架作者似乎在上述理念上达成了共识,或者正在朝着这个方向发展。
因此,对于我们自己的框架,让我们试着实现这些理念的最基本部分,首先从响应性开始。
响应性
人们常说 “?React 不是响应式的”。这意味着 React 具有更多基于拉取而不是推送的模型。简单来说,在最糟糕的情况下,React 假设整个虚拟 DOM 树都需要从头开始重建,防止这些更新的唯一方法是实现 React.memo(或在旧时代,shouldComponentUpdate)。
虚拟 DOM 缓解了“摧毁一切,从头开始”的策略的一些成本,但并未完全解决它。要求开发人员编写正确的 memo 代码是一场失败的战斗(请查看 ?React Forget,这是一个持续尝试解决此问题的项目)。
相反,现代框架使用了一种推送式的响应模型。在这种模型中,组件树的各个部分订阅状态更新,并仅在相关状态更改时更新 DOM。这更注重“默认情况下高性能”的设计,以换取一些前期记账(bookkeeping)成本(特别是在内存方面),以跟踪哪些状态部分与 UI 的哪些部分相关。
需要注意的是,这种技术不一定与虚拟 DOM 方法不兼容:Preact Signals 和 Million 这样的工具表明你可以有一个混合系统。如果你的目标是保留现有的虚拟 DOM 框架(例如 React),但对于性能敏感的情况有选择地应用推送模型,则这是有用的。
在这篇文章中,我不打算详细讨论信号本身的细节,或者像细粒度响应等更微妙的主题,但我会假设我们将使用一种响应系统。
注意:在讨论什么算是 “响应式” 时有很多细微差别。我的目标是在这里对比 React 和 React 时代后的框架,尤其是 Solid、Svelte v5 的“runes”模式和 Vue Vapor。
克隆 DOM 树
很长一段时间以来,在 JavaScript 框架的共同智慧中,渲染 DOM 的最快方法被认为是逐个创建和挂载每个 DOM 节点。换句话说,您使用诸如 createElement、setAttribute 和 textContent 这样的 API 逐步构建 DOM:
const div = document.createElement('div')
div.setAttribute('class', 'blue')
div.textContent = 'Blue!'
另一种选择是将一个庞大的 HTML 字符串直接插入 innerHTML,让浏览器为您解析它:
const container = document.createElement('div')
container.innerHTML = `
<div class="blue">Blue!</div>
`
这种天真的方法有一个很大的缺点:如果您的 HTML 中有任何动态内容(例如,红色而不是蓝色),那么您将需要一遍又一遍地解析 HTML 字符串。此外,您会在每次更新时清除 DOM,这将重置诸如 <input> 的值之类的状态。
注意:使用 innerHTML 也涉及到 ?安全性问题。但在本文的目的中,让我们假设 HTML 内容是可信任的。
然而,有一天人们发现,解析一次 HTML,然后对整个内容调用 cloneNode(true) 是相当快的:
const template = document.createElement('template')
template.innerHTML = `
<div class="blue">Blue!</div>
`
template.content.cloneNode(true) // this is fast!
在这里,我使用了 <template> 标签,它的优势在于创建 “不活动”(inert)的 DOM。换句话说,诸如 <img> 或 <video autoplay> 这样的元素不会自动开始下载任何内容。
与手动使用 DOM API 相比,这种克隆技术有多快呢?为了演示,这里有一个?小型基准测试。Tachometer 报告称,在 Chrome 中,克隆技术大约快 50%,在 Firefox 中快 15%,在 Safari 中快 10%(这将根据 DOM 大小和迭代次数而变化,但您能够理解主要趋势)。
有趣的是,<template> 是一个较新的浏览器 API,在 IE11 中不可用,最初设计用于 Web 组件。有些讽刺的是,这种技术现在被用于各种 JavaScript 框架,无论它们是否使用 Web 组件。
注:供参考,这里是在 Solid、Vue Vapor 和 Svelte v5 中对 <template> 使用 cloneNode 的方式。
这种技术有一个主要挑战,即如何在不重置 DOM 状态的情况下高效更新动态内容。在构建我们的玩具框架时,我们将在后面详细介绍这一点。
现代 JavaScript API
我们已经接触到了一个在很大程度上很有帮助的新 API,那就是 <template>。另一个稳步获得关注的是 ?Proxy,它可以使构建响应系统变得更加简单。
在构建我们的玩具示例时,我们还将使用带标签的模板文字(tagged template literals)创建一个像这样的 API:
const dom = html`
<div>Hello ${ name }!</div>
`
并非所有的框架都使用这个工具,但一些显著的框架包括 Lit、HyperHTML 和 ArrowJS。带标签的模板文字可以在不需要编译器的情况下更轻松地构建人体工学的 HTML 模板 API。
步骤 1:构建响应性
响应性是我们将构建框架的基础。响应性将定义状态的管理方式以及在状态更改时 DOM 如何更新。
让我们从一些“梦幻代码”开始,以说明我们想要的:
const state = {}
state.a = 1
state.b = 2
createEffect(() => {
state.sum = state.a + state.b
})
基本上,我们想要一个称为 state 的“魔术对象”,有两个属性:a 和 b。每当这些属性发生变化时,我们希望设置 sum 为这两个属性的和。
假设我们事先不知道属性(或者没有编译器来确定它们),一个普通的对象将无法满足这个要求。所以让我们使用 Proxy,它可以在设置新值时作出反应:
const state = new Proxy({}, {
get(obj, prop) {
onGet(prop)
return obj[prop]
},
set(obj, prop, value) {
obj[prop] = value
onSet(prop, value)
return true
}
})
目前,我们的 Proxy 没有做任何有趣的事情,只是给我们提供了一些 onGet 和 onSet 钩子。所以让我们使其在微任务之后刷新更新:
let queued = false
function onSet(prop, value) {
if (!queued) {
queued = true
queueMicrotask(() => {
queued = false
flush()
})
}
}
注意:如果您对 queueMicrotask 不熟悉,它是一个较新的 DOM API,基本上与 Promise.resolve().then(...) 相同,但输入更少。
为什么要刷新更新呢?主要是因为我们不希望运行太多的计算。如果我们在 a 和 b 都改变时更新,那么我们将无用地计算两次和。通过将刷新合并到一个微任务中,我们可以变得更加高效。
接下来,让我们让刷新更新 sum:
function flush() {
state.sum = state.a + state.b
}
这很好,但它还不是我们的“梦幻代码”。我们需要实现 createEffect,以便仅在 a 和 b 更改时计算 sum(而不是在其他地方更改时)。
为此,让我们使用一个对象来跟踪哪些效果需要运行哪些属性:
const propsToEffects = {}
接下来是至关重要的部分!我们需要确保我们的效果可以订阅正确的属性。为此,我们将运行效果,记录它调用的任何 get 调用,并创建属性与效果之间的映射。
为了解释清楚,记住我们的“梦幻代码”是:
createEffect(() => {
state.sum = state.a + state.b
})
当这个函数运行时,它调用了两个 getter:state.a 和 state.b。这些 getter 应该触发响应系统注意到该函数依赖于这两个属性。
为了实现这一点,让我们从一个简单的全局变量开始,用于跟踪“当前”效果:
let currentEffect
然后,createEffect 函数将在调用函数之前设置此全局变量:
function createEffect(effect) {
currentEffect = effect
effect()
currentEffect = undefined
}
这里的重要之处在于,效果会立即被调用,同时全局的 currentEffect 在提前设置。这是我们跟踪它可能调用的任何 getter 的方式。
现在,我们可以在我们的 Proxy 中实现 onGet,它将设置全局 currentEffect 与属性之间的映射:
function onGet(prop) {
const effects = propsToEffects[prop] ??
(propsToEffects[prop] = [])
effects.push(currentEffect)
}
运行一次后,propsToEffects 应该如下所示:
{
"a": [theEffect],
"b": [theEffect]
}
这里的 theEffect 是我们想要运行的“sum”函数。
接下来,我们的 onSet 应该将需要运行的任何效果添加到一个 dirtyEffects 数组中:
const dirtyEffects = []
function onSet(prop, value) {
if (propsToEffects[prop]) {
dirtyEffects.push(...propsToEffects[prop])
// ...
}
}
此时,我们已经有了所有的要素,使 flush 调用所有 dirtyEffects:
function flush() {
while (dirtyEffects.length) {
dirtyEffects.shift()()
}
}
把它们结合在一起,我们现在有了一个完全功能的响应性系统!您可以自己尝试在 DevTools 控制台中设置 state.a 和 state.b - 只要其中一个发生更改,state.sum 就会更新。
现在,有很多高级情况我们在这里没有涵盖:
- 在效果抛出错误时使用 try/catch
- 避免运行相同的效果两次
- 防止无限循环
- 在后续运行中订阅效果到新的属性(例如,如果某些 getter 仅在 if 块中被调用)
然而,对于我们的玩具示例来说,这已经足够了。让我们继续进行 DOM 渲染。
步骤 2:DOM 渲染
我们现在有了一个功能完备的响应性系统,但它实质上是“无头”的。它可以跟踪变化并计算效果,但仅此而已。
然而,在某个时候,我们的 JavaScript 框架实际上需要将一些 DOM 渲染到屏幕上(这其实是整个目的)。
在本节中,让我们暂时忘记响应性,想象一下我们只是尝试构建一个函数,它能够 1)构建一个 DOM 树,和 2)高效地更新它。
再次,让我们从一些“梦幻代码”开始:
function render(state) {
return html`
<div class="${state.color}">${state.text}</div>
`
}
正如我提到的,我正在使用带标签的模板文字,就像 Lit 一样,因为我发现它们是一种在不需要编译器的情况下编写 HTML 模板的好方法。(我们马上会看到为什么我们实际上可能希望使用编译器。)
我们从之前复用了我们的 state 对象,这次有一个 color 和 text 属性。也许 state 是这样的:
state.color = 'blue'
state.text = 'Blue!'
当我们将这个 state 传递给 render 时,它应该返回应用了 state 的 DOM 树:
<div class="blue">Blue!</div>
然而,在我们继续之前,我们需要简要了解一下带标签的模板文字。我们的 html 标签只是一个接收两个参数的函数:tokens(静态 HTML 字符串的数组)和 expressions(评估的动态表达式):
function html(tokens, ...expressions) {
}
在这种情况下,tokens 是(去掉空白):
[
"<div class=\"",
"\">",
"</div>"
]
和 expressions:
[
"blue",
"Blue!"
]
tokens 数组的长度始终比 expressions 数组长 1,因此我们可以简单地将它们一起进行压缩:
const allTokens = tokens
.map((token, i) => (expressions[i - 1] ?? '') + token)
这将给我们一个字符串数组:
[
"<div class=\"",
"blue\">",
"Blue!</div>"
]
我们可以将这些字符串连接在一起以生成我们的 HTML:
const htmlString = allTokens.join('');
然后,我们可以使用 innerHTML 将其解析为 <template>:
function parseTemplate(htmlString) {
const template = document.createElement('template');
template.innerHTML = htmlString;
return template;
}
这个模板包含了我们的惰性 DOM(在技术上是 DocumentFragment),我们可以随时克隆它:
const cloned = template.content.cloneNode(true);
当然,每次调用 html 函数时都解析完整的 HTML 对性能来说不是很好。幸运的是,带标签的模板文字具有一个内建特性,将在这里非常有帮助。
对于带标签的模板文字的每个独特用法,每当调用该函数时,tokens 数组始终相同 - 实际上,它是相同的对象!
例如,考虑这种情况:
function sayHello(name) {
return html`<div>Hello ${name}</div>`;
}
每当调用 sayHello 时,tokens 数组将始终相同:
[
"<div>Hello ",
"</div>"
]
tokens 的唯一不同之处是对带标签模板的完全不同位置:
html`<div></div>`
html`<span></span>` // 与上述不同
我们可以利用这一点,通过使用 WeakMap 将 tokens 数组映射到生成的模板:
const tokensToTemplate = new WeakMap();
function html(tokens, ...expressions) {
let template = tokensToTemplate.get(tokens);
if (!template) {
// ...
template = parseTemplate(htmlString);
tokensToTemplate.set(tokens, template);
}
return template;
}
这有点令人惊叹的概念,但 tokens 数组的唯一性实际上意味着我们可以确保每次对 html 进行调用时只解析一次 HTML。
接下来,我们只需要一种方法来使用 expressions 数组(与 tokens 不同,它可能在每次调用时都不同)更新克隆的 DOM 节点。
为了简单起见,让我们只是用占位符替换 expressions 数组中的每个索引:
const stubs = expressions.map((_, i) => `__stub-${i}__`);
如果我们像以前一样将其压缩,它将创建这个 HTML:
<div class="__stub-0__">
__stub-1__
</div>
我们可以编写一个简单的字符串替换函数来替换这些占位符:
function replaceStubs(string) {
return string.replaceAll(/__stub-(\d+)__/g, (_, i) => (
expressions[i]
));
}
现在每当调用 html 函数时,我们可以克隆模板并更新占位符:
const element = cloned.firstElementChild;
for (const { name, value } of element.attributes) {
element.setAttribute(name, replaceStubs(value));
}
element.textContent = replaceStubs(element.textContent);
注意:我们使用 firstElementChild 来获取模板中的第一个顶级元素。对于我们的玩具框架,我们假设只有一个。
现在,这仍然不是非常高效的 - 特别是,我们正在更新不一定需要更新的 textContent 和属性。但对于我们的玩具框架来说,这已经足够好了。
我们可以通过使用不同的 state 进行渲染来测试它:
document.body.appendChild(render({ color: 'blue', text: 'Blue!' }));
document.body.appendChild(render({ color: 'red', text: 'Red!' }));
这样就可以了!
步骤 3:结合响应性和 DOM 渲染
由于我们已经有了上面渲染系统中的 createEffect,现在我们可以将两者结合起来根据状态更新 DOM:
const container = document.getElementById('container');
createEffect(() => {
const dom = render(state);
if (container.firstElementChild) {
container.firstElementChild.replaceWith(dom);
} else {
container.appendChild(dom);
}
});
这实际上是有效的!我们可以将这个与响应性部分的 “sum” 示例结合起来,只需创建另一个效果来设置文本:
createEffect(() => {
state.text = `Sum is: ${state.sum}`;
});
这将呈现 “Sum is 3”:
你可以尝试操作这个玩具示例。如果你设置 state.a = 5,那么文本将自动更新为 “Sum is 7”。
下一步
有许多改进我们可以对这个系统进行,特别是 DOM 渲染部分。
最值得注意的是,我们缺少一种更新深度 DOM 树内元素内容的方法,例如:
<div class="${color}">
<span>${text}</span>
</div>
为此,我们需要一种方法来唯一标识模板内的每个元素。有很多方法可以做到这一点:
- Lit 在解析 HTML 时使用一套正则表达式和字符匹配的系统,以确定占位符是否在属性或文本内容中,以及目标元素的索引(按深度优先 TreeWalker 顺序)。
- Svelte 和 Solid 等框架在编译期间有幸解析整个 HTML 模板,这提供了相同的信息。它们还生成调用 firstChild 和 nextSibling 遍历 DOM 的代码,以找到要更新的元素。
注意:使用 firstChild 和 nextSibling 进行遍历类似于 TreeWalker 方法,但比 element.children 更高效。这是因为浏览器在内部使用链表来表示 DOM。
无论我们决定采用 Lit 风格的客户端解析还是 Svelte/Solid 风格的编译时解析,我们想要的是类似于这样的映射:
[
{
elementIndex: 0, // 上面的 <div>
attributeName: 'class',
stubIndex: 0 // 表达式数组中的索引
},
{
elementIndex: 1 // 上面的 <span>
textContent: true,
stubIndex: 1 // 表达式数组中的索引
}
]
这些绑定将告诉我们确切需要更新哪些元素,需要设置哪个属性(或 textContent),以及在哪里找到替换占位符的表达式。
下一步是避免每次都克隆模板,而是直接基于表达式更新 DOM。换句话说,我们不仅想要一次解析 - 我们只想一次克隆和设置绑定。这将将每个后续更新减少到最少的 setAttribute 和 textContent 调用。
注意:你可能会想知道模板克隆的目的是什么,如果我们最终还是需要调用 setAttribute 和 textContent。答案是,大多数 HTML 模板在很大程度上都是静态内容,只有一些动态的“孔”。通过使用模板克隆,我们克隆了绝大多数的 DOM,只对“孔”做额外的工作。这是使这个系统如此出色的关键洞察。
另一个有趣的模式是实现迭代(或重复器),这带来了一系列的挑战,比如在更新之间协调列表以及处理有效替换的“键”。
不过我有点疲倦,这篇博文已经够长了。所以我把剩下的部分留给读者自己来完成吧!
结论
就是这样。在这篇(冗长的)博文中,我们实现了自己的 JavaScript 框架。请随意将其用作你全新 JavaScript 框架的基础,发布到世界上,激怒 Hacker News 的群众。
个人而言,我发现这个项目非常有教育意义,这也是我一开始为什么要做的一部分。我还希望用一个更小、更自定义的解决方案替换我的表情符号选择器组件的当前框架。在这个过程中,我成功地编写了一个微小的框架,通过所有现有的测试,并比当前实现小约 6kB,我对此感到相当自豪。
在将来,我认为如果浏览器 API 足够全面,将更容易构建自定义框架将会很有趣。例如,DOM Part API 提案将消除我们上面构建的 DOM 解析和替换系统的很多繁琐工作,同时也为潜在的浏览器性能优化敞开了大门。我还可以想象(带有一些疯狂的手势)Proxy 的扩展可能会使构建完整的响应性系统变得更容易,而不用担心刷新、批处理或循环检测等细节。
如果所有这些东西都到位,那么你可以想象在实际上拥有一个“在浏览器中的 Lit”,或者至少一种快速构建你自己“在浏览器中的 Lit”的方法。与此同时,我希望这个小练习有助于说明一些框架作者考虑的事情,以及你最喜欢的 JavaScript 框架底层的一些机制。
感谢 Pierre-Marie Dartus 在这篇文章初稿中提供的反馈。
原文:?https://nolanlawson.com/2023/12/02/lets-learn-how-modern-javascript-frameworks-work-by-building-one/