vue编译流程分析

2,455 阅读7分钟

vue实例生成vnode是基于render函数,但平时我们很少直接写render函数,而是写template,vue会通过编译将template转化成render函数。

在vue包的package.json中,"module"字段指向的是"dist/vue.runtime.esm.js",所以通过import Vue from "vue"引入的vue是运行时runtime的代码,不包含编译部分,这样写会报错。

import Vue from "vue"
var vm = new Vue({
    el'#root',
    template: '<div>test</div>'
})

能在.vue文件中使用template是因为vue-loader在构建的时候将template编译成了render函数。

要debug vue的编译过程可以在webpack配置文件中改变vue的引用。

module.exports = {
    ...
    resolve: {
        alias: {
            'vue': path.join(__dirname,"node_modules/vue/dist/vue.esm.js")
        }	
    }
}

也可以直接在html文件中引入带编译器的vue版本,不用webpack。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <div id="root"></div>
    <script src="./node_modules/vue/dist/vue.js"></script>
    <script>
        var vm = new Vue({
            el'#root',
            template: '<div>test</div>'
        })
    </script>
</body>
</html>

编译入口

Vue 项目中的platform/web下的 entry-runtime.js 文件是 Vue 用于构建仅包含运行时的源码文件,而 entry-runtime-with-compiler.js 是用于构建同时包含编译器和运行时的源码文件。

对比运行时的entry-runtime.js可以看出,entry-runtime-with-compiler.js扩展了Vue.prototype.$mount方法,将编译相关的工作都封装在了这个方法里。

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el,hydrating) {
  el = el && query(el)
  const options = this.$options //this.$options是new Vue(options)传入的options
  //如果有render函数,就跳过这一段直接执行mount方法
  if (!options.render) {
    let template = options.template
    if (template) {   //如果没有render函数,有template就用template
      ...
    } else if (el) {   //如果没有render函数,也没有template,就用el生成template
      template = getOuterHTML(el)
    }
    if (template) {   //将template生成编译生成render函数,并赋值给this.$options
      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
    }
  }
  return mount.call(this, el, hydrating)
}

所以如果既有render函数又有template,就会直接使用render,忽略template。

生成render函数的是compileToFunctions方法。compileToFunctions方法又是一系列高阶函数生成的,将参数传递解耦。

compileToFunctions(template,options,vm)

const {compileToFunctions} = createCompiler(baseOptions)

const createCompiler = createCompilerCreator(baseCompile)

function createCompilerCreator (baseCompile){
    return function createCompiler (baseOptions) {
    	function compile(template, options)){
            baseCompile(template.trim(), finalOptions)
        }
        return {
          compile,
          compileToFunctions: createCompileToFunctionFn(compile)
        }
    }
}

function createCompileToFunctionFn(compile){
    return function compileToFunctions (template,options,vm){
    	compile(template, options)
    }
}

函数执行的顺序是:createCompilerCreator -> createCompiler -> createCompileToFunctionFn -> compileToFunctions -> compile -> baseCompile

执行compileToFunctions(template,options,vm)方法,最终会执行baseCompile(template.trim(), finalOptions)方法,它也是编译的核心函数。

编译核心流程

baseCompile方法定义如下:

