头图
让我们通过构建一个现代 JavaScript 框架来了解其工作原理。

我的日常工作是开发 JavaScript 框架 (LWC)。虽然我已经在这个框架上工作了近三年,但我仍然觉得自己是个门外汉。当我阅读大型框架领域的最新动态时,我常常会被自己不知道的事情压得喘不过气来。

不过,了解某些东西如何工作的最好方法之一就是自己动手创建。另外,我们还得让那些 “days since last JavaScript framework” 的话题继续下去。所以,让我们编写自己的现代 JavaSctipt 框架吧!

什么是“现代 JavaScript 框架”?

React 是一个很棒的框架,我不是来这里讨论它的。但就本文而言,“现代 JavaScript 框架”指的是“后 React 时代的框架”,即 LitSolidSvelteVue 等。

React 长期以来一直主导着前端领域,以至于每个新框架都在它的影响下成长。这些框架都深受 React 的启发,但它们以惊人相似的方式从 React 演变而来。尽管 React 本身在不断创新,但我发现后 React 框架彼此之间的相似度比现在的 React 更相似。

简单起见,我不打算谈论 AstroMarkoQwik 等服务器优先的框架。这些框架各有千秋,但与客户端框架相比,它们的思想传统略有不同。因此,在这篇文章中,我们只讨论客户端渲染。

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

在我看来,“后 React 框架”都趋向于相同的基本理念:

  1. 使用响应式(如 signals)进行 DOM 更新。
  2. 使用克隆模版进行 DOM 渲染。
  3. 使用现代 Web API(如 <template>Proxy),使上述所有操作变得更容易。

需要明确的是,这些框架在微观层面以及它们如何处理 Web 组件、编译和面向用户的 API 等方面存在很大差异。甚至并非所有框架都使用 Proxy。但从广义上讲,大多数框架的作者似乎都同意上述观点,或者他们在朝着这个方向努力。

因此,对于我们自己的框架来说,让我们从响应式入手,尽力实现这些思想理念。

响应式(Reactivity)

人们常说 “React 不是响应式的”。这句话的意思是,React 采用的是 pull-based 的模式,而不是 push-based 的模式。简单地说就是 React 假定您的整个虚拟 DOM 树都需要从头开始重建,而防止这些更新的唯一方法就是实现 useMemo(或者以前的 shouldComponentUpdate)。

使用虚拟 DOM 可以减轻“一切从零开始”策略的一些成本,但并不能完全解决问题。要求开发人员编写正确的备忘录代码是一场失败的战斗(有关解决此问题的尝试,请参阅 React Forget)。

相反,现代框架使用的是 push-based 的响应模型。在这种模型中,组件树的各个部分都会订阅状态更新,只有在相关状态发生变化时才会更新 DOM。这优先考虑了“默认情况下的高性能”设计,以换取一些前期记录成本(尤其是在内存方面)来跟踪哪些状态与 UI 的部分相关联。

请注意,这种技术并不一定与虚拟 DOM 方法不兼容:Preact SignalsMillion 等工具都表明,您可以使用混合系统。如果您的目标是保留现有的虚拟 DOM 框架(如 React),但在对性能更为敏感的场景中选择性地应用基于 push-based 的模型,那么这种方法就非常有用。

在这篇文章中,我不会重述 signals 本身的细节,也不会讨论细粒度响应式等更微妙的话题,但我会假设我们将使用响应式系统。

注意:在谈论什么是“响应式”时,有很多细微差别。我的目标是将 React 与后 React 框架进行对比,特别是 Solid、“runes” 模式下的 Svelte v5 和 Vue Vapor

克隆 DOM 树

长期以来,JavaScript 框架的集体智慧都认为,渲染 DOM 的最快方法是单独创建和加载每个 DOM 节点。换句话说,您可以使用 createElementsetAttributetextContent 等 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,然后在整个 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> 这样的东西不会自动开始下载任何东西。

与手动 DOM API 相比,速度有多快?下面是一个小型基准测试。根据 Tachometer 的报告,克隆技术在 Chrome 浏览器中的运行速度大约快 50%,在 Firefox 浏览器中快 15%,在 Safari 浏览器中快 10%(这将根据 DOM 大小和迭代次数的不同而有所变化,但你可以大致有个了解)。

有趣的是,<template> 是一种新的浏览器 API,在 IE11 中不可用,最初是为 Web 组件设计的。有点讽刺的是,这种技术现在被用于各种 JavaScript 框架,无论它们是否使用 Web 组件。

注意:以下是 SolidVue VaporSvelte v5 中的 <template> 上使用 cloneNode 的用法,以供参考。

这种技术有一个主要的挑战,那就是如何在不破坏 DOM 状态的情况下高效更新动态内容。我们稍后将在构建玩具框架时介绍这一点。

