通过构建一个现代JavaScript框架来学习其工作原理

203 阅读26分钟

image.png

在我的日常工作中,我负责开发一个JavaScript框架(LWC)。尽管我已经做了将近三年,但我仍然觉得自己是个业余爱好者。当我阅读更大的框架世界里发生的事情时,我常常被自己不知道的东西所淹没。

然而,学习某样东西如何工作的最好方法之一就是亲自构建它。而且,我们还得继续保持那些"距离上一个JavaScript框架诞生的天数"的梗。所以,让我们来编写我们自己的现代JavaScript框架吧!

什么是"现代JavaScript框架"?

React是一个很棒的框架,我并不是来贬低它的。但就本文而言,"现代JavaScript框架"指的是"后React时代的框架" - 即Lit、Solid、Svelte、Vue等。

React主导前端领域已经很长时间了,以至于每一个较新的框架都是在它的阴影下成长起来的。这些框架都深受React的启发,但它们以惊人相似的方式远离了React。尽管React本身也在不断创新,但我发现后React时代的框架现在彼此之间比起与React来说更相似。

为了简单起见,我也会避免讨论像Astro、Marko和Qwik这样的服务器优先框架。这些框架本身很出色,但它们来自于与客户端专注框架稍微不同的知识传统。所以在本文中,让我们只讨论客户端渲染。

是什么让现代框架与众不同?

从我的角度来看,后React框架都收敛于相同的基础理念:

  1. 使用响应式(如信号)进行DOM更新。
  2. 使用克隆模板进行DOM渲染。
  3. 使用现代Web API如<template>Proxy,这些使得上述操作更加容易。

需要明确的是,这些框架在微观层面上存在很大差异,在处理Web组件、编译和面向用户的API等方面也各不相同。并非所有框架都使用Proxy。但从大体上讲,大多数框架作者似乎都同意上述想法,或者正朝着这个方向发展。

因此,对于我们自己的框架,让我们尝试做最少的工作来实现这些想法,从响应式开始。

响应式

人们常说"React不是响应式的"。这意味着React更多是一种基于拉取而非推送的模型。粗略地简化一下:在最坏的情况下,React假设你的整个虚拟DOM树需要从头重建,而防止这些更新的唯一方法是实现React.memo(或在旧时代,使用shouldComponentUpdate)。

使用虚拟DOM可以缓解"全部推倒重来"策略的一些成本,但它并不能完全解决问题。而要求开发者编写正确的memo代码是一场注定失败的战斗。(参见React Forget,这是一个正在进行的尝试来解决这个问题。)

相反,现代框架使用基于推送的响应式模型。在这个模型中,组件树的各个部分订阅状态更新,并且只在相关状态发生变化时更新DOM。这优先考虑了"默认高性能"的设计,以换取一些前期的记账成本(特别是在内存方面)来跟踪哪些状态与UI的哪些部分相关联。

请注意,这种技术不一定与虚拟DOM方法不兼容:像Preact Signals和Million这样的工具显示,你可以拥有一个混合系统。如果你的目标是保留现有的虚拟DOM框架(例如React),但有选择地应用基于推送的模型以获得更高性能的场景,这很有用。

在这篇文章中,我不会重述信号本身的细节,或更微妙的主题如细粒度响应性,但我假设我们将使用一个响应式系统。

注意:在讨论什么算作"响应式"时,有很多细微差别。我的目标是对比React与后React框架,特别是Solid、处于"runes"模式的Svelte v5和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中有任何动态内容(例如,red而不是blue),那么你就需要一遍又一遍地解析HTML字符串。此外,你每次更新都会清空DOM,这将重置如<input>的值这样的状态。

注意:使用innerHTML也有安全隐患。但就本文而言,让我们假设HTML内容是可信的。[1]

然而,在某个时候,人们发现解析一次HTML然后对整个内容调用cloneNode(true)是相当快的:

