Vue 模板编译原理详解

1,288 阅读5分钟

前言

前边有分享过 React 的工作原理,那么 Vue 又是怎样的编译原理呢,经过几天的翻阅,视频,现在来分享给大家,共勉 。

参考

在分享 Vue 响应式原理的时候,我们有看到一张图,很详细的体现了Vue实例的整个生命周期,那么今天我们就从这张图来作为入口

从图上来看,结合我们之前分享的响应式原理,可以知道,new Vue(options) 主要执行了 this._init(options) 初始化方法,而这个方法就定义在 init.js 中 ,下方简约代码详解

// 入口文件 src/core/instance/index.js
// 删掉多余的代码
import { initMixin } from './init' 
function Vue (options) {
  // _init 是Vue的原型方法,定义在 initMixin 中,看上边导入路径
  this._init(options) 
}
initMixin(Vue) // 初始化各个_init方法, 包含初始化 options, render, events,beforCreated,created 等
export default Vue // 导出 Vue 构造函数

initMixin 模块又做了些什么事儿 ? code position : src/core/instance/init.js

export function initMixin (Vue) {
 // 原型上的 _init , 也是那句 this._init(options) 
  Vue.prototype._init = function (options) {
    const vm = this
    // 合并options
    vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
    )
    vm._self = vm 
    initLifecycle(vm) // 初始化生命周期 
    initEvents(vm) // 初始化事件
    initRender(vm) // 初始化 render
    callHook(vm, 'beforeCreate') // 执行创建前生命周期
    initInjections(vm) // 注入 data/props
    initState(vm) // 初始化state
    initProvide(vm) // 初始化 provide 
    callHook(vm, 'created') // 执行创建后
    
    // 通过 $mount 方法挂载实例 
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

截至到这里,整个 Vue 实例的初始化阶段暂且告一段落,总结下来就是 :

new Vue(options) => 调用 _init() 方法合并用户实例opstions和本身的options => 初始化一系列生命周期,事件,render, provider和inject注入 => 最后调用 $mount 挂载实例

直到 $mount 挂载 , 我们回到今天的主题 模板编译 , 顾名思义,就是 Vue 是如何将 template 变真实的 dom 渲染到页面上的,那自然是在挂载的时候才会发生的事儿

$mount

首先来看一看 $mount 方法是如何实现的,源码上不止一个 $mount ,由于我们编写 Vue 可以写 render 方法,也可以写 template 模板, 所以 $mount 就分为了两种

  1. render 函数 => vNode 虚拟dom => 真实 dom
  2. template 模板 => Ast 抽象语法树 => render => vNode 虚拟dom => 真实 dom

而我们今天分享的主题是 模板编译 , 那自然就是第2种了

不需要模板编译,原型上定义的 $mount

src/platforms/web/runtime/index.js

import { mountComponent } from 'core/instance/lifecycle';

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

需要模板编译,引用上边 原型上定义的 $mount

src/platforms/web/entry-runtime-with-compiler.js

删除多余的代码

import Vue from './runtime/index';
import { compileToFunctions } from './compiler/index' 

// 缓存上面的 $mount 原型方法,最后调用
const mount = Vue.prototype.$mount; 
// 重写编译模板的原型方法 $mount 
Vue.prototype.$mount = function(
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el);

  // 不能挂载到 body 和 html 上 
  if (el === document.body || el === document.documentElement) {     
    return this;
  }

  const options = this.$options;
  
  // 如果没有 render 函数,将创建一个 render 函数
  // 并挂载到当前的 options 上,来自于 compileToFunctions 方法
  if (!options.render) { 
      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;
  }
  // 调用上边缓存的 $mount 方法
  return mount.call(this, el, hydrating);
};

看了两段 $mount 的方法实体,其实重写的 $mount 只是把 template 模板转换成 render ,再调用回引用的 $mount , 代码就是最后一句 return mount.call(this, el, hydrating);

模板编译

通过上边的分析,可以知道没有 render 则需要调用 compileToFunctions 方法,将 template 模板字符串转换为 render 方法

compileToFuncitons 来自 import { compileToFunctions } from './compiler/index' 如下代码

/* @flow */
import { parse } from './parser/index' 
import { optimize } from './optimizer' 
import { generate } from './codegen/index' 
// compileToFunctions 方法来自于这里
import { createCompilerCreator } from './create-compiler'

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
 // 通过 parse 生成 ast
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
   // 优化 ast
    optimize(ast, options)
  }
  // 生成 render 函数字符串,然后通过 new Function(code) 转换为函数
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

编译核心

好,看到这里,我们知道调用 $mount 挂载后,会调用 compileToFunctions 将 template 模板字符串转换为 render 函数

转换为 render 函数过程 :(parse) 生成 ast => (optimize) 优化ast => (generate) 生成 render 函数

parse

在分享 AST 之前,我们首先要知道,啥叫 ast , 它能做什么 ?

一句话简单概括一下,AST 的全称是 Abstract Syntax Tree,也就是抽象语法树,用来表示代码的语法数据结构

来一个参考实例 - 大概的结构 - 参数不一定全