function baseCompile (template,options){
  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完成。

  • 1、将模板字符串解析为AST
  • 2、优化AST
  • 3、将AST转换成render函数

从字符串到AST

AST是什么

AST,是抽象语法树(Abstract Syntax Tree)的缩写,是源代码的抽象语法结构的树状表现形式。在这里,源代码就是指template中的代码,而将要生成的AST是一个javascript对象,它可以描述template中代码的结构。

以这样的一个template为例

new Vue({
    el'#root',
    template: '<div class="tpl"><p v-for="item in list">{{item}}</p><p>test</p></div>',
    data() {
        return {
            list : ['aaa','bbb','ccc']
        }
    }
})

它生成的AST如下。 AST是一个树形结构的对象,和dom树类似。比如这颗树的根节点就是最外层的div,对应上面模板的内容我们可以看到它的tag: "div", attrsMap: {class: "tpl"}, 它的children有两个,对应两个p标签p标签的结构和div相同。再往下可以看到p标签children 它们在template中对应的部分分别是{{item}}test,它们的结构和divp不同,一个是表达式节点,一个是文本节点。template编译一共有三种节点:

  • 1、元素节点,type为1
  • 2、表达式文本节点,type为2
  • 3、普通文本节点, type为3

生成AST的流程

const ast = parse(template.trim(), options)

parse方法的整体过程:

function parse(template,options){
	...//解析options
    
    let root
    
    parseHTML(template, {
    	warn,
        
        //some options...此处省略
        
        /**解析过程中的回调函数**/
        start(){}, -->解析开始标签时调用
        end(){}, -->解析结束标签时调用
        chars(){}, -->解析文本时调用
        comment(){}, -->解析注释时调用
    })
    
    return root
}

parse方法的主体是parseHTML, parseHTML要做的可以概括为两件事:

1、用正则表达式匹配出开始标签、结束标签、文本、注释等内容

2、在匹配出这些内容后,结合各自对应的回调函数进行处理,生成AST节点

parseHTML的整体流程是循环解析template,用正则做匹配,根据匹配情况做不同的处理,直到整个template解析完。

function parseHTML (html, options) {
    let index = 0
    let lastTag    //上一次匹配的得到的标签
    function advance (n) {   //推进向前,得到下一次要解析的html
        index += n
        html = html.substring(n)
    }
    
    while(html){
        if(!lastTag || !isPlainTextElement(lastTag)){   //不在script和style标签中
            let textEnd = html.indexOf('<')
            if(textEnd === 0){    //html的第一个字符是"<", 分下面几种情况,下面是伪代码
            	//1、注释 <!---->
                if(isComment){
                    if (options.shouldKeepComment) {
                        options.comment() //如果保留注释,执行options传入的注释回调
                    }
                    advance (commentLength)
                    continue
                }
                
                //2、条件注释  <![if !IE]>  <![endif]>
                if(isConditionalComment){
                    advance (conditionalCommentLength)
                    continue
                }
                
                //3、doctype  <!DOCTYPE html>
                if(isDoctype){
                    advance (doctypeLength)
                    continue
                }
                
                //4、结束标签
                if(isEndTag){
                    advance(endTag.length)
                    parseEndTag()
                    continue
                }
                
                //5、开始标签
                if(isStartTag){
                    parseStartTag()   //advance是在parse的过程中执行
                    handleStartTag()
                    continue
                }   
            }
            
            //"<"字符不在第一个位置
            if (textEnd >= 0) { 如abc< , abc<<< , abc<<<div>, abc<div> , abc</div>等
                text = extractedText  对应上面分别是abc< , abc<<< , abc<<, abc , abc
            }
            
            //纯文本,没有"<"
            if (textEnd < 0) { 
                text = html 
            }
            
            advance(text.length)
            options.chars()  //执行options传入的文本回调
            
        }else{//如果在script或style标签中
            handlePlainTextElement()
            advance(plainTextElementLength)
            parseEndTag()
        }
    }
}

解析开始标签

const startTagMatch = parseStartTag()
if (startTagMatch) {
   handleStartTag(startTagMatch)
   if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
       advance(1)
   }
   continue
}

解析开始标签分两步:

  • 1、由parseStartTag方法解析html,拿到匹配到的结果
  • 2、调用handleStartTag方法处理匹配到的结果
function parseStartTag () {
    const start = html.match(startTagOpen)  // ["<div","div",index:0,...]
    if (start) {
    
      //创建一个对象,保存开始标签中的信息
      const match = {
        tagName: start[1],
        attrs: [], //保存所有属性的数组
        start: index
      }
      advance(start[0].length)
      let end, attr
      
      //在匹配到开始标签的">" 或 "/>"前,遍历匹配开始标签中的属性
      //attribute匹配普通属性,dynamicArgAttribute匹配动态属性,这个正则略复杂,可以借助正则工具看
      const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
      const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
      
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) { 
      
        //arrr = [" class="tpl"", "class", "=", "tpl", undefined, undefined,index:0,...]

        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr) 
      }
      if (end) { //end = [">", "", index:0, ...]
        match.unarySlash = end[1]  //判断是否是自闭合标签,如<img />
        advance(end[0].length)
        match.end = index
        return match
      }
    }
}

解析<div class="tpl">得到的对象,主要的信息就是:标签名tagName,属性数组attrs,是否是自闭合标签unarySlash。 下一步是传入这个对象,执行handleStartTag方法。

