vue模板渲染--compile

模板渲染过程在实际使用vue的过程可能并不需要太深理解,但就vue来说,这些底层思想可以更好地让我们理解这个框架,以及了解为什么Vue的API要如此设计…

vue2+与vue1+的模板渲染过程完全不同,vue1使用的是DocumentFragment API,具体就不介绍了(可以直接跳到MDN去了解),而vue2开始则使用了Virtual DOM,基于Virtual DOM,vue2支持了服务端渲染SSR,以及JSX语法。介绍渲染流程之前,先说明两个数据结构:抽象语法树AST,以及VNode。

AST(抽象语法树)

AST 的全称是 Abstract Syntax Tree(抽象语法树),是源代码的抽象语法结构的树状表现形式,计算机学科中编译原理的概念。而vue就是将模板代码映射为AST数据结构,进行语法解析。这里采用了flow的语法,flow是一个JS静态类型检查工具。
在 Vue 中,ASTNode 分几种不同类型,关于 ASTNode 的定义在 flow/compile.js 里面,请看下图:

看一下 Vue 2.0 源码中 AST 数据结构 的定义:

declare type ASTNode = ASTElement | ASTText | ASTExpression
declare type ASTElement = { // 有关元素的一些定义
  type: 1;
  tag: string;
  attrsList: Array<{ name: string; value: string }>;
  attrsMap: { [key: string]: string | null };
  parent: ASTElement | void;
  children: Array<ASTNode>;
  //......
}
declare type ASTExpression = {
  type: 2;
  expression: string;
  text: string;
  tokens: Array<string | Object>;
  static?: boolean;
  // 2.4 ssr optimization
  ssrOptimizability?: number;
};
declare type ASTText = {
  type: 3;
  text: string;
  static?: boolean;
  isComment?: boolean;
  // 2.4 ssr optimization
  ssrOptimizability?: number;
};

我们用一个简单的例子来说明一下:

<div id="demo">
  <h1>Latest Vue.js Commits</h1>
  <p>{{1 + 1}}</p>
</div>

我们想一想这段代码会生成什么样的 AST 呢?


VNODE数据结构

VNODE就是vue中的虚拟dom节点,VNODE 数据结构 如下:

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
}

真实DOM存在什么问题,为什么要用虚拟DOM

我们为什么不直接使用原生 DOM 元素,而是使用真实 DOM 元素的简化版 VNode,最大的原因就是 document.createElement 这个方法创建的真实 DOM 元素会带来性能上的损失。我们来看一个 document.createElement 方法的例子

let div = document.createElement('div');
for(let k in div) {
  console.log(k);
}

打开 console 运行一下上面的代码,会发现打印出来的属性多达 228 个,而这些属性有 90% 多对我们来说都是无用的。VNode 就是简化版的真实 DOM 元素,关联着真实的dom,比如属性elm,只包括我们需要的属性,并新增了一些在 diff 过程中需要使用的属性,例如 isStatic。

模板渲染流程

先来一张图:


首先从$mount开始,可以看到,mount其实就是拿到了html模板作为template,然后将这个template通过compileToFunctions方法编译成render函数:

if (template) {
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    mark('compile')
  }

  const { render, staticRenderFns } = compileToFunctions(template, { //对获取到的template进行编译
    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')
  }
}

那么这个compileToFunctions做了什么呢?主要将 template 编译成 render 函数。首先读缓存,没有缓存就调用 compile 方法拿到 render 函数 的字符串形式,再通过 new Function 的方式生成 render 函数。

// 有缓存的话就直接在缓存里面拿
const key = options && options.delimiters
            ? String(options.delimiters) + template
            : template
if (cache[key]) {
    return cache[key]
}
const res = {}
const compiled = compile(template, options) // compile 后面会详细讲
res.render = makeFunction(compiled.render) //通过 new Function 的方式生成 render 函数并缓存
const l = compiled.staticRenderFns.length
res.staticRenderFns = new Array(l)
for (let i = 0; i < l; i++) {
    res.staticRenderFns[i] = makeFunction(compiled.staticRenderFns[i])
}
......
}
return (cache[key] = res) // 记录至缓存中

现在我们具体看一下compile方法,上文中提到 compile 方法就是将 template 编译成 render 函数 的字符串形式。

