大家好,我是「柒八九」。一个「专注于前端开发技术/Rust
及AI
应用知识分享」的Coder
。
前言
不知道大家看到过上面的图没,它被国内技术媒体传的沸沸扬扬,无端中又挑起了「框架之争」。随后各路主角粉墨登场。「你方唱罢我登场」。
由于见证过太多框架的起起伏伏,鄙人认为「框架它写的再好,也只是你手中的兵器」。我们不应该「为器所困」,我们应该作为兵器的主人,在战场上所向披靡。
就像我们的总设计师邓小平所说- 「黄猫、黑猫,只要捉住老鼠就是好猫」。
但是,作为一个被React
浸染多年的开发者而言,从心底里发出感叹,React
的心智模式真的很大,对于新手来说需要克服的东西还真不少。虽然它作出了很多优化的措施,但是它前期的设计思路已经禁锢它接下来的路。就像尤雨溪
所说,不否认早期的React
在对前端框架的设计有一种创造性的突破,但是随着浏览器各项原生API
的突破,让我们在设计前端框架时候,有了另外的可能。
还有一点需要说明,其实在上面我们提到的各种前端框架中,在它们官网的显眼位置都会标榜自己是响应式(Reactive
)的。提到响应式
就不得不提响应式编程(Reactive Programming
)
❝Reactive Programming
is a declarative programming paradigm built on data-centric event emitters. 响应式编程是一种基于以「数据为中心」的事件发射器构建的「声明式编程范式」。
声明式编程范式
意味着代码描述行为而不是如何实现它。常见的示例是HTML
/Template
,我们可以在其中描述将看到的内容,而不是如何更新它。以数据为中心的事件发射器
。响应式系统的关键是「参与者就是数据」。每条数据负责发出自己的事件,以在其值发生更改时通知其订阅者。有许多不同的方法可以实现这一点,但核心始终是这种「以数据为中心的事件发射器」。
❞
按照上面的定义,我们来套入React
框架中。
React
组件由状态驱动,setState
调用有点像数据事件。而React
的Hooks
和JSX
基本上都是声明式的。从表面上看,React
就是响应式编程的一种实现。
只有一个关键区别,React
将「数据事件与组件更新解耦」。中间有一个调度程序(Scheduler[1])。我们可能会setState
十几次,React
会注意到哪些组件已计划更新,但是在准备实际更新之前上面的更新是不会被触发的。
所以,从这点来看React
其实不是响应式的。但是,但是,但是,React
为我们做了很多事情,让我们最后的效果是能达到数据的监听和处理。「所以,React
是不是响应式不是我们关心的重点」。
所以现在的各种前端框架,从对数据的响应性(Reactivity
)的实现可以分为两大模型。
- 「拉取型」 - 典型代表
React
, 数据事件与组件更新解耦,它需要在特定的事件触发后,数据才会流向它需要到的地方,并且触发指定的DOM更新 - 「推送型」 - 典型代表
Vue/Solid/Svelte
基于信号和自动跟踪计算。
这篇文章,我们不讲哪类框架孰好孰坏,我们来讲讲。如果给你一个命题- 设计一个微型的前端框架应该具有哪些特征。
好了,天不早了,干点正事哇。
我们能所学到的知识点
❝
- 现代JavaScript框架
- 第一步:构建响应式
- 第二步:构建渲染引擎
- 第三步:响应系统+渲染引擎
1. 现代JavaScript框架
各个框架下载量对比图
由于历史原因和广泛的社区支持,React[2]在现今前端开发中还是有着举足轻重的地位。就像前言
中提到的一样,React
它的设计思路和方式已经和当今比较主流的前端框架有了些许的不同。即Lit[3]、Solid[4]、Svelte[5]、Vue[6]等,同时我们也可以为它们起一个高端大气上档次的名字 -- 「后React时代的框架」。
为何将上面的框架定义为后React时代的框架
。原因也不复杂,第一个吃螃蟹的人,总是会有领域的话语权和规范的定义权。也可以认为这是一种标准。React
在前端领域占主导地位已经如此之久,以至于每个较新的框架都在它的影响下成长。这些框架深受React
启发,但都以相似的方式从React
演变出去。尽管React
本身继续进行创新,但后React时代
的框架之间更加相似,而不是与React
相似。
后React时代的框架
都在特定的实现中达到令人发指的统一:
- 基于Proxy[7]实现响应式(
Reactivity
)进行「DOM更新」 - 使用克隆的
<template>
进行「DOM渲染」。
可能有些框架的内核不是完全按照上述的规范去做的,但是它们的大体架构都是基于上面的设计理念去完成的。也就是说并非所有框架都使用Proxy
来实现响应式。总体而言,大多数框架作者似乎都同意上述理念,或者正在朝这个方向发展。
因此,如果我们要实现自己的框架设计的话,上面的设计准则是需要遵守的。按照上面的顺序,我们首先需要着手解决的就是如何实现响应式
。
响应式
前言
中,我们说过React
其实不是响应式框架。这意味着React
是一个「拉取型而不是推送型的模型」。简单来说:在最坏的情况下,React
假设我们的整个虚拟DOM树
需要「从头开始重建」,防止这些全局更新的唯一方法是实现React.memo
/shouldComponentUpdate
。
使用虚拟DOM
可以缓解「全部抹掉重新开始」策略的一些成本,但并不能完全解决此问题。React
为我们提供React.memo/shouldComponentUpdate
方法,但是要求开发人员编写正确的memo
代码就是一场豪赌。毕竟,给别人太大的权限,反而让机器
不能按既定的路线运行。
相反,现代框架使用「基于推送的响应式模型」。在此模型中,组件树的各个部分订阅状态更新,并且「仅在相关状态发生变化时更新DOM」。
克隆DOM树
createElement
长期以来,在实现JS框架时有一个不成文的规定,「渲染DOM最快的方法是逐个创建和安装每个DOM节点」。换句话说,我们使用像createElement
、setAttribute
和textContent
这样的API逐步构建DOM:
const div = document.createElement('div')
div.setAttribute('class', 'blue')
div.textContent = '前端柒八九!'
innerHTML
另一种替代方法是直接将一个大的HTML字符串塞入innerHTML
,并让浏览器解析它:
const container = document.createElement('div')
container.innerHTML = `
<div class="blue">前端柒八九!</div>
`
使用innerHTML
有一个缺点:如果我们的HTML中有「任何动态内容」(例如,div 内容由front789
变成前端柒八九
),那么我们需要「反复解析HTML字符串」。而且,「每次更新都会重置DOM状态」,比如<input>
的value
值。
并且,使用innerHTML
也存在安全隐患,也就是我们常说的Cross-site scripting
(XSS
)。(这块也是网络安全的重要的一环)
对template进行clone处理
另外一种替代方案就是对template
进行cloneNode(true)
。这种方案其实算是innerHTML
的优化版本。
const template = document.createElement('template')
template.innerHTML = `
<div class="blue">前端柒八九!</div>
`
template.content.cloneNode(true) // 这样很快!
这里我使用了<template>
标签,这有助于创建「惰性DOM」。换句话说,像<img>
或<video autoplay>
不会自动开始下载任何内容。
有趣的是,<template>
是一个比较新的浏览器API,在IE11
中不可用,最初是为Web Components[8]而设计的。也不知道大家对Lit
是否了解,它就是基于Web Components
创建的前端框架。还有一点可能大家会感到意外,现在主流的前端框架中都使用了template
的cloneNode
来处理DOM。
Solid内部实现
Vue Vapor内部实现
Svelte v5 内部实现
大家可以参考以下的链接自行查阅
- Solid内部实现[9]
- Vue Vapor内部实现[10]
- Svelte v5 内部实现[11]
这种技术有一个主要挑战,即如何在不重置DOM状态的情况下有效更新动态内容。
2. 第一步:构建响应式
❝响应式
是我们构建前端框架的基础。 ❞
响应式
将定义「状态的管理方式」,以及「状态更改时DOM的更新方式」。
伪代码
让我们用伪代码来描述一下,我们想要达到的目的。
const state = {}
state.a = 1
state.b = 2
createEffect(() => {
state.sum = state.a + state.b
})
- 起初定义了一个空
state
对象,该对象用于记录当前应用的状态信息(这也是state
的名称的由来)。之所以是空对象,说明我们应用刚开始是莽荒时代,一穷二白,啥都没有 state.a =1/state.b=2
代表在应用的交互阶段,有数据的变更处理createEffect
该方法表示,如果有特定数据的变更,我们希望框架会为我们执行针对特定数据的操作,上面的操作是当state.a/state.b
,无论这两个属性如何改变,我们都希望将sum
设置为两者的和。
我们有了既定的诉求,那就需要朝着这个方向前进。由于,我们在实际操作过程中是无法知晓到底是哪些属性需要跟踪,对于框架来说,一切都是未知的,我们不知道属性名,那么如果还是用普通对象来维护state
的话,就无法达到我们的目的。
Proxy包装state
所以,我们需要对state
做一次改造,我们使用Proxy
,它可以在设置/读取
对应属性时候,能够拿到更多详细的属性信息(obj/prop/value
)。
❝如果大家了解Vue
的话,是不是会感觉更熟悉。
- 在
Vue 2.x
中,通过Object.defineProperty
来监听对象属性的变化,从而实现数据的响应式; - 而在
Vue 3.x
中,Vue
使用了JavaScript
的Proxy
对象来替代Object.defineProperty
,以提供更强大和灵活的响应式系统。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
改造state
的同时,我们还预留了onGet/onSet
钩子,用于监听当state
的值被读取
或者设置
时,触发相应的操作。
处理onSet()
接下来,让我们先来完善onSet()
,我们要达到两个要求
- 利用
防抖
对操作进行优化,让其不会频繁触发 - 利用
微任务
让更新操作更快的发生(在此次事件循环中被执行)
那按照上面的指导思路,我们可以构建如下的onSet()
- 在全局定义一个
queued
用于标记是否已经进入队列,这步用于防抖处理
- 在
onSet
内部,先检查queued
变量,如果为false
,表示现在没有入队
- 利用queueMicrotask[12]将后续逻辑(
flush()
真正执行值变更的操作)放入微任务队列
queueMicrotask
是一个较新的DOM API,基本上与Promise.resolve().then(...)
相同,但更简洁- 我们将更新逻辑合并到一个微任务中,因为我们不想运行太多计算。如果我们在a和b都改变时更新,那么我们就会无谓地计算两次和。
let queued = false
function onSet(prop, value) {
if (!queued) {
queued = true
queueMicrotask(() => {
queued = false
flush()
})
}
}
function flush() {
state.sum = state.a + state.b
}
记录当前的操作(currentEffect
)
在处理完onSet
后,其实我们已经能够达到当a
和b
的值发生变化后,计算sum
的功能,但是呢,由于我们没有对state.x
进行白名单处理
,也就意味着,当我们执行了非a/b
值的赋值时,也会进行sum
的计算。我们希望的是仅在a
和b
改变时计算sum
(而不是其他东西改变时)
为此,我们「使用一个对象来跟踪哪些effect
需要为哪些属性运行」:
const propsToEffects = {}
接下来是最重要的部分,不要分神,咬咬牙,马上就好。乖。
我们需要「确保effect
可以订阅正确的属性」。为此,我们会运行effect
,并且需要特别注意在effect
中出现的「任何get调用」,并在属性
和effect
之间创建映射关系。
我们之前的伪代码中有如下的操作。
createEffect(() => {
state.sum = state.a + state.b
})
当这个函数运行时,它调用了state
对象中的两个getter
:state.a
和state.b
。这些getter
应该触发响应系统,并且需要有方式去记录函数依赖这些属性(a/b
)。
我们可以为这个函数起一个统一的名称effect
,也就是effect
代表着我们需要处理的数据操作。如果看过React
源码解析的同学,是不是对effect
的这个概念很熟悉,它就代表着由于一些数据变更,引起的其他数据的震动
(CRUD
)的操作。
❝如果对React
实现原理感兴趣的话,可以参考之前的文章。
❞
为实现这一点,我们定义一个全局变量currentEffect
,用于跟踪当前
effect是什么:
let currentEffect
然后,createEffect
函数会在调用函数之前
设置此全局变量,为了让我们的程序更有健壮性,因为我们无法预知用户提供的effect
是否功能完备,所以我还可以使用try/catch
进行错误的收集和拦截。
function createEffect(effect) {
currentEffect = effect
try {
effect();
} catch (error) {
throw new Error(`Effect 初始化错误:${error}`)
}
currentEffect = undefined
}
我们在effect()
被执行之前,利用currentEffect
来记录当前更新操作的信息。这样我们就可以在state
中getter
被触发时,能够通过currentEffect
能够建立 props
和effect
之间的映射关系。
onGet()中建立props和effect的映射
function onGet(prop) {
const effects = propsToEffects[prop]
?? (propsToEffects[prop] = new Set());
effects.add(currentEffect);
}
当上述的effect
被执行后,propsToEffects
中就会记录下属性和effect
直接的关系。
看起来是不是有点魔力所在,其实一切的魔力都在JS
的Set
类型是一个引用类型,我们通过propsToEffects[prop]
返回了一个集合(空/有值
)给effects
,借用传统OOP
语言的概念,effects
是一个指向堆内存
地址的「指针」。对其上面的任何操作,都是在原数据上处理的。
还有一点需要说明,上面我们使用了Set
来存储effect
。这样做可以杜绝我们将同一个effect
多次添加进来,这样在后面真正根据propsToEffects[prop]
执行相关的effect
时,就不会发生相同的函数触发多次的情况。
让我们用代码简单解释一下。
// 0. 定义一个effect函数
const effect = () => {
state.sum = state.a + state.b
}
// 1. 我们使用Array来跟踪effect
// 调用2次createEffect
createEffect(effect)
createEffect(effect)
// 此时的propsToEffects
{
a: [ƒ, ƒ]
b: [ƒ, ƒ]
}
====华丽的分割线==========
// 2. 我们使用Set来跟踪effect
// 调用2次createEffect
createEffect(effect)
createEffect(effect)
// 此时的propsToEffects
{
a: Set(f)
b: Set(f)
}
当effect
触发时,按照我们的例子来讲()=>{state.sum = state.a + state.b}
触发时候,它首先会触发state
的两个getter
:state.a
和state.b
,随后在onGet(props)
中我们就可以收集props
(a/b
)和effect
直接的映射关系。
那我们最终就会得到如下的propsToEffects
数据结构:
{
"a": Set(sum函数),
"b": Set(sum函数)
}
改造onSet()
既然props
和effect
的映射关系已经有了,当在触发setter:x
时,我们就需要根据将与x
相关的effect
收集起来,然后在一个合适的时机统一执行。
根据我们的示例来讲。通过createEffect(()=>{state.sum = state.a + state.b})
我们已经建立了x
(a/b
)与sum函数
之间的映射关系。
然后,在后续任何x
的变更,按照我们预想,应该会触发sum函数
,然后执行指定的更新操作。
这个数据收集的信息,就是发生在x
(a/b
)的赋值阶段,也就是我们可以在state
的set
中进行处理。
我们可以通过一个全局变量dirtyEffects
(也是Set
类型)来记录待会需要发生的操作。(dirtyEffects
是不是也很熟悉,在React
的更新时,也有类似的变量信息)
在onSet
将「即将」需要运行的effect
添加到一个dirtyEffects
集合中:
const dirtyEffects = new Set();
function onSet(prop, value) {
if (propsToEffects[prop]) {
propsToEffects[prop].forEach((effect) => {
dirtyEffects.add(effect);
});
// ...
}
}
在刷新阶段执行effect
上面的代码中,我们将刷新操作放置在了flush
里面。现在既然可以在全局dirtyEffects
存储了effect
,那么我们在flush
中执行与更新相关的操作。
在编写flush
之前,我们先额外讲讲「无限循环」的情况。
假设我们有以下代码段:
createEffect(() => {
console.count('state.a值变化了:', state.a);
state.a++;
});
上面示例代码中,effect
依赖于 state.a
的值。当 state.a
改变时,这个effect
会重新执行。然而,这个effect
在执行的过程中也修改了 state.a
的值。这会导致它自己被再次触发,因为它所依赖的状态发生了变化。这个过程会不断重复,因为每次效果执行时,它都会改变 state.a
的值,从而导致自己再次被触发。结果就是一个无限循环。
所以,我们需要杜绝上面的情况发生,在我们的代码中,我们采用了基于「运行次数限制」的循环退出条件。这样就可以反正无限循环发生。同时,我们使用WeakMap[13]来记录执行的次数。
const effectRunCounts = new WeakMap();
function flush() {
dirtyEffects.forEach((effect) => {
const count = effectRunCounts.get(effect) ?? 0;
if (count < 100) {
// 防止无限循环,限制最大运行次数
effectRunCounts.set(effect, count + 1);
try {
effect();
} catch (error) {
throw new Error(`Effect error:${error}`)
}
}
});
dirtyEffects.clear();
}
代码运行
我们将上面的代码做一下汇总,这样我们就拥有一个根据特定规则自动响应值变化的响应系统。
const propsToEffects = {};
const dirtyEffects = new Set();
const effectRunCounts = new WeakMap();
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] = new Set());
effects.add(currentEffect);
}
}
function onSet(prop, value) {
if (propsToEffects[prop]) {
propsToEffects[prop].forEach((effect) => {
dirtyEffects.add(effect);
});
if (!queued) {
queued = true;
queueMicrotask(() => {
queued = false;
flush();
});
}
}
}
function flush() {
dirtyEffects.forEach((effect) => {
const count = effectRunCounts.get(effect) ?? 0;
if (count < 100) {
// 防止无限循环,限制最大运行次数
effectRunCounts.set(effect, count + 1);
try {
effect();
} catch (error) {
throw new Error(`Effect error:${error}`)
}
}
});
dirtyEffects.clear();
}
let currentEffect;
function createEffect(effect) {
currentEffect = effect;
try {
effect();
} catch (error) {
throw new Error(`Effect 初始化错误:${error}`)
}
currentEffect = undefined;
}
丑媳妇总是要见公婆的,让我们来验证一下我们自己的响应性系统。
// 为state赋值, 其实这步类似与react中的useState的操作。
state.a = 1
state.b = 2
// 注册值与值之间的关联(这里是sum/a/b)
const effect = () => {
state.sum = state.a + state.b
}
createEffect(effect);
//1. 验证是否可以剔除重复的effect
createEffect(effect);
console.log({ ...state }) // {a: 1, b: 2, sum: 3}
// 2. 验证是否可以杜绝无限执行
const effectLoop = () => {
console.count('执行次数')
state.a++;
}
createEffect(effectLoop);
// 3. 验证响应性
console.log('修改b为', 789)
state.b = 789
queueMicrotask(() => {
console.log({ ...state }) // {a: 1, b: 789, sum: 790}
})
自此,现在就有了属于我们的寄几个儿
的响应性系统。别看它小,但是它属于是精干型的。
3. 第二步:构建渲染引擎
通过第一步的操作我们现在有了属于自己的响应系统,从浏览器的架构角度来看,它是headless
,就像Puppeteer[14]和Chrome/Chromium
之间的关系。它可以跟踪更改和计算数据更新,但能力也仅限如此。
然而,作为一个功能完备的前端框架,我们不仅需要能探查数据之间的联动,更重要的是基于数据的变更,从而处理页面的渲染。那么,我们就来实现页面渲染的逻辑。话不多说,开搞。
先来一个基调,我们的框架有一个render
函数,可以基于state
进行DOM
的渲染。(就像其他前端框架那样)
function render(state) {
return html`
<div class="${state.color}">${state.text}</div>
`
}
正如上面的所示,我们使用了ES6
的模版标签[15],如果大家了解过Lit
的话,就不会感觉到陌生了。
模版标签
是编写HTML模板极其优雅的方式。并且会有意想不到的奇效。
上一节,我们不是对state
进行了Proxy
处理了吗。假设state
有color
和text
属性。
state.color = 'blue'
state.text = '前端柒八九!'
当我们将state
传递给render
时,它应该返回如下的DOM树:
<div class="blue">前端柒八九!</div>
让我们一步步来完成这些黑魔法。
标签函数 - html`...`
❝Tags allow you to parse template literals with a function.
- The first argument of a tag function contains an array of string values.
- The remaining arguments are related to the expressions.
❞
上面的内容是截取MDN对标签函数的解释[16]
翻译成中文就是:
❝标签允许使用函数解析模板字面量
。标签函数的「第一个参数」包含一个「字符串值数组」。「其余参数与表达式有关」。 ❞
将上面的规则代入到我们html
中就会有下面的函数签名。
function html(tokens, ...expressions) {
}
针对我们提供的示例来讲,tokens
就是如下的数据结构:
[
"<div class=\"",
"\">",
"</div>"
]
❝文中为了数据的展示方便,tokens
中的换行(\n
)和空格(
)暂时剔除掉(其实是有的,大家可自行验证) ❞
对应的expressions
格式为:
[
"blue",
"前端柒八九!"
]
将tokens和expressions数据融合处理
心细的同学可以发现。
❝标记数组
的长度总是比表达式数组
多1 ❞
所以我们可以很容易地将它们组合在一起:
const allTokens = tokens
.map((token, i) => (expressions[i - 1] ?? '') + token)
通过allTokens
将静态的tokens
和动态的expressions
融合到一起了。就会获取到下面的数组信息。
[
"<div class=\"",
"blue\">",
"前端柒八九!</div>"
]
生成HTML并插入到Template中
万事俱备,只欠东风,我们可以将这些字符串连接在一起生成HTML:
const htmlString = allTokens.join('')
然后我们可以使用innerHTML
将其解析为<template>
:
function parseTemplate(htmlString) {
const template = document.createElement('template')
template.innerHTML = htmlString
return template
}
该template
包含我们想要在页面中展示的惰性DOM(其本质上是一个DocumentFragment[17]),并且我们可以随意克隆它:
const cloned = template.content.cloneNode(true)
利用WeakMap
来保持标记数组与模板之间的映射
执行完,上面所有的流程后,我们就可以在页面中插入我们想要展示的DOM
信息了,但是上面的处理有一个弊端,那就是每次调用html
函数时都需要解析完整的HTML,这在DOM
数量少的时候还可以,但是数据大的话,就会有性能问题了。
老天有眼,模版标签
有一个内置功能,可以帮助我们解决上面的问题。
❝对于特定结构模版标签
,当函数被调用时,标记数组总是相同的。 ❞
例如,有如下的标签函数
function sayHello(name) {
return html`<div>Hello ${name}</div>`
}
❝每次调用sayHello
时,标记数组总是相同的: ❞
[
"<div>Hello ",
"</div>"
]
而下面的情况就是两个不同的情况。
html`<div></div>` html`<span></span>`
我们可以利用这一点,使用一个WeakMap
来保持「标记数组与模板之间的映射」:
const tokensToTemplate = new WeakMap()
function html(tokens, ...expressions) {
let template = tokensToTemplate.get(tokens)
if (!template) {
// ...
template = parseTemplate(htmlString)
tokensToTemplate.set(tokens, template)
}
return template
}
❝标记数组的唯一性本质上意味着我们可以「确保每个html`...`调用只解析HTML一次」。 ❞
处理expressions数组
接下来,我们只需要一种方法来使用expressions
数组(与标记不同,「每次调用时可能不同」)更新克隆的DOM节点。
占位处理
为简单起见,我们用每个索引的占位符
替换expressions
数组:
const stubs = expressions.map((_, i) => `__stub-${i}__`)
这样我们就会得到这样的HTML:
<div class="__stub-0__">
__stub-1__
</div>
expressions的值替换占位符
我们可以编写一个简单的字符串替换函数来替换占位符:
function replaceStubs (string) {
return string.replaceAll(/__stub-(\d+)__/g, (_, i) =>
expressions[i]
)
}
更新DOM信息
现在,每次调用html
函数时,我们都可以克隆模板并更新占位符:
const element = cloned.firstElementChild
for (const { name, value } of element.attributes) {
element.setAttribute(name, replaceStubs(value))
}
element.textContent = replaceStubs(element.textContent)
我们不像其他成熟的框架处理页面结构复杂的情况,这里假设我们template
就只有单个元素。也就是可以通过firstElementChild
获取到整个需要处理的dom
结构
代码运行
我们将上面的代码做一下汇总,这样我们就拥有一个小型渲染引擎。
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]
))
)
// 获取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)
}
// 处理数据
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>
`
}
我们可以将上面的代码在devTool
上运行,紧接着调用render()
,并将其生成的DOM
渲染到页面中
document.body.appendChild(
render({ color: 'blue', text: 'front789!' })
)
document.body.appendChild(
render({ color: 'red', text: '前端柒八九!' })
)
如果一切顺利的话,你就会在body
的最下面看到在html`...`中定义的DOM
结构+render()
中传人的数据信息。
4. 第三步:响应系统+渲染引擎
第一步,我们构建了响应系统,第二步,我们拥有了自己的渲染引擎。现在,就让我们将它们结合起来,创建一个真正的前端渲染框架。
我们使用之前在Rust 赋能前端-开发一款属于你的前端脚手架介绍过的f_cli
构建一个简单的前端应用。(当然也可以用一个静态页面)
然后,将我们响应系统
和渲染引擎
的代码复制到Console
标签下,或者可以将代码复制到Sources-snippet
下。
const container = document.getElementById('front789')
createEffect(() => {
const dom = render(state)
if (container.firstElementChild) {
container.firstElementChild.replaceWith(dom)
} else {
container.appendChild(dom)
}
})
在代码运行后,我们在控制台中修改state.text
的值,就会发现页面中的Dom
发生了变化了。
也就是说,我们通过100
多行,构建了一个能够响应变量变化的前端框架。
后记
Reference
[1]
Scheduler: https://github.com/facebook/react/tree/0ac3ea471fbcb7d79bc7d36179e960c72c779e76/packages/scheduler
[2]
React: https://react.dev/
[3]
Lit: https://lit.dev/
[4]
Solid: https://www.solidjs.com/
[5]
Svelte: https://svelte.dev/
[6]
Vue: https://vuejs.org/
[7]
Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
[8]
Web Components: https://developer.mozilla.org/en-US/docs/Web/API/Web_components
[9]
Solid内部实现: https://github.com/ryansolid/dom-expressions/blob/998e60384e31dc335290299e78f19e995f828b07/packages/dom-expressions/src/client.js#L75
[10]
Vue Vapor内部实现: https://github.com/vuejs/core-vapor/blob/42d2f3dd9876c1c5f898c6507df1a845c7045d35/packages/runtime-dom/src/nodeOps.ts#L73
[11]
Svelte v5 内部实现: https://github.com/sveltejs/svelte/blob/7f237c2e41115b420f0d6432c51c85ec3b5ecaf5/packages/svelte/src/internal/client/reconciler.js#L101
[12]
queueMicrotask: https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask
[13]
WeakMap: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
[14]
Puppeteer: https://pptr.dev/
[15]
模版标签: https://es6.ruanyifeng.com/#docs/string#%E6%A0%87%E7%AD%BE%E6%A8%A1%E6%9D%BF
[16]
MDN对标签函数的解释: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates
[17]
DocumentFragment: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment