Skip to content

编译过程设计思路

知识要点

  • Vue的挂载过程是怎样的?
  • 一个编译器由哪些部分组成?
  • Vue的整体编译过程是怎样的?
  • Vue编译过程的设计思路

概览

前面的章节已经提到过,在实例化Vue的时候,首先会经过选项合并环节,将用户传入的选项配置与Vue自身的配置进行合并。其次是数据初始化环节,生命周期、事件和数据响应式处理都是在这个环节进行的。最后的挂载环节($mount) 就是我们接下来重点学习的对象。

在通过源码来分析Vue的编译实现之前,我们先通过一张图从全局来看看Vue的编译流程。

img

(图片来自这里

$mount主要分为两个阶段(这里指的是带模板编译的情况):编译阶段更新阶段

编译阶段主要任务是将template编译成一个能生成对应Vnode(虚拟Dom)的render函数。其核心就是图中所示的compile过程。

更新阶段主要任务是将render函数生成的虚拟Dom映射成真实Dom。其核心对应的是图中所示的patch过程。

接下来我们将着重学习下编译阶段,看看编译的过程是怎样的。

编译原理

在了解Vue的编译过程之前,我们先了解一下编译原理。《编译原理》中提到,一个编译器的结构通常包含以下几个部分:

  1. 词法分析:这个过程会将源程序的字节流组成为有意义的词素的系列。对于每个词素,词法分析器都会以词法单元(token) 的形式输出。比如1 + 2这里的1+2分别会看作一个词法单元。
  2. 语法分析:将词法分析生成的词法单元用树形结构来表示。一个常用的表示方法是语法树(syntax tree)
  3. 词义分析:语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。
  4. 中间代码生成:在把一个源程序翻译成目标代码的过程中,一个编译器可能构造出一个或逗哥中间表示。语法树是一种中间表示形式。
  5. 代码优化:改进优化中间代码,以便生成更好的目标代码。
  6. 代码生成:代码生成器以源程序的中间表示形式作为输入,并把它映射到目标语言。
  7. 符号表管理:记录源程序中使用的变量的名字,并收集和每个名字的各种属性有关的信息。

这7个步骤讨论的是一个编译器的逻辑组织形式。在一个特定的实现中,多个步骤的活动可以组合成一趟。每趟读入一个输入文件并产生一个输出文件。比如,前端步骤中的词法分析、语法分析、语义分析,以及中间代码的生成可以被组合在一起成为一趟。代码优化可以作为一个可选的趟。然后可以有一个为特定目标机生成代码的后端趟。

为什么我们要了解这个编译的步骤呢?因为它实在太重要了,浏览器页面的渲染过程、babel的转换过程、Vue的代码编译过程等等,都离不开这几个步骤,可见其重要性。下面我们将结合这几个过程看看Vue是如何实现编译的。

编译过程

最能代表整个Vue编译过程的就是以下几行代码:

javascript
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
  optimize(ast, options)
}
const code = generate(ast, options)

正如前面编译原理里面对的解释,这里也正好可以分为三趟,一趟是parse,用于生成抽象语法树(ast)。二趟是代码优化,也是可选的。三趟是generate,用于生成可以生成虚拟Dom的代码。下面我们用一个实际例子来说明这三趟做了什么工作。

首先,先定义一个简单的模板如下:

javascript
<div>
  编译过程:{{ isEasy }}
</div>

经过parse函数进行解析,我们会得到一个抽象语法树,如下:

json
{
  "type": 1,
  "tag": "div",
  "attrsList": [],
  "attrsMap": {},
  "rawAttrsMap": {},
  "children": [
    {
      "type": 2,
      "expression": "\"\\n  编译过程\"+_s(isEasy)+\"\\n\"",
      "tokens": [
        "\n  编译过程",
        {
          "@binding": "isEasy"
        },
        "\n"
      ],
      "text": "\n  编译过程{{ isEasy }}\n",
      "static": false
    }
  ],
  "plain": true,
  "static": false,
  "staticRoot": false
}

通过语法树的结构其实我们能够比较清楚地看出,外层有一个div的标签,它的children中包含一个type为2的节点,也就是文本节点。这个文本节点里有几个token,包含文字,动态绑定的值,和换行符。也就是说我们用一个对象结构的语法树,可以完全表示我们写的template字符串,这样就可以极大地方便我们后续的操作了。

接着是optimize方法,这里没有表示出来,它主要就是将上述的语法树进行优化,也就是改变一些属性等,从而使得生成目标代码变得更加容易。

