从 template 到 Virtual DOM

远子 •  2021年07月29日

<template>
  <div>{{ welcome }}</div>
</template>

<script>
export default {
  name: "HelloWorld",
  data() {
    return { welcome: "Hello World" };
  },
};
</script>

上边这段 Vue 代码前端应该都看得懂,即使是刚入行的新人。

但你有想过这段代码经过怎样的过程,最终变成页面吗?这篇文章里,我想和你一起探索一下。

第 1 章:template 标签

首先是 template 标签,和 pdivspan 一样,<template></template> 是 HTML 的一个标签,它并不是 Vue 创造的。template 可以理解为一个存储在文档中以便后续使用的内容片段,你可以在 MDN 找到详细的文档

template 的内容在加载页面时不会呈现,我们来写个例子:

image-20210729101144431

页面是空白的,template 里的 Hello World 并没有展示。

template 有个只读的属性:content,我们可以通过 JavaScript 实例化,再来个例子:

image-20210729101349273

我们可以简单的把 template 理解为一个不展示的 div<div style="display: none;"></div>

第 2 章:从模板引擎到组件

现代的框架诸如 Vue、React、Angular 都在提倡组件化,通过组件化的思想拆分复杂的页面结构。

事实上,在 jQuery 年代,组件化已经很流行了,只不过名字叫 “模板引擎”。

各个语言都有自己的模板引擎,比如 Node 的 Ejs、Jade、Nunjucks,PHP 的 Smarty、Mustache、Blade,Python 的 string.Template、Mustache 等等。

我们借助 lodash 提供的 compile 函数实现一个 “模板引擎” 驱动的页面,你可以在这里找到 compile 函数的文档。

compile 函数虽然称不上 “模板引擎”,但足以说明问题。

// 使用自定义的 {{ }} 模板分隔符
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;

const compiler = _.template("<div>{{ welcome }}</div>");
const html = compiler({ welcome: "Hello World" });

document.getElementById("app").innerHTML = html;

预览效果如下图:

image-20210729104651122

简单地说,模板引擎的本质是:把数据填充到模板后,生成页面,即 模板 + 数据 => html

如果把上边的模板渲染的逻辑封装成一个函数,那么一个组件就诞生了:

// 使用自定义的 {{ }} 模板分隔符
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;

const MyComponent = function (data) {
  const compiler = _.template("<div>{{ welcome }}</div>");
  const html = compiler(data);
  return html;
};

document.getElementById("app").innerHTML = MyComponent({ welcome: "Hello World" });

发现了吗?模板引擎和组件的本质如出一辙.

组件的本质虽然没变,但组件的产出变了。模板引擎的产出是 HTML,而组件的产出是虚拟 DOM(Virtual DOM,后续简称 VDOM)。

那为什么要产出 VDOM 呢?这是由于现在 JavaScript 除了传统的浏览器平台外,可以承担更多的职责,比如用 JS 开发 iPhone、Andriod、小程序、桌面端、服务端等平台的应用,而 HTML 仅局限于浏览器端。使用 VDOM 的目的是一套代码、多个平台使用。

想一下,DOM 是什么?

DOM 是 HTML 和 XML 对文档结构的描述,DOM 仅仅适用于 HTML 或者 XML,和平台强相关,DOM 只能在浏览器中使用。而 “虚拟DOM” 顾名思义就是假的、模拟的 DOM,VDOM 用 Object 描述 HTML。

VDOM 把渲染过程抽象了,使得 HTML 可以渲染到其他平台。比如 HTML 的 button 标签映射成 iOS 平台的 UIButton,这样我们的代码便可以运行在浏览器和 iPhone 两个平台。

众所周知,操作 VDOM 比操作原生 DOM 的性能强很多,但是这并非 VDOM 的目标,VDOM 的目标在于分层和抽象,使得框架脱离浏览器的束缚,运行在更多平台。

第 3 章:理解虚拟 DOM

上一章我们聊过,“VDOM 用 Object 描述 HTML”,那么具体是怎么描述的呢?这一章我们来探讨一下 VDOM 的构成。

第 1 节:最简单的 VDOM

假如我们有一个 div 标签:

<div>Hello World</div>

我们用 vdom.tag 存储当前标签种类,用 vdom.content 存储内容:

const vdom = {
  tag: "div",
  content: "Hello World"
};

是不是很简单?

VDOM 转换为真实 DOM 的过程称为 “渲染”,VDOM 中的元素称为 “节点”(node),来实现一个超简单的渲染器:

const render = (node) => {
  let html;
  if (node.tag === "div") {
    html = `<div>${node.content}</div>`;
  }
  return html;
};

运行效果如下图:

image-20210729150940822

当然,这个渲染器没什么意义,我们只要知道 VDOM 是 HTML 的中间态,最终要渲染成 HTML 就足够了。

第 2 节:支持属性

上边的例子太简单,我们给这个 div 加点料:

<div id="Hey" class="text-red" style="width: 100px; height: 100px; background: red;">Hello World</div>

在 VDOM 中这样表示:

const vdom = {
  tag: "div",
  content: "Hello World",
  props: [
    { name: "id", value: "Hey" },
    { name: "class", value: "text-red" },
    {
      name: "style",
      value: "width: 100px; height: 100px; background: red;",
    },
  ],
};

是不是也很简单?

改造一下渲染器,支持 props:

const render = (node) => {
  let html;
  if (node.tag === "div") {
    const props = vdom.props.map((prop) => {
      return `${prop.name}="${prop.value}"`;
    });
    html = `<div ${props.join("")}>${node.content}</div>`;
  }
  return html;
};

运行效果如下图:

image-20210729151405718

JavaScript 比 HTML 灵活很多,用 JavaScript 可以轻松地表示复杂的 HTML。

第 3 节:支持嵌套结构

我们知道 DOM 是一个树,我们来用 VDOM 描述下面这颗 DOM 树:

<div>
  <h1>FBI Warning</h1>
  <p>You're under arrest</p>
  <p>
    <button>OK</button>
  </p>
</div>

我们用 vdom.children 存储子元素:

const vdom = {
  tag: "div",
  children: [
    { tag: "h1", content: "FBI Warning" },
    { tag: "p", content: "You're under arrest" },
    {
      tag: "p",
      children: [{ tag: "button", content: "OK" }],
    },
  ],
};

渲染器改成递归渲染:

const renderNode = (node) => {
  let html;

  if (node.tag === "div") {
    if (node.children) {
      const htmls = node.children.map((child) => renderNode(child));
      html = htmls.join("");
    } else {
      html = `<div>${node.content}</div>`;
    }
  }

  if (node.tag === "h1") {
    if (node.children) {
      const htmls = node.children.map((child) => renderNode(child));
      html = htmls.join("");
    } else {
      html = `<h1>${node.content}</h1>`;
    }
  }

  if (node.tag === "p") {
    if (node.children) {
      const htmls = node.children.map((child) => renderNode(child));
      html = htmls.join("");
    } else {
      html = `<p>${node.content}</p>`;
    }
  }

  if (node.tag === "button") {
    if (node.children) {
      const htmls = node.children.map((child) => renderNode(child));
      html = htmls.join("");
    } else {
      html = `<button>${node.content}</button>`;
    }
  }

  if (node.tag === null) {
    html = node.content;
  }

  return html;
};

const render = (nodes) => {
  const htmls = nodes.map((node) => renderNode(node));
  return htmls.join("");
};

我们根据 node.tag 做不同的渲染逻辑,代码有些冗余但没关系。

运行效果如下图:

image-20210729153638386

第 4 节:Node 的种类

我们的渲染器冗余了很多代码,照这么写下去的话,要把 HTML 所有的标签都判断一遍,并且缺失了很多重要的功能。不过意会即可,我们知道渲染过程需要根据节点的类型,执行不同的逻辑就够了。

Vue 根据 DOM 的使用场景做了详细的分类,把 DOM 元素共分为五类,分别是:html/svg 标签、组件、纯文本、Fragment 和 Protal。这五类 VNode 囊括了所有的 DOM 元素。

image-20210729154210019

TODO:此处应该从 Vue 的源码出发。

template 内的 HTML 浏览器是不认识的,需要转换一次,转换后的结果称为 “抽象语法树”(Abstract Syntax Tree,即 AST),抽象语法树是源代码语法结构的一种抽象表示。

AST 和 VDOM 这两个概念很容易弄混,两者有本质的区别:

  • AST 是在编译过程中出现的,Vue 中 .vue 后缀的文件里,包含 HTML、JS、CSS,不再是传统的文件,其中 template 标签中包含了 Vue 自定义的语法,比如 v-ifv-for,这些语法不属于 HTML 规范,JS 引擎也无法处理,因此会在交给浏览器运行前,需要将其转换成标准的 JS 语法,也就是我们常说的 build 过程。
  • VDOM 是运行时出现的一种中间数据态,是数据到真实 DOM 中间的过渡状态,Vue 在 mounted 的时候会生成一次VDOM,为了提高性能,在 re-render 时,会有一次 patch 过程,patch 的目的是比对新旧 VDOM,以最小代价更新真实DOM。

