Vue | 模板是如何编译的 🧬

1,561 阅读7分钟

在 Vue 中创建 HTML,我们可以通过模板、手动书写渲染函数或使用 JSX 这几种方式。其中渲染函数是最原始的方式,模板最终也会被编译为渲染函数 —— 执行渲染函数后,我们可以得到 vnode 用于虚拟 DOM 渲染。

今天就来了解一下 Vue 中较为重要的模板编译原理吧,看看我们平时书写的模板是如何转化为最终的视图的。

image.png

一、整体流程

我们平时使用 Vue 模板时,可能是这样的:

<template>
  <div class="container">
    <h2 v-if="showTitle">{{ isCheck ? '查看' : id ? '编辑' : '新建' }}</h2>
  </div>
<template>

<script>
export default {
  data() {
    return {
     isCheck: true,
     id: '123',
     showTitle: true
   }
}
</script>

我们可以直接将变量嵌入到 HTML 中,还能使用 Vue 提供的 v- 指令等等,十分方便,那 Vue 底层到底是怎么编译我们所书写的模板的呢?

先说结论

模板编译的主要目标就是生成渲染函数,而渲染函数的作用是每次执行它,它就会使用当前最新的状态生成一份新的 vnode,然后使用这个 vnode 进行渲染。

将模板编译成渲染函数可以分两个步骤,先将模板解析成 AST(Abstract Syntax Tree,抽象语法树),然后再使用 AST 生成渲染函数。但是由于静态节点不需要总是重新渲染,所以在生成 AST 之后、生成渲染函数之前这个阶段,可以做一个操作,那就是遍历一遍 AST,给所有静态节点做一个标记,这样在虚拟 DOM 中更新节点时,如果发现节点有这个标记,就不会重新渲染它。

所以,在大体逻辑上,模板编译分三部分内容:

  • 将模板解析为 AST

  • 遍历 AST 标记静态节点

  • 使用 AST 生成渲染函数

这三部分内容在模板编译中分别抽象出三个模块来实现各自的功能,分别是:

  • 解析器

  • 优化器

  • 代码生成器

image.png

1. 解析器 —— 将模板解析成 AST

在解析器内部,分成了很多小解析器,其中包括过滤器解析器(解析过滤器)、文本解析器(解析带变量的文本)和 HTML 解析器(核心)。

AST 和 vnode 类似,都是使用 JavaScript 中的对象来表示节点。

2. 优化器 —— 标记静态节点

什么是静态节点?没有使用任何变量,后续不会发生变化的节点。比如:

<p>我是一个静态节点,因为我没用到什么变量。</p>

为什么要标记静态节点?避免做一些无用功,因为静态节点不会随着状态的变化而变化,在虚拟 DOM patch 时可以跳过。

3. 代码生成器——将 AST 转换成“代码字符串”

什么是代码字符串?就是我们要输入到渲染函数中的内容。举个例子:

<h1 @click="c">Hello world!</h1>

这样的模板经过编译之后生成的“代码字符串”大概长这个样子:

`with(this){
  return _c(
    'h1',
    {
      on:{'click': c}
    },
    [-v('Hello world!')]
  )
}`
// 这里 _c 表示创建元素类型的vnode 
// _v 表示创建文本类型的 vnode

生成的代码字符串将用于渲染函数:

const code = `with(this) {
  return _c(
    'h1',
    {
      on:{'click': c}
    },
    [-v('Hello world!')]
  )
}`
const hello = new Function(code)
hello() // 会生成对应的 vnode

二、解析器

前面提到了,解析器的作用是将模板解析为 AST。其实 AST 不是什么神奇的东西,它就是 JavaScript 中的对象而已,对象中的属性保存各个节点的属性数据。

比如这样一个简单的模板:

<div>
  <h2>{{ name }}</h2>
</div>

它转换为 AST 之后大概长这样:

{
  tag: "div",
  type: 1,
  parent: undefined,
  children: [
    {
      tag: "h2",
      type: 1,
      parent: {
        tag: "div",
        // ...
      },
      children: [
        {
          type: 2,
          text: "{{name}}",
          expression: "_s(name)",
        },
      ],
    },
  ],
}

当然这只是一个例子,真正的 AST 会有更多的属性。

1. 解析器内部运行原理

前面提到,在解析器内部分成了很多小解析器,其中包括过滤器解析器、文本解析器和 HTML 解析器。其中最重要的是 HTML 解析器,这里来看看它是如何工作的。

解析器会从前往后解析,伪代码:

parseHTML(template, {
  start(tag, attrs, unary) {
    // 解析到标签的开始位置,触发
    // 参数:标签名,标签属性,是否自闭合标签
    const element = createASTElement(tag, attrs, currentParent);
  },
  end() {
    // 解析到标签的结束位置,触发
  },
  chars(text) {
    // 解析到文本时,触发
    const element = { type: 3, text }
  },
  comment(text) {
    // 解析到注释时,触发
    const element = { type: 3, text, isComment: true }
  },
})

AST 节点是有层级关系的,我们需要一套实现层级关系的逻辑,让每个 AST 节点都知道它的父级是谁。怎么实现呢?我们维护一个栈即可。由于解析器解析是从前往后,所以我们可以在触发 start 时将当前构建的节点推入栈中,触发 end 时,从栈中弹出一个节点。⏏️ 这样就可以记录它们的层级关系了。

image.png

以这个简单模板为例:

<div>
  <h2>{{ name }}</h2>
</div>

它构建为 AST 的过程大致如下(空格部分忽略):

  1. 模板的开始位置是 div 的开始标签,于是会触发 start 钩子函数,触发后会构建一个 div 节点,此时栈为空,说明这个 div 节点没有父级节点。将这个 div 节点推入栈中,模板字符串中的 <div> 部分被截取掉。
  2. 此时模板的开始位置是 h2 的开始标签,触发 start 钩子函数,触发后会构建一个 h2 节点,此时栈中最后一个节点是 div 节点,说明 h2 节点的父级节点是 div 节点,将 h2 节点添加至 div 节点的子节点中。将这个 h2 节点推入栈中,模板字符串中的 <h2> 部分被截取掉。
  3. 此时模板的开始位置是一段文本,会触发钩子函数 chars。chars 触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是 h2,这说明文本节点的父节点是 h2,于是将文本节点添加到 h2 节点的子节点中。由于文本节点没有子节点,所以文本节点不会被推入栈中。最后,将文本{{ name }}从模板中截取掉。
  4. 此时模板的开始位置是 h2 的结束标签</h2>,于是会触发钩子函数 end。当 end 触发后,会从栈中弹出一个节点出来,也就是把 h2 节点从栈中弹出来,并将 </h2> 从模板中截取掉。
  5. 此时模板的开始位置是 div 的结束标签,于是会触发钩子函数 end。当 end 触发后,会从栈中弹出一个节点出来,也就是把 div 节点从栈中弹出来,并将 </div> 从模板中截取掉。
  6. 模板被截取空了,HTML 解析完毕。

了解了构建 AST 的大致过程,我们需要进一步了解它是如何实现每一轮循环中的截取的呢?它怎么知道哪个是开始标签、结束标签或者文本注释呢?答案就是 —— 正则匹配。从上面的构建过程中可以看到,每一轮循环都是从模板字符串的“开头”截取的,所以如果想要判断是否为开始标签,我们需要先判断:

  1. HTML 模板是不是以 < 开头
  2. 借助正则表达式判断是否符合开始标签的特征
  3. 解析标签属性(也是一小部分一小部分去截取)

image.png

更为详细的步骤可以去看 Vue 源码 html-parser.js 中的 parseStartTag 方法 👀 截取其余类型的节点同理。

前面提到需要维护一个栈来记录节点之间的层级关系,这个栈还有另一个作用,那就是能判断 HTML 标签是否可以正确闭合。【这里你是不是想到了 leetcode 经典算法题:有效括号 👀】

比如:

<div><p></span></div>

这里解析到 </span> 结束标签时,此时栈中最后一个节点是<p><p> </span> 是不匹配的,所以会报错~

总结来说,HTML 解析器的整体逻辑大致是这样的:

// 首先,parseHTML是一个函数,它有两个参数:模板和选项
function parseHTML(template, options) {
  // 我们将模板一小段一小段进行截取,用循环来实现
  while (template) {
    // lastTag 表示父元素
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 父元素为正常元素
      // 可能的情况有:文本、注释、条件注释、DOCTYPE、结束标签、开始标签
      // 除了文本以外,其余都是以 < 开头的
      let textEnd = template.indexOf("<");
      if (textEnd === 0) {
        // 以 < 开头
        // 分别判断是不是注释、条件注释、DOCTYPE、结束标签、开始标签
      }
      if (textEnd < 0) {
        // 肯定是文本
        text = template;
        template = "";
      }
      // 处理文本
      if (text) {
        options.chars(text);
      }
    } else {
      // 父元素为 script/style/textarea
      // 纯文本元素,截取并触发 chars钩子函数即可
    }
  }
}

