VUE2 模板编译原理

vue 模板的编译到渲染,结合源码的分析介绍从 template 到 AST,到 VNode(虚拟 DOM),再将 VNode 挂载渲染成真是的 DOM。本文从思路流程方面分析模板编译的整个过程,不着重一字一句的具体代码解读。这个过程的代码比较机械枯燥,可以参看文末的参考链接。


从 vue 模板到渲染成 dom,整个流程如图:

整体而言,Vue 的处理方式大致分为三步:

  • 将模板进行解析,得到一棵抽象语法树 AST(parse / optimize)
  • 根据抽象语法树 AST 得到虚拟 DOM 树(generate / render)
  • 将虚拟 DOM 渲染为真实的 DOM

注:抽象语法树(AST)是指对源码进行解析后形成的树状的语法结构。通俗地理解,有了 AST 以后,后续处理可以直接在树结构上进行,不用再处理源码中的各种书写格式、括号优先级等问题。

实现编译的核心源码入口:

javascript 复制代码
// src/compiler/index.js
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
  }
})

步骤一 将模板进行解析为抽象语法树 AST

parse() 方法是将源码转换成 AST 的方法,它的核心是调用 parseHTML() 方法解析。相关源码位于 src/compiler/parser/index.js。

parseHTML() 做的事就是从头扫描 HTML 字符串,按一定的规则判断当前字符是什么(标签、属性、文本、注释等等),并调用相应的回调方法,从而完成 HTML 字符串的解析,并返回 AST 树。

示例:

html 复制代码
<!-- 模板 template -->
<div class="counter">
  <div>{{ count }}</div>
  <button @click="increment">+</button>
</div>

经过 parse / parseHTML 解析后生成的 AST 是这样的:

javascript 复制代码
{
    attrsList: [],
    attrsMap: {
        class: "counter"
    },
    children: [{
        attrsList: [],
        attrsMap: {},
        children: [{
            end: 37,
            expression: "_s(count)",
            start: 26,
            text: "{{ count }}",
            tokens: [{
                @binding: "count"
            }]
        }],
        end: 43,
        parent: {...},
        plain: true,
        rawAttrsMap: {},
        start: 21,
        tag: "div",
        type: 1,
    },{
        attrsList: [{
            end: 69,
            name: "@click",
            start: 51,
            value: "increment",
        }],
        attrsMap:{
            @click: "increment",
        },
        children: [{
            end: 71,
            start: 70,
            text: "+",
            type: 3  // text
        }],
        end: 80,
        events:{
            click:{
                dynamic: false,
                end: 69,
                start: 51,
                value: "increment"
            }
        },
        hasBindings: true,
        parent: {...},
        plain: false,
        rawAttrsMap:{
            @click:{
                end: 69,
                name: "@click",
                start: 51,
                value: "increment",
            }
        },
        start: 43,
        tag: "button",
        type: 1
    }],
    end: 86,
    parent: undefined,
    plain: false,
    rawAttrsMap:{
        class:{
            end: 20,
            name: "class",
            start: 5,
            value: "counter"
        }
    }
    start: 0,
    staticClass: "\"counter\"",
    tag: "div",
    type: 1
}

这一步之后,进行 optimize() 处理。

optimize() 是对 AST 进行优化的过程,以提升后续渲染性能。这个方法位于 src/compiler/optimizer.js,作用是分析出纯静态的 DOM(不含表达式,可以直接渲染的 DOM),将它们放入常量中,在后续 patch 的过程中可以忽略它们。

optimize() 处理逻辑,当一个元素有表达式时肯定就不是静态的,当一个元素是文本节点时,肯定是静态的,如果子元素是非静态的,则父元素也是非静态的。

optimize() 处理后的 AST:

javascript 复制代码
{
    attrsList: [],
    attrsMap: {
        class: "counter"
    },
    children: [{
        attrsList: [],
        attrsMap: {},
        children: [{
            end: 37,
            expression: "_s(count)",
            start: 26,
            // 看这里
            static: false,
            text: "{{ count }}",
            tokens: [{
                @binding: "count"
            }]
        }],
        end: 43,
        parent: {...},
        plain: true,
        rawAttrsMap: {},
        // 看这里
        static: false,
        staticRoot: false,
        start: 21,
        tag: "div",
        type: 1,
    },{
        attrsList: [{
            end: 69,
            name: "@click",
            start: 51,
            value: "increment",
        }],
        attrsMap:{
            @click: "increment",
        },
        children: [{
            end: 71,
            start: 70,
            // 看这里
            static: true,
            text: "+",
            type: 3
        }],
        end: 80,
        events:{
            click:{
                dynamic: false,
                end: 69,
                start: 51,
                value: "increment"
            }
        },
        hasBindings: true,
        parent: {...},
        plain: false,
        rawAttrsMap:{
            @click:{
                end: 69,
                name: "@click",
                start: 51,
                value: "increment",
            }
        },
        start: 43,
        // 看这里
        static: false,
        staticRoot: false,
        tag: "button",
        type: 1
    }],
    end: 86,
    parent: undefined,
    plain: false,
    rawAttrsMap:{
        class:{
            end: 20,
            name: "class",
            start: 5,
            value: "counter"
        }
    }
    start: 0,
    // 看这里
    static: false,
    staticClass: "\"counter\"",
    staticRoot: false,
    tag: "div",
    type: 1
}

步骤二 将 AST 树转化为 VNode (虚拟 DOM 树)

实现编译的核心源码入口:

javascript 复制代码
// src/compiler/index.js
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() 和 optimize() 运行完之后,执行 generate():