const template = document.createElement('template')
template.innerHTML = `
  <div class="blue">Blue!</div>
`
template.content.cloneNode(true) // 这很快!

这里我使用了<template>标签,它有创建"惰性"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状态的情况下高效地更新动态内容。我们稍后在构建我们的玩具框架时会涉及到这一点。

DocumentFragment<template> 有什么异同点?

1. 定义和用途

  • DocumentFragment
    • 是一个轻量级的文档对象,用于在内存中构建 DOM 树。
    • 它不会直接影响页面的渲染,直到将其添加到真实的 DOM 中。
    • 通常用于批量添加元素以提高性能。
  • <template>
    • 是一个 HTML 元素,用于定义可重用的 DOM 结构。
    • 它的内容在页面加载时不会被渲染,而是可以在 JavaScript 中克隆和插入到 DOM 中。
    • 适用于存储 HTML 片段,以便在需要时动态生成内容。

2. 结构

  • DocumentFragment
    • 没有实际的标签,通常通过 JavaScript 创建。
    • 它可以包含任意数量的子节点(元素、文本节点等)。
  • <template>
    • 是一个实际的 HTML 标签,通常包含 <template> 标签内的内容。
    • 内容可以是任何有效的 HTML,但在页面加载时不会被渲染。

3. 使用示例

DocumentFragment 示例

// 1. 创建一个 DocumentFragment
const fragment = document.createDocumentFragment();

// 2. 创建一些元素并添加到 Fragment 中
for (let i = 0; i < 5; i++) {
    const listItem = document.createElement('li');
    listItem.textContent = `Item ${i + 1}`;
    fragment.appendChild(listItem); // 将元素添加到 Fragment
}

// 3. 将 Fragment 添加到 DOM 中
const ul = document.getElementById('myList');
ul.appendChild(fragment); // 一次性将所有元素添加到 DOM

<template> 示例

<template id="my-template">
  <div>
    <h2>This is a template</h2>
    <p>Content from the template will be rendered later.</p>
  </div>
</template>

<script>
  // 获取模板内容
  const template = document.getElementById('my-template').content.cloneNode(true);
  // 将模板内容添加到 DOM 中
  document.body.appendChild(template);
</script>

4. 性能

  • DocumentFragment:由于它是在内存中操作的,通常在批量添加元素时性能更好。
  • <template>:虽然也不会立即渲染,但每次使用时需要克隆内容,性能上可能略逊一筹。

总结

  • DocumentFragment 主要用于优化性能,通过在内存中构建 DOM 结构。
  • <template> 主要用于定义可重用的 HTML 结构,便于在需要时动态生成内容。

document.importNode和cloneNode有什么区别?

document.importNodecloneNode 都是用于复制节点的 JavaScript 方法,但它们在使用场景、功能和返回值上有一些显著的区别。下面是这两者的详细比较:

1. 方法定义

  • cloneNode
    • 该方法用于复制当前节点(及其子节点)。
    • 语法:node.cloneNode(deep),其中 deep 是一个布尔值,指示是否递归克隆子节点。
  • document.importNode
    • 该方法用于从一个文档中导入节点,并将其克隆到当前文档中。
    • 语法:document.importNode(importedNode, deep),其中 importedNode 是要导入的节点,deep 也是一个布尔值,指示是否递归导入子节点。

2. 使用场景

  • cloneNode
    • 用于克隆 DOM 树中的节点,适用于同一文档。
    • 示例:复制一个元素并在同一文档中多次使用。
  • document.importNode
    • 用于从一个文档(如 <template> 或 iframe)导入节点,适用于跨文档操作。
    • 示例:从 <template> 中导入内容并添加到当前文档中。

3. 返回值

  • cloneNode
    • 返回一个节点的克隆副本(包括属性和子节点,取决于 deep 参数)。
  • document.importNode
    • 返回一个在当前文档中可用的节点副本,特别适用于从其他文档(如模板)导入的节点。

4. 示例代码

使用 cloneNode