2. 文本解析器

文本分两类,一类是纯文本,另一类是带变量的文本。在 HTML 解析器解析文本时,不会区分文本中是否有变量,这部分工作由文本解析器 parseText 完成。

parseText 首先需要判断文本中是否含有变量,我们知道变量的存在形式是 {{}},所以:

function parseText(text) {
  // 好烦正则 0.0
  const tagRE = /\{\{((?:.|\n)+?)\}\}/g;
  if (!tagRE(text)) {
    return;
  }
  const match = tagRE.exec(text);
  // 匹配到 {{}},截取出来,将其转换为 _s()
}

_s 是 这个 toString 函数的别名:

image.png

So,诸如 {{name}} 的变量将被转换为 _s(name)

三、优化器

优化器的作用是在 AST 中找到静态节点并将其标记出来。具体来说,它会标记所有的静态节点和静态根节点。前面已经提及什么是静态节点,那静态根节点又是什么呢?如果一个节点的所有字节点都是静态节点且它的父节点是动态节点,那么这个节点就是静态根节点。

表现到代码中,是将静态节点的 static 属性设置为 true,将静态根节点的 staticRoot 属性设置为 true。源码中的实现为:

function optimize(root) {
  if (!root) return;
  // 标记静态节点
  markStatic(root);
  // 标记静态根节点
  markStaticRoots(root);
}
function markStatic(node) {
  // 判断当前节点是否为静态节点
  node.static = isStatic(node);
  if (node.type === 1) {
    // 1 表示元素节点
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i];
      // 递归判断子节点
      markStatic(child);
      // 如果子节点是动态节点,那父节点也属于动态节点
      if (!child.static) {
        node.static = false;
      }
      // 这一步可以保证静态节点的所有子节点都是静态节点
    }
  }
}
function isStatic(node) {
  if (node.type === 2) {
    // 2 表示带变量的动态文本节点
    return false;
  }
  if (node.type === 3) {
    // 3 表示不带变量的纯文本节点
    return true;
  }
  return !!(
    node.pre || // 如果使用了 v-pre 直接断定为静态节点,因为 v-pre 用于跳过编译,显示原始标签及内容
    (!node.hasBindings && // 没有动态绑定
      !node.if &&
      !node.for && // 没有v-if或v-for
      !isBuiltInTag(node.tag) && // 不是内置标签
      isPlatformReservedTag(node.tag) && // 不是组件
      !isDirectChildOfTemplateFor(node) && // 父节点不能是带v-for的template标签
      Object.keys(node).every(!isStaticKey)) // 节点中不存在动态节点才有的属性
  );
}

