Vue.js 原理分析

本文内容提炼于《Vue.js设计与实现》,全书共 501 页,对 Vue.js 的设计原理从 0 到 1,循序渐进的讲解。

篇幅比较长,需要花些时间慢慢阅读,在合适的位置会给出在线示例以供调试。

一、概览

Vue.js 是一款声明式框架,注重结果;早年间流行的 jQuery 是典型的命令式框架,注重过程。命令式的代码需要维护实现目标的整个过程,例如手动完成 DOM 元素的创建、更新、删除等工作。

Vue.js 帮我们封装了过程, 其内部是命令式的实现,而暴露给用户的则是声明式。虽然声明式代码的性能劣于命令式,但是可维护性更强,其更新性能公式如下。

复制代码
声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

虚拟 DOM,就是为了公式中找出差异的性能消耗而出现的。虽然其更新性能理论上不可能比原生的 JavaScript 直接操作 DOM 更高,但是它能保证应用程序的性能下限而不至于太差。

虽然在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时会多出一个 Diff 的性能消耗,但是它毕竟也是 JavaScript 层面的运算,所以不会产生数量级的差异。

1)架构

Vue.js 3 采用了运行时 + 编译时的架构,运行时是指用户可以直接提供数据对象从而无须编译。而编译时是指用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。

纯运行时没办法分析用户提供的内容,也就无法对内容做进一步优化。纯编译时虽然能直接编译成可执行的 JavaScript 代码(性能可能会更好),但是有损灵活性,即用户提供的内容必须编译后才能用。

2)核心要素

Vue.js 作为一款优秀的框架,其核心要素包括:

  1. 开发体验,例如提供友好的警告信息,输出更友好的信息等。
  2. 代码体积,例如支持 Tree-Shaking,构建生产环境时移除开发代码等。
  3. 特性开关,例如为框架添加新特性,支持遗留 API 等。
  4. 错误处理,例如为用户提供统一的错误处理接口。
  5. 支持TypeScript,为 JavaScript 提供类型检查等功能。

3)声明式地描述

Vue.js 3 是一个声明式的 UI 框架,即用户在使用 Vue.js 3 开发页面时可以声明式地描述 UI。

除了使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述(其实就是虚拟 DOM),而使用 JavaScript 对象描述 UI 会更加灵活。虚拟 DOM 其实就是用 JavaScript 对象来描述真实的 DOM 结构,如下所示。

复制代码
const vnode = {
  tag: "div",
  props: {
    onClick: () => alert("hello")
  },
  children: "click me"
};

4)渲染器

渲染器(renderer)的作用就是把虚拟 DOM 渲染为真实 DOM,平时编写的 Vue.js 组件都是依赖渲染器来工作的,下面是一个简单的渲染器实现。

复制代码
function renderer(vnode, container) {
  // 使用 vnode.tag 作为标签名称创建 DOM 元素
  const el = document.createElement(vnode.tag);
  // 遍历 vnode.props,将属性、事件添加到 DOM 元素
  for (const key in vnode.props) {
    if (/^on/.test(key)) {
      // 如果 key 以 on 开头,说明它是事件
      el.addEventListener(
        key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
        vnode.props[key] // 事件处理函数
      );
    }
  }
  // 处理 children
  if (typeof vnode.children === "string") {
    // 如果 children 是字符串,说明它是元素的文本子节点
    el.appendChild(document.createTextNode(vnode.children));
  } else if (Array.isArray(vnode.children)) {
    // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
    vnode.children.forEach((child) => renderer(child, el));
  }
  // 将元素添加到挂载点下
  container.appendChild(el);
}

现在所做的还仅仅是创建节点,渲染器的精髓是在更新节点的阶段,涉及 Diff 算法。

复制代码
const vnode = {
  tag: "div",
  props: {
    onClick: () => alert("hello")
  },
  children: "click again" // 从 click me 改成 click again
};

对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 API 操作 DOM 来完成渲染工作。

5)组件的本质

虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。

组件的本质就是一组 DOM 元素的封装,可以定义一个函数来代表组件,其返回值就是组件要渲染的内容。

复制代码
const MyComponent = function () {
  return {
    tag: "div",
    props: {
      onClick: () => alert("hello")
    },
    children: "click me"
  };
};

可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,就可以用虚拟 DOM 来描述组件了,用 tag 属性来存储组件函数:

复制代码
const vnode = {
  tag: MyComponent
};

为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数。

复制代码
function renderer(vnode, container) {
  if (typeof vnode.tag === "string") {
    // 说明 vnode 描述的是标签元素
    mountElement(vnode, container);
  } else if (typeof vnode.tag === "function") {
    // 说明 vnode 描述的是组件
    mountComponent(vnode, container);
  }
}

mountElement 函数与上文中 renderer 函数的内容一致。如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。

复制代码
function mountComponent(vnode, container) {
  // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
  const subtree = vnode.tag();
  // 递归地调用 renderer 渲染 subtree
  renderer(subtree, container);
}

6)模板

无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。提到模板的工作原理,那就需要讲解一下 Vue.js 中的另外一个重要组成部分:编译器。

编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同。编译器的作用其实就是将模板编译为渲染函数。

复制代码
<div @click="handler">
  click me
</div>

对于编译器来说,模板就是一个普通的字符串,它会分析上述字符串并生成一个功能与之相同的渲染函数:

复制代码
render() {
  return h('div', { onClick: handler }, 'click me')
}

以我们熟悉的 .vue 文件为例,一个 .vue 文件就是一个组件,如下所示:

复制代码
<template>
  <div @click="handler">
    click me
  </div>
</template>
<script>
  export default {
    data() {/* ... */ },
    methods: {
      handler: () => {/* ... */ }
    }
</script>

其中 <template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上,所以最终在浏览器里运行的代码就是:

复制代码
export default {
  data() {/* ... */ },
  methods: {
    handler: () => {/* ... */ }
  }
  render() {
    return h('div', { onClick: handler }, 'click me')
  }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。

7)有机整体

组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的, 因此 Vue.js 的各个模块之间是互相关联、互相制约的,共同构成一个有机整体。

我们在学习 Vue.js 原理的时候,应该把各个模块结合到一起去看,才能明白到底是怎么回事。

二、响应系统

响应系统也是 Vue.js 的重要组成部分,Vue.js 3 采用 ES6 的 Proxy 实现响应式数据。

1)副作用函数