const originalElement = document.getElementById('myElement');
const clonedElement = originalElement.cloneNode(true); // 递归克隆
document.body.appendChild(clonedElement); // 将克隆的元素添加到文档中

使用 document.importNode

<template id="myTemplate">
    <li>Item 1</li>
    <li>Item 2</li>
</template>

<ul id="myList"></ul>

<script>
    const template = document.getElementById('myTemplate');
    const clone = document.importNode(template.content, true); // 导入模板内容
    const ul = document.getElementById('myList');
    ul.appendChild(clone); // 将克隆的内容添加到 DOM 中
</script>

总结

  • cloneNode 主要用于在同一文档中复制节点。
  • document.importNode 用于从一个文档导入节点,特别适合处理 <template> 等结构。

现代JavaScript API

我们已经遇到了一个很有帮助的新API,就是<template>。另一个正在稳步获得关注的是Proxy,它可以使构建响应式系统变得更加简单。

当我们构建我们的玩具示例时,我们还将使用标记模板字面量来创建像这样的API:

const dom = `
  <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钩子。所以让我们让它在一个微任务(queueMicrotask)之后刷新更新:

let queued = false

function onSet(prop, value) {
  if (!queued) {
    queued = true
    queueMicrotask(() => {
      queued = false
      flush()
    })
  }
}

注意:如果你不熟悉queueMicrotask,它是一个较新的DOM API,基本上与Promise.resolve().then(...)相同,但打字更少。

什么是宏任务和微任务?

为了解释浏览器中的宏任务和微任务,让我们先简要介绍一下这两个概念,然后通过一个例子来说明它们的区别和执行顺序。

宏任务(Macro-task):

  • 宏任务代表了JavaScript运行时较大的、独立的工作单元。
  • 常见的宏任务包括:整体的JavaScript代码、setTimeout、setInterval、I/O操作、UI渲染、ajax,fetch等。

微任务(Micro-task):

  • 微任务是更小的任务,它们在当前宏任务结束后、下一个宏任务开始前执行。
  • 常见的微任务包括:Promise的then/catch/finally回调、queueMicrotask()、MutationObserver等。

执行顺序:

  1. 执行当前的宏任务(例如整体的JavaScript代码)
  2. 执行所有可用的微任务
  3. 进行UI渲染(如果需要)
  4. 执行下一个宏任务

下面我们通过一个简单的代码示例来说明这个过程:

console.log('1. 宏任务开始');

setTimeout(() => {
  console.log('5. 定时器回调(宏任务)');
}, 0);

Promise.resolve().then(() => {
  console.log('3. Promise回调(微任务)');
});

queueMicrotask(() => {
    console.log('4. queueMicrotask回调(微任务)');
})

console.log('2. 宏任务结束');

这段代码的执行顺序将会是:

  1. "1. 宏任务开始"
  2. "2. 宏任务结束"
  3. "3. Promise回调(微任务)"
  4. "4. queueMicrotask回调(微任务)"
  5. "5. 定时器回调(宏任务)"

解释:

  1. 首先执行整体代码(第一个宏任务)
  2. 遇到setTimeout,将其回调放入宏任务队列
  3. 遇到Promise,将其回调放入微任务队列
  4. 遇到queueMicrotask,将其回调放入微任务队列
  5. 打印"宏任务结束"
  6. 第一个宏任务(整体代码)结束,检查微任务队列,执行Promise回调,执行queueMicrotask
  7. 所有微任务执行完毕,进入下一个事件循环,执行setTimeout的回调(新的宏任务)

这个机制允许JavaScript在单线程环境中高效地处理异步操作,同时保证了某些任务(如Promise回调)能够优先执行,提高了响应性和性能。

DOM操作是同步的还是异步的?

DOM 操作在 JavaScript 中通常是同步的。

这意味着,当你对 DOM 进行操作时(如增删元素、修改属性、改变样式等),这些操作会立即生效,浏览器会同步更新其内存中的 DOM 结构。例如,如果你使用 document.getElementById() 获取某个元素,或用 element.textContent 修改内容,JavaScript 引擎会立刻执行这些操作。

示例:

const element = document.createElement('div');
document.body.appendChild(element);
element.textContent = 'Hello, World!';

console.log(document.body.innerHTML);

在这个例子中:

  1. 创建了一个 <div> 元素并将其添加到 DOM 中。
  2. 修改了它的文本内容。
  3. 使用 console.log 打印 DOM 的 innerHTML,会立即反映出所做的变化。

输出结果

<div>Hello, World!</div>

DOM 操作的异步部分:

尽管 DOM 操作本身是同步的,但浏览器的渲染(将修改后的 DOM 更新到页面上)可能是异步的。浏览器在接收到 DOM 修改后,不一定会立即更新屏幕上的显示。它会根据优化策略,将 DOM 的更改批量处理,然后进行一次重绘和回流。

  • 重绘(Repaint) :当只是修改了样式属性(比如颜色),但布局没有改变时,浏览器会进行重绘。
  • 回流(Reflow) :当元素的尺寸、位置或布局发生改变时,浏览器会重新计算元素的布局,并进行回流。

这种渲染更新的异步特性是为了提高性能,避免频繁地重新渲染页面。在实际操作 DOM 时,你可能会修改多个属性,但浏览器会等到一轮操作完成后再进行一次渲染。

总结:

  • DOM 操作(增删节点、修改属性等)是同步的,操作会立即生效。
  • 浏览器的渲染(将 DOM 更新反映到屏幕上)通常是异步的,浏览器会在后台进行优化和批量处理。

微任务执行的时机是DOM修改后还是DOM修改后并且UI重绘之后?

微任务的执行时机是在DOM修改之后,但在浏览器重新绘制(repaint)之前。这个过程可以分为以下几个步骤:

  1. JavaScript执行

    • 执行当前的宏任务(例如,一个事件处理函数)。
    • 在这个过程中,可能会修改DOM。
  2. 微任务队列执行

    • 当前宏任务执行完毕后,立即执行所有排队的微任务。
    • 这时,DOM已经被修改,但浏览器还没有重新渲染页面。
  3. 渲染更新

    • 微任务队列清空后,浏览器会检查是否需要更新渲染。
    • 如果需要,浏览器会进行样式计算、布局和绘制操作。
  4. 显示

    • 最后,更新后的内容被显示在屏幕上。

关键点

  1. DOM修改是同步的:当JavaScript代码修改DOM时,这些修改会立即应用到DOM树上。
  2. 渲染是异步的:虽然DOM树被修改了,但这些改变不会立即反映在屏幕上。浏览器会在适当的时机进行渲染。
  3. 微任务在渲染之前:所有微任务都会在下一次渲染之前执行。这意味着即使有多次DOM修改,也可能只会触发一次渲染。

可以在DOM修改后执行的方法有哪些?

为了确保在 DOM 渲染 完成后再执行下一步操作,你可以使用几种不同的方法,因为 JavaScript 本身对 DOM 操作是同步的,但浏览器的**渲染(Repaint 和 Reflow)**可能是异步的,通常不会立即完成。

常见的几种方法:

1. 使用 requestAnimationFrame

requestAnimationFrame 是一个专门为高效更新 DOM 而设计的 API,它会在浏览器下一次重绘之前调用指定的回调函数。这个方法确保回调在下一次渲染时执行,适合需要在页面完成渲染后执行的任务。

// 假设你已经修改了 DOM,现在想在渲染完成后做点什么
document.body.appendChild(document.createElement('div'));

requestAnimationFrame(() => {
  // 此时可以保证 DOM 已经被渲染到页面上
  console.log('DOM 已渲染,执行下一步操作');
});

2. 使用 setTimeout

尽管 setTimeout 的最小延迟为 0ms,但它会将回调推迟到下一轮事件循环中执行,这意味着可以等待当前操作完成后,再进行下一步操作。使用 setTimeout 可以间接确保 DOM 操作完成,并且已经被渲染到页面上。

// 假设你已经修改了 DOM
document.body.appendChild(document.createElement('div'));

// 使用 setTimeout 推迟执行,确保渲染完成
setTimeout(() => {
  console.log('DOM 应该已经渲染,执行下一步操作');
}, 0);

虽然 setTimeout 并不能完全保证 DOM 渲染的时机(浏览器的渲染策略可能有所不同),但在大多数情况下,它能确保下一步操作在 DOM 变化后执行。

3. 使用 MutationObserver

MutationObserver 是一个监听 DOM 变化的工具。当 DOM 发生变化时,它会触发回调,你可以在回调中进行操作,确保在 DOM 变动发生后进行下一步处理。

// 创建一个 MutationObserver
const observer = new MutationObserver((mutationsList) => {
  for (let mutation of mutationsList) {
    if (mutation.type === 'childList') {
      console.log('DOM 变动已发生,执行下一步操作');
    }
  }
});

// 观察 DOM 的变化
observer.observe(document.body, { childList: true });

// 添加新的 DOM 节点
document.body.appendChild(document.createElement('div'));

4. 使用 queueMicrotask

queueMicrotask 可以确保代码在当前事件循环的任务完成之后、微任务队列执行之前运行,这样在某些情况下可以确保 DOM 变更完成后执行任务,但它不会等待渲染完成。因此这并不是严格保证 DOM 渲染的方式。

document.body.appendChild(document.createElement('div'));

queueMicrotask(() => {
  console.log('DOM 变化已经处理,可能已渲染');
});

5. 使用 awaitPromise 结合异步操作

如果你的操作涉及异步逻辑,可以结合 Promiseasync/await 来让操作在下一轮事件循环中执行。

document.body.appendChild(document.createElement('div'));

(async () => {
  await new Promise(resolve => setTimeout(resolve, 0));
  console.log('异步操作完成,DOM 可能已经渲染');
})();

总结:

  1. requestAnimationFrame 是最常用的方式,用于确保操作在下一次屏幕重绘之前执行,因此适合等待 DOM 渲染完成后再执行操作。
  2. setTimeout 是一种简单的解决方案,可以确保在当前事件循环完成后执行。
  3. MutationObserver 可以监听 DOM 变化,是一种更复杂但强大的方法。
  4. queueMicrotask 是微任务队列的一部分,虽然能立即执行任务,但不能确保渲染完成。

vue中$nextTick内部是怎么实现的?

Vue 的 $nextTick 方法利用了 JavaScript 的异步任务队列机制,主要通过微任务(microtask)来实现。其核心思想是将回调函数推迟到下一个 DOM 更新周期之后执行。以下是其实现的基本步骤:

  1. 任务队列: Vue 维护了一个任务队列,用于存储 $nextTick 的回调函数。
  2. 异步策略: Vue 会尝试使用不同的异步策略,按照以下优先级: a. Promise(如果浏览器支持) b. MutationObserver c. setImmediate(如果在 IE 或 Node.js 环境中可用) d. setTimeout(作为最后的备选方案)
  3. 触发机制: 当调用 $nextTick 时,Vue 会: a. 将回调函数加入任务队列 b. 如果还没有待处理的刷新,则使用选定的异步策略来安排一个"刷新"操作
  4. 执行过程: 在下一个事件循环的微任务阶段: a. Vue 会执行所有待处理的 DOM 更新 b. 然后按照队列顺序执行所有 $nextTick 回调
  5. 错误处理: 如果在执行回调时发生错误,错误会被捕获并报告,但不会阻止其他回调的执行。

简化的代码示意:

let callbacks = [];
let pending = false;

function nextTick(cb) {
  callbacks.push(cb);
  if (!pending) {
    pending = true;
    Promise.resolve().then(flushCallbacks);
  }
}

function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

这个简化版本展示了 $nextTick 的核心概念:将回调添加到队列,并在下一个微任务中执行它们。

为什么要刷新更新?主要是因为我们不想运行太多的计算。如果我们在a和b都改变时更新,那么我们将无用地计算两次和。通过将刷新合并到一个单一的微任务中,我们可以更加高效。

接下来,让我们让flush更新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就会更新。

<div>(Use the DevTools console to set <code>state.a</code> or <code>state.b</code>, and then do <code>console.log({...state})</code> to see the result.)
const propsToEffects = {}
const dirtyEffects = []
let queued = false

const state = new Proxy({}, {
  get(obj, prop) {
    onGet(prop)
    return obj[prop]
  },
  set(obj, prop, value) {
    obj[prop] = value
    onSet(prop, value)
    return true
  } })


function onGet(prop) {
  if (currentEffect) {
    const effects = propsToEffects[prop] ?? (propsToEffects[prop] = [])
    effects.push(currentEffect)
  }
}

function flush() {
  while (dirtyEffects.length) {
    dirtyEffects.shift()()
  }
}

function onSet(prop, value) {
  if (propsToEffects[prop]) {
    dirtyEffects.push(...propsToEffects[prop])
    if (!queued) {
      queued = true
      queueMicrotask(() => {
        queued = false
        flush()
      })
    }
  }
}

function createEffect(effect) {
  currentEffect = effect
  effect()
  currentEffect = undefined
}

// Initial state

state.a = 1
state.b = 2

createEffect(() => {
  state.sum = state.a + state.b
})

// Let's test it out!

console.log({ ...state })

console.log('Setting a to', 5)
state.a = 5

Promise.resolve().then(() => {
  console.log({ ...state })
})

现在,这里有很多高级情况我们没有涉及:

  • 使用try/catch以防效果抛出错误
  • 避免运行同一效果两次
  • 防止无限循环
  • 在后续运行中将效果订阅到新属性(例如,如果某些getter只在if块中被调用)

然而,这对我们的玩具示例来说已经足够了。让我们继续讨论DOM渲染。

第2步:DOM渲染

我们现在有了一个功能性的响应式系统,但它基本上是"无头"的。它可以跟踪变化并计算效果,但仅此而已。

然而,在某个时候,我们的JavaScript框架需要实际地向屏幕渲染一些DOM。(这基本上是整个重点。)

对于这一部分,让我们暂时忘记响应式,想象我们只是试图构建一个函数,可以1)构建一个DOM树,2)高效地更新它。

再次从一些梦想代码开始:

function render(state) {
  return `
    <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
}