Vue 中有完整、成熟的 AST 实现,位于 compiler-core,可以读一下。

第 4 章:Vue 的本质

我们再回过头想一下 Vue 的本质是什么呢?

还记得第 2 章里用 JavaScript 编写的组件吗?我们把它改造成 Vue Style,下边是 HelloWorld.vue

<template>
  <div>{{ welcome }}</div>
</template>

<script>
export default {
  name: "HelloWorld",
  data() {
    return { welcome: "Hello World" };
  },
};
</script>

Vue 的伪代码:

const parse = (text) => {
  // TODO: 解析 text 得到 template 的内容
  const template = "<div>{{ welcome }}</div>";

  // TODO: 解析 text 得到 data 的内容
  const data = { welcome: "Hello World" };

  return { template, data };
};

export default {
  html: "",
  createApp: function (component) {
    const { template, data } = parse(component);
    const compiler = _.template(template);
    this.html = compiler(data);
    return this;
  },
  mount: function (el) {
    document.getElementById(el).innerHTML = this.html;
  },
};

最后是入口文件:

import { createApp } from "./Vue.ts"
import HelloWorld from "./HelloWorld.vue";

Vue.createApp(HelloWorld).mount("app");

上边的代码没有任何实际意义,连 parse 函数都没有实现。不过,相信你已经发现了,Vue 的 template 最终要和 data 一起,采用类似 “模板引擎” 的方式拼合在一起,最终输出 HTML 供浏览器使用。

所以,Vue 的本质是什么呢?Vue 文件本质上就是普普通通的组件,Vue 里将 HelloWorld.vue 这样的文件称为 “单文件组件”(Single File Component,即 SFC)。一个 Vue 项目本质上是组件的嵌套和组合,像积木一样拼装而成。

或者说 Vue 是 “高级别的模板引擎”,Vue 提供了各种指令、语法糖供开发者使用,大大简化了组件的写法,提升了开发效率。

第 1 节:解析 *.vue 文件

Vue 文件使用 .vue 作为后缀名,其本质是个 txt 文件,我们来验证一下:

image-20210729133549617

先创建两个文件,一个叫 HelloWorld.vue ,另一个叫 HelloWorld.txt,两个文件编写同样的内容。

然后使用 fs 模块读取文件内容,我们发现 vueContenttxtContent 是等价的。

后缀名只是一种约定,我们完全可以把一个 HTML 文件命名成 HelloWorld.css,把一个 CSS 文件命名成 HelloWorld.html,只不过得不到 IDE 的支持,当然了,这样做卵用没有,也没人这么干。

一个文件之所以是 Vue 文件,不是后缀名决定的,是解析器。

我们使用 @vue/compiler-sfc 来解析一下上边的 HelloWorld.vue 文件:

image-20210729135229954

现在我们的 vue 文件被转换成了一个普通的 JS 对象,格式化一下:

image-20210729135450835

HelloWorld.vue<template></template> 里的内容被转换成了如下结构:

{
  "type": "template",
  "content": "\n  <div>{{ welcome }}</div>\n",
  "loc": {
    "start": {"column": 11, "line": 1, "offset": 10},
    "end": {"column": 1, "line": 3, "offset": 38},
    "attrs": {},
    "ast": {},
    "map": {}
  }
}

这便是我们的渲染器缺少的 parse 函数。其核心逻辑是:

  1. *.vue 文件中解析出 <template></template> 的内容;
  2. *.vue 文件中解析出 <script></script> 的内容;
  3. *.vue 文件中解析出 <style></style> 的内容,如果是 Less/Scss 的话,会使用对应的 loader 编译为 CSS;

其中解析出的 template 部分,姑且可以称为 “Vue DOM” 表示这是 Vue 里的 DOM,浏览器并不认识,可以这样理解:Vue DOM = DOM + Vue扩展,Vue DOM 最终要转换成浏览器认识的 AST。

第 2 节:从 Vite 到 VDOM

@vue/compiler-sfc 是一个底层的包,不直接面对普通开发者,一般被构建工具使用,比如:webpack、rollup、vite,我们以 vite 为例,梳理一下整体的调用链。

vite 在遇到 *.vue 后缀的文件时,会使用 @vitejs/plugin-vue 这个插件解析, @vitejs/plugin-vue 的伪代码如下:

