Vue.js 源码解析 2 - 编译与渲染函数

2,095 阅读13分钟
原文链接: github.com

Vue.js 源码解析 2 - 编译与渲染函数

Vue 构建 UI 的方案是解析模板, 构造渲染函数, 构建 VNode 树, 以及对 VNode 树的 patch. 这篇文章将会分析这些过程是如何发生的. 这一部分的内容很多 (比前一篇还要长!), 因此我不会贴很多源码, 而是通过一个例子在较高的抽象层面上进行分析, 但想要了解细节以及如何处理不同的标签和指令, 还是得去阅读源码.

你可以在 lets-read-vue 中找到经我注释后的源码, 以及本文中的例子.


Vue._init 函数执行的最后调用了 this.$mount, 这个方法将会把 Vue 实例挂载到 DOM 中. 它分为以下三个子过程:

  1. 从模板到渲染函数. 从 entry-with-compiler 里的代码可以看出: 当 Vue 实例的 $mount 方法被调用时, 如果 $options 上没有渲染函数, 就会去编译模板生成渲染函数并挂在 $options.render 上. 如果是单文件组件开发, vue-loader 会在编译时编译模板.
  2. 调用渲染函数生成 VNode.
  3. 对新老 VNode 调用 patch 算法, 对发生更新的部分调用平台原生方法生成平台元素 (比如 document.createElement), 然后将元素挂载到界面当中.

接下来我们分阶段讲解这一过程. 我将会在一开始就给出例子. 这个例子不包含 slot 和 component, 我将这些内容留到后面讲组件化机制的文章里.

<body>
  <div id="app">
  </div>
  <script src="https://vuejs.org/js/vue.js"></script>
  <script>
    var vm = new Vue({
      template: `<div id="root">
        <h1 class="todo-list">Here's something to do</h1>
        <ul>
          <li v-for="todo in todos">{{ todo }}</li>
        </ul>
        <p v-if="completed">
          Completed!
        </p>
        <p v-else>Not completed...</p>
      </div>`,
      data: () => ({
        todos: ['Write article', 'Read source code'],
        completed: true,
        message: 'Wendell'
      }),
      el: '#app'
    })
  </script>
</body>

为了更好地观察其中一些处理后的结果, 我在 Vue.js 的源码里加入了一些 console.log! 如果了解例子的结果, 可以去打开 lets-read-vue 中的 render-demo.html 文件, 然后打开控制台.

从模板到渲染函数

/src/compiler/index.js 是编译器的入口文件. 可以看到编译过程分为 parse, optimize 和 generate 三个阶段.

export const createCompiler = createCompilerCreator(function baseCompile(
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 对字符串形式构成的模板进行解析, 优化和生成处理, 最终形成抽象语法树和渲染函数
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

parse

将字符串模板抽象成一个语法树, 并返回这个树的根节点, 注意这些节点并不是 VNode. 抽象语法树的数据结构如下:

{
    type: 1, // 类型, 可取的值有 1, 2, 3, 分别代表 HTMLElement, 表达式节点和纯文本节点
    tag, // 标签
    attrsList: attrs, // 属性列表, 保存了一组 Attr 对象
    attrsMap: makeAttrsMap(attrs), // 根据属性列表生成的属性对象
    parent, // 父节点
    children: [] // 子节点
}

// 属性对象就是一个保存名值的对象
type Attr = { name: string; value: string };

parse 的算法大致如下:

  1. 调用 parseHTML 函数从头到尾遍历一遍模板字符串, parseHTML 会在适当的时机调用回调函数来创建抽象语法树节点
  2. parseHTML 利用正则解析模板. 可能会遇到以下几种情况:
  3. 遇到评论元素, 这时调用 parse 提供的 comment 函数进行处理, 这不重要, 略过
  4. 开始标签, 调用 start
  5. 闭合标签, end
  6. 文本, char

parseHTML 在调用这些回调的时候, 都会传递一个对象作为参数, 这个对象包含标签名和保存属性信息的 attrs 数组. 而 parse 对以上四种情况有对应的处理方法, 我们着重分析. 至于 parseHTML 的实现细节, 为了篇幅起见我们就当做黑盒子, 如果你想了解它是如何工作的, 请看注释后的源码.

start

如果遇到了一个新的起始标签, 就要创建一个新的节点, 而且还要将它插入到父节点的孩子队列中. 我们需要一个栈来管理已经遇到开始标签的所有节点, 以及当前的父节点, parse 函数内就定义了这样的一些数据结构:

const stack = [] // 保存根节点到当前处理位置的所有节点
let root // 保存根节点
let currentParent // 保存最近一个没有闭合的节点, 作为当前父节点

start 被回调的时候, 会创建一个新的抽象语法树节点. 如果不存在根节点, 就把这个新节点设置为根节点, 然后根据 attrs 参数来进行进一步的处理:

  1. 处理 v-for 指令, 将被循环的对象 for, 循环的别名 alias, 以及 keyvalue 拓展到当前节点上
  2. v-if, 标记 v-if v-else v-else-if, 如果是 v-if 那么就要在节点的 ifConditions 数组中记录它依赖的属性
  3. v-once, 简单地做一个标记
  4. 处理 ref key slot component attrs

对节点的属性进行处理之后, 就把自己插入到父节点的 children 的数组当中去. 注意, 如果当前节点有 v-else-if 或者 v-else 属性的话, 就不成为父节点的一个子节点, 而是作为对应的 v-ififConditions 里面的一项.

最后看这个节点是否是一个自闭合的节点, 如果不是, 就要入栈, 并且将这个节点设置为当前父节点, 等待后续子节点被处理.

end

这个函数被回调的时候, 说明当前节点已经处理完毕, 要将栈最后一个元素弹出并修改当前父节点, 即在 DOM 中要上升一层.

char

这个函数被回调的时候, 说明遇到了一个文本节点. 它首先会将文本当做模板字符串解析, 创建一个 type 为 2 的节点, 如果没有发现 {{ }} 语法, 就当做普通文本解析, 创建 type 为 3 的节点. 最终这个新创建的节点会被 push 到当前父节点的 children 中去.

当模板字符串全部被处理完毕之后, parseHTML 函数返回, parse 紧接着返回根节点, parse 过程完成.

optimize

读者们应该知道 VNode 能够加快渲染速度的原因之一是能够比较新旧 VNode 的差异, 并且只在 DOM 中发生变化的那一部分, 即 patch 算法. 而 optimize 的任务就是将不可能会发生变化的那一部分节点标记出来 (静态节点), 加快 patch 的速度.

optimize 的过程有两部分, 一是标记静态节点, 一是标记静态根节点.

静态节点

function markStatic(node: ASTNode) {
  node.static = isStatic(node)
  // 如果 AST 节点类型为 1, 处理子节点
  if (node.type === 1) {
    // do not make component slot content static. this avoids
    // 1. components not able to mutate slot nodes
    // 2. static slot content fails for hot-reloading

    // 如果该节点是 slot 节点, 不能标记为静态, 否则会导致组件无法改变 slot 里的节点, 也不会热重载
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    // 遍历子节点, 递归调用此函数
    // 深度优先遍历
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        node.static = false
      }
    }
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

markStatic 是一个递归方法, 从抽象语法树的根节点开始.

function isStatic(node: ASTNode): boolean {
  // 表达式节点非静态, 文本节点肯定是静态的
  if (node.type === 2) { // expression
    return false
  }
  if (node.type === 3) { // text
    return true
  }
  // 当节点没有动态绑定, 非 if 或 for 节点, 非 slot/component 节点, 非子组件, 不是 '具有 v-for 指令的 template 标签' -> 才能是静态的, 否则是动态的
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}

以下情况, 我们 (或者说 isStatic 函数) 会认为当前被处理的节点是或者不是一个静态节点:

  1. 节点的 type 值为 2, 表达式节点肯定不是静态节点
  2. 节点的 type 值为 3, 文本节点肯定不是静态节点
  3. 当节点有前置节点, 没有动态绑定, 非 if 或 for 节点, 非 slot/component 节点, 非子组件, 不是*具有 v-for 指令的 template 的直接子节点, 才能是静态的, 否则是动态的

处理完当前节点之后, 还要处理当前节点的 children, 如果有一个节点是非静态的, 当前节点就不能是静态的. 另外, 如果当前节点是一个 v-if 节点, 那么还要处理 ifConditions. 对于 ifConditions 里的 block 测试是否是静态的, 如果不是, 当前节点也不能是静态的.

可以看出这个算法是 DFS.

静态根节点

function markStaticRoots(node: ASTNode, isInFor: boolean) {
  if (node.type === 1) {
    if (node.static || node.once) {
      node.staticInFor = isInFor
    }
    // For a node to qualify as a static root, it should have children that
    // are not just static text. Otherwise the cost of hoisting out will
    // outweigh the benefits and it's better off to just always render it fresh.
    if (node.static && node.children.length && !(
      node.children.length === 1 &&
      node.children[0].type === 3
    )) {
      // 如果是静态节点, 有不止一个子节点, 或者有一个子节点但不是文本节点, 就标记为静态根
      // 作为静态 '根' 节点, 找到了就行了
      node.staticRoot = true
      return
    } else {
      // 如果静态节点只有一个纯文本节点, 那么就不标记为静态根节点, 作者认为这样得不偿失
      node.staticRoot = false
    }
    // 如果节点没有被标记为静态的, 那么尝试标记孩子节点为静态根节点
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for)
      }
    }
    // 标记 ifConditions 内的 blocks
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor)
      }
    }
  }
}