javascript 复制代码
// src\compiler\codegen\index.js
export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

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)
  } else if (el.tag === 'template' && !el.slotTarget) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
    // component or element
    let code
    //组件的处理
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      //核心的body部分
      //1、生成节点的数据对象data的字符串
      const data = el.plain ? undefined : genData(el, state)
      //2、查找其子节点,生成子节点的字符串
      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      //3、将tag,data,children拼装成字符串
      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
  }

generate() 返回的 render 表达式结构像这样:

javascript 复制代码
_c(
  // 1、标签
  'div',
  //2、模板相关属性的数据对象 
  {
   ...  
  },
  //3、子节点,循环其模型
  [
    _c(...)
  ]
javascript 复制代码
<template>
  <div id="app">
    <h1>Hello</h1>
    <span>{{message}}</span>
  </div>
</template>
// 上述dom 对应的虚拟 render 表达式
with(this){
  return _c('div',{
    attrs:{"id":"app"}
  },
  [
    _c('h1',[
        _v("Hello")
    ]),
    _c('span',[
        _v(_s(message))
    ])
  ])
}

示例中出现较多的方法是_c(),即 vm._c(),这个方法本质是 createElement() 的封装(源码在src\core\instance\render.js)。除此之外,在 render() 方法中,还有可能出现_v()、_s()、_h()、_m() 等诸多辅助方法。

javascript 复制代码
// src/core/instance/render-helpers/index.js
export function installRenderHelpers (target: any) {
  target._o = markOnce // 处理 v-once
  target._n = toNumber // 处理修饰符.number  <input v-model.number="age" type="number">
  target._s = toString // ......
  target._l = renderList
  target._t = renderSlot // 处理 slot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners

在后续组件进行挂载时,render 方法会被调用,这些辅助方法会将 render 转化为虚拟 DOM(VNode)。

虚拟 DOM(VNode)是什么样的?

可看一下 VNode 类:

javascript 复制代码
export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  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; // for SSR caching
  devtoolsMeta: ?Object; // used to store functional render context for devtools
  fnScopeId: ?string; // functional scope id support

  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (): Component | void {
    return this.componentInstance
  }
}

说白了,虚拟 DOM 就是一种使用 JS 数据结构模拟 DOM 元素及其关系的方法。

DOM 操作之所以慢,主要有两方面原因:

  • DOM 操作属于使用 JavaScript 调用浏览器提供的接口的过程,并不都是在 JS 引擎中直接完成,中间有不少的性能开销
  • DOM 本身非常庞大,属性和方法极多,构建、销毁、修改都有比较大的性能开销

使用 VNode 不再需要关注 DOM 元素所有的属性和方法,仅仅只需要关注元素类型、属性、子内容等即可,因此 VNode 可以解决上述两个导致 DOM 操作慢的问题。

除此之外,VNode 元素之间也会形成和 DOM 树类似的树状结构,开发者可以将 DOM 元素的对比、变更提前到 VNode 层面去完成,直到 VNode 完成变更以后,计算出发生变动的 VNode,最后再根据这些 VNode 去进行真实 DOM 元素的变更。这样就可以大大减少需要进行的 DOM 操作,从而提升性能。

虚拟 DOM(VNode) 因为是纯 JavaScript 数据结构,因此具有很好的跨平台性。

步骤三 挂载,将虚拟 DOM 渲染为真实的 DOM

Vue.prototype. <math xmlns="http://www.w3.org/1998/Math/MathML"> m o u n t 定义在 p l a t f o r m s / w e b / r u n t i m e / i n d e x . j s , mount 定义在 platforms/web/runtime/index.js, </math>mount定义在platforms/web/runtime/index.js,mount 本质上是调用了 mountComponent()

javascript 复制代码
// core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 省略一大段对render的判断

  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // 定义Watcher
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true)

  // 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
}

vm._update() 的第一个参数 render 方法返回的虚拟 DOM,其负责将虚拟 DOM 渲染到真实的 DOM 中。

javascript 复制代码
// core/instance/lifecycle.js
// _update() 主要源码
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

这段代码首先判断了 vm._vnode 是否存在,如果不存在,则说明这个组件是初次渲染,否则说明之前渲染过,这一次渲染是需要进行更新。针对这两种情况,分别用不同的参数调用了 __patch__() 方法:

  • 如果是初次渲染,第一个参数是真实的 DOM 元素,后续使用 createElm() 根据虚拟 DOM 创建新 DOM。
  • 如果不是初次渲染,第一个参数是前一次渲染的虚拟 DOM,后续调用 patchVnode() 方法,进入虚拟 DOM 对比和更新的流程

最底层的处理逻辑,还是通过原始的 dom 操作方法 insertBefore、appendChild 等处理渲染的。


参考:TooooBug恰恰虎

相关推荐
布瑞泽的童话17 分钟前
无需切换平台?TuneFree如何搜罗所有你爱的音乐
前端·vue.js·后端·开源
白鹭凡29 分钟前
react 甘特图之旅
前端·react.js·甘特图
2401_8628867833 分钟前
蓝禾,汤臣倍健,三七互娱,得物,顺丰,快手,游卡,oppo,康冠科技,途游游戏,埃科光电25秋招内推
前端·c++·python·算法·游戏
书中自有妍如玉41 分钟前
layui时间选择器选择周 日月季度年
前端·javascript·layui
Riesenzahn41 分钟前
canvas生成图片有没有跨域问题?如果有如何解决?
前端·javascript
f89790707044 分钟前
layui 可以使点击图片放大
前端·javascript·layui
小贵子的博客44 分钟前
ElementUI 用span-method实现循环el-table组件的合并行功能
javascript·vue.js·elementui
忘不了情1 小时前
左键选择v-html绑定的文本内容,松开鼠标后出现复制弹窗
前端·javascript·html
码上飞扬1 小时前
前端框架对比选择:如何在众多技术中找到最适合你的
vue.js·前端框架·react·angular·svelte