export function compile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const AST = parse(template.trim(), options) //1. parse
  optimize(AST, options)  //2.optimize
  const code = generate(AST, options) //3.generate
  return {
    AST,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

这个函数主要有三个步骤组成:parse,optimize 和 generate,分别输出一个包含 AST,staticRenderFns 的对象和 render函数 的字符串。

parse 函数,主要功能是将 template字符串解析成 AST,采用了 jQuery 作者 John Resig 的 HTML Parser。前面定义了ASTElement的数据结构,parse 函数就是将template里的结构(指令,属性,标签等)转换为AST形式存进ASTElement中,最后解析生成AST。

optimize 函数(src/compiler/optimizer.js)主要功能就是标记静态节点,为后面 patch 过程中对比新旧 VNode 树形结构做优化。被标记为 static 的节点在后面的 diff 算法中会被直接忽略,不做详细的比较。

generate 函数(src/compiler/codegen/index.js)主要功能就是根据 AST 结构拼接生成 render 函数的字符串。

1. parse(解析器)

在 parse 函数中,我们先是定义了非常多的全局属性以及函数,然后调用了 parseHTML 这么一个函数,这也是 parse 最核心的函数,这个函数会不断的解析模板,填充 root,最后把 root(AST) 返回回去。

parseHTML

在这个函数中,最重要的是 while 循环中的代码,而在解析过程中发挥重要作用的有这么几个正则表达式。

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/

Vue 通过上面几个正则表达式去匹配开始结束标签、标签名、属性等等。

关于 while 的详细注解我放在我仓库里了,有兴趣的可以去看看。

在 while 里,其实就是不断的去用 html.indexOf('<') 去匹配,然后根据返回的索引的不同去做不同的解析处理:

  • 等于 0:这就代表这是注释、条件注释、doctype、开始标签、结束标签中的某一种
  • 大于等于 0:这就说明是文本、表达式
  • 小于 0:表示 html 标签解析完了,可能会剩下一些文本、表达式

parse 函数就是不断的重复这个工作,然后将 template 转换成 AST,在解析过程中,其实对于标签与标签之间的空格,Vue 也做了优化处理,有些元素之间的空格是没用的。

compile 其实要说要说非常多的篇幅,但是这里只能简单的理一下思路,具体代码还需要各位下去深扣。

2. optimize(优化器)

从代码中的注释我们可以看出,优化器的目的就是去找出 AST 中纯静态的子树:

1.把纯静态子树提升为常量,每次重新渲染的时候就不需要创建新的节点了
2.在 patch 的时候就可以跳过它们

optimize 的代码量没有 parse 那么多,我们来看看:

export function optimize (root: ?ASTElement, options: CompilerOptions) {
  // 判断 root 是否存在
  if (!root) return
  // 判断是否是静态的属性
  // 'type,tag,attrsList,attrsMap,plain,parent,children,attrs'
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  // 判断是否是平台保留的标签,html 或者 svg 的
  isPlatformReservedTag = options.isReservedTag || no
  // 第一遍遍历: 给所有静态节点打上是否是静态节点的标记
  markStatic(root)
  // 第二遍遍历:标记所有静态根节点
  markStaticRoots(root, false)
}

下面两段代码我都剪切了一部分,因为有点多,这里就不贴太多代码了。

第一遍遍历

function markStatic (node: ASTNode) {
  node.static = isStatic(node)
  if (node.type === 1) {
    ...
  }
}

其实 markStatic 就是一个递归的过程,不断地去检查 AST 上的节点,然后打上标记。

刚刚我们说过,AST 节点分三种,在 isStatic 这个函数中我们对不同类型的节点做了判断:

function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // expression
    return false
  }
  if (node.type === 3) { // text
    return true
  }
  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)
  ))
}

可以看到 Vue 对下面几种情况做了处理:

  1. 当这个节点的 type 为 2,也就是表达式节点的时候,很明显它不是一个静态节点,所以返回 false
  2. 当 type 为 3 的时候,也就是文本节点,那它就是一个静态节点,返回 true
  3. 如果你在元素节点中使用了 v-pre 或者使用了 <pre> 标签,就会在这个节点上加上 pre 为 true,那么这就是个静态节点
  4. 如果它是静态节点,那么需要它不能有动态的绑定、不能有 v-if、v-for、v-else 这些指令,不能是 slot 或者 component 标签、不是我们自定义的标签、没有父节点或者元素的父节点不能是带 v-for 的 template、 这个节点的属性都在 type,tag,attrsList,attrsMap,plain,parent,children,attrs 里面,满足这些条件,就认为它是静态的节点。

接下来,就开始对 AST 进行递归操作,标记静态的节点,至于里面做了哪些操作,可以到上面那个仓库里去看,这里就不展开了。

第二遍遍历

第二遍遍历的过程是标记静态根节点,那么我们对静态根节点的定义是什么,首先根节点的意思就是他不能是叶子节点,起码要有子节点,并且它是静态的。在这里 Vue 做了一个说明,如果一个静态节点它只拥有一个子节点并且这个子节点是文本节点,那么就不做静态处理,它的成本大于收益,不如直接渲染。

同样的,我们在函数中不断的递归进行标记,最后在所有静态根节点上加上 staticRoot 的标记,关于这段代码也可以去上面的仓库看一看

3. generate(代码生成器)

在这个函数中,我们将 AST 转换成为 render 函数字符串,代码量还是挺多的,我们可以来看一看。

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  // 这就是编译的一些参数
  const state = new CodegenState(options)
  // 生成 render 字符串
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