这个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和属性。但对于我们的玩具框架来说,这已经足够了。

我们可以通过使用不同的状态进行渲染来测试它:

document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))
document.body.appendChild(render({ color: 'red', text: 'Red!' }))

这有效!

.blue {
  color: blue
}
.red {
  color: red;
}
const tokensToTemplate = new WeakMap()

function parseTemplate(htmlString) {
  const template = document.createElement('template')
  template.innerHTML = htmlString
  return template
}

function html(tokens, ...expressions) {
  const replaceStubs = (string) => (
    string.replaceAll(/__stub-(\d+)__/g, (_, i) => (
      expressions[i]
    ))
  )
  // get or create the template
  let template = tokensToTemplate.get(tokens)
  if (!template) {
    const stubs = expressions.map((_, i) => `__stub-${i}__`)
    const allTokens = tokens.map((token, i) => (stubs[i - 1] ?? '') + token)
    const htmlString = allTokens.join('')
    template = parseTemplate(htmlString)
    tokensToTemplate.set(tokens, template)
  }
  // clone and update bindings
  const cloned = template.content.cloneNode(true)
  const element = cloned.firstElementChild
  for (const { name, value } of element.attributes) {
    element.setAttribute(name, replaceStubs(value))
  }
  element.textContent = replaceStubs(element.textContent)
  return element
}