现代 JavaScript API

我们已经遇到了一个能提供很大帮助的新 API,那就是 <template>。另一个正在稳步流行的 API 是 Proxy,它可以让响应式系统的构建变得更加简单。

当我们构建玩具示例时,我们也将使用标记模版字面量(Tagged Template Literals),简单来说它可以让我们用另一种方式进行函数调用,来创建这样的 API:

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

并非所有框架都使用这一方式,但值得关注的包括 Lit、HyperHTMLArrowJS。标记模版字面量可以使构建符合人体工程学的 HTML 模板 API 变得更加简单,而无需编译器。

步骤一:构建响应式

响应式是我们构建框架其余部分的基础。响应式将定义如何管理状态,以及状态发生变化时 DOM 如何更新。

让我们从一些"梦想代码"开始,来说明我们想要什么:

const state = {}

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

基本上,我们需要一个名为 state 的 "神奇对象",它有两个道具(props):ab

假设我们事先不知道 props(或编译器无法确定 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
  }
})

现在,除了给我们提供一些 onGetonSet 钩子外,我们的 Proxy 并没有做任何有趣的事情。因此,我们要让它在微任务之后刷新更新:

let queued = false
function onSet(prop, value) {
  if(!queued) {
    queued = true
    queueMicrotask(() => {
      queued = false
      flush()
    })
  }
}
注意:如果您不熟悉 queueMicrotask,它是一种较新的 DOM API,与 Promise.resolve().then(…) 基本相同,但输入量较少。

为什么要刷新更新?主要是因为我们不想进行过多的计算。如果每当 ab 都发生变化时就更新,那么我们就会无用地计算两次总和。通过将刷新合并到一个微任务中,我们可以提高效率。

接下来,让 flush 更新总和:

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

这很好,但还不是我们的 "梦想代码"。我们需要实现 createEffect,以便仅当 ab 发生变化时(而不是当别的东西发生变化时!)才会计算总和。

为此,我们需要一个对象来跟踪哪些 props 需要运行哪些效果(effects):

const propsToEffects = {}

接下来就是关键的部分了!我们需要确保我们的 effects 可以订阅正确的 props。为此,我们将运行 effect,记录下它进行的任何 get 调用,并在 prop 和 effect 之间创建映射。

请记住,我们的 "梦想代码 "是:

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

当该函数运行时,它会调用两个 getter :state.astate.b。这些 getter 会触发响应式系统,使其注意到该函数依赖于两个 props。

为了实现这一点,我们将从一个简单的全局开始,以跟踪 "当前" effect 是什么:

let currentEffect

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

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

这里最重要的一点是,effect 会被立即调用,并提前设置全局 currentEffect 。这样我们就可以跟踪 effect 可能调用的任何 getter。

现在,我们可以在代理 Proxy 中实现 onGet,它将设置全局 currentEffect 和属性之间的映射:

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

运行一次后,propsToEffects 应该是这样的:

{
  "a": [theEffect],
  "b": [theEffect] 
}

其中,theffect 是我们要运行的 "求和 "函数。

接下来,我们的 onSet 将把需要运行的 effects 添加到 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.astate.b,只要其中一个发生变化,state.sum 就会更新。

const propsToEffcts = {}
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
})

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

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

现在,还有很多高级案例,我们在这里就不一一介绍了:

  1. 在 effect 出错时使用try/catch
  2. 避免重复运行同一 effect
  3. 防止无限循环
  4. 在后续运行中向新 props 订阅 efftcts(例如,如果某些 getter 只在 if 代码块中调用)

不过,这些对于我们的玩具示例来说已经足够了,让我们继续进行 DOM 渲染。

步骤二:DOM 渲染

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

不过,在某些时候,我们的 JavaScript 框架需要将一些 DOM 实际呈现到屏幕上。(这也是关键所在)。

在本节中,让我们暂时忘掉响应性,想象一下我们只是在尝试构建一个函数,它可以:1)构建 DOM 树;2)高效地更新 DOM 树。

再次,让我们从一些“梦想代码”开始:

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

正如我所提到的,我使用标记模版字面量(ala Lit),因为我发现这是一种无需编译器就能编写 HTML 模板的好方法。(稍后我们将看到为什么我们需要编译器)。

我们将重复使用之前的 state 对象,这次将使用 colortext 属性。状态可能如下:

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

当我们将该 state 传递给 render 时,它应该返回应用了该状态的 DOM 树:

<div class="blue">Blue!</div>

不过,在进一步了解之前,我们需要对标记模版字面量进行一个简单的入门。我们的 html 标签只是一个函数,它接收两个参数:tokens(静态 HTML 字符串数组)和 expressions(计算的动态表达式):