function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      //345匹配三种属性值的不同写法 3: "tpl" 双引号,4: 'tpl' 单引号,5: tpl 没有引号
      const value = args[3] || args[4] || args[5] || ''
    
      attrs[i] = { //将每一个属性转换成name和value这种结构的对象
        name: args[1],
        value: decodeAttr(value, shouldDecodeNewlines)
      }
    
    }

    if (!unary) { //如果不是自闭合标签,push到stack
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
      lastTag = tagName
    }

    if (options.start) { //执行options传入的开始标签回调
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

解析结束标签

const endTagMatch = html.match(endTag)

//如果endTag是</div>,endTagMatch就是["</p>","p",index:0,...]

if (endTagMatch) {
   const curIndex = index
   advance(endTagMatch[0].length)
   parseEndTag(endTagMatch[1], curIndex, index)
   continue
}

结束标签的解析比开始标签简单许多,得到匹配结果后,执行parseEndTag方法。

parseEndTag方法做了一件事,就是通过stack检查标签是否闭合。

handleStartTag方法中,如果标签不是自闭合标签,会将这个标签push到stack。parseEndTag方法会比较结束标签名和栈顶标签名是不是相同,如果相同就是闭合标签,直接pop;如果不同,就往栈底方向继续找,直到相同的标签名或者栈底,依次提示非闭合的标签,并将非闭合的标签和闭合的标签(如果有)一起pop。

 function parseEndTag (tagName, start, end) {
    let pos, lowerCasedTagName
	//从栈顶开始查找tagName相同的标签
    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      pos = 0
    }

    if (pos >= 0) {
      for (let i = stack.length - 1; i >= pos; i--) {
        warn()
        if (options.end) { //执行回调
          options.end(stack[i].tag, start, end)
        }
      }

      //pop非闭合的标签和闭合的标签(如果有)
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    }
  }

开始标签的回调函数

解析完开始标签后,会调用开始标签的回调start方法。

if (options.start) { 
   options.start(tagName, attrs, unary, match.start, match.end)
}

这个函数要做的事:

  • 根据传入的tagName, attrs, unary等会生成一个 AST 节点
  • 处理AST节点上的属性,attrs

生成AST节点的方法

function createASTElement (tag,attrs,parent) { 

 //创建一个AST节点需要标签名、属性值、和它的父节点信息
 //一个AST节点的基本属性有以下这些

  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs), //将属性转换成键值对形式
    rawAttrsMap: {},
    parent,
    children: []
  }
}

function makeAttrsMap (attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    map[attrs[i].name] = attrs[i].value
  }
  return map
}

start方法的执行过程:

const stack = []
let root
let currentParent
start (tag, attrs, unary, start, end) { 
    // 	生成AST节点
    let element = createASTElement(tag, attrs, currentParent)
    
    //处理v-for、v-if、v-once这些指令
    processFor(element)
    processIf(element)
    processOnce(element)

    //如果没有没有根节点,就把当前节点设为根节点
    if (!root) {
       root = element  
    }

    //如果不是自闭合标签,就入栈,并将currentParent指向当前节点
    if (!unary) {
       currentParent = element
       stack.push(element)
    } else {
       closeElement(element)
    }
}

结束标签的回调函数

创建AST树是一个深度优先的过程,从子节点向父节点回溯的条件就是执行结束标签的回调函数,借助栈来完成。在开始标签的回调函数中,遇到非自闭合的标签就入栈,执行结束标签回调函数就出栈。

end (tag, start, end) {
    const element = stack[stack.length - 1]
    // pop stack
    stack.length -= 1
    currentParent = stack[stack.length - 1]

    closeElement(element)
}

function closeElement(element){
    ...
    currentParent.children.push(element)
    ...
}

文本的回调函数

chars (text, start, end) {
    //如果文本节点的父节点为空,则不处理,比如template里没有标签,直接是文本的情况
    if (!currentParent) { 
        return
    }
    const children = currentParent.children
    if (inPre || text.trim()) {
    	//父节点是否是script或style
    	text = isTextTag(currentParent) ? text : decodeHTMLCached(text)
    } 
    if (text) {
        let res
        let child
        
        //parseText方法用于解析表达式,如果是普通字符串,就返回undefined,执行else if的逻辑
        if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
          child = {    //表达式文本节点
            type: 2,  
            expression: res.expression,
            tokens: res.tokens,
            text
          }
        } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
          child = {   //普通文本节点
            type: 3, 
            text
          }
        }
  
        //将文本节点挂载到父节点,文本节点都是叶子节点
        if (child) {
          children.push(child)   
        }
      }
    },

parseText方法的第二个参数delimiters, 就是用来匹配表达式文本的,默认值也就是我们常用的双括号{{x}}

