2024新年礼物-写一个前端框架

JavaScript/前端
275
0
0
2024-04-09

大家好,我是「柒八九」。一个「专注于前端开发技术/RustAI应用知识分享」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 调用有点像数据事件。而ReactHooksJSX基本上都是声明式的。从表面上看,React就是响应式编程的一种实现。

只有一个关键区别,React「数据事件与组件更新解耦」。中间有一个调度程序(Scheduler[1])。我们可能会setState十几次,React 会注意到哪些组件已计划更新,但是在准备实际更新之前上面的更新是不会被触发的。

所以,从这点来看React其实不是响应式的。但是,但是,但是,React为我们做了很多事情,让我们最后的效果是能达到数据的监听和处理。「所以,React是不是响应式不是我们关心的重点」

所以现在的各种前端框架,从对数据的响应性(Reactivity)的实现可以分为两大模型。

  1. 「拉取型」 - 典型代表React, 数据事件与组件更新解耦,它需要在特定的事件触发后,数据才会流向它需要到的地方,并且触发指定的DOM更新
  2. 「推送型」 - 典型代表 Vue/Solid/Svelte 基于信号和自动跟踪计算。

这篇文章,我们不讲哪类框架孰好孰坏,我们来讲讲。如果给你一个命题- 设计一个微型的前端框架应该具有哪些特征。

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 现代JavaScript框架
  2. 第一步:构建响应式
  3. 第二步:构建渲染引擎
  4. 第三步:响应系统+渲染引擎

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节点」。换句话说,我们使用像createElementsetAttributetextContent这样的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创建的前端框架。还有一点可能大家会感到意外,现在主流的前端框架中都使用了templatecloneNode来处理DOM。

Solid内部实现

Vue Vapor内部实现

Svelte v5 内部实现

大家可以参考以下的链接自行查阅

  1. Solid内部实现[9]
  2. Vue Vapor内部实现[10]
  3. 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 使用了 JavaScriptProxy 对象来替代 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(),我们要达到两个要求

  1. 利用防抖对操作进行优化,让其不会频繁触发
  2. 利用微任务让更新操作更快的发生(在此次事件循环中被执行)

那按照上面的指导思路,我们可以构建如下的onSet()

  1. 在全局定义一个queued用于标记是否已经进入队列,这步用于防抖处理
  • onSet 内部,先检查 queued 变量,如果为 false,表示现在没有入队
  1. 利用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后,其实我们已经能够达到当ab的值发生变化后,计算sum的功能,但是呢,由于我们没有对state.x进行白名单处理,也就意味着,当我们执行了非a/b值的赋值时,也会进行sum的计算。我们希望的是仅在ab改变时计算sum(而不是其他东西改变时)

为此,我们「使用一个对象来跟踪哪些effect需要为哪些属性运行」:

const propsToEffects = {}

接下来是最重要的部分,不要分神,咬咬牙,马上就好。乖。

我们需要「确保effect可以订阅正确的属性」。为此,我们会运行effect,并且需要特别注意在effect中出现的「任何get调用」,并在属性effect之间创建映射关系。

我们之前的伪代码中有如下的操作。

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

当这个函数运行时,它调用了state对象中的两个getter:state.astate.b。这些getter应该触发响应系统,并且需要有方式去记录函数依赖这些属性(a/b)。

我们可以为这个函数起一个统一的名称effect,也就是effect代表着我们需要处理的数据操作。如果看过React源码解析的同学,是不是对effect的这个概念很熟悉,它就代表着由于一些数据变更,引起的其他数据的震动(CRUD)的操作。

❝如果对React实现原理感兴趣的话,可以参考之前的文章。
  1. React_Fiber机制
  2. React_Fiber机制(下)

为实现这一点,我们定义一个全局变量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来记录当前更新操作的信息。这样我们就可以在stategetter被触发时,能够通过currentEffect能够建立 propseffect之间的映射关系。

onGet()中建立props和effect的映射

function onGet(prop) {
  const effects = propsToEffects[prop] 
  ?? (propsToEffects[prop] = new Set());
  effects.add(currentEffect);
}

当上述的effect被执行后,propsToEffects中就会记录下属性和effect直接的关系。

看起来是不是有点魔力所在,其实一切的魔力都在JSSet类型是一个引用类型,我们通过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.astate.b,随后在onGet(props)中我们就可以收集props(a/b)和effect直接的映射关系。

那我们最终就会得到如下的propsToEffects数据结构:

{
  "a": Set(sum函数),
  "b": Set(sum函数)
}

改造onSet()

既然propseffect的映射关系已经有了,当在触发setter:x时,我们就需要根据将与x相关的effect收集起来,然后在一个合适的时机统一执行。

根据我们的示例来讲。通过createEffect(()=>{state.sum = state.a + state.b})我们已经建立了xa/b)与sum函数之间的映射关系。

然后,在后续任何x的变更,按照我们预想,应该会触发sum函数,然后执行指定的更新操作。

这个数据收集的信息,就是发生在x(a/b)的赋值阶段,也就是我们可以在stateset中进行处理。

我们可以通过一个全局变量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处理了吗。假设statecolortext属性。

state.color = 'blue'
state.text = '前端柒八九!'

当我们将state传递给render时,它应该返回如下的DOM树:

<div class="blue">前端柒八九!</div>

让我们一步步来完成这些黑魔法。

标签函数 - html`...`

❝Tags allow you to parse template literals with a function.
  1. The first argument of a tag function contains an array of string values.
  2. The remaining arguments are related to the expressions.

上面的内容是截取MDN对标签函数的解释[16]

翻译成中文就是:

❝标签允许使用函数解析模板字面量。标签函数的「第一个参数」包含一个「字符串值数组」「其余参数与表达式有关」。 ❞

将上面的规则代入到我们html中就会有下面的函数签名。

function html(tokens, ...expressions) {
}

针对我们提供的示例来讲,tokens就是如下的数据结构:

[
  "<div class=\"",
  "\">", 
  "</div>"
]
❝文中为了数据的展示方便,tokens中的换行(\n)和空格(&nbsp;)暂时剔除掉(其实是有的,大家可自行验证) ❞

对应的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