function html(tokens, ...expressions) {
  console.log(tokens)
  console.log(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>` // Different from above

我们可以通过使用 WeakMap 来保存 tokens 数组与生成 template 的映射来充分利用这一点:

const tokensToTemplate = new WeakMap()

function html(tokens, ...expressions) {
  let template = tokensToTemplate.get(tokens)
  if(!template) {
    // ...
    template = parseTemplate(htmlString)
    tokensToTemplate.set(tokens, tempalte)
    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) {
  elements.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!' }))

这招管用!

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!' }))

image-20231204154727467.png

步骤三:将响应式与 DOM 渲染相结合

由于我们已经有了上面渲染系统中的 createEffect,现在可以将两者结合起来,根据状态更新 DOM:

const container = document.getElementById('container')

createEffect(() => {
  const dom = render(state)
  if(container.firstElementChild) {
    container.firstElementChild.replaceWith(dom)
  } else {
    container.appendChild(dom)
  }
})

这实际上是可行的!我们可以将其与响应式部分的"求和"示例结合起来,只需创建另一个 effect 来设置 text 即可:

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

这将渲染 "Sum is 3":

image-20231204155344921.png

你可以试试这个玩具示例,如果设置 state.a = 5,文本就会自动更新为 "Sum is 7"。

下一步工作

我们可以对这个系统进行很多改进,尤其是 DOM 渲染部分。

最值得注意的是,我们缺少一种方法来更新深层 DOM 树中元素的内容,例如:

<div class="${color}">
    <span>${text}</span>
</div>

为此,我们需要一种方法来唯一标识模板内的每个元素。有很多方法可以做到这一点:

  1. Lit 在解析 HTML 时,会使用正则表达式和字符匹配系统来确定占位符是否位于属性或文本内容中,以及目标元素的索引(按 TreeWalker 深度优先顺序)。
  2. Svelte 和 Solid 等框架可以在编译过程中解析整个 HTML 模板,从而提供相同的信息。它们还会生成调用 firstChildnextSibling 的代码,以遍历 DOM 找到要更新的元素。
注意:使用 firstChildnextSibling 遍历与 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)需要设置,以及在哪里找到 expressions 来替换存根。

下一步是避免每次都克隆模板,而是直接根据 expressions 更新 DOM。

换句话说,我们不仅希望解析一次,还希望只克隆和设置绑定一次。这将把每次后续更新减少到最少的 setAttributetextContent 调用。

注意:您可能会问,如果我们最终还是需要调用 setAttributetextContent,那么克隆模板有什么意义呢?答案是,大多数 HTML 模板基本上都是静态内容,只有少数几个动态 "漏洞"。通过使用模板克隆,我们可以克隆 DOM 的绝大部分内容,同时只为 "漏洞"做额外的工作。这就是该系统运行良好的关键所在。

另一种有趣的实现模式是迭代(或中继器),它也有自己的挑战,比如在更新之间协调列表,以及处理 "键"以实现高效替换。

不过我累了,这篇博文也写得够长了。所以我把剩下的内容留给读者练习!

结论

就是这样。在一篇(冗长的)博文中,我们实现了自己的 JavaScript 框架。您可以将此作为您全新 JavaScript 框架的基础,向全世界发布,让 Hacker News 的读者们大开眼界。

我个人觉得这个项目很有教育意义,这也是我最初做这个项目的部分原因。我还想用一个更小、更定制化的解决方案来替换 my emoji picker component 的现有框架。在这个过程中,我成功地编写了一个很小的框架,它通过了所有现有的测试,而且比当前的实现小 6kB,我为此感到非常自豪。

未来,我认为如果浏览器 API 的功能足够齐全,那么构建自定义框架就会变得更加容易。例如,DOM Part API 提案将省去我们上面构建的 DOM 解析和替换系统的大量繁琐工作,同时也为潜在的浏览器性能优化打开了大门。我还可以想象(胡乱比划一番),Proxy 的扩展可以让我们更轻松地构建一个完整的响应式系统,而无需担心刷新、批处理或周期检测等细节问题。

如果所有这些都到位了,那么你就可以想象自己实际上拥有了一个 "浏览器中的 Lit",或者至少是一种快速构建自己的 "浏览器中的 Lit "的方法。同时,我希望这个小练习有助于说明框架作者所考虑的一些事情,以及你最喜欢的 JavaScript 框架背后的一些机制。

感谢 Pierre-Marie Dartus 对本文草稿的反馈意见。

原文参考:https://nolanlawson.com/2023/12/02/lets-learn-how-modern-java...


破晓L
2.1k 声望3.6k 粉丝

智慧之子 总以智慧为是