内容来源于“Let’s learn how modern JavaScript frameworks work by building one”,我在本文中不会逐字翻译,只会将关键部分列出。
React 是一个很棒的框架,但本文要实现的“现代 JavaScript 框架”是指“后 React 时代的框架”——即 Lit、Solid、Svelte、Vue 等,并且只讨论客户端渲染。
后 React 框架都集中在相同的基本思想上:
- 响应式更新 DOM。
- 使用克隆模板进行 DOM 渲染。
- 使用现代 Web API,例如 <template> 和 Proxy。
一、克隆 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,这会重置状态,例如文本框内的值。
注意,使用 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> 标签的优点是创建“惰性”DOM。换句话说,像 <img> 或 <video autoplay> 不会自动开始下载任何内容。
根据 Tachometer 的报告,与手动 DOM API 相比,克隆技术在 Chrome 中的运行速度大约快 50%,在 Firefox 中快 15%,在 Safari 中快 10%。
<template> 是一种新的浏览器 API,在 IE11 中不可用,最初是为 Web 组件设计的,现在被用于各种 JavaScript 框架,无论它们是否使用 Web 组件。
这项技术有一个重大挑战,那就是如何在不破坏 DOM 状态的情况下高效更新动态内容,我们将在稍后讨论这个问题。
二、现代 JavaScript API
我们已经遇到了一个非常有用的新 API,那就是 <template>。另一个正在稳步流行的 API 是 Proxy,它可以让响应式系统的构建变得更加简单。
当我们构建玩具示例时,我们也将使用标记模板字面量来创建这样的 API:
const dom = html`
<div>Hello ${ name }!</div>
`
标记模板字面量可以使构建符合人体工程学的 HTML 模板 API 变得更加简单,而无需编译器。
1)创建响应式
响应式是我们构建框架其余部分的基础。响应式将定义如何管理状态,以及状态更改时 DOM 如何更新。
const state = {} state.a = 1 state.b = 2 createEffect(() => { state.sum = state.a + state.b })
我们想要一个名为 state 的“神奇对象”,它有两个 props:a 和 b。每当这些 props 发生变化时,我们都希望将 sum 设置为两者的总和。
假设我们事先不知道 props,一个普通的对象是不够的。因此,让我们使用 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(...) 基本相同,但输入量较少。
为什么要在 flush() 更新?主要是因为不想运行太多计算。如果在 a 和 b 发生变化时进行更新,那么将无用地计算两次总和。
通过将刷新合并为单个微任务,我们可以提高效率。
function flush() { state.sum = state.a + state.b }
这套代码很棒,但我们还需要实现 createEffect(),以便仅当 a 和 b 更改时才计算总和(而不是当其他内容更改时)。
为此,让我们使用一个对象来跟踪哪些 props 需要得到哪些副作用(effect),副作用会直接或间接影响其他函数的执行。
const propsToEffects = {}
接下来就是关键部分了!需要确保副作用可以订阅正确的 props。为此,需要记下它进行的任何 get 调用,并在 prop 和副作用之间创建映射。
createEffect(() => { state.sum = state.a + state.b })
当该函数运行时,它会调用两个 getter:state.a 和 state.b。这些 getter 应该触发响应系统以注意到该函数依赖于这两个 props。
为了实现这一点,我们将从一个简单的全局变量开始来跟踪当前副作用是什么:
let currentEffect
然后,createEffect() 函数将在调用该回调之前设置此全局值:
function createEffect(effect) { currentEffect = effect effect() currentEffect = undefined }
现在,我们可以在代理中实现 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()() } }
将所有这些放在一起,现在拥有一个功能齐全的响应式系统。
查看在线示例,可以自己尝试一下,只要 state.a 和 state.b 其中之一发生更改,state.sum 就会更新。
2)DOM 渲染
上述响应式系统可以跟踪变化并计算副作用,但仅此而已,JavaScript 框架还需要将一些 DOM 渲染到屏幕上。
在本节中,让我们暂时忘记响应式,假设我们只是尝试构建一个函数,该函数可以构建 DOM 树和有效地更新它。
再次,让我们从一段梦想代码开始:
function render(state) { return html` <div class="${state.color}">${state.text}</div> ` }
重新使用之前的状态对象,这次带有颜色和文本属性。
state.color = 'blue'
state.text = 'Blue!'
当我们将此状态对象传递给渲染函数时,它应该返回应用了状态的 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)
这将为我们提供一个字符串数组:
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>` // Different from above
我们可以通过使用 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 数组更新克隆的 DOM 节点(与 tokens 不同,其每次都可能不同)。
为了简单起见,我们只需将 expressions 数组替换为每个索引的占位符:
const stubs = expressions.map((_, i) => `__stub-${i}__`)
如果我们像以前一样将其压缩,它将创建以下 HTML:
<div class="__stub-0__"> __stub-1__ </div>
我们可以编写一个简单的字符串替换函数来替换 stubs:
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 来获取模板中的第一个顶级元素。对于我们的玩具框架,我们假设只有一个。
我们可以通过不同状态的渲染来测试它,查看在线示例。
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> above attributeName: 'class', stubIndex: 0 // index in expressions array }, { elementIndex: 1 // <span> above textContent: true, stubIndex: 1 // index in expressions array } ]
这些绑定将准确地告诉我们哪些元素需要更新,哪些属性(或 textContent)需要设置,以及在哪里可以找到替换 stub 的 expression。
下一步是避免每次都克隆模板,而是直接根据 expressions 更新 DOM。 换句话说,我们只想解析一次,即只想克隆和设置绑定一次。
这会将后续更新减少到最少的 setAttribute() 和 textContent 调用。
另一个有趣的实现模式是迭代(或重复器),它有自己的一系列挑战,例如协调更新之间的列表和处理“键”以实现高效替换。