静态根节点是一个静态子树的根.

markStaticRoots 同样是一个递归方法, 从抽象语法树的根节点开始. 只有非文本节点的静态节点才有可能成为静态根节点. 具体来说有以下情况:

  1. 不只有一个子节点, 标记为静态根节点
  2. 有一个子节点, 但不是文本节点, 标记为静态根节点
  3. 如果只有一个文本节点, 不标记为静态根节点

如果没有被标记为静态根节点, 就要检查 childrenifConition, 和上面的过程一样.

除了标记静态根节点之外, 这个方法还要标记静态节点是否在一个 for 标签之内.

到这里, 你可以检查例子的控制台, 我输出了此时解析并优化好的抽象语法树.

generate

最后 generate 根据抽象语法树构造出渲染函数 (实际上是函数的字符串).

export function generate(
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    // 使用 with 使得模板中的表达式能够正确的访问到 Vue 实例的属性, 以及产生 VNode 的
    // _c 这样子的函数
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

有一系列的函数负责根据当前节点的类型和属性处理抽象语法树的一部分, 构造出整个渲染函数 ( 其实是拼凑出渲染函数的字符串). gen 一系列函数也是递归遍历抽象语法树执行的.

// 根据节点的不同属性来调用不同的函数产生生成函数
export function genElement(el: ASTElement, state: CodegenState): string {
  // 每个节点对于某个处理过程都有一个是否处理过的标志位
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state) // 静态根节点
  } else if (el.once && !el.onceProcessed) {
    return genOnce(el, state) // v-once 节点
  } else if (el.for && !el.forProcessed) {
    return genFor(el, state) // v-for 节点
  } else if (el.if && !el.ifProcessed) {
    return genIf(el, state) // v-if 节点
  } else if (el.tag === 'template' && !el.slotTarget) {
    return genChildren(el, state) || 'void 0' // 不带 slot 的 template 节点
  } else if (el.tag === 'slot') { // slot
    return genSlot(el, state)
  } else {
    // component or element
    // 组件或者 HTML 标记节点, 大头
    let code
    if (el.component) {
      // 组件
      code = genComponent(el.component, el, state)
    } else {
      // HTML 节点
      const data = el.plain ? undefined : genData(el, state)

      // genChildren 会返回一个数组, 其中包括了所有子节点的 _c
      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
        }${
        children ? `,${children}` : '' // children
        })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

