大家好,这里是大家的林语冰。
了解某物如何工作的最好方法之一是自己构建它。另外,我们必须让那些"自上一个 JS 框架以来的日子"模因继续下去。因此,让我们尝试科普编写自己的现代 JS 框架的思路!
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请临幸 Let's learn how modern JavaScript frameworks work by building one。
"现代 JS 框架"是什么鬼物?
React 是一个巨好用的框架,我不是来研究它的。但就本文而言,"现代 JS 框架"指的是"后 React 时代的框架"(Vue、Solid 等)。
React 在前端领域"一超多强"已经很久了,以至于每个较新的框架都在其影响下成长起来。这些框架都深受 React 启发,但它们以惊人的相似方式从中演变而来。尽管 React 本身一直在推陈出新,但我发现今时今日的后 React 框架彼此之间更相似,而不是更像 React。
为了简单起见,我还将避免谈论诸如 Next 和 Nuxt 之类的服务端优先框架。这些框架各领风骚,但与聚焦客户端的框架相比,它们来自略有不同的背景知识。因此,在本文中,我们只讨论 CSR(客户端渲染)。
现代框架为何与众不同?
我的个人心证是,后 React 框架都万变不离其宗:
- 诉诸响应性更新 DOM(比如 Vue)。
- 诉诸克隆模板渲染 DOM。
- 使用现代 Web API(比如
<template>
和Proxy
),使上述所有操作更容易。
现在需要明确的是,这些框架在微观层面上一龙一猪,以及它们如何处理 Web 组件、编译和面向用户的 API 等内容。并非所有框架都使用 Proxy
。但从广义上讲,大多数框架作者似乎都对上述想法心照不宣,或者它们正在朝着此方向前进。
因此,对于我们自己的框架,让我们尝试最低限度地实现这些想法,从响应性开始。
响应性
人们常说"React 不是响应式的"。这意味着,React 有一个偏向拉取筑基(pull-based)而不是推送筑基(push-based)的模型。为了过度简化万物:在最坏的情况下,React 假设您的整个虚拟 DOM 树需要从零重建,而阻止这些更新的唯一方法是实现 React.memo
(或者在以前,诉诸 shouldComponentUpdate
)。
使用虚拟 DOM 可以解决"卷土从来"策略的某些成本,但不能完全解决。要求开发者编写正确的 memo
代码乃困兽犹斗。
相反,现代框架使用推送筑基的响应式模型。在此模型中,组件树的各个部分订阅状态更新,当且仅当相关状态更改时才更新 DOM。这优先考虑"默认性能"设计,换取某些前期的 bookkeeping cost(尤其是在内存方面),以跟踪状态的哪些部分与其对应的 UI 相关联。
请注意,此技术未必不兼容虚拟 DOM:诸如 Preact Signals 之类的工具表明您可以有一个混合系统。如果您的目标是保留现有的虚拟 DOM 框架(比如 React),但选择性地将推送筑基的模型应用于性能敏感的场景,这将好处多多。
在本文中,我不打算更微妙的主题,比如细粒度的响应性,但我假设我们将使用一个响应式系统。
注意:在谈论"响应性"是什么鬼物时,有很一大坨细微差别。我的目标是将 React 与后 React 框架进行对比,尤其是 Solid 和 Vue Vapor(蒸汽模式)。
克隆 DOM 树
长久以来,JS 框架中的集体智慧是渲染 DOM 的最快方法是单独创建和挂载每个 DOM 节点。换而言之,您可以使用诸如 createElement/setAttribute/textContent
之类的 API 来逐个构建 DOM:
js
const div = document.createElement('div')
div.setAttribute('class', 'blue')
div.textContent = 'UP 主牛逼!'
一种备胎方案是将一个巨型的 HTML 字符串塞进 innerHTML
,让浏览器为您解析它:
js
const container = document.createElement('div')
container.innerHTML = `
<div class="blue">UP 主再次牛逼!/div>
`
这种幼稚的方案有一个"阿喀琉斯之踵"(死穴):如果您的 HTML 中有任何动态内容(比如,"女粉"替换了 "UP 主"),那么您需要反复解析 HTML 字符串。另外,每次更新都会摧毁 DOM,这会重置状态,比如 <input>
的 value
。
注意:使用
innerHTML
也存在安全隐患。但出于本文的目的,让我们假设 HTML 内容是可信的。
虽然但是,在某些时候,人们发现解析一次 HTML,然后调用 cloneNode(true)
整个事情相当快:
js
const template = document.createElement('template')
template.innerHTML = `
<div class="blue">UP 主又双叒叕牛逼!</div>
`
template.content.cloneNode(true) // 这特别快!
这里我使用了一个 <template>
标签,它的优点是创建"inert DOM"(惰性 DOM)。换而言之,诸如 <img>
或 <video autoplay>
之类的东东不会自动开始下载任何内容。
与手动操作 DOM API 相比,这有多快呢?为了证明这一点,这里有一个迷你基准测试。Tachometer 报告说,克隆技术在:
- Chrome 中快了大约 50%
- Firefox 中快了 15%
- 在 Safari 中快了10%
这根据 DOM 大小和迭代次数会有所不同,但仅供粉丝参考。
有趣的是,<template>
是一个全新的浏览器 API,在 IE11 中不可用,最初是为 Web Components 设计的。讽刺的是,这种技术现在被用于各种 JS 框架中,无论它们是否使用了 Web Components。
注意:这里是
cloneNode
和<template>
在 Solid 和 Vue Vapor 中的用法,仅供粉丝参考。
现代 JS API
我们已经邂逅了一个好处多多的新 API,那就是 <template>
。另一个稳定吸粉的 API 是 Proxy
,它可以使构建响应性系统变得更简单。
当我们构建玩具示例时,我们还将使用标签模板字面量来创建如下所示的 API:
js
const dom = html`<div>给 ${name} 点赞!</div> `
并非所有框架都使用此工具,但值得注意的框架包括但不限于 Lit。标签模板字面量可以使构建符合人体工程学的 HTML 模板 API 变得更简单,而无需编译器。
第 1 步:构建响应性
响应性是我们构建框架其余部分的基础。响应性定义了如何管理状态,以及 DOM 在状态更改时如何更新。
让我们从某些"理想代码"开始,来说明我们的设计动机:
js
const state = {}
state.a = 1
state.b = 2
createEffect(() => {
state.sum = state.a + state.b
})
基本上,我们想要一个名为 state
的"魔术对象",还有两个 props
:a
和 b
。每当这些 props
变化时,我们期望把 sum
设置为两者之和。
假设我们事先不知道 props
(或者有编译器来确定它们),一个普通的对象就鞭长莫及。因此让我们使用 Proxy
,只要设置了新值,它就会有所响应:
js
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
钩子。因此,让我们在微任务后刷新更新:
js
let queued = false
function onSet(prop, value) {
if (!queued) {
queued = true
queueMicrotask(() => {
queued = false
flush()
})
}
}
注意:如果您不了
queueMicrotask
,它是一个较新的 DOM API,与Promise.resolve().then(...)
大同小异,但短小精悍。
为什么要刷新更新?主要是因为我们不想运行太多计算。如果我们在 a
和 b
更改时都更新,那么我们会冗余计算 sum
两次。通过将刷新合并到单个微任务中,我们可以降本增效。
接下来,让我们诉诸 flush
更新 sum
:
js
function flush() {
state.sum = state.a + state.b
}
这很棒棒哒,但它还不是我们的"理想代码"。我们需要实现 createEffect
,以便当且仅当 a
和 b
更改时才计算 sum
(而不是在其他内容更改时也计算!)。
为此,让我们使用一个对象来跟踪需要为哪些 props
运行对应的 effect
(效应):
js
const propsToEffects = {}
接下来是关键部分!我们需要确保我们的 effect
可以订阅正确的 props
。为此,我们将运行 effect
,记录它所做的任何 get
调用,并在 props
和 effect
之间创建映射。
为了分而治之,请记住我们的"理想代码"是:
js
createEffect(() => {
state.sum = state.a + state.b
})
当此函数运行时,它会调用两个 getter
:state.a
和 state.b
。这些 getter
应该触发响应式系统注意到该函数依赖这两个 props
。
为了实现这一点,我们将从一个简单的全局变量开始,以跟踪"当前"的 effect
是哪一个:
js
let currentEffect
然后,createEffect
函数会在调用该函数之前设置此全局变量:
js
function createEffect(effect) {
currentEffect = effect
effect()
currentEffect = undefined
}
此处的重点是,effect
是立即调用的,全局 currentEffect
则是预先设置的。这就是我们跟踪它可能调用的任何 getter
的方案。
现在,我们可以在 Proxy
中实现 onGet
,这将在全局 currentEffect
和属性之间建立映射:
js
function onGet(prop) {
const effects = propsToEffects[prop] ?? (propsToEffects[prop] = [])
effects.push(currentEffect)
}
运行一次后,propsToEffects
应如下所示:
js
{
"a": [theEffect],
"b": [theEffect]
}
其中 theEffect
就是我们要运行的"sum"函数。
接下来,我们的 onSet
应该将任何需要运行的 effect
添加到 dirtyEffects
数组中:
js
const dirtyEffects = []
function onSet(prop, value) {
if (propsToEffects[prop]) {
dirtyEffects.push(...propsToEffects[prop])
// ...
}
}
在这一点上,我们已经为 flush
准备好了所有部分来调用所有 dirtyEffects
:
js
function flush() {
while (dirtyEffects.length) {
dirtyEffects.shift()()
}
}
综上所述,我们现在拥有了一个功能齐全的响应性系统!您可以自己尝试使用它并尝试在 DevTools(开发者工具)控制台中设置 state.a
和 state.b
------ 每当任何其中之一更改时都会更新 state.sum
。
现在,有一大坨高级案例,我们在这里没有介绍:
- 使用
try/catch
防止effect
报错 - 避免同一
effect
两次运行 - 防止无限循环
- 在后续运行中订阅新
props
的effect
(比如,如果某些getter
有且仅有在if
区块被调用)
虽然但是,这对于我们的玩具示例而言已经超纲了。让我们继续讨论 DOM 渲染。
第 2 步:DOM 渲染
我们现在有一个函数式响应性系统,但它本质上是"headless(无头的)"。它可以跟踪变化和计算 effect
,但仅此而已。
但是,在某些时候,我们的 JS 框架需要实际将某些 DOM 渲染到屏幕上。(这才是重点。)
在本节中,让我们暂时忘记响应性,想象一下我们只是试图构建一个函数,该函数可以:
- 构建 DOM 树
- 有效地更新它
再次,让我们从若干理想代码开始:
js
function render(state) {
return html` <div class="${state.color}">${state.text}</div> `
}
如我所言,我使用的是标签模板字面量,因为我发现它们是在不需要编译器的情况下编写 HTML 模板的好方法。(我们稍后会看到为什么我们实际上需要编译器。)
我们正在复用之前的 state
对象,这次是 color
和 text
属性。也许状态是这样的:
js
state.color = 'pink'
state.text = 'UP 主牛逼!'
当我们将此 state
传递给 render
时,它应该返回应用了该状态的 DOM 树:
html
<div class="pink">UP 主牛逼!</div>
不过,在我们进一步讨论之前,我们需要快速了解标签模板字面量。我们的 html
标签只是一个接收两个参数的函数:tokens
(静态 HTML 字符串数组)和 expressions
(计算的动态表达式):
js
function html(tokens, ...expressions) {}
本例中的 tokens
(删除了空格)是:
js
['<div class="', '">', '</div>']
而 expressions
则是:
js
['pink', 'UP 主牛逼!']
tokens
数组总是比 expressions
数组长一点,因此我们可以简单地将它们压缩在一起:
js
const allTokens = tokens.map((token, i) => (expressions[i - 1] ?? '') + token)
这会给我们一个字符串数组:
js
['<div class="', 'pink">', 'UP 主牛逼!</div>']
我们可以将这些字符串连接在一起合成我们的 HTML:
js
const htmlString = allTokens.join('')
然后我们可以用 innerHTML
将其解析成一个 <template>
:
js
function parseTemplate(htmlString) {
const template = document.createElement('template')
template.innerHTML = htmlString
return template
}
这个模板包含我们的 inert DOM(技术上是一个 DocumentFragment
),我们可以随意克隆它:
js
const cloned = template.content.cloneNode(true)
当然,每当调用 html
函数时解析完整的 HTML 对性能来说并不好。幸运的是,标签模板字面量有一个内置功能在此处大有助益。
对于标签模板字面的每个唯一用法,每当调用函数时,tokens
数组始终是相同的 ------ 事实上,它是完全相同的对象!
举个栗子,考虑以下情况:
js
function sayHello(name) {
return html`<div>Hello ${name}</div>`
}
每当调用 sayHello
时,tokens
数组始终相同:
js
['<div>Hello ', '</div>']
tokens
唯一不同的时间是标签模板的完全不同位置:
js
html`<div></div>`
html`<span></span>` // 和上一行不同
我们可以利用这点来发挥我们的优势,使用 WeakMap
来保持 tokens
数组到 template
结果的映射:
js
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
数组替换为每个索引的占位符:
js
const stubs = expressions.map((_, i) => `__stub-${i}__`)
如果我们像以前一样压缩它,它将创建这个 HTML:
html
<div class="__stub-0__">__stub-1__</div>
我们可以编写一个简单的字符串替换函数来替换 stubs
:
js
function replaceStubs(string) {
return string.replaceAll(/__stub-(\d+)__/g, (_, i) => expressions[i])
}
现在,每当调用 html
函数时,我们都可以克隆模板并更新占位符:
js
const element = cloned.firstElementChild
for (const { name, value } of element.attributes) {
element.setAttribute(name, replaceStubs(value))
}
element.textContent = replaceStubs(element.textContent)
注意:我们用
firstElementChild
来抓取模板中的首个顶级元素。对于我们的玩具框架,我们假设有且仅有一个。
现在,这仍然不是非常有效 ------ 值得注意的是,我们正在更新 textContent
和不一定需要更新的属性。但对于我们的玩具框架来说,这已经棒棒哒。
我们可以用不同的 state
渲染来测试它:
js
document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))
document.body.appendChild(render({ color: 'red', text: 'Red!' }))
这能奏效!
第 3 步:结合响应式和 DOM 渲染
由于我们已经从上述渲染系统获得 createEffect
,我们现在可以将两者结合起来,根据状态更新 DOM:
js
const container = document.getElementById('container')
createEffect(() => {
const dom = render(state)
if (container.firstElementChild) {
container.firstElementChild.replaceWith(dom)
} else {
container.appendChild(dom)
}
})
这确实有效!我们可以将其与响应性部分的"sum"示例相结合,只需创建另一个 effect
来设置 text
:
js
createEffect(() => {
state.text = `Sum is: ${state.sum}`
})
这会渲染"Sum is 3"。您可以体验这个玩具例子。如果设置 state.a = 5
,那么文本将自动更新为"Sum is 7"。
后续步骤
我们可以对这个系统进行一大坨优化,尤其是 DOM 渲染位。
最值得注意的是,我们缺少一种更新深度 DOM 树中元素内容的方法,举个栗子:
html
<div class="${color}">
<span>${text}</span>
</div>
为此,我们需要一种方法来唯一标识模板中的每个元素。有一大坨方案可以做到这一点:
- Lit 在解析 HTML 时,使用正则表达式和字符匹配系统来确定占位符是否位于属性或文本内容中,以及目标元素的索引(按深度优先
TreeWalker
顺序)。 - 诸如 Vue 和 Solid 之类的框架可以在编译过程中解析整个 HTML 模板,提供相同的信息。它们还生成调用
firstChild
和nextSibling
遍历 DOM 以查找要更新的元素的代码。
注意:遍历
firstChild
和nextSibling
的方案类似TreeWalker
,但比element.children
更高效。这是因为浏览器在后台使用链表来表示 DOM。
无论我们决定进行 Lit 风格的客户端解析还是 Svelte/Solid 风格的编译时解析,我们想要的都是如下的映射:
js
[
{
elementIndex: 0, // 上述的 <div>
attributeName: 'class',
stubIndex: 0 // expressions 数组中的索引
},
{
elementIndex: 1 // 上述的 <span>
textContent: true,
stubIndex: 1 // expressions 数组中的索引
}
]
这些绑定将准确地告诉我们哪些元素需要更新,需要设置哪些属性(或 textContent
),以及在哪里可以找到替换 stub
的 expression
。
下一步是避免每次都克隆模板,而只是基于 expressions
直接更新 DOM。换而言之,我们不仅要解析一次,还要克隆和设置绑定一次。这会将每次后续更新减少到最低限度的 setAttribute
和 textContent
调用。
注意:如果无论如何我们最终都需要调用
setAttribute
和textContent
,那您可能想知道模板克隆的意义是什么。答案是,大多数 HTML 模板主要是有若干动态"holes(空洞)"的静态内容。通过模板克隆,我们克隆了大多数 DOM,而只对"holes"做了额外的工作。这是使该系统运行良好的关键见解。
另一个有趣的实现模式是迭代(或重复器),它们有自己的一系列挑战,比如协调更新之间的列表和处理"键"以实现有效的替换。
完结撒花
我们已经有实现自己的 JS 框架的基本法。请随意将其作为全新 JS 框架的基础,孵化您自己的 JS 框架。
在未来,私以为如果浏览器 API 功能足够齐全,更易于构建自定义框架,那简直酷毙了。举个栗子,DOM Part API 提案将消除我们上述构建的 DOM 解析和替换系统的一大坨苦差事,同时也为潜在的浏览器性能优化打开了大门。我还可以想象,扩展 Proxy
更易于构建一个完整的响应性系统,而不必担心刷新、批处理或循环检测等细节。
您现在收看的是《前端翻译计划》,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎持续关注前端生态。谢谢大家的点赞,掰掰~