【六】Vue2 源码 — 编译详解

之前我们分析过模板到真实 DOM 渲染的过程,中间有一个环节是把模板编译成 render 函数,这个过程我们把它称作编译。

虽然我们可以直接为组件编写 render 函数,但是编写 template 模板更加直观,也更符合我们的开发习惯。

Vue.js 提供了 2 个版本,一个是 Runtime + Compiler,一个是 Runtime only。前者是包含编译代码的,可以把编译过程放在运行时做,后者是不包含编译代码的,需要借助 webpack 的 vue-loader 事先把模板编译成 render 函数。

编译入口

当我们使用 Runtime + Compiler 的 Vue.js,它的入口是 src/platforms/web/entry-runtime-with-compiler.js,看一下它对 $mount 函数的定义:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' &&
      warn(
        `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
      )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

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

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  return mount.call(this, el, hydrating)
}

这段函数逻辑之前分析过,编译的入口就是在这里:

compileToFunctions 方法就是把模板 template 编译生成 render 以及 staticRenderFns,它定义在 src/platforms/web/compiler/index.js 中:

可以看到 compileToFunctions 方法实际上是 createCompiler 方法的返回值,createCompiler 方法接收一个编译配置参数,接下来我们看一下 createCompiler 方法的定义,在 src/compiler/index.js 中:

createCompiler 方法实际上是通过调用 createCompilerCreator 方法返回的,该方法传入的参数是一个函数,真正的编译过程都在这个 baseCompile 函数里执行,createCompilerCreator 定义在 src/compiler/create-compiler.js 中:

可以看到该方法返回了一个 createCompiler 的函数,createCompiler 接收一个 baseOptions 的参数,返回的是一个对象,包括 compile方法属性和 compileToFunctions 属性。这个 compileToFunctions 对应的就是 $mount 函数调用的 compileToFunctions 方法,它是调用 createCompileToFunctionFn 方法的返回值,createCompileToFunctionFn 定义在 src/compiler/to-function.js 中:

至此,我们总算找到了 compileToFunctions 的最终定义,它接收 3 个参数:编译模板 template,编译配置 options 和 Vue 实例 vm。核心的编译过程就一行代码:

compile 函数在执行 createCompileToFunctionFn 的时候作为参数传入,它是 createCompiler 函数中定义的 compile 函数:

compile 函数执行的逻辑是先处理配置参数,真正执行编译过程就一行代码:

baseCompile 在执行 createCompilerCreator 方法时作为参数传入,如下:

所以编译的入口我们终于找到了,它主要就是执行了如下几个逻辑:

  • 解析模板字符串生成 AST

  • 优化语法树

  • 生成代码

编译入口逻辑之所以这么绕,是因为 Vue.js 在不同的平台下都会有编译的过程,因此编译过程中依赖的配置 baseOptions 会有所不同。而编译过程会多次执行,但同一个平台下每一次的编译过程配置又是相同的,为了不让这些配置在每次编译过程都通过参数传入,Vue.js 利用了函数柯里化的技巧很好的实现了 baseOptions 的参数保留。同样,Vue.js 也是利用函数柯里化的技巧把基础的编译过程函数抽出来,通过 createCompilerCreator(baseCompile) 的方式把真正编译的过程和其他逻辑,如对编译配置处理、缓存处理等剥离开,这样的设计还是非常巧妙的。

parse

编译过程首先就是对模板做解析,生成 AST,它是一种抽象语法树,是对源代码抽象语法结构的树状表现形式。在很多编译技术中,如 babel 编译 ES6 的代码都会先生成 AST。

这个过程是比较复杂的,它会用到大量正则表达式对字符串解析。为了直观的演示 parse 的过程,我们先来看一个例子:

经过 parse 过程后,生成的 AST 如下:

可以看到,生成的 AST 是一个树状结构,每一个节点都是一个 ast element,除了它自身的一些属性,还维护了它的父子关系,如 parent 指向它的父节点, children 指向它的所有子节点。先对 AST 有一些直观的印象,那么接下来我们来分析一下这个 AST 是如何得到的。

parse 整体流程

首先来看一下 parse 的定义,在 src/compiler/parser/index.js 中:

parse 函数的代码很长,先把它拆成伪代码的形式,方便对整体流程现有一个大致的了解。接下来我们就来分解分析每段伪代码的作用。

从 options 中获取方法和配置

对应伪代码:

parse 函数的输入是 templateoptions,输出是 AST 的根节点。template 就是我们的模板字符串,而 options 实际上是和平台相关的一些配置,它定义在 src/platforms/web/compiler/options.js 中:

这些属性和方法之所以放到 platforms 目录下是因为它们在不同的平台(web 和 weex)的实现是不同的。

我们用伪代码 getFnsAndConfigFromOptions 表示了这一过程,它的实际代码如下:

这些方法和配置都是后续解析的时候需要的。

解析 HTML 模板

对应伪代码:

对于 template 模板的解析主要是通过 parseHTML 函数,它定义在 src/compiler/parser/html-parser.js 中:

由于 parseHTML 的逻辑也非常复杂,因此也用了伪代码的方式表达,整体来说它的逻辑就是循环解析 template,用正则做各种匹配,对于不同情况分别进行不同的处理,直到整个 template 被解析完毕。在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾。

为了更加直观地说明 advance 的作用,可以通过一幅图表示,最开始 index 是 0 :

然后调用 advance 函数:

得到结果:

匹配的过程中主要利用了正则表达式,如下:

通过这些正则表达式,我们可以匹配注释节点、文档类型节点、开始闭合标签等。

  • 注释节点、文档类型节点

对于注释节点和文档类型节点的匹配,如果匹配到,我们仅仅做的是前进即可。

对于注释和条件注释节点,前进至它们的末位位置;对于文档类型节点,则前进它自身长度。

  • 开始标签

首先通过 parseStartTag 解析开始标签:

对于开始标签,除了标签名之外,还有一些标签相关的属性。函数先通过正则表达式 startTagOpen 匹配到开始标签,然后定义了 match 对象,接着循环去匹配开始标签中的属性并添加到 match.attrs 中,直到匹配的开始标签的闭合符结束。如果匹配到闭合符,则获取一元斜线符,前进到闭合符尾,并把当前索引赋值给 match.end

parseStartTag 对开始标签解析拿到 match 后,紧接着会执行 handleStartTagmatch 做处理:

handleStartTag 的核心逻辑很简单,先判断开始标签是否是一元标签,类似 <img> 、 <br/> 这样,接着对 match.attrs 遍历并做了一些处理,最后判断如果非一元标签,则往stack 里 push 一个对象,并且把 tagName 赋值给 lastTag

最后调用了 options.start 回调函数,并传入一些参数。

  • 闭合标签

先通过正则 endTag 匹配到闭合标签,然后前进到闭合标签末尾,然后执行 parseEndTag 方法对闭合标签做解析。

parseEndTag 的核心逻辑很简单,在介绍之前我们回顾一下在执行 handleStartTag 的时候,对于非一元标签(有 endTag),我们都把它构成一个对象压入到 stack 中,如图所示:

那么对于闭合标签的解析,就是倒序 stack 找到第一个和当前 endTag 匹配的元素。如果是正常的标签匹配,那么 stack 的最后一个元素应该和当前的 endTag 匹配,但是考虑到如下错误情况:

这个时候当 endTag</div> 的时候,从 stack 尾部找到的标签是 <span>,就不能匹配,因此这种情况会报警告。匹配后把栈到 pos 位置的都弹出,并从 stack 尾部拿到 lastTag

最后调用了 options.end 回到函数,并传入一些参数。

  • 文本

接下来判断 textEnd 是否是大于等于 0 的,满足则说明从当前位置到 textEnd 位置都是文本,并且如果 < 是纯文本中的字符,就继续找到真正的文本结束的位置,然后前进到结束的位置。

再继续判断 textEnd 小于 0 的情况,则说明整个 template 解析完毕了,把剩余的 html 都复制给了 text

最后调用了 options.chars 回到函数,并传入 text 参数。

因此,在循环解析整个 template 的过程中,会根据不同的情况,去执行不同的回调函数。

Last updated