// markStatic 标记静态节点时,我们可以确保,静态节点的所有子节点都是静态节点
// 所以标记静态根节点的逻辑是:从上到下找到的第一个静态节点一定是静态根节点
function markStaticRoots(node) {
  if (node.type === 1) {
    // 1 表示元素节点
    if (node.static && node.children.length) {
      node.staticRoot = true;
      // d]当前节点如果是静态根节点,就无须判断子节点了,直接return
      return;
    } else {
      node.staticRoot = false;
    }
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        const child = node.children[i];
        // 递归判断子节点
        markStaticRoots(node.children[i]);
      }
    }
  }
}

四、代码生成器

代码生成器的作用是将 AST 转换成渲染函数中的代码字符串,用于传入渲染函数中执行从而生成 vnode。

比如之前举的例子:

`with(this){
  return _c(
    'h1',
    {
      on:{'click': c}
    },
    [-v('Hello world!')]
  )
}`
// 这里 _c 表示创建元素类型的vnode 
// _v 表示创建文本类型的 vnode

可以看到,代码字符串其实是嵌套的函数调用。那具体地,它怎么生成呢?

代码字符串的生成过程是递归的过程,从顶向下处理 AST 节点。节点有三种类型,根据节点类型生成对应的代码字符串,由 with 语句包裹起来:

  • 元素节点:由 createElement 方法创建 (别名_c)参数:_c(<tagname>,<data>,<children>)
function genElement(el, state) {
  // plian=true 表示节点没有属性,是在编译时生成的属性
  // genData 用于生成节点属性字符串
  const data = el.plian ? undefined : genData(el, state);
  const children = genChildren(el, state);
  // 拼接起来
  code = `_c('${el.tag}'${
    data ? `,${data}` : "" // data
  }${
    children ? `,${children}` : "" // children
  })`;
  return code;
}
  • 文本节点:由 createTextVNode 方法创建 (别名_v)
function genText(text) {
  return `_v(${
    text.type === 2
      ? text.expression // no need for () because already wrapped in _s()
      : JSON.stringify(text.text)
    // 这里用 JSON.stringify 给文本包一层字符串
    // 比如:"'这里是字符串'"
  })`;
}
  • 注释节点:由 createEmptyVNode 方法创建 (别名_e)
function genComment(comment) {
  return `_e(${JSON.stringify(comment.text)})`;
}

代码字符串的生成就是字符串拼接的过程。

PS:参考《深入浅出 Vue.js》