我们的例子最终构建出的渲染函数是这样的:

function anonymous() {
  // this 指向当前 Vue 实例, 这样 todos, completed 才能指向 Vue 实例属性
  with (this) {
    return _c(
      'div',
      {
        attrs: { "id": "root" }
      }, 
      [
        _c('h1',
          { staticClass: "todo-list" },
          [
            _v("Here's something to do")
          ]),
        _v(" "),
        _c('ul',
          _l((todos), function (todo) { return _c('li', [_v(_s(todo))]) })),
        _v(" "),
        (completed) ?
          _c('p', [_v("Completed!")]) :
          _c('p', [_v("Not completed...")])
      ]
    )
  }
}

在阅读这一部分源码的过程中你可能会疑惑 _c, _m, _l 都是些什么, 它们是产生 VNode 的函数, 在 instance/render-helpers 中注册到 Vue.prototype 上 (除了 _c, 它是在 initRender 的时候被注册的), 执行渲染的时候就会被调用.

从渲染函数到 VNode

不管是只有运行时, 还是带编译器 $mount 最后都要执行如下代码:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined // 如果 options 中提供了 el, 就挂载到对应的 DOM 节点上
  return mountComponent(this, el, hydrating)
}

mountComponent 函数在 core/instance/lifecyle 中.

// $mount 函数执行过程中会调用这个函数, 这也是 mount 的全部过程
export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 如果没有渲染函数, 就创建一个空节点, 并提醒 runtime-only 版本不含编译器什么的
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  // 触发 beforeMount 钩子
  callHook(vm, 'beforeMount')

  let updateComponent // 这个函数很重要, 它是 Vue 触发重渲染的入口
  // 在性能测试的时候, updateComponent 还要做一些事情, 我们不关心
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      // ...
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined

  // 我们会将这个新创建的 Watcher 作为 vm._watcher
  // 因为 watcher 的第一次更新需要调用 $foreceUpdate, 而这个方法依赖于 _watcher 已经定义
  // 所以当 updateComponent 的依赖更新的时候, updateComponent 会被调用

  // 重新再看一次 Watcher 的代码
  new Watcher(vm, updateComponent, noop, {
    before() {
      // 这里让创建的 Watcher 在被更新之前触发 beforeUpdate 钩子
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

在为 updateComponent 构造 Watcher 的时候, 会立即被调用一次, 进行生成 VNode 树和 patch 的过程.

// 在 core/instance/render 中
Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  // reset _rendered flag on slots for duplicate slot check
  if (process.env.NODE_ENV !== 'production') {
    for (const key in vm.$slots) {
      // $flow-disable-line
      vm.$slots[key]._rendered = false
    }
  }

  if (_parentVnode) {
    vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
  }

  // set parent vnode. this allows render functions to have access
  // to the data on the placeholder node.
  vm.$vnode = _parentVnode
  // render self
  let vnode
  try {
    // 调用渲染函数, 传入 vm._renderProxy, vm.$createElement 作为参数
    // 目前看没看出这两个参数有什么用... 之后会明白的
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    handleError(e, vm, `render`)
    // return error render result,
    // or previous vnode to prevent render error causing blank component
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      if (vm.$options.renderError) {
        try {
          vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
        } catch (e) {
          handleError(e, vm, `renderError`)
          vnode = vm._vnode
        }
      } else {
        vnode = vm._vnode
      }
    } else {
      vnode = vm._vnode
    }
  }
  // return empty vnode in case the render function errored out
  // 如果渲染函数出错, 就创建一个空的 VNode
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
      warn(
        'Multiple root nodes returned from render function. Render function ' +
        'should return a single root node.',
        vm
      )
    }
    vnode = createEmptyVNode()
  }
  // set parent
  vnode.parent = _parentVnode
  return vnode
}

