JavaScript 框架来了解其工作原理

在我的日常工作中,我参与开发一个JavaScript框架(LWC)。尽管我已经在这个项目上工作了将近三年,但我仍然觉得自己是个外行。当我阅读有关更大框架世界的内容时,我经常被那些我不知道的东西所淹没。

然而,学习某个东西如何工作最好的方法之一是自己动手去构建它。而且,我们必须继续那些“自上次JavaScript框架以来的天数”的笑话。所以让我们写一个属于我们自己的现代JavaScript框架吧!

什么是“现代JavaScript框架”?

React是一个很棒的框架,我不是来批评它的。但是对于这篇文章来说,“现代JavaScript框架”指的是“后React时代的框架”,例如Lit、Solid、Svelte、Vue等。

React在前端领域已经占据了很长时间,以至于每个新框架都在它的阴影下成长。这些框架都深受React的启发,但它们在一些方面已经演变得与React有所不同。尽管React本身也在不断创新,但我发现后React框架彼此之间的相似性比它们与React的相似性更大。

为了简单起见,我也不会讨论像Astro、Marko和Qwik这样的服务器优先框架。这些框架各自优秀,但它们来自不同的知识传统,所以在这篇文章中,我们只谈论客户端渲染

现代框架有什么不同?

在我看来,后React框架都集中在相同的基础理念上:
  • 使用响应性(例如signals⑧)进行DOM更新。
  • 使用克隆的模板进行DOM渲染。
  • 使用现代Web API,例如<template>和Proxy,使上述所有操作更容易。

需要明确的是,这些框架在微观层面上有很多差异,以及它们如何处理Web组件编译和用户界面API等问题。并不是所有框架都使用Proxy。但总体而言,大多数框架作者似乎同意上述理念,或者正在朝这个方向发展。

所以对于我们自己的框架,让我们尝试做最少的事情来实现这些理念,从响应性开始。

响应式

常有人说“React不是响应式的”。这意味着React更偏向于拉取模型而不是推送模型。大体来说,React假设你的整个虚拟DOM树需要从头开始重建,而避免这些更新的唯一方法是实现React.memo(或者在早期,使用shouldComponentUpdate)。

使用虚拟DOM可以减轻一些“全部推倒重来”策略的成本,但不能完全解决。而且要求开发人员编写正确的memo代码是一场失败的战斗。(请参见React Forget,这是一个正在尝试解决这个问题的项目。

https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023

相反,现代框架使用推送式响应模型。在这种模型中,组件树的各个部分订阅状态更新,并且只有在相关状态更改时才更新DOM。这种设计优先考虑“默认情况下的性能”,以换取一些前期的记录成本(特别是在内存方面)来跟踪哪些状态部分与UI的哪些部分相关。

需要注意的是,这种技术并不一定与虚拟DOM方法不兼容:像Preact Signals和Million这样的工具表明你可以拥有一个混合系统。如果你的目标是保留现有的虚拟DOM框架(例如React),在更敏感的性能场景中选择性地应用推送模型,这种方法很有用。

在这篇文章中,我不会详细讨论signals⑧本身,或者更细微的主题如细粒度响应性①,但我会假设我们会使用一个响应式系统。

细粒度响应

https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf

注意:在谈论什么是reactive时,有很多细微差别。我在这里的目标是将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中有任何动态内容(例如红色而不是蓝色),你需要一遍又一遍地解析HTML字符串。此外,你每次更新都在推倒DOM,这会重置诸如<input>的值等状态。
注意:使用innerHTML也有安全隐患。但在这篇文章的目的中,我们假设HTML内容是可信的。
然而,某个时刻,人们发现解析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组件。

这种技术的一个主要挑战是如何在不推倒DOM状态的情况下高效更新动态内容。我们将在构建我们的玩具框架时讨论这个问题。

现代JavaScript API

我们已经遇到了一个非常有帮助的新API,那就是<template>。另一个正在逐渐流行的是Proxy,它可以使构建响应系统变得更简单。

当我们构建我们的玩具例子时,我们还会使用标记模板字面量来创建一个这样的API:

const dom = html`
  <div>Hello ${ name }!</div>
`

不是所有框架都使用这个工具,但值得注意的包括Lit、HyperHTML和ArrowJS。标记模板字面量可以使构建符合人体工程学的HTML模板API变得更加简单,而不需要编译器。

第一步:构建响应性

响应性是我们构建其余框架的基础。响应性将定义如何管理状态,以及状态变化时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改变时更新,那么我们将无用地计算和两次。通过将刷新合并到一个微任务中,我们可以高效得多。

接下来,让flush更新sum:

function flush() {
  state.sum = state.a + state.b;
}

这很棒,但还不是我们的“理想代码”。我们需要实现createEffect,使sum仅在a和b改变时计算(而不是在其他东西改变时!)。

为此,让我们使用一个对象来跟踪哪些属性需要运行哪些效果:

const propsToEffects = {};

下来是关键部分!我们需要确保我们的效果可以订阅到正确的属性。为此,我们将运行效果,记录它调用的任何get,并在属性和效果之间创建一个映射。

为了详细说明,请记住我们的“理想代码”是:

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

当这个函数运行时,它调用了两个getter:state.a和state.b。这些getter应该触发响应系统注意到该函数依赖于这两个属性。

要做到这一点,我们将从一个简单的全局变量开始,来跟踪当前的效果:

let currentEffect = null;

然后,createEffect函数将在调用该函数之前设置这个全局变量:

function createEffect(effect{
  currentEffect = effect;
  effect();
  currentEffect = null;
}

这样任何getter都会知道记录当前的效果。接下来是 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
  • 避免运行相同的效果两次
  • 防止无限循环
  • 将效果订阅到后续运行的新属性(例如,在 if 块中仅调用某些 getter)
然而,对于我们的玩具示例来说,这已经足够了。让我们继续讨论 DOM 渲染。

第二步:DOM 渲染

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

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

在本节中,让我们暂时忘记响应性,想象我们只是试图构建一个函数,该函数能够构建一个 DOM 树并高效地更新它
再次,让我们从一些理想的代码开始:
function render(state{
  return html`
    <div class="${state.color}">${state.text}</div>
  `

}

正如我之前提到的,我使用了标记模板字面量,就像 Lit 一样,因为我发现它们是一种在不需要编译器的情况下编写 HTML 模板的好方法。(我们马上会看到为什么我们实际上可能需要一个编译器。)

我们再次重用之前的状态对象,这次带有颜色和文本属性。也许状态是这样的:

state.color = 'blue'
state.text = 'Blue!'

当我们将此状态传递给 render 时,它应该返回应用了状态的 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 数组更新克隆的 DOM 节点(这很可能每次都不同,不像 tokens)。

为了保持简单,让我们只是用每个索引的占位符替换 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!' }))
这有效!
完整的代码,如下:

第三步:结合响应性和 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”示例结合起来,只需创建另一个效果来设置text:

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

这将呈现“Sum is 3”:

您可以尝试一下这个小例子。如果您设置了state.a = 5,那么文本将自动更新为“总数为 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 风格的编译时解析,我们想要的是类似这样的映射:

[
  {
    elementIndex0// 上面的 <div>
    attributeName: 'class',
    stubIndex0 // 在 expressions 数组中的索引
  },
  {
    elementIndex1// 上面的 <span>
    textContent: true,
    stubIndex1 // 在 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⑦ 对这篇文章草稿的反馈。

脚注

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

原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/79463.html

(0)
guozi的头像guozi
上一篇 2024年5月30日 下午6:45
下一篇 2024年5月30日

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注