function render(state) {
  return html`
    <div class="${state.color}">${state.text}</div>
  `
}

// Let's test it out!
document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))

// And again!
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":

<div id="container"></div>
.blue {
  color: blue
}
.red {
  color: red;
}
// Reactivity
const propsToEffects = {}
const dirtyEffects = []
let queued = false

const state = new Proxy({}, {
  get(obj, prop) {
    onGet(prop)
    return obj[prop]
  },
  set(obj, prop, value) {
    obj[prop] = value
    onSet(prop, value)
    return true
  } })


function onGet(prop) {
  if (currentEffect) {
    const effects = propsToEffects[prop] ?? (propsToEffects[prop] = [])
    effects.push(currentEffect)
  }
}

function flush() {
  while (dirtyEffects.length) {
    dirtyEffects.shift()()
  }
}

function onSet(prop, value) {
  if (propsToEffects[prop]) {
    dirtyEffects.push(...propsToEffects[prop])
    if (!queued) {
      queued = true
      queueMicrotask(() => {
        queued = false
        flush()
      })
    }
  }
}

function createEffect(effect) {
  currentEffect = effect
  effect()
  currentEffect = undefined
}

// Initial state

state.a = 1
state.b = 2

createEffect(() => {
  state.sum = state.a + state.b
})

