粗解Vue的模板编译

339 阅读11分钟

写在前面

Vue 模板编译的入口在哪儿?从创建实例到模板编译前Vue都做了些什么? 一文中的 步骤 - 5 揭示了答案 —— vm.$mount(vm.$options.el)
如果你不记得 vm 的身上什么时候挂载了 $mount 方法的话,Vue构造函数的创建过程 一文中 platforms/web/runtime/index.js 这一节会给你答案。$mount 方法并不是挂载在 vm 上,其实是在 Vue构造函数 上的。但是通过文件路径可知,这个方法属于平台集成的,因此有两种甚至更多的 $mount 写法。但是我们这只介绍 web 平台的。

模板编译准备

入口

if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

运行时版本

Vue.prototype.$mount = function(el, hydrating) {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

带编译版本

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(el, hydrating) {
  el = el && query(el)
  const options = this.$options
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } 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) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  return mount.call(this, el, hydrating)
}

我们可以发现带编译版本和运行时版本并没有什么本质的区别,甚至还直接把运行时版本的 $mount 缓存了起来,然后又 call 调用了,只是在其中加了一些操作。至于什么操作,看这个名字也知道,带编译
那什么叫带编译呢?

带编译

我们试想一下,一共有几种方式创建模板?

  • el
// 方式1
new Vue({
  el: '#app',
})
// 方式2
new Vue({
  el: document.querySelector('#app'),
})
  • template
// 方式1
new Vue({
  template: '#app',
})
// 方式2
new Vue({
  template: '<div id="app"></div>',
})
// 方式3
new Vue({
  template: document.querySelector('#app'),
})
  • render
new Vue({
  render(h) {
    return h('div#app')
  },
})

简单列举一下就能发现,一共有3大类共6种创建模板的方式。
带编译的目的就是将这么多种方式统合成一种方式,即 render 方式。
那么,我问个问题,当 options 中同时存在 el / template / render 时,会渲染哪个?

const options = this.$options
if (!options.render) {
  const template = options.template
  if (template) {
    // ...
  } else if (el) {
    // ...
  }
}

通过以上代码可知,优先级最高的是 render,其次是 template,最后是 el。但是不要忘了方法开头对于 el 的判断。虽然 el 节点内的子节点可有可无 ( 注意: 不是 el 本身可有可无 ),可一旦 el 被定义为 body 或者 html 就会报错,下面的 render 再怎么优先级高也不会执行。
其次,如果 options 中 el / template / render 都不存在时会发生什么呢?
答: 什么都不会发生。
我 Vue 好歹是一个 JavaScript 框架,我在乎你有没有 UI?我 JavaScript 是语言,你 HTML 算什么?你就一超文本标记...语言?哎,你怎么也有语言俩字???大兄弟,稍微有些尴尬,咱改日再聊。。。
如果你们不相信可以去试试,vm 不管有没有模板都不影响运行,但是也基本没啥用了就是了。。。
至于为什么,因为 Diff算法 中的 _patch_ 需要一个 oldVNode,当第一次还不存在 虚拟DOM 时会使用 真实DOM,那么 真实DOM 哪儿来呢?只能从 el 中取。所以使用 Vue 驱动视图的前提是一定要存在一个可以挂载结果的节点。
此外,我再做一个知识拓展。Vue-Cli 内置的版本其实是运行时版本,由于 Vue-Cli 会自动帮我们搭建脚手架,因此在 index.html 中我们可以看到天然就存在一个 #app 的元素。其次,在 main.js 中 Vue-Cli 也会自动帮我们生成一个 vm,其中就将 el: '#app' 给传了过去。
你也许会产生疑问,既然 Vue-Cli 是运行时版本,那么 .vue 文件中的 template 几个意思?
那是因为 vue-loader 帮我们处理了一切。

To options.render

上面我们说了,带编译的目的就是为了将众多创建模板的方式统一改成使用 render 的方式。
那么,这一节我们就来看看,源码中是怎么把这么多方式统一成使用 render 方式的。

  • 通过 render 方式创建模板表示: 观战不参与
  • 将剩余创建模板的方式通过不同的方式获取到它的 DOM元素 并赋值给 template
  • 将 template 通过 compileToFunctions 方法转换成 render 方式所需要的 JavaScript 可执行代码,然后赋值给 options.render