最后则是generate方法,我们看看它会将语法树处理成怎样的:

javascript
with(this){return _c('div',[_v("\n 编译过程"+_s(isEasy)+"\n")])}

这段代码很简短,它的主要作用是通过处理语法树,将标签、变量、样式等等属性,然后拼接成一段能生成Vnode的代码。比如这段代码用_s方法将isEasy生成字符串,拼接后用_v方法生成文本Vnode,通过_c生成最终的Vnode。大致结构如下:

img

因此,通过parseoptimizegenerate三个阶段,我们就能将任意按照Vue规范写的模板编译成对应的Vnode。后续会详细分析下这三个阶段的具体过程是怎样的,这里了解一个大概即可。

编译入口

在整体上对编译有了一定认识后,我们将开始进入编译的源码分析环节了。编译的最开始入口是在实例化过程中调用了$mount函数。先找到$mount函数的定义所在。

一个是在platforms/web/runtime/index.js之中:

javascript
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

另一个则在platforms/web/entry-runtime-with-compiler.js中:

javascript
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  ...
  const { render, staticRenderFns } = compileToFunctions(template, {
    outputSourceRange: process.env.NODE_ENV !== 'production',
    shouldDecodeNewlines,
    shouldDecodeNewlinesForHref,
    delimiters: options.delimiters,
    comments: options.comments
  }, this)
  ...
return mount.call(this, el, hydrating)
}

为什么需要这样做呢?因为runtime里是没有编译环节的,所以只需要进行更新虚拟Dom即可。而后者是需要编译template的,为了实现这一目的,需要重写$mount方法:将原有$mount缓存起来,然后在编译模板后再调用原有的$mount方法。

$mount方法里主要做了几层判断,主要是针对template不同写法而做的不同处理:

javascript
if (template) {
 // * 如果是 string
  if (typeof template === 'string') {
     // 如 #app
     if (template.charAt(0) === '#') {
       ...
     }
   } else if (template.nodeType) {
     // 如果 template 是实际节点
  ...
   } else {
     // 否则是无效template
     if (process.env.NODE_ENV !== 'production') {
       warn('invalid template option:' + template, this)
     }
     return this
   }
} else if (el) {
  // 使用 el
  template = getOuterHTML(el)
}

做完了template的处理后,就要开始正式编译了:

javascript
const { render, staticRenderFns } = compileToFunctions(template, {
  outputSourceRange: process.env.NODE_ENV !== 'production',
  shouldDecodeNewlines,
  shouldDecodeNewlinesForHref,
  delimiters: options.delimiters,
  comments: options.comments
}, this)

可以看出,将template方法编译成render函数的核心方法是compileToFunctions方法,找到该方法,在platforms/web/compiler/index.js中:

javascript
const { compile, compileToFunctions } = createCompiler(baseOptions)

发现compileToFunctions方法又是createCompiler方法创建的,找到src/compiler/index.js文件:

javascript
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
 ...
})

createCompiler方法又是createCompilerCreator方法创建的,找到src/compiler/create-compiler.js文件:

javascript
export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {
    function compile (
      template: string,
      options?: CompilerOptions
    ): CompiledResult {
    ...
    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

这里非常绕,因为分了好几个模块,且回调了很多次,非常难以理解。在讲解这个之前,我们先简单了解一下函数科里化。比如现在有一个sum函数用于加法运算,代码如下:

javascript
const sum = (a, b) => { return a + b }
sum(1, 2) // 3

现在我们想将其转换成sum(1)(2)这种形式执行,那么该如何定义sum函数呢?定义如下:

javascript
const sum = (a) => {
  return (b) => {
   return a + b
  }
}

函数科里化就是将接收多个参数的函数变为接收一个单一参数的函数。由于篇幅有限,这里介绍得比较简单,有兴趣的话可以单独查找相关文章进行学习。

但是为什么要这么做呢?其实这样做的最大好处就是代码复用。回到Vue的编译过程,我们可以将代码简化成如下代码:

javascript
 function createCompilerCreator (baseCompile) {
  return function createCompiler (baseOptions) {
    function compile (
   template,
      options
    ) {
      ...
     const compiled = baseCompile(template.trim(), finalOptions)
      ...
    }
    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

有没有发现和sum函数很像?这里同样也是把baseCompilebaseOptions两个函数分开来传。下面我们来看一下为什么要这么做?

先看createCompiler,这里我们传入了baseCompile函数,那么他就会按照baseCompile来编译。

javascript
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
 ...
})

// src/compiler/create-compiler.js
const compiled = baseCompile(template.trim(), finalOptions)

但是如果我们想以不同的方式来编译模板该怎么办呢?比如服务端渲染的模板编译过程和浏览器编译的过程是不一样的。这里就可以重写一个服务端编译过程,如叫做serverCompile,然后调用createCompilerCreator(serverCompile)就能实现一个基于服务端的编译流程了。而且不需要改变createCompilerCreator内部的内容,也不需要把createCompilerCreator内部的内容复制过来重写一遍。

同样的道理,看下compileToFunctions方法:

javascript
const { compile, compileToFunctions } = createCompiler(baseOptions)

不同的平台它们的节点处理方式,样式定义等等都是不一样的,因此需要根据不同的平台生成不同的编译函数。所有这里将这些不同点抽离出来了作为一个baseOptions参数,以后想要在不同平台下生成编译函数就只需要传入不同的options就行了。这也就是为什么这段代码是在platforms文件下的原因。

通过科里化的处理,Vue将不同平台不同端的代码就抽离封装起来了,刚开始可能觉得很难理解,但是一旦理解了之后,会发现是一个十分巧妙的过程,非常值得我们学习。

一些细节

接下来我们再看看这个过程中的一些其他的细节。

首先是baseOptions,它是在platforms/web/compiler/options.js文件里定义。

javascript
const baseOptions: CompilerOptions = {
  expectHTML: true,
  // 平台相关的节点处理
  modules,
  // 平台相关的指令
  directives,
  //* tag 是否是pre
  isPreTag,
  //* 是否是单标签
  isUnaryTag,
  //* 必须使用属性,比如 option 需要 selected
  mustUseProp,
  //* 自闭和标签
  canBeLeftOpenTag,
  //* html / svg 标签
  isReservedTag,
  //* svg / math
  getTagNamespace,
  //* 获取 staticKeys => 'staticClass,staticStyle'
  staticKeys: genStaticKeys(modules)
}

这里都是平台相关的一些方法和属性,大部分已经有相关注释,这里就不再重复。需要注意的是directivesv-modelv-textv-html三个指令就是在这里定义的。

然后就是baseCompile,这个函数就是我们前面提到的编译流程核心实现的地方,后面章节会进行详细讲解:

javascript
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
 optimize(ast, options)
}
const code = generate(ast, options)

再接着就是src/complier/create-compiler.js最内层的compile函数。

javascript
const finalOptions = Object.create(baseOptions)
...
finalOptions.warn = warn
const compiled = baseCompile(template.trim(), finalOptions)
compiled.errors = errors
compiled.tips = tips

其实它就是在编译流程前后做了一些options处理和添加消息提示函数的工作,最后返回了编译后的代码:

javascript
return {
  compile,
  compileToFunctions: createCompileToFunctionFn(compile)
}

这里需要看一下createCompileToFunctionFn函数。我们前面提到了编译后会生成一段render代码:

javascript
with(this){return _c('div',[_v("\n 编译过程"+_s(isEasy)+"\n")])}

这段代码实际上生成的时候是字符串,那么我们怎么才能执行这段的代码呢?答案就是new Function(code):

这里createCompileToFunctionFn主要做了两项工作:

第一项就是将编译好的代码缓存起来,模板作为key,这样下次就不用再次编译了:

javascript
const key = options.delimiters
  ? String(options.delimiters) + template
  : template
if (cache[key]) {
  return cache[key]
}

第二项工作就是将编译好的代码字符串通过new Function转换成函数形式:

javascript
function createFunction (code, errors) {
 ...
  return new Function(code)
}

res.render = createFunction(compiled.render, fnGenErrors)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
 return createFunction(code, fnGenErrors)
})

这里的render函数容易理解,就是用于生成Vnode的函数。那么staticRenderFns是做什么用的呢?其实staticRenderFns也是编译里的一些优化,对于那么静态的节点,由于不会因为数据的变化而导致节点发生变化,那么每次编译后的Vnode其实是一样的。那么,我们可以直接将这些Vnode缓存下来,下次编译的时候就不用再次编译了,staticRenderFns就是存储这些Vnode的地方。

总结

这一章我们从整体上了解Vue的挂载过程,同时也学习了编译的基本原理,了解了Vue整体的编译过程。随后通过源码的形式,解释了为什么Vue会以多次回调的形式来处理编译函数。下一章我们将会结合源码来学习Vue内部实际的编译过程,从而了解template是如何转换成能生成Vnoderender函数的。