// HTML rendering

const tokensToTemplate = new WeakMap()

function parseTemplate(htmlString) {
  const template = document.createElement('template')
  template.innerHTML = htmlString
  return template
}

function html(tokens, ...expressions) {
  const replaceStubs = (string) => (
    string.replaceAll(/__stub-(\d+)__/g, (_, i) => (
      expressions[i]
    ))
  )
  // get or create the template
  let template = tokensToTemplate.get(tokens)
  if (!template) {
    const stubs = expressions.map((_, i) => `__stub-${i}__`)
    const allTokens = tokens.map((token, i) => (stubs[i - 1] ?? '') + token)
    const htmlString = allTokens.join('')
    template = parseTemplate(htmlString)
    tokensToTemplate.set(tokens, template)
  }
  // clone and update bindings
  const cloned = template.content.cloneNode(true)
  const element = cloned.firstElementChild
  for (const { name, value } of element.attributes) {
    element.setAttribute(name, replaceStubs(value))
  }
  element.textContent = replaceStubs(element.textContent)
  return element
}

function render(state) {
  return html`
    <div class="${state.color}">${state.text}</div>
  `
}

// Tying it all together

state.color = 'blue'

const container = document.getElementById('container')

createEffect(() => {
  console.log('rendering', state)
  const dom = render(state)
  if (container.firstElementChild) {
    container.firstElementChild.replaceWith(dom)
  } else {
    container.appendChild(dom)
  }
})

createEffect(() => {
  state.text = `Sum is: ${state.sum}`
})

你可以玩玩这个玩具示例。如果你设置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 // expressions数组中的索引
  },
  {
    elementIndex: 1 // 上面的 <span>
    textContent: true,
    stubIndex: 1 // expressions数组中的索引
  }
]

这些绑定会准确地告诉我们哪些元素需要更新,哪个属性(或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对这篇文章草稿的反馈。

脚注

  1. 现在我们已经构建了框架,你可以看到为什么传递给innerHTML的内容可以被认为是可信的。所有HTML标记要么来自标记模板字面量(在这种情况下,它们是完全静态的,由开发者编写),要么是占位符(也是由开发者编写的)。用户内容只通过setAttribute或textContent设置,这意味着不需要HTML净化来避免XSS攻击。尽管你可能还是应该使用CSP。

这是一篇非常详细的技术文章,介绍了如何从头开始构建一个现代JavaScript框架。它涵盖了响应式系统、DOM渲染和两者的结合等核心概念。这个Markdown格式的中文翻译保留了原文的结构,包括代码示例和章节划分。如果您需要对特定部分进行更详细的解释或有任何问题,请随时告诉我。

参考:nolanlawson.com/2023/12/02/…