虚假的 compileToFunctions

上面判断了一大堆只是为了给 options 添加一个 render 方法,而其中最为核心的就是获取 render 方法里面所需要的 JavaScript 可执行代码。

const { render, staticRenderFns } = compileToFunctions(template, {
  shouldDecodeNewlines,
  delimiters: options.delimiters,
  comments: options.comments
}, this)

从以上代码我们可以知道,只要把 template 传入 compileToFunctions 就可以获得 render 方式所需要的 JavaScript 可执行代码了,但是它是怎么提供的呢?其次,shouldDecodeNewlines / options.delimiters / options.comments 这仨是啥?
那三个参数咱先不谈,先来瞧瞧 compileToFunctions 做了啥。
从这代码结构来看,内部应该是这样的:

function compileToFunctions(template, options) {
  return {
    render,
    staticRenderFns
  }
}

当我们找到源代码 const { compile, compileToFunctions } = createCompiler(baseOptions) 发现,好像事情并不简单,这玩意也是被返回的,说明它本身也是被更高阶函数所返回的。那就继续追根溯源呗,然后发现了以下代码:

const createCompiler = createCompilerCreator(function baseCompile (template, options) {
  const ast = parse(template.trim(), options)
  optimize(ast, options)
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

是不是直呼内行?好家伙,你这里面还藏着一个回调,而且你这还是另一个方法的返回???那么,继续?

function createCompilerCreator (baseCompile) {
  return function createCompiler (baseOptions) {
    function compile (template, options) {
      const finalOptions = Object.create(baseOptions)
      // resolve `finalOptions`
      // ...
      const compiled = baseCompile(template, finalOptions)
      return compiled
    }
    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

发现不但还有一个 createCompileToFunctionFn 方法,还把 compile 方法传了进去,我似乎懂了,这里面应该还套着一层。。。
累了,毁灭吧。。。
可最后我还是梳理了一下这中间的关系,最终写成了一个最简单版本:

function compileToFunctions(template, options) {
  // ... 处理 options
  /* 将 template 转换成 AST 语法树 */
  const ast = parse(template.trim(), options)
  /* 标记静态节点和静态根节点 */
  optimize(ast, options)
  /* 根据 AST 语法树生成 render 方法内的 h函数 方式的 JavaScript 可执行代码字符串 */
  const compiled = generate(ast, options)
  /* 将生成的 JavaScript 可执行代码字符串转换成一个函数用来执行它,即 render 方法 */
  const render = new Function(compiled.render)
  /*
  -> 将静态节点生成的 JavaScript 可执行代码字符串转换成一个函数用来执行它,即 staticRenderFns 方法。
  -> 需要与上面的 render 区分开,旨在 Diff 时只更新 render 中生成的 虚拟DOM,不管静态节点 ( 因为静态节点不受数据控制,根本不可能因为数据变化而产生需要 Diff 的应用场景 )
  */
  const staticRenderFns = compiled.staticRenderFns.map(code => new Function(code))
  return {
    render,
    staticRenderFns,
  }
}

真实的 compileToFunctions

我感觉上面的虽然简单但不够具体,又梳理了一下,整理了一个简+详版本:

const cache = Object.create(null)
function compileToFunctions(template, options, vm) {
  options = extend({}, options)
  const warn = options.warn || baseWarn
  delete options.warn
  const key = options.delimiters
    ? String(options.delimiters) + template
    : template
  if (cache[key]) {
    return cache[key]
  }
  /* compile start */
  const compiled = ((template, options) => {
    const baseOptions = {
      expectHTML: true,
      modules,
      directives,
      isPreTag,
      isUnaryTag,
      mustUseProp,
      canBeLeftOpenTag,
      isReservedTag,
      getTagNamespace,
      staticKeys: genStaticKeys(modules)
    }
    const finalOptions = Object.create(baseOptions)
    if (options) {
      if (options.modules) {
        finalOptions.modules =
          (baseOptions.modules || []).concat(options.modules)
      }
      if (options.directives) {
        finalOptions.directives = extend(
          Object.create(baseOptions.directives),
          options.directives
        )
      }
      for (const key in options) {
        if (key !== 'modules' && key !== 'directives') {
          finalOptions[key] = options[key]
        }
      }
    }
    const compiled = ((template, options) => {
      const ast = parse(template.trim(), options)
      optimize(ast, options)
      const code = generate(ast, options)
      return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
      }
    })(template, finalOptions)
    return compiled
  })(template, options)
  /* compile end */
  const res = {}
  const fnGenErrors = []
  res.render = ((code, errors) => {
    try {
      return new Function(code)
    } catch (err) {
      errors.push({ err, code })
      return noop
    }
  })(compiled.render, fnGenErrors)
  res.staticRenderFns = compiled.staticRenderFns.map(code => {
    return ((code, errors) => {
      try {
        return new Function(code)
      } catch (err) {
        errors.push({ err, code })
        return noop
      }
    })(code, fnGenErrors)
  })
  return (cache[key] = res)
}

由此可知,我们的 render 实际上是来自于 generate 方法,而调用 generate 方法的前提又是传入一个 AST语法树。为了获取 AST语法树 我们需要将 template 里面 DOM字符串 传给 parse 方法。
此外,在 parse 和 generate 方法中间还夹杂着一个 optimize 方法,它的作用是标记静态节点和静态根节点。毕竟 Vue 是通过数据驱动视图,但是一个 DOM块 如果没有数据去驱动它,便不需要在 Diff 更新时遍历它。

模板编译启动

是不是被上面的 compileToFunctions 绕得七荤八素,以至于都快忘了我们最终的目的了?如果忘了的话就先赶紧看看上面,这才刚获取完 options.render,接下来才是正菜。
模板编译正式开始,进入 $mount 方法。当然,惯例提供一个精准度较高的简写版:

Vue.prototype.$mount = el => {
  const vm = this
  vm.$el = el
  callHook(vm, 'beforeMount')
  const updateComponent = () => {
    vm._update(vm._render(), false)
  }
  vm._watcher = new Watcher(vm, updateComponent, () => {})
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

习惯了 Vue 源码尿性的人一看就知道这短短的14行代码中有且仅有三个最为重要的方法 _update / _render / Watcher。
这三个组合成了 Vue 数据驱动视图的原理。_render 负责生成 虚拟DOM,_update 负责开启 Diff算法,new Watcher 负责执行 _update,三者环环相扣,妙不可言。
此外,还有一个点需要注意,那就是专门定义了 $el 这个属性。
从 虚拟DOM 变成 真实DOM 是怎么变的?请记住,就是运用了 vm.$el。

_update

目录: core/instance/lifecycle.js

Vue.prototype._update = function (vnode, hydrating) {
  const vm = this
  if (vm._isMounted) {
    callHook(vm, 'beforeUpdate')
  }
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const prevActiveInstance = activeInstance
  activeInstance = vm
  vm._vnode = vnode
  if (!prevVnode) {
    vm.$el = vm.__patch__(
      vm.$el, vnode, hydrating, false /* removeOnly */,
      vm.$options._parentElm,
      vm.$options._refElm
    )
    vm.$options._parentElm = vm.$options._refElm = null
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  activeInstance = prevActiveInstance
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
}

这段代码一上来就先判断了 _isMounted,从变量名也可以看出它和我们熟知的 mounted 生命周期有一定的关系,当 _isMounted 为 false 时说明该实例中的 虚拟DOM 还没有被挂载到 DOM树 上,那么就不会触发 beforeUpdate 生命周期。
为什么要做这一步操作呢?
因为 Vue 无论是第一次生成或是之后的 Diff 更新 DOM 都是走的 _update 方法,第一次生成时由于页面上还没有任何一个 虚拟DOM,所以这时候就不会去调用 beforeUpdate 生命周期钩子,而后的 Diff 最小量更新时因为已经生成过 虚拟DOM了,所以这时候就会调用 beforeUpdate 生命周期钩子。
之后的代码印证了上面这句话,即 if (!prevVnode)
_update 方法中最重要的地方就在于调用了 _patch_ 方法了,如果你了解过 Diff算法 那你应该听过这个方法。它需要传入两个参数 —— oldVNode & newVNode。那问题来了,oldVNode 哪儿来呢?答案就是 vm._vnode。
那我第一次生成的时候还没有 vm._vnode 呢,这怎么办?
不用虚,人家允许你传入 真实DOM,既然是第一次,那你就传 el 呗。
因此大家只要记住,_update 的目的就是为了开启 Diff算法,然后返回新的 真实DOM 挂载到 $el 上就行了。

_render

目录: core/instance/render.js

Vue.prototype._render = function () {
  const vm = this
  const { render, _parentVnode } = vm.$options
  if (vm._isMounted) {
    for (const key in vm.$slots) {
      const slot = vm.$slots[key]
      if (slot._rendered) {
        vm.$slots[key] = cloneVNodes(slot, true /* deep */)
      }
    }
  }
  vm.$scopedSlots = (_parentVnode && _parentVnode.data.scopedSlots) || emptyObject
  vm.$vnode = _parentVnode
  let vnode = render.call(vm._renderProxy, vm.$createElement)
  try {
    vnode
  } catch (e) {
    handleError(e, vm, `render`)
    vnode = vm._vnode
  }
  vnode.parent = _parentVnode
  return vnode
}

上面 _update 方法中我说了 oldVNode 的由来,但是没提 newVNode 是怎么来的,但你可以返回这一节第一个 JavaScript 源码你就会知道,newVNode 就是 _render 方法生成并返回的。
那么这个方法是怎么生成 虚拟DOM 的呢?
上面 带编译 这部分我曾提过: 带编译的目的就是将这么多种方式统合成一种方式,即 render 方式。因此,只需要将这个 render 给调用了,那就会生成 虚拟DOM。
从创建实例到模板编译前Vue都做了些什么? 一文中的 步骤 - 3 里面我讲过 render.call(vm._renderProxy, vm.$createElement)new Vue(options) 中具体调用,但是我还有一个地方没有讲。如果你还记得在创建 Vue构造函数 时不但在其原型上添加了 $createElement 方法,还添加了一个 _c 方法,这两个方法的传参除了最后一个布尔值,其他的都是一模一样的。那这个 _c 是在什么时候调用的呢?
不卖关子,只有我们自己写的 render 方法才会进入 $createElement,如果是 el 或者 template 生成的 render 则会走 _c。但依然由于这里 call 了一个 vm._renderProxy 的原因,_c 也是会指向当前 vm。

渲染 watcher

我们知道,Watcher 并非是一个作用的类,根据参数的不同,它的实例被分成 渲染 watcher 计算 watcher 侦听 watcher。由于这章节是为了讲模板编译,所以我将简化 Watcher 类,写一个只属于 渲染 watcher 的 Watcher 类。

/*
updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
vm._watcher = new Watcher(vm, updateComponent, noop)
*/
let uid = 0
class Watcher {
  constructor(vm, updateComponent) {
    this.vm = vm
    this.id = ++uid
    this.getter = updateComponent
    this.value = this.get()
  }
  run () {
    this.value = this.get()
  }
  get () {
    Dep.target = this
    let value
    try {
      value = this.getter()
    } finally {
      Dep.target = null
    }
    return value
  }
  update () {
    queueWatcher(this)
  }
}

是不是觉得有点懵,就这么短?哎,就这么短。我们生成实例的时候其实就已经调用了 updateComponent 方法了,这时候进入第一次模板编译,然后将该 渲染 watcher 存入 vm._watcher 中,并且推入 vm._watchers 中。那问题来了,当 Diff 时怎么再次激活这个 watcher 呢?
我们可以看到,get 方法会去调用了 this.getter 方法,也就是 updateComponent 方法,这个方法中又调用了 vm._render(),如果你还记得这个方法被调用时它的内部代码那你应该会发现这时候会进入依赖收集。
不清楚的话我来举个例子:

render(h) {
  return h('div', this.msg)
}

以上代码会生成一个 div,内部是一个文本节点,显示的是 data.msg 的数据。
好,返回到 get 方法中来,我们可以看到第一行,先将 Dep.target 设置为当前 watcher 实例。
然后触发 updateComponent 的调用,这样又会走入 _render 的调用中。通过上面的代码例子我们可以知道,在生成 虚拟DOM 之前它会先去访问 this.data 身上的 msg 属性。由于模板编译是在响应式初始化完毕之后执行的,所以这时候 this.data 里面所有的数据都被设置了 getter / setter。
因此在这次访问 msg 时会发现 Dep.target 存在,便开始进行依赖收集,将当前的 渲染 watcher 放入 dep.subs 中。理所当然的,在 msg 的值被修改的时候会触发依赖,将 dep.subs 里面的 watcher 实例给调用更新。
既然说到要调用更新,那么我们的 Watcher 类中就不可避免需要一个 update 方法。这时候,我们的异步渲染就登场了!

异步渲染

上一节我们讲到说 渲染 watcher 在被绑定的数据更改后会调用其 update 方法,即 queueWatcher。

const queue = []
let has = {}
let index = 0
let waiting = flushing = false
function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

flushSchedulerQueue 方法的原理就是重置 has / waiting / flushing,然后调用 queue 里面的 watcher 实例的 run 方法,接着调用 updated 生命周期钩子,很简单的一个方法,所以我们快速略过就行。我们来看下面这个数据改动会怎么走:

vm._data.msg = 'hello Vuex'
vm._data.msg = 'hello VueRoute'

按道理说我们给 msg 修改了两次,那么它就会触发两次依赖,这样我们的页面会在改变后重新渲染两次。但是真的有这个必要吗?第一次的改变被迅速修改后我们真的要去渲染一次吗?这不是浪费性能吗?因此我们回到 queueWatcher 中来看。
其中定义了一个 has 表记录当前 watcher 是否已存在。如果是同步操作,由于 flushSchedulerQueue 中存在重置操作,我们两次都会判断出当前 watcher 实例在 has 表中不存在,但如果是异步处理了 flushSchedulerQueue 方法呢?第一次会判断不存在,然后进入判断条件体内部;第二次则会判断存在,直接跳过。这样的好处是什么?在极短的速度内修改值,我们只会去执行一次渲染,以此来提高性能。
因为执行 flushSchedulerQueue 的方式是异步的,所以 watcher 实例中调用的 run 方法也会是有延迟的。而 run 方法又是调用了 updateComponent 方法,updateComponent 又会去调用 _render 方法,最后 _render 方法访问 msg 时拿到的 msg 其实就已经是 'hello VueRoute' 了。
那么,是怎么让执行 flushSchedulerQueue 的方式是异步的呢?我们可以看到那个既熟悉又陌生的方法 —— nextTick。对,就是你想的那个 nextTick,虽然写法有一点点差别,但是是它是它就是它。
虽然我不打算在这章中讲它,但是别急,之后某篇文章中它就是主角。

写在最后

自此,我们模板编译部分就讲完了。
大致流程如下:

  • 将所有的非 render(h) { h() } 创建模板的方式统一成 template 方式
  • 将 template 中的 真实DOM 通过进出栈的方式转换成 AST语法树
  • 将 AST语法树 转成统一的 render(h) { _c() / h() } 形式
  • 调用 $mount 方法创建 渲染 watcher
  • 首次激活 渲染 watcher 调用上面统一而成的 render 方法,将其生成的 虚拟DOM 传入 _update 方法
  • 调用 _update 方法,使用获得的 虚拟DOM 或挂载在 vm.$el 上的根节点的 真实DOM 通过 _patch_ 方法创建页面并挂载到 vm.$el 属性上
  • 修改模板编译过程中使用到的数据的值时会触发绑定在这个值中存放的该 渲染 watcher 的依赖,即 updateComponent 方法
  • 通过异步渲染的方式获取最后一次修改的值,再通过 _update 中的 _patch_ 方法更新渲染后的节点 明眼人都看得出来我留了三个坑 —— AST语法树 / _patch_ / nextTick,别急,慢慢来,一口气吃不成胖子。