我们暂时先省略和 _parentVNode 相关的部分 (留到讲组件化机制的时候), _render 所做的是尝试调用渲染函数获取 VNode 树, 交给 _update 进行处理. 接下来我们来看渲染函数的执行过程以及, VNode 的数据结构, 了解 VNode 树是如何生成的.

VNode 的生成

VNode 数据结构如下:

// VNode 数据结构
export default class VNode {
  tag: string | void; // 标签
  data: VNodeData | void; // 数据
  children: ?Array<VNode>; // 孩子节点
  text: string | void; // 文本
  elm: Node | void; // DOM 元素
  ns: string | void; // 命名空间
  context: Component | void; // 在其上下文内渲染的组件
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // 组件占位节点

  // strictly internal
  raw: boolean; // 包含 raw HTML (仅适用于服务端渲染)
  isStatic: boolean; // 提升后的静态节点
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // 注释节点
  isCloned: boolean; // 是否克隆节点
  isOnce: boolean; // 是否 v-once 节点
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // 为了服务端渲染缓存
  fnScopeId: ?string; // functional scope id support
}

在渲染函数被调用的时候, 一系列带下划线的 VNode 生成函数会调用 VNode 的函数, 设置其属性并通过 children 记录其子 VNode 节点, 从而形成一个 VNode 树.

考虑到篇幅, 我仅会提及例子中会遇到的函数, 你可以在 core/instance/render-helpers/index.js 找到全部的这些方法 (除了 _c):

function anonymous() {
  // this 指向当前 Vue 实例, 这样 todos, completed 才能指向 Vue 实例属性
  with (this) {
    return _c(
      'div',
      {
        attrs: { "id": "root" }
      }, 
      [
        _c('h1',
          { staticClass: "todo-list" },
          [
            _v("Here's something to do")
          ]),
        _v(" "),
        _c('ul',
          _l((todos), function (todo) { return _c('li', [_v(_s(todo))]) })),
        _v(" "),
        (completed) ?
          _c('p', [_v("Completed!")]) :
          _c('p', [_v("Not completed...")])
      ]
    )
  }
}

_c, 定义在 core/instance/render 中, 用来构造元素节点:

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

createElement 较为重要, 它最经常被调用, 还要负责维护 VNode 之间的父子关系.

_v 用来构造文本节点.

export function createTextVNode(val: string | number) {
  return new VNode(undefined, undefined, undefined, String(val))
}

_ l 用来构造列表节点, 它的参数是一个数组和一个用于生成数组成员节点的回调函数.

export function renderList(
  val: any,
  render: (
    val: any,
    keyOrIndex: string | number,
    index?: number
  ) => VNode
): ?Array<VNode> {
  let ret: ?Array<VNode>, i, l, keys, key
  if (Array.isArray(val) || typeof val === 'string') {
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
  } else if (typeof val === 'number') {
    ret = new Array(val)
    for (i = 0; i < val; i++) {
      ret[i] = render(i + 1, i)
    }
  } else if (isObject(val)) {
    keys = Object.keys(val)
    ret = new Array(keys.length)
    for (i = 0, l = keys.length; i < l; i++) {
      key = keys[i]
      ret[i] = render(val[key], key, i)
    }
  }
  if (isDef(ret)) {
    ret._isVList = true
  }
  return ret
}

可以看到 v-if 并没有对应的函数, 我们可以从渲染函数中看到它是靠若干个三元组实现的.

当渲染函数执行完毕之后, VNode 树就构造好了, 并被交给 _update 方法.

到这里, 你可以检查例子的控制台, 我输出 VNode 树.


至此, 我们已经了解了从模板字符串到生成 VNode 的全部过程. 现在文章已经很长了, 所以我将会在这里停止, 从 VNode 到 DOM 的过程会在我的下一篇文章中分析.