let str = '<div id="content">我是文本</div>'// 转化为 ast 
let ast = {
    type:1// 1 : 标签 ,2 : 表达式 , 3 :文本 , 原生 nodeType
    tag:'div', // 元素
    parent: undefined, // 父级
    attrs: [{name: "id", value: "'content'"}], // 属性
    children:[ // 子集 , 可能有多个
    	{
            type:3,
            text:'我是文本'
        }
    ]
}

源码在这一块儿就相当的复杂了, 各种正则匹配 ,有兴趣的可以去源码看看,src/compiler 下对应的 parser

我听过公开课,也阅读过几篇模板编译的文章,在这里可以简单的总结一下,parser 就是将 html 字符串,通过截取拼装的方式,来组成 ast 语法树对象,具体的截取逻辑就是: 如:

let html = "<div>123</div>"
if(html.indexOf("<") == 0) => 截取操作 开始标签
if(html.indexOf("html") >= 0) => 截取操作 文本
if(html.indexOf(">") == 0) => 截取操作 结束标签
  1. 开始标签通过 indexOf 判断是否包含开始标签 < 的方式
  2. 结束标签 同上 => >
  3. 文本则是长度 >= 0

注:当然实际并没有这么简单哈,我只是举一个小例子

重要 依次截取下去,当遇到开始标签的时候就去创建一个 ast 对象,当遇到结束标签,则把当前对象记录在全局上,如此循环,就会形成一个多层的 AST 对象,如上边的参考实例 。

optimize

优化 ast , 那具体它是怎么优化的呢 , 其实只是给每一个 ast 对象添加了一个标识静态属性 isStatic :true/false , 为什么添加这个属性呢 ? 这里就牵扯到 diff , 后边的虚拟 dom , 我将会专门写一篇关于 diff 的文章,到时再具体分析 .

这里呢,简单说一下,其实就是 diff 的时候,比对到静态标签,一律跳过,因为 isStatic:true 的时候,意味着这个标签或者元素是静态的,不需要编译和解析的,不含变量的,不是slot/component 等 ,所以直接跳过,也算是一种优化吧 .

generate

有点像 React render 经过 bebal-loader 编译后的感觉,Vue 就是将 AST 树遍历,生成 render 函数 , 然后执行 render 就会生成虚拟 dom , 最后就是创建与diff渲染真实dom了 。 我们来看看 generate 函数

src/compiler/codegen/index.js 43行

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  // 通过 genElement() 转换成 _c() 的形式
  const code = ast ? genElement(ast, state) : '_c("div")'
  // 结果是返回一个使用 with 包裹的函数
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

这里我就没有继续去查看每一个方法是怎么实现的了,只了解了一下,例举几个遍历 ast 树的编译方法 :

1.genElement:用来生成基本的 createElement 结构 ,_c() 的形式

2.genData: 处理ast结构上的一些属性,用来生成data

3.genChildren:处理ast的children,并在内部调用genElement,形成子元素的 _c() 方法

  1. ...... 还有很多啊,如: genIf , genFor , genStatic , genOnce 等等对应的处理

可能有的童鞋对 _c 不太了解,说实话,我看到这里的时候也一脸蒙蔽,后来才发现,这些只是 vue 定义的一些特殊方法的简写,如:

_c:对应的是 createElement 方法,创建一个元素(Vnode)
_v:创建一个文本结点
_s:把一个值转换为字符串(eg: {{data}})
_m:渲染静态内容
......

那好,经过 generate 的转换之后,就形成了这种形式

// 编译前
<template>
  <div id="content">
    {{msg}}
    <p>123</p>
  </div>
</template>

// 编译后
{
  render: with(this) {
    return _c('div', {
      attrs: {
        "id": "content"
      }
    }, [
    	_v("\n" + _s(msg) + "\n"),
        _c("p",
           _m("123")
        )
       ]
    )
  },
  staticRenderFns:.....
}

编译后,执行 render 就是传说中的虚拟 dom 了 , 到虚拟 dom 之后就是纯对象处理了,解析 => diff => 渲染 .

有人可能会说虚拟 dom 到底是什么样的,其实它跟 ast 语法树很像,也是一个类似的对象,只是 ast 树描述的是带有语法的树结构,而虚拟 dom 描述的是真实的 dom 结构 。 那什么是带有语法的树, 如:{ text : {{msg}} } , ast 里边就会包含这种的情况,而虚拟 dom 不会 。

with

  // generate 函数执行后,返回一个对象. 有一个陌生的 with 函数  
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }

看到这个函数,可能可能会有些陌生,我也是百度了之后才知道它的用处,简单的来说就是with相当于一种简写方式 在with函数包裹的代码区域,通过节点名称就能调用对象,举个例子

var obj={ name: "hisen", age:18 };

function f(obj){
    console.log(`${obj.name}:${obj.age}`)
}
// 普通调用
f(obj);
VM679:2 hisen:18

// with 调用
with(obj){
 console.log(`${name}:${age}`)
}
VM744:2 hisen:18

区别就在写法上更简洁明了,不用重复编写同名对象,目前的ES6解构其实也挺好

结束

最后引用参考文章 彩云coding 的一张精简图,来描述整个 Vue 的编译过程

欢迎点赞,小小鼓励,大大成长

相关链接