副作用函数指执行它会直接或间接影响其他函数的执行。副作用很容易产生,例如一个函数修改了全局变量。

复制代码
// 全局变量
let val = 1
function effect() {
 val = 2 // 修改全局变量,产生副作用
}

2)响应式数据的实现

要让 obj 对象变成响应式数据,可以通过拦截它的读取和设置操作来实现。

复制代码
const obj = { text: 'hello world' }

例如当读取字段 obj.text 时,把副作用函数 effect 存储到一个桶里。在设置 obj.text 时,再把副作用函数 effect 从桶里取出并执行即可。

在 ES6 之前,只能通过 Object.defineProperty 函数实现(Vue.js 2 的实现方式)。在 ES6+ 中,可以使用代理对象 Proxy 来实现(Vue.js 3 的实现方式)。根据如上思路,采用 Proxy 来实现:

复制代码
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { text: "hello world" }; // 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用函数从桶里取出并执行
    bucket.forEach((fn) => fn());
    // 返回 true 代表设置操作成功
    return true;
  }
});

首先,创建了一个用于存储副作用函数的桶 bucket,它是 Set 类型。接着定义原始数据 data,obj 是原始数据的代理对象,我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。

当读取属性时将副作用函数 effect 添加到桶里,即 bucket.add(effect),然后返回属性值。当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行,这样我们就实现了响应式数据。

可以使用下面的代码来测试一下,或直接查看在线示例

复制代码
// 副作用函数
function effect() {
  document.body.innerText = obj.text;
}
// 执行副作用函数,触发读取
effect();
// 1 秒后修改响应式数据
setTimeout(() => {
  obj.text = "hello vue3";
}, 1000);

但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。因此我们要想办法去掉这种硬编码的机制。

3)完善的响应式系统

首先需要提供一个注册副作用的函数,来解决硬编码的问题。

复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}

可以按照如下所示的方式使用 effect 函数:

复制代码
effect(
  // 一个匿名的副作用函数
  () => {
    document.body.innerText = obj.text;
  }
);

当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn, 这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数:

复制代码
const obj = new Proxy(data, {
  get(target, key) {
    // 将 activeEffect 中存储的副作用函数收集到"桶"中
    if (activeEffect) {            // 新增
      bucket.add(activeEffect);    // 新增
    }                              // 新增
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach((fn) => fn());
    return true;
  }
});

但如果我们再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:

复制代码
effect(
  // 匿名副作用函数
  () => {
    console.log("effect run"); // 会打印 2 次
    document.body.innerText = obj.text;
  }
);
setTimeout(() => {
  // 副作用函数中并没有读取 notExist 属性的值
  obj.notExist = "hello vue3";
}, 1000);

在匿名副作用函数内并没有读取 obj.notExist 属性的值,所以理论上,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数的重新执行。

但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。注意,在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的"桶"。

导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到"桶"里。

将属性与副作用函数之间建立一种树型数据结构,联系建立起来之后, 就可以解决前文提到的问题。

拿上面的例子来说,如果我们设置了 obj.text2 的值,就只会导致 effectFn2 函数重新执行,并不会导致 effectFn1 函数重新执行。

复制代码
target
└── text1
    └── effectFn1
└── text2
    └── effectFn2

接下来我们尝试用代码来实现这个新的"桶"。首先,需要使用 WeakMap 代替 Set 作为桶的数据结构:

复制代码
// 存储副作用函数的桶
const bucket = new WeakMap()

然后修改 get/set 拦截器代码:

复制代码
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return target[key];
    // 根据 target 从"桶"中取得 depsMap,它也是一个 Map 类型:key -->effects
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()));
    }
    // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
    // 里面存储着所有与当前 key 相关联的副作用函数:effects
    let deps = depsMap.get(key);
    // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    // 最后将当前激活的副作用函数添加到"桶"里
    deps.add(activeEffect);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 根据 target 从桶中取得 depsMap,它是 key --> effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key);
    // 执行副作用函数
    effects && effects.forEach((fn) => fn());
  }
});

之所以使用 WeakMap,是因为它对 key 是弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。

如果 target 对象没有任何引用,说明用户侧不再需要它,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。

最后,我们对上文中的代码做一些封装处理。在目前的实现中,当读取属性值时,我们直接在 get 拦截函数里编写把副作用函数收集到"桶"里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。

同样, 我们也可以把副作用函数重新执行的逻辑封装到 trigger 函数中,查看在线示例

复制代码
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 把副作用函数从桶里取出并执行
    trigger(target, key);
  }
});
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect,直接 return
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach((fn) => fn());
}

4)分支切换

在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok 值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

复制代码
effect(function effectFn() {
  document.body.innerText = obj.ok ? obj.text : 'not'
})

当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之间建立的联系如下:

复制代码
data
└── ok
    └── effectFn
└── text
    └── effectFn

当字段 obj.ok 的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text 不会被读取,只会触发字段 obj.ok 的读取操作。所以理想情况下副作用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集。

但按照前文的实现,我们还做不到这一点。也就是说,当我们把字段 obj.ok 的值修改为 false,也会导致副作用函数重新执行,查看在线示例

复制代码
obj.ok = false;
obj.text = 'hello vue3';

解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,参考书籍 P52 页。

5)诸多细节

实现响应式数据要比想象中难很多,还需要考虑诸多细节,此节只会列出其中的几点,完整细节可阅读书籍第四章。

首先是 effect 支持嵌套,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的,当组件发生嵌套时,就发生了 effect 嵌套。

复制代码
effect(function effectFn1() {
  effect(function effectFn2() { /* ... */ })
  /* ... */
})

其次是避免无限递归循环,在 effect 注册的副作用函数内有一个自增操作 obj.foo++,该操作会引起栈溢出。

复制代码
effect(() => obj.foo++)

再有就是可调度性,所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

还有就是副作用允许过期,避免竞态问题。下面代码乍一看似乎没什么问题,但仔细思考会发现这段代码会发生竞态问题。

复制代码
let finalData;
watch(obj, async () => {
  // 发送并等待网络请求
  const res = await fetch("/path/to/request");
  // 将请求结果赋值给 data
  finalData = res;
});

假设我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。

随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。

此时请求 A 和请求 B 都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求 B 先于请求 A 返回结果,就会导致 finalData 中存储的是 A 请求的结果。