import fs from "fs";
import { parse } from '@vue/compiler-sfc'

export default function vuePlugin() {
  return {
    name: "vite:vue",
    load(src) {
      return fs.readFileSync(src, 'utf-8');
    },
    transform(src) {
      return transformMain(src);
    }
  }
}

async function transformMain(src) {
  const { descriptor, errors } = createDescriptor(src);

  const { code: scriptCode } = await genScriptCode();
  const { code: templateCode } = await genTemplateCode();
  const { code: stylesCode } = await genStyleCode();

  const output = [
    scriptCode,
    templateCode,
    stylesCode,
  ];
  return { code: output.join("") }
}

function createDescriptor(src) {
  return parse(src);
}

根据 vite 的约定,在遇到 vue 文件时,@vitejs/plugin-vue 会调用 load 方法,使用 fs 模块获取文件内容,然后执行 transform 方法,调用 @vue/compiler-sfc 包提供的 parse 方法将 vue 文件转换成浏览器可以执行的代码。

是不是和上一节我们自己写的 node 代码一毛一样?

假设有个 HelloWorld.vue 文件,内容如下:

<template>
  <div>Hello World</div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  name: 'HelloWorld',
})
</script>

以上代码被 transform 处理后的结果为:

import { defineComponent } from "vue";
const _sfc_main = defineComponent({
  name: "HelloWorld"
});

import { openBlock as _openBlock, createBlock as _createBlock } from "vue"

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock("div", null, "Hello World"))
}

_sfc_main.render = _sfc_render
_sfc_main.__file = "/Users/rmlzy/Documents/awesome/hello-vue3/src/components/HelloWorld.vue"
export default _sfc_main

发现蹊跷了吗?template(浏览器不认识) 被 transform 成 JavaScript 代码(浏览器认识),这里的 _createBlock("div", null, "Hello World") 便是 <div>Hello World</div> 的 AST。

_createBlock 函数是什么呢?是辅助创建 VDOM 的函数,经常被称为 h 函数,一个最简单的 h 函数如下:

function h(tag, data = null, children = null) {
  return {
    _isVNode: true,
    tag: "div",
    data: null,
    children: null
  }
}

另外,从 transform 的输出代码我们可以发现,template 被挂载到了 _sfc_main 下,也就是说上边的代码等价于:

import { defineComponent } from 'vue'
export default defineComponent({
  name: 'HelloWorld',
  render() {
    return _createBlock("div", null, "Hello World");
  }
});

总结一下,vite 看到 *.vue 的文件就交给 plugin-vue 插件处理,plugin-vue 插件首先读取文件内容,将文件内容传递给 compiler-sfccompiler-sfc 负责将代码转换成浏览器认识的 JS 代码,也就是 AST。

我们看一下 compiler-sfc 都干了啥,伪代码如下:

import * as CompilerDOM from '@vue/compiler-dom'

export function parse(source, { compiler = CompilerDOM }) {
  const descriptor = {
    source,
    template: null,
    script: null,
    styles: []
  };
  const errors = [];
  const ast = compiler.parse(source);
  ast.children.forEach(node => {
    if (node.tag === 'template') {
      descriptor.template = createBlock(node, source);
    }
  });
  return { descriptor, errors };
}

注意这里的 compiler 默认是 CompilerDOM ,这正是为了让 Vue 代码可以运行在浏览器以外的平台而做的分层设计。

compiler-sfc 又去调用了 compiler-domcompiler-domparse 函数是核心,也是最复杂的环节,它的作用是逐个字符的检测,最终把文本格式的 vue 代码转成 VDOM。

我们可以通过阅读测试文件,理解 compiler-domparse 函数,测试文件位于 parse.spec.ts

TODO:此处应该从测试用例反推 Vue 源码

比如下边的测试用例,textarea 内有元素时应该被视为文本:

test('textarea handles comments/elements as just text', () => {
  const ast = parse(
    '<textarea>some<div>text</div>and<!--comment--></textarea>',
    parserOptions
  );
  const element = ast.children[0] as ElementNode;
  const text = element.children[0] as TextNode;

  expect(text).toStrictEqual({
    type: NodeTypes.TEXT,
    content: 'some<div>text</div>and<!--comment-->',
    loc: {
      start: { offset: 10, line: 1, column: 11 },
      end: { offset: 46, line: 1, column: 47 },
      source: 'some<div>text</div>and<!--comment-->'
    }
  })
})

(完)