export function parseText (text,delimiters){
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  
  if (!tagRE.test(text)) {   //没有匹配到{{x}},普通文本,就返回
    return
  }
  
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  
  while ((match = tagRE.exec(text))) {
    index = match.index
  
    // 提取表达式开始前的普通文本字符串
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    
    // 提取表达式文本的内容
    const exp = parseFilters(match[1].trim())
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    
    lastIndex = index + match[0].length
  }
  
  // 提取表达式后的普通文本字符串
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

这里最核心的方法就是exec。参考MDN

当正则表达式使用 "g" 标志时,可以多次执行 exec 方法来查找同一个字符串中的成功匹配。当你这样做时,查找将从正则表达式的 lastIndex 属性指定的位置开始。

匹配这样的文本<p>text:{{a}}, {{b}} and so on</p>,得到的结果:

优化AST

生成AST后,下一步就是对它做优化。因为Vue是响应式的,但模板中并不是所有数据都是响应式,所以在patch过程中可以跳过这些静态数据的处理。怎样确定哪些是需要跳过的静态节点,就是优化optimize要做的事情。

export function optimize (root, options) {
  if (!root) return
  
  //标记AST树中的静态节点
  markStatic(root)
  
  //标记静态根
  markStaticRoots(root, false)
}

markStatic就是调用isStatic判断当前节点是否是静态节点,如果是就把static属性设置为 true, 否则设为false。如果是当前节点元素节点,就遍历所有子节点,同样调用markStatic方法,这样递归下去,直到叶子节点。

function markStatic (node) {
  node.static = isStatic(node) 
  
  if (node.type === 1) {
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      
      //如果子节点中任何一个不是静态节点,那当前节点也不就是静态的
      if (!child.static) {
        node.static = false
      }
    }
    
  }
}

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

markStaticRoots的定义,它里面有一段注释,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.意思大概就是说,如果一个元素节点,它只有一个普通文本节点,比如这样<div>abc</div>,那么把它标记成静态节点的消耗还大一些,还不如不标。所以就新增了一个staticRoot属性,要是这种情况,staticRoot属性就为false。子节点的staticRoot属性不影响父节点的staticRoot属性。

function markStaticRoots (node: ASTNode, isInFor: boolean) {
  if (node.type === 1) {

    // 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)
      }
    }
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor)
      }
    }
  }
}

AST转换成render函数

function generate (ast,options) {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

generate函数生成的是render字符串。后面会调用new Function(renderFuncString)生成render函数。

<div class="tpl"><p v-for="item in list">{{item}}</p><p>test</p></div>
with(this){
    return _c('div',{
    	    staticClass:"tpl"
        },[
            _l((list),function(item){
            	return _c('p',[_v(_s(item))])
            }),
            _c('p',[_v("test")])
        ],2)
}

with(obj)将后面的{}中的语句块中对obj属性的访问可以省略书写obj,不用每次都去写 obj.属性 的形式,而是直接使用属性名。

比如上面,_c也就是this._c, _l也就是this._l,等等。这里的this是vue实例。

_c, _l, _v都是函数的缩写。

//src/core/instance/render.js
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

//src/core/instance/render-helpers/index.js
function installRenderHelpers (target) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  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
  target._d = bindDynamicKeys
  target._p = prependModifier
} 

_l是renderList,_v是createTextVNode。

const code = ast ? genElement(ast, state) : '_c("div")'render字符串的主体是调用genElement方法生成的。

export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  }

  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 && !state.pre) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
 
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
    
      let data
      if (!el.plain || (el.pre && state.maybeComponent(el))) {
        data = genData(el, state)
      }

      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
    }
    
    return code
  }
}

genElement方法中,首先考虑有各种指令的情况,比如v-for,v-once,v-if,v-slot等等,如果有这些指令,就进入对应的生成方法。如果没有,就先调用genData(el, state)生成data,也就是例子中的{staticClass:"tpl"},再调用genChildren方法生成children。

function genChildren (el,state,checkSkip,altGenElement,altGenNode) {
  var children = el.children;
  if (children.length) {
    ...
    var gen = altGenNode || genNode;
    return ("[" + (children.map(function (c) { return gen(c, state); }).join(',')) + "]" + (normalizationType$1 ? ("," + normalizationType$1) : ''))
  }
}

genChildren遍历children数组,每个child节点调用genNode方法来处理。

function genNode (node, state) {
  if (node.type === 1) {
    return genElement(node, state)
  } else if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
}

genNode方法判断节点类型,如果是元素节点,就又调用genElement,如果是注释节点,就调用genComment,否则,当文本节点处理调用genText。

从AST根节点到叶子节点,整个过程是递归调用genElement,直到元素节点没有子节点,或者子节点是文本节点或注释节点。处理每一个节点时,根据它们自身的属性,选择对应的运行时渲染函数缩写。

vue源码系列文章:

vue2.0的响应式原理

vue编译流程分析

vuex原理之由浅入深手写vuex

vue组件从构建VNode到生成真实节点树