6)原始值和非原始

Vue.js 3 对原始值和非原始值各自提供了一种响应式方案,完整内容参考第五和第六章。

原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值,在 JavaScript 中,原始值是按值传递的,而非按引用传递。

这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。另外,JavaScript 中的 Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹,由此引入了 ref 的概念。

为了区分 ref 与普通响应式对象,我们还为"包裹对象"定义了一个值为 true 的属性,即 __v_isRef,用它作为 ref 的标识。

非原始值其实就是对象,响应系统应该拦截对象的一切读取操作,以便当数据变化时能够正确地触发响应,包括访问属性、key in obj、for-in。

浅响应(shallowReactive)只会让对象的第一层属性是响应的,而深响应(reactive)会让所有层成为响应的。

当用户尝试修改只读数据时,会收到一条警告信息。这样就实现了对数据的保护,例如组件接收到的 props 对象应该是一个只读数据。

7)数组

在 JavaScript 中,数组是一个特殊的对象,但对数组的操作与对普通对象的操作存在着不同,包括:

  • 通过索引访问数组元素值:arr[0]。访问数组的长度:arr.length。
  • 把数组作为对象,使用 for...in 循环遍历。
  • 使用 for...of 迭代遍历数组。
  • 数组的原型方法,如 concat/join/every/find 等,以及其他所有不改变原数组的原型方法。

可以看到,对数组的读取操作要比普通对象丰富得多。对数组元素或属性的设置操作有:

  • 通过索引修改数组元素值:arr[1] = 3。
  • 修改数组长度:arr.length = 0。
  • 数组的栈方法:push/pop/shift/unshift。
  • 修改原数组的原型方法:splice/fill/sort 等。

除了通过数组索引修改数组元素值这种基本操作之外,数组本身还有很多会修改原数组的原型方法。

调用这些方法也属于对数组的操作,有些方法的操作语义是"读取",而有些方法的操作语义是"设置"。因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。

8)Set 和 Map

集合类型包括 Map/Set 以及 WeakMap/WeakSet。使用 Proxy 代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。

Set 类型的原型属性和方法如下:

  • size:返回集合中元素的数量。
  • add(value):向集合中添加给定的值。
  • clear():清空集合。
  • delete(value):从集合中删除给定的值。
  • has(value):判断集合中是否存在给定的值。
  • keys():返回一个迭代器对象。可用于 for...of 循环,迭代器对象产生的值为集合中的元素值。
  • values():对于 Set 集合类型来说,keys() 与 values() 等价。
  • entries():返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值[value, value]。

Map 和 Set 这两个数据类型的操作方法相似。它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加元素,而 Map 类型使用 set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值。

三、渲染器

Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。

渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。渲染器不仅能够渲染真实 DOM 元素,它还是框架跨平台能力的关键。

1)结合响应系统

既然渲染器用来渲染真实 DOM 元素,那么严格来说,下面的函数就是一个合格的渲染器:

复制代码
function renderer(domString, container) {
  container.innerHTML = domString
}

在下面这段代码中,我们首先定义了一个响应式数据 count,它是一 个 ref,然后在副作用函数内调用 renderer 函数执行渲染。

复制代码
const count = ref(1);
effect(() => {
  renderer(`<h1>${count.value}</h1>`, document.getElementById("app"));
});
count.value++;

副作用函数执行完毕后,会与响应式数据建立响应联系。当我们修改 count.value 的值时,副作用函数会重新执行,完成重新渲染。所以上面的代码运行完毕后,最终渲染到页面的内容是 <h1>2</h1>。

我们利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。这个过程与渲染器的具体实现无关,在上面给出的渲染器的实现中,仅仅设置了元素的 innerHTML 内容。

2)基本概念

通常使用英文 renderer 来表达"渲染器"。千万不要把 renderer 和 render 弄混了,前者代表渲染器,而后者是动词,表示"渲染"。

在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树型结构。所以,我们经常能听到"虚拟节点"这样的词,即 virtual node,简写成 vnode。

渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,用英文 mount 表达。例如 Vue.js 组件中的 mounted 钩子就会在挂载完成时触发。这就意味着,在 mounted 钩子中可以访问真实 DOM 元素。

渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置。这里的"挂载点"其实就是一个 DOM 元素,渲染器会把该 DOM 元素作为容器元素,并把内容渲染到其中,用英文 container 来表达容器。

渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。渲染器不仅可以用来渲染,还可以用来激活已有的 DOM 元素,这个过程通常发生在同构渲染的情况下。

复制代码
function createRenderer() {
  function render(vnode, container) {}
  function hydrate(vnode, container) {}
}

这个例子说明,渲染器的内容非常广泛,而用来把 vnode 渲染为真实 DOM 的 render 函数只是其中一部分。实际上,在 Vue.js 3 中,甚至连创建应用的 createApp 函数也是渲染器的一部分。

在下面的示例中,首先调用 createRenderer 函数创建一个渲染器,接着调用渲染器的 renderer.render 函数执行渲染。

复制代码
const renderer = createRenderer()
// 首次渲染
renderer.render(vnode, document.querySelector('#app'))

当多次在同一个 container 上调用 renderer.render 函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作。

试图找到并更新变更点的过程叫作"打补丁"(或更新),英文通常用 patch 来表达。但实际上,挂载动作本身也可以看作一种特殊的打补丁,它的特殊之处在于旧的 vnode 是不存在的。

下面这段代码给出了 render 函数的基本实现。patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。

复制代码
function createRenderer() {
  function render(vnode, container) {
    if (vnode) {
      // 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
      patch(container._vnode, vnode, container);
    } else {
      if (container._vnode) {
        // 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
        // 只需要将 container 内的 DOM 清空即可
        container.innerHTML = "";
      }
    }
    // 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
    container._vnode = vnode;
  }
  return {
    render
  }
}

3)自定义渲染器

通过将渲染器设计为可配置的"通用"渲染器,即可实现渲染到任意目标平台上。

编写一个渲染器,将浏览器特定的 API 抽离,这样就可以使得渲染器的核心不依赖于浏览器。

将 patch 函数也编写在 createRenderer 函数内。在后续的讲解中,如果没有特殊声明,我们编写的函数都定义在 createRenderer 函数内。

复制代码
function createRenderer() {
  function patch(n1, n2, container) {
    // 如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
    if (!n1) {
      mountElement(n2, container);
    } else {
      // n1 存在,意味着打补丁,暂时省略
    }
  }
  // ......
}

我们调用 mountElement 完成挂载,它的实现如下:

复制代码
function mountElement(vnode, container) {
  //创建DOM元素
  const el = document.createElement(vnode.type); 
  // 处理子节点,如果子节点是字符串,代表元素具有文本节点
  if (typeof vnode.children === "string") {
    // 因此只需要设置元素的 textContent 属性即可
    el.textContent = vnode.children;
  }
  // 将元素添加到容器中
  container.appendChild(el);
}

首先调用 document.createElement 函数,以 vnode.type 的值作为标签名称创建新的 DOM 元素。接着处理 vnode.children,如果它的值是字符串类型,则代表该元素具有文本子节点,这时只需要设置元素的 textContent 即可。最后调用 appendChild 函数将新创建的 DOM 元素添加到容器元素内。这样, 我们就完成了一个 vnode 的挂载。

我们的目标是设计一个不依赖于浏览器平台的通用渲染器,但很明显,mountElement 函数内调用了大量依赖于浏览器的 API。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离。

可以将这些操作 DOM 的 API 作为配置项,该配置项可以作为 createRenderer 函数的参数:

复制代码
// 在创建 renderer 时传入配置项
const renderer = createRenderer({
  // 用于创建元素
  createElement(tag) {
    return document.createElement(tag);
  },
  // 用于设置元素的文本节点
  setElementText(el, text) {
    el.textContent = text;
  },
  // 用于在给定的 parent 下添加指定元素
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor);
  }
});

我们把用于操作 DOM 的 API 封装为一个对象,并把它传递给 createRenderer 函数。在 mountElement 等函数内就可以通过配置项来取得操作 DOM 的 API 了:

复制代码
function createRenderer(options) {
  // 通过 options 得到操作 DOM 的 API
  const { createElement, insert, setElementText } = options;
  // 在这个作用域内定义的函数都可以访问那些 API
  function mountElement(vnode, container) {}
  function patch(n1, n2, container) {}
  function render(vnode, container) {}
  return {
    render
  };
}

接着,我们就可以使用从配置项中取得的 API 重新实现 mountElement 函数:

复制代码
function mountElement(vnode, container) {
  // 调用 createElement 函数创建元素
  const el = createElement(vnode.type);
  if (typeof vnode.children === "string") {
    // 调用 setElementText 设置元素的文本节点
    setElementText(el, vnode.children);
  }
  // 调用 insert 函数将元素插入到容器内
  insert(el, container);
}

重构后的 mountElement 函数在功能上没有任何变化。不同的是,它不再直接依赖于浏览器的特有 API 了。这意味着,只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作。

我们可以实现一个用来打印渲染器操作流程的自定义渲染器,如下所示,查看在线示例