可以看到在最后代码生成阶段,最重要的函数就是 genElement 这个函数,针对 AST 的属性(不同的指令、属性),我们会选择不同的代码生成函数。最后我们按照 AST 生成拼接成一个字符串,如下所示:

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)
  } else if (el.for && !el.forProcessed) {
    return genFor(el, state)
  } 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 {
      const data = el.plain ? undefined : genData(el, state)

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

在 render 这个函数字符串中,我们会看到一些函数,那么这些函数是在什么地方定义的呢?我们可以在 core/instance/index.js 这个文件中找到这些函数:

// v-once
target._o = markOnce
// 转换
target._n = toNumber
//把一个值转换为字符串。(eg: {{data}})
target._s = toString
// v-for
target._l = renderList
// slot
target._t = renderSlot
// 是否相等
target._q = looseEqual
// 检测数组里是否有相等的值
target._i = looseIndexOf
// 渲染静态树
target._m = renderStatic
// 过滤器处理
target._f = resolveFilter
// 检查关键字
target._k = checkKeyCodes
// v-bind
target._b = bindObjectProps
// 创建文本节点
target._v = createTextVNode
// 创建空节点
target._e = createEmptyVNode
// 处理 scopeslot
target._u = resolveScopedSlots
// 处理事件绑定
target._g = bindObjectListeners
// 创建 VNode 节点
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

生成 render 的 generate 函数的输入也是 AST,它递归了 AST 树,为不同的 AST 节点创建了不同的内部调用方法,等待后面的调用。生成 render 函数的过程如下:


假设我们有这么一段 template

<template>
  <div id="test">
    {{val}}
    <img src="http://xx.jpg">
  </div>
</template>

最终会被转换成这样子的函数字符串

{render: "with(this){return _c('div',{attrs:{"id":"test"}},[[_v(_s(val))]),_v(" "),_m(0)])}"}

以上就是 compile 函数中三个核心步骤的介绍,compile之后我们得到了 render 函数 的字符串形式,后面通过 new Function 得到真正的渲染函数。数据发现变化后,会执行 Watcher 中的 _update 函数(src/core/instance/lifecycle.js),_update 函数会执行这个渲染函数,输出一个新的 VNode 树形结构的数据。然后在调用 patch 函数,拿这个新的 VNode 与旧的 VNode 进行对比,只有发生了变化的节点才会被更新到真实 DOM 树上。

mount后续

通过compile生成render方法之后,会进一步执行mount方法,在$mount中可以看到最后一句话:return mount.call(this, el, hydrating),这个mount实际上就是runtime中的mount,执行的就是lifecycle中的mountComponent方法,看一下基本逻辑:

// 触发 beforeMount 生命周期钩子
callHook(vm, 'beforeMount')

let updateComponent //updateComponent是watcher更新时的回调,用于更新视图操作
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  updateComponent = () => {
    const name = vm._name
    const id = vm._uid
    const startTag = `vue-perf-start:${id}`
    const endTag = `vue-perf-end:${id}`

    mark(startTag)
    const vnode = vm._render()
    mark(endTag)
    measure(`vue ${name} render`, startTag, endTag)

    mark(startTag)
    vm._update(vnode, hydrating)
    mark(endTag)
    measure(`vue ${name} patch`, startTag, endTag)
  }
} else {
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
}

// 以前是直接new Watch赋值给vm._watcher,现在这一步放到了watcher的构造函数中
// 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
new Watcher(vm, updateComponent, noop, null, 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

首先会new一个watcher对象(主要是将模板与数据建立联系),在watcher对象创建后,会运行传入的方法 vm._update(vm._render(), hydrating) 。其中的vm._render()主要作用就是运行前面compiler生成的render方法,并返回一个vNode对象。vm.update() 则会对比新的 vdom 和当前 vdom,并把差异的部分渲染到真正的 DOM 树上。

patch

patch.js 就是新旧 VNode 对比的 diff 函数,主要是为了优化dom,通过算法使操作dom的行为降到最低,diff 算法来源于 snabbdom,是 VDOM 思想的核心。snabbdom 的算法为了 DOM 操作跨层级增删节点较少的这一目标进行优化,它只会在同层级进行, 不会跨层级比较。

参考链接:
从源码理解 Vue 模板编译
Vue源码--深入模板渲染
咱来聊聊 Vue - compile

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容

  • vue2.0和1.0模板渲染的区别 Vue 2.0 中模板渲染与 Vue 1.0 完全不同,1.0 中采用的 Do...
    我是上帝可爱多阅读 1,254评论 0 4
  • 写在前面 这篇文章算是对最近写的一系列Vue.js源码的文章(https://github.com/answers...
    染陌同学阅读 2,099评论 0 14
  • Vue 2.0 中模板渲染与 Vue 1.0 完全不同,1.0 中采用的 DocumentFragment,而 2...
    亱空阅读 2,639评论 0 0
  • 前几天想学学Vue中怎么编写可复用的组件,提到要对Vue的render函数有所了解。可仔细一想,对于Vue的ren...
    kangaroo_v阅读 115,810评论 13 171
  • 就目前看来,我是很喜欢亲情的,不想离家。最喜欢有一套可以温暖身体的床,以及温暖人心的亲情,以及不断学习创新,尝试新...
    梦开阅读 812评论 0 0