复制代码
const renderer = createRenderer({
  createElement(tag) {
    console.log(`创建元素 ${tag}`);
    return { tag };
  },
  setElementText(el, text) {
    console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`);
    el.textContent = text;
  },
  insert(el, parent, anchor = null) {
    console.log(`将 ${JSON.stringify(el)} 添加到${JSON.stringify(parent)} 下`);
    parent.children = el;
  }
});

在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag },并将其作为创建出来的"DOM 元素"。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关的 API,而是自定义了一些逻辑,并打印信息到控制台。

复制代码
const vnode = {
  type: "h1",
  children: "hello"
};
// 使用一个对象模拟挂载点
const container = { type: "root" };
renderer.render(vnode, container);

这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行。

自定义渲染器并不是"黑魔法",它只是通过抽象的手段,让核心代码不再依赖平台特有的 API,再通过支持个性化配置的能力来实现跨平台。

4)挂载子节点

为了描述元素的子节点,我们需要将 vnode.children 定义为数组:

复制代码
const vnode = {
  type: "div",
  children: [
    {
      type: "p",
      children: "hello"
    }
  ]
};

为了完成子节点的渲染,我们需要修改 mountElement 函数。

复制代码
function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    // 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }
  insert(el, container);
}

在上面这段代码中,我们增加了新的判断分支。使用 Array.isArray 函数判断 vnode.children 是否是数组,如果是数组,则循环遍历它,并调 patch 函数挂载数组中的虚拟节点。

传递给 patch 函数的第一个参数是 null。因为是挂载阶段,没有旧 vnode,所以只需要传递 null 即可。传递给 patch 函数的第三个参数是挂载点。由于我们正在挂载的子元素是 div 标签的子节点,所以需要把刚刚创建的 div 元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

5)元素属性

为了描述元素的属性,我们需要为虚拟 DOM 定义新的 vnode.props 字段。

复制代码
const vnode = {
  type: "div",
  // 使用 props 描述一个元素的属性
  props: {
    id: "foo"
  },
  children: [
    {
      type: "p",
      children: "hello"
    }
  ]
};

vnode.props 是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历 props 对象的方式, 把这些属性渲染到对应的元素上。

复制代码
function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略 children 的处理
  // 如果 vnode.props 存在才处理它
  if (vnode.props) {
    // 遍历 vnode.props
    for (const key in vnode.props) {
      // 调用 setAttribute 将属性设置到元素上
      el.setAttribute(key, vnode.props[key]);
    }
  }
  insert(el, container);
}

除了使用 setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置。实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象(如下所示),都存在缺陷,为元素设置属性比想象中要复杂得多。

复制代码
el[key] = vnode.props[key]

HTML Attributes 指的就是定义在 HTML 标签上的属性,例如 id="my-input"、type="text"。当浏览器解析 HTML 代码后,会创建一个与之相符的 DOM 元素对象。

这个 DOM 对象会包含很多属性(properties),这些属性就是所谓的 DOM Properties。很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties,但 DOM Properties 与 HTML Attributes 的名字不总是一模一样。

例如 class="foo" 对应的 DOM Properties 则是 el.className。另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties。例如 aria-* 类的 HTML Attributes 就没有与之对应的 DOM Properties。

类似地,也不是所有的 DOM Properties 都有与之对应的 HTML Attributes,例如可以用 el.textContent 来设置元素的文本内容, 但并没有与之对应的 HTML Attributes 来完成同样的工作。

注意,渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。在浏览器中运行下面这句代码,发现浏览器仍然会将按钮禁用,因为使用 setAttribute 函数设置的值总是会被字符串化。

复制代码
el.setAttribute('disabled', false)

要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。

复制代码
function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略 children 的处理
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
      // 用 in 操作符判断 key 是否存在对应的 DOM Properties
      if (key in el) {
        // 获取该 DOM Properties 的类型
        const type = typeof el[key];
        // 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
        if (type === "boolean" && value === "") {
          el[key] = true;
        } else {
          el[key] = value;
        }
      } else {
        // 如果要设置的属性没有对应的 DOM Properties,则使用setAttribute 函数设置属性
        el.setAttribute(key, value);
      }
    }
  }
  insert(el, container);
}

检查每一个 vnode.props 中的属性, 看看是否存在对应的 DOM Properties,如果存在,则优先设置 DOM Properties。同时,我们对布尔类型的 DOM Properties 做了值的矫正, 即当要设置的值为空字符串时,将其矫正为布尔值 true。

但上面给出的实现仍然存在问题,因为有一些 DOM Properties 是只读的,例如 input 的 form 属性,在代码中增加特殊处理的判断,用函数替换上面第 8 行的判断条件。

复制代码
function shouldSetAsProps(el, key, value) {
  // 特殊处理
  if (key === "form" && el.tagName === "INPUT") return false;
  // 兜底
  return key in el;
}

这是一个特殊的例子,还有一些其他类似于这种需要特殊处理的情况。

只要在后续迭代过程中"见招拆招",代码就会变得越来越完善,框架也会变得越来越健壮。

最后,我们需要把属性的设置也变成与平台无关,因此需要把属性设置相关操作也提取到渲染器选项中。

复制代码
const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag);
  },
  setElementText(el, text) {
    el.textContent = text;
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor);
  },
  // 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
  patchProps(el, key, prevValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === "boolean" && nextValue === "") {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  }
});

而在 mountElement 函数中,只需要调用 patchProps 函数, 并为其传递相关参数即可,查看在线示例

复制代码
function mountElement(vnode, container) {
  // ......
  if (vnode.props) {
    for (const key in vnode.props) {
      // 调用 patchProps 函数即可
      patchProps(el, key, null, vnode.props[key]);
    }
  }
  // 调用 insert 函数将元素插入到容器内
  insert(el, container);
}

6)卸载

卸载操作发生在更新阶段,更新指的是在初次挂载完成之后,后续渲染会触发更新。

不能简单地使用 innerHTML 清空容器来完成卸载操作,原因有三:

  1. 容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数。
  2. 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。
  3. innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数。

正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除,修改 mountElement 函数,如下所示。

复制代码
function mountElement(vnode, container) {
  // 让 vnode.el 引用真实 DOM 元素
  const el = vnode.el = createElement(vnode.type)
  // ......
}

当我们调用 createElement 函数创建真实 DOM 元素时,会把真实 DOM 元素赋值给 vnode.el 属性。这样,在 vnode 与真实 DOM 元素之间就建立了联系,我们可以通过 vnode.el 来获取该虚拟节点所对应的真实 DOM 元素。

当卸载操作发生的时候,只需要根据虚拟节点对象 vnode.el 取得真实 DOM 元素,再将其从父元素中移除即可:

复制代码
function render(vnode, container) {
  if (vnode) {
    patch(container._vnode, vnode, container);
  } else {
    if (container._vnode) {
      // 根据 vnode 获取要卸载的真实 DOM 元素
      const el = container._vnode.el;
      // 获取 el 的父元素
      const parent = el.parentNode;
      // 调用 removeChild 移除元素
      if (parent) parent.removeChild(el);
    }
  }
  container._vnode = vnode;
}

其中 container._vnode 代表旧 vnode, 即要被卸载的 vnode。然后通过 container._vnode.el 取得真实 DOM 元素,并调用 removeChild 函数将其从父元素中移除即可。

由于卸载操作是比较常见且基本的操作,所以我们应该将它封装到 unmount 函数中,以便后续代码可以复用。

复制代码
function unmount(vnode) {
  const parent = vnode.el.parentNode;
  if (parent) {
    parent.removeChild(vnode.el);
  }
}

现在 unmount 函数的代码还非常简单,后续我们会慢慢充实它,让它变得更加完善。在 render 函数中调用它来完成卸载任务了,查看在线示例

复制代码
function render(vnode, container) {
  if (vnode) {
    // ......
  } else {
    if (container._vnode) {
      // 调用 unmount 函数卸载 vnode
      unmount(container._vnode);
    }
  }
  // ......
}

在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,还可以检测虚拟节点 vnode 的类型,若是组件,能调用其生命周期函数。

7)更新子节点

元素的子节点都有哪些情况,如下面的 HTML 代码所示:

复制代码
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div> 05
<!-- 多个子节点 -->
<div>
  <p />
  <p />
</div>

既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。所以,我们可以总结出更新子节点时的九种可能。

接下来我们就开始着手实现,patchElement 函数的代码如下所示:

复制代码
function patchElement(n1, n2) {
  const el = (n2.el = n1.el);
  const oldProps = n1.props;
  const newProps = n2.props;
  // 第一步:更新 props
  for (const key in newProps) {
    if (newProps[key] !== oldProps[key]) {
      patchProps(el, key, oldProps[key], newProps[key]);
    }
  }
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProps(el, key, oldProps[key], null);
    }
  }
  // 第二步:更新 children
  patchChildren(n1, n2, el);
}

更新子节点是对一个元素进行打补丁的最后一步操作。我们将它封装到 patchChildren 函数中,并将新旧 vnode 以及当前正在被打补丁的 DOM 元素 el 作为参数传递给它。

复制代码
function patchChildren(n1, n2, container) {
  // 判断新子节点的类型是否是文本节点
  if (typeof n2.children === "string") {
    // 旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点
    // 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c));
    }
    // 最后将新的文本节点内容设置给容器元素
    setElementText(container, n2.children);
  }
}

如果新子节点的类型不是文本子节点,我们需要再添加一个判断分支,判断它是否是一组子节点。

复制代码
function patchChildren(n1, n2, container) {
  // 判断新子节点的类型是否是文本节点
  if (typeof n2.children === "string") {
    // ......
    // 新子节点是一组子节点
  } else if (Array.isArray(n2.children)) {
    // 判断旧子节点是否也是一组子节点
    if (Array.isArray(n1.children)) {
      // 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法,暂时省略
    } else {
      // 此时,旧子节点要么是文本子节点,要么不存在
      // 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
      setElementText(container, "");
      n2.children.forEach((c) => patch(null, c, container));
    }
  }
}

如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,这里就涉及我们常说的 Diff 算法。

如果要简单点,那么可以先直接卸载全部旧子节点,再挂载全部新子节点。

8)Diff算法

当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。

操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

在上一节中, 采用了一种简单粗暴的更新方式,不仅对 DOM 元素无法进行复用,还需要大量的 DOM 操作才能完成更新,这样会产生极大的性能开销。

对其进行改进后的方案是,遍历新旧两组子节点中数量较少的那一组,并逐个调用 patch 函数进行打补丁,然后比较新旧两组子节点的数量,如果新的一组子节点数量更多,那么说明有新子节点需要挂载;否则说明在旧的一组子节点中,有节点需要卸载。

而在虚拟节点中有个 key 属性,它就像虚拟节点的"身份证号"。在更新时,渲染器通过 key 属性找到可复用的节点,然后尽可能地通过 DOM 移动操作来完成更新,避免过多地对 DOM 元素进行销毁和重建。

其实,简单 Diff 算法就是在寻找需要移动的节点的,其逻辑就是拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。

简单 Diff 算法的问题在于,它对 DOM 的移动操作并不是最优的。双端 Diff 算法会在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。对于同样的更新场景,其执行的 DOM 移动操作次数将更少。

第三种用于比较新旧两组子节点的方式叫快速 Diff 算法。该算法最早应用于 ivi 和 inferno 这两个框架,Vue.js 3 借鉴并扩展了它,快速 Diff 算法在实测中性能最优。

它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。

四、编译器

Vue.js 的模板和 JSX 都属于领域特定语言(DSL),它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。

编译器其实只是一段程序,它用来将一种语言 A 翻译成另外一种语言 B。其中,语言 A 通常叫作 (source code),语言 B 通常叫做目标代码(object code 或 target code)。

编译器将源代码翻译为目标代码的过程叫作编译(compile)。完整的编译过程通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤。

整个编译过程分为编译前端和编译后端。编译前端包含词法分析、语法分析和语义分析,它通常与目标平台无关,仅负责分析源代码。

编译后端则通常与目标平台有关,编译后端涉及中间代码生成和优化以及目标代码生成。但是,编译后端并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。中间代码生成和优化这两个环节有时也叫"中端"。

对于 Vue.js 模板编译器来说,源代码就是组件的模板,而目标代码是能够在浏览器平台上运行的 JavaScript 代码,或其他拥有 JavaScript 运行时的平台代码。

Vue.js 模板编译器的目标代码其实就是渲染函数,详细而言:

  1. Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。
  2. 将模板 AST(transform)成 JavaScript AST。
  3. 根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。

1)模板 AST

AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。所谓模板 AST,其实就是用来描述模板的抽象语法树。假设我们有如下模板:

复制代码
<div>
  <h1 v-if="ok">Vue Template</h1>
</div>

这段模板会被编译为如下所示的 AST:

复制代码
const ast = {
  // 逻辑根节点
  type: "Root",
  children: [
    // div 标签节点
    {
      type: "Element",
      tag: "div",
      children: [
        // h1 标签节点
        {
          type: "Element",
          tag: "h1",
          props: [
            // v-if 指令节点
            {
              type: "Directive", // 类型为 Directive 代表指令
              name: "if", // 指令名称为 if,不带有前缀 v-
              exp: {
                // 表达式节点
                type: "Expression",
                content: "ok"
              }
            }
          ]
        }
      ]
    }
  ]
};

AST 其实就是一个具有层级结构的对象。模板 AST 具有与模板同构的嵌套结构。每一棵 AST 都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root 节点的 children 存在。

我们可以通过封装 parse 函数来完成对模板的词法分析和语法分析,得到模板 AST。

复制代码
const template = `
 <div>
   <h1 v-if="ok">Vue Template</h1>
 </div>
`;
const templateAST = parse(template);

parse 函数接收字符串模板作为参数,并将解析后得到的 AST 作为结果返回。在语义分析的基础上,我们即可得到模板 AST。

2)JavaScript AST

我们还需要将模板 AST 转换为 JavaScript AST。因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST。

例如描述一个渲染函数的声明,其基本的数据结构如下:

复制代码
// 函数声明
function render() {
  return h("div", [h("p", "Vue"), h("p", "Template")]);
}
// JavaScript AST
const FunctionDeclNode = {
  type: "FunctionDecl", // 代表该节点是函数声明
  // 函数的名称是一个标识符,标识符本身也是一个节点
  id: {
    type: "Identifier",
    name: "render" // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
  },
  params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
  // 渲染函数的函数体只有一个语句,即 return 语句
  body: [
    {
      type: "ReturnStatement",
      return: null // 暂时留空
    }
  ]
};

我们可以封装 transform 函数来完成模板 AST 到 JavaScript AST 的转换工作。

复制代码
const templateAST = parse(template)
const jsAST = transform(templateAST)

3)代码生成

代码生成本质上是字符串拼接的艺术,访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。

接上文,在有了 JavaScript AST 后,我们就可以根据它生成渲染函数了,这一步可以通过封装 generate 函数来完成。

复制代码
const templateAST = parse(template)
const jsAST = transform(templateAST)
const code = generate(jsAST)

在上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回。

复制代码
function generate(node) {
  const context = {
    // 存储最终生成的渲染函数代码
    code: "",
    // 在生成代码时,通过调用 push 函数完成代码的拼接
    push(code) {
      context.code += code;
    }
  };
  // 调用 genNode 函数完成代码生成的工作,
  genNode(node, context);
  // 返回渲染函数代码
  return context.code;
}

首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码,还定义了 context.push 函数,用来完成代码拼接,接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。

genNode 函数用于完成代码生成的工作。而代码生成的原理其实很简单,只需要匹配各种类型的 JavaScript AST 节点,并调用对应的生成函数即可。

复制代码
function genNode(node, context) {
  switch (node.type) {
    case "FunctionDecl":
      genFunctionDecl(node, context);
      break;
    case "ReturnStatement":
      genReturnStatement(node, context);
      break;
    case "CallExpression":
      genCallExpression(node, context);
      break;
    case "StringLiteral":
      genStringLiteral(node, context);
      break;
    case "ArrayExpression":
      genArrayExpression(node, context);
      break;
  }
}

在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。当然,如果后续需要增加节点类型,只需要在 genNode 函数中添加相应的处理分支即可。

接下来,我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数。

复制代码
function genFunctionDecl(node, context) {
  // 从 context 对象中取出工具函数
  const { push, indent, deIndent } = context;
  // node.id 是一个标识符,用来描述函数的名称,即 node.id.name
  push(`function ${node.id.name} `);
  push(`(`);
  // 调用 genNodeList 为函数的参数生成代码
  genNodeList(node.params, context);
  push(`) `);
  push(`{`);
  // 缩进
  indent();
  // 为函数体生成代码,这里递归地调用了 genNode 函数
  node.body.forEach((n) => genNode(n, context));
  // 取消缩进
  deIndent();
  push(`}`);
}

genFunctionDecl 函数用来为函数声明类型的节点生成对应的 JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:

复制代码
function render () {
  //... 函数体
}

在 genFunctionDecl 函数内部调用了 genNodeList 函数来为函数的参数生成对应的代码。它的实现如下:

复制代码
function genNodeList(nodes, context) {
  const { push } = context;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    genNode(node, context);
    if (i < nodes.length - 1) {
      push(", ");
    }
  }
}

genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。这里要注意的一点是, 每处理完一个节点,需要在生成的代码后面拼接逗号字符(,)。

复制代码
// 如果节点数组为
const node = [节点 1,节点 2,节点 3]
// 那么生成的代码将类似于
'节点 1,节点 2,节点 3'
// 如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明 
('节点 1,节点 2,节点 3')
// 如果在这段代码的前后分别添加方括号,那么它将是一个数组
['节点 1,节点 2,节点 3']

其余几个生成器函数的实现可以参考书籍 P406 的内容。

五、同构

Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。

这实际上描述了 Vue.js 的两种渲染方式,即客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)。另外, Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphic rendering)。

与 SSR 相比,CSR 会产生所谓的白屏问题。实际上,CSR 不仅仅会产生白屏问题,它对 SEO(搜索引擎优化)也不友好。不过 SSR 是在服务端完成页面渲染的,所以它需要消耗更多服务端资源。CSR 则能够减少对服务端资源的消耗。并且由于 CSR 不需要进行真正的跳转,用户会感觉更加流畅。

从这些角度来看,无论是 SSR 还是 CSR,都不可以作为银弹,我们需要从项目的实际需求出发,决定到底采用哪一个。那么,我们能否融合 SSR 与 CSR 两者的优点于一身呢?答案是"可以的",这就是接下来我们要讨论的同构渲染。

1)同构渲染

同构渲染分为首次渲染(即首次访问或刷新页面)以及非首次渲染。

同构渲染中的首次渲染与 SSR 的工作流程是一致的。也就是说,当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。另外,该静态的 HTML 页面中也会包含 <link>、<script> 等标签。

除此之外,同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML 页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据。直白地说,服务器通过 API 请求的数据会被序列化为字符串,并拼接到静态的 HTML 字符串中,最后一并发送给浏览器,这么做实际上是为了后续的激活操作。

假设浏览器已经接收到初次渲染的静态 HTML 页面,接下来浏览器会解析并渲染该页面。在解析过程中,浏览器会发现 HTML 代码中存在 <link> 和 <script> 标签,于是会从 CDN 或服务器获取相应的资源,这一步与 CSR 一致。

当 JavaScript 资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的水合(hydration)。激活包含两部分工作内容。

  1. Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。
  2. Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。后续操作都会按照 CSR 应用程序的流程来执行。

同构渲染除了也需要部分服务端资源外,其他方面的表现都非常棒。 另外,对同构渲染最多的误解是,它能够提升可交互时间(TTI)。事实是同构渲染仍然需要像 CSR 那样等待 JavaScript 资源加载完成,并且客户端激活完成后,才能响应用户操作。因此,理论上同构渲染无法提升可交互时间。

同构渲染的"同构"一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。例如,我们用 Vue.js 编写一个组件,该组件既可以在服务端运行,被渲染为 HTML 字符串;也可以在客户端运行,就像普通的 CSR 应用程序一样。

2)渲染为HTML字符串

给出如下虚拟节点对象,它用来描述一个普通的 div 标签:

复制代码
const ElementVNode = {
  type: "div",
  props: {
    id: "foo"
  },
  children: [{ type: "p", children: "hello" }]
};

为了将虚拟节点 ElementVNode 渲染为字符串,我们需要实现 renderElementVNode 函数。该函数接收用来描述普通标签的虚拟节点作为参数,并返回渲染后的 HTML 字符串。

在不考虑任何边界条件的情况下,实现 renderElementVNode 非常简单,如下面的代码所示:

复制代码
function renderElementVNode(vnode) {
  // 取出标签名称 tag 和标签属性 props,以及标签的子节点
  const { type: tag, props, children } = vnode;
  // 开始标签的头部
  let ret = `<${tag}`;
  // 处理标签属性
  if (props) {
    for (const k in props) {
      // 以 key="value" 的形式拼接字符串
      ret += ` ${k}="${props[k]}"`;
    }
  }
  // 开始标签的闭合
  ret += `>`;
  // 处理子节点
  // 如果子节点的类型是字符串,则是文本内容,直接拼接
  if (typeof children === "string") {
    ret += children;
  } else if (Array.isArray(children)) {
    // 如果子节点的类型是数组,则递归地调用 renderElementVNode 完成渲染
    children.forEach((child) => {
      ret += renderElementVNode(child);
    });
  }
  // 结束标签
  ret += `</${tag}>`;
  // 返回拼接好的 HTML 字符串
  return ret;
}

接着,我们可以调用 renderElementVNode 函数完成对 ElementVNode 的渲染,查看在线示例

复制代码
// <div id="foo"><p>hello</p></div>
console.log(renderElementVNode(ElementVNode));

实际上,将一个普通标签类型的虚拟节点渲染为 HTML 字符串,本质上是字符串的拼接。不过,上面给出的函数仅仅用来展示核心原理,并不满足生产要求。因为它存在以下四点缺陷,而这些都属于边界条件。

  1. 函数在渲染标签类型的虚拟节点时,还需要考虑该节点是否是自闭合标签,术语叫作 void element,例如 input、link、meta 等。
  2. 对于属性(props)的处理会比较复杂,要考虑属性名称是否合法,还要对属性值进行 HTML 转义。
  3. 子节点的类型多种多样,可能是任意类型的虚拟节点,如 Fragment、组件、函数式组件、文本等,这些都需要处理。
  4. 标签的文本子节点也需要进行 HTML 转义,即将特殊字符转换为对应的 HTML 实体,例如 < 转义为实体 &lt;。

把组件渲染为 HTML 字符串与把普通标签节点渲染为 HTML 字符串并没有本质区别。组件的渲染函数用来描述组件要渲染的内容,它的返回值也是虚拟 DOM。所以,我们只需要执行组件的渲染函数取得对应的虚拟 DOM,再将该虚拟 DOM 渲染为 HTML 字符串,并作为 renderComponentVNode 函数的返回值即可。

3)激活

对于同构渲染来说,组件的代码会在服务端和客户端分别执行一次。在服务端,组件会被渲染为静态的 HTML 字符串,然后发送给浏览器,浏览器再把这段纯静态的 HTML 渲染出来。

由于浏览器在渲染了由服务端发送过来的 HTML 字符串之后,页面中已经存在对应的 DOM 元素,所以组件代码在客户端运行时,不需要再次创建相应的 DOM 元素。但是,组件代码在客户端运行时,仍然需要做两件重要的事:

  1. 在页面中的 DOM 元素与虚拟节点对象之间建立联系。
  2. 为页面中的 DOM 元素添加事件绑定。

实际上,我们可以用代码模拟从服务端渲染到客户端激活的整个过程,如下所示:

复制代码
// html 代表由服务端渲染的字符串
const html = renderComponentVNode(compVNode);
// 假设客户端已经拿到了由服务端渲染的字符串
// 获取挂载点
const container = document.querySelector("#app");
// 设置挂载点的 innerHTML,模拟由服务端渲染的内容
container.innerHTML = html;
// 接着调用 hydrate 函数完成激活
renderer.hydrate(compVNode, container);

其中 CompVNode 的代码如下:

复制代码
const MyComponent = {
  name: "App",
  setup() {
    const str = ref("foo");
    return () => {
      return {
        type: "div",
        children: [
          {
            type: "span",
            children: str.value,
            props: {
              onClick: () => {
                str.value = "bar";
              }
            }
          },
          { type: "span", children: "baz" }
        ]
      };
    };
  }
};
const CompVNode = {
  type: MyComponent
};

与 renderer.render 函数一样,renderer.hydrate 函数也是渲染器的一部分,因此它也会作为 createRenderer 函数的返回值。

复制代码
function createRenderer(options) {
  function render(vnode, container) { }
  function hydrate(vnode, container) { }
  return {
    render,
    hydrate
  };
}

真实 DOM 元素与虚拟 DOM 对象都是树型结构,并且节点之间存在一一对应的关系。因此,我们可以认为它们是"同构"的。而激活的原理就是基于这一事实,递归地在真实 DOM 元素与虚拟 DOM 节点之间建立关系。

另外,在虚拟 DOM 中并不存在与容器元素(或挂载点)对应的节点。因此,在激活的时候,应该从容器元素的第一个子节点开始:

复制代码
function hydrate(vnode, container) {
  // 从容器元素的第一个子节点开始
  hydrateNode(container.firstChild, vnode);
}

其中,hydrateNode 函数接收两个参数,分别是真实 DOM 元素和虚拟 DOM 元素。

复制代码
function hydrateNode(node, vnode) {
  const { type } = vnode;
  // 1. 让 vnode.el 引用真实 DOM
  vnode.el = node;
  // 2. 检查虚拟 DOM 的类型,如果是组件,则调用 mountComponent 函数完成激活
  if (typeof type === "object") {
    mountComponent(vnode, container, null);
  } else if (typeof type === "string") {
    // 3. 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配
    if (node.nodeType !== 1) {
      console.error("mismatch");
      console.error("服务端渲染的真实 DOM 节点是:", node);
      console.error("客户端渲染的虚拟 DOM 节点是:", vnode);
    } else {
      // 4. 如果是普通元素,则调用 hydrateElement 完成激活 
      hydrateElement(node, vnode)
    }
  }
  // 5. 重要:hydrateNode 函数需要返回当前节点的下一个兄弟节点,以便继续 进行后续的激活操作
  return node.nextSibling;
}

首先,要在真实 DOM 元素与虚拟DOM元素之间建立联系,即 vnode.el = node。这样才能保证后续更新操作正常进行。

其次,我们需要检测虚拟 DOM 的类型, 并据此判断应该执行怎样的激活操作。对于普通元素的激活操作,则可以通过 hydrateElement 函数来完成。

最后,hydrateNode 函数需要返回当前激活节点的下一个兄弟节点,以便进行后续的激活操作。

hydrateNode 函数的返回值非常重要,它的用途体现在 hydrateElement 函数内,如下面的代码所示:

复制代码
// 用来激活普通元素类型的节点
function hydrateElement(el, vnode) {
  // 1. 为 DOM 元素添加事件
  if (vnode.props) {
    for (const key in vnode.props) {
      // 只有事件类型的 props 需要处理
      if (/^on/.test(key)) {
        patchProps(el, key, null, vnode.props[key]);
      }
    }
  }
  // 递归地激活子节点
  if (Array.isArray(vnode.children)) {
    // 从第一个子节点开始
    let nextNode = el.firstChild;
    const len = vnode.children.length;
    for (let i = 0; i < len; i++) {
      // 激活子节点,注意,每当激活一个子节点,hydrateNode 函数都会返回当前子节点的下一个兄弟节点,
      // 于是可以进行后续的激活了
      nextNode = hydrateNode(nextNode, vnode.children[i]);
    }
  }
}

hydrateElement 函数有两个关键点。

  1. 因为服务端渲染是忽略事件的,浏览器只是渲染了静态的 HTML 而已,所以激活 DOM 元素的操作之一就是为其添加事件处理程序。
  2. 递归地激活当前元素的子节点,从第一个子节点 el.firstChild 开始,递归地调用 hydrateNode 函数完成激活。

对于组件的激活,我们还需要针对性地处理 mountComponent 函数。由于服务端渲染的页面中已经存在真实 DOM 元素,所以当调用 mountComponent 函数进行组件的挂载时,无须再次创建真实 DOM 元素。