阅读 2043

Vue源码解析-compiler

上一篇,我们介绍了vue实现响应式的原理。其中,有2点需要重点展开分析:

    1. vue组件化实现
    1. render函数执行过程中发生了什么

在讨论上述2个问题之前,我们先待 了解 compiler过程。这是核心前提,只有先熟悉了它,我们才能清晰的认识到 数据的流向。

好了,废话不多说,扭起袖子就是干~

一. Compiler

在vue中,我们写template,显然浏览器不认识。那么就需要有个解释过程。

compiler 分两种情况:

    1. 构建时compiler
    1. 运行时compiler

构建时compiler

本地开发时,使用webpack + vue-loader,来处理.vue文件。 例如:

<template>
  <div>{{ a }}</div>
</template>

<script>
export default {
  data() {
    return {
      a: '1'
    }
  }
}
</script>

<style>

</style>
复制代码

打包时,vue-loader会 将 .vue文件的内容,转化为render函数

运行时compiler

运行时compiler,我们 不使用 vue-loader这样的插件,而且直接写template,让vue在浏览器运行的时候,动态将template转化为render函数。例如:

<html>
  <head>
    <meta charset="utf-8"/>
  </head>

  <body>
    <div id='root'>

    </div>
    <script src="../vue/dist/vue.js"></script>
    <script>

      let vm = new Vue({
        el: '#root',
        template: '<div>{{ a }}</div>',
        data() {
          return {
            a: "这是根节点"
          }
        }
      })
      
    </script>
  </body>
</html>
复制代码

本质上,构建时compiler和运行时compiler都是转化为render函数。 显示构建时效率更高,在我们的生产环境中,尽量避免运行的时候,再去compiler。

细心的同学会问了:既然都是转化为render函数,那是不是也可以手写render函数?

答案是肯定的,例如:

<html>
  <head>
    <meta charset="utf-8"/>
  </head>

  <body>
    <div id='root'>

    </div>
    <script src="../vue/dist/vue.js"></script>
    <script>

      let vm = new Vue({
        el: '#root',
        data() {
          return {
            a: "这是根节点"
          }
        },
        render(createElement) {
          return createElement('div', {
            attrs: {
              id: 'test'
            }
          }, this.a)
        }
      })
    </script>
  </body>
</html>
复制代码

手写render,vue会直接执行render, 省去了compiler过程。 但是手写render,对于我们开发和维护 都不友好。还是建议大家 使用 webpack + vue-loader,构建时compiler。

另外,如果是学习的话,建议运行时compiler。

下面,我们将采用运行时compiler,来一探究竟。

image.png

二. compileToFunctions

mount挂载时,如果没有传入render函数,vue会 先执行 compileToFunctions函数,返回render函数,并将render函数,挂载到vm.$options上。以便后续 执行 patch之前,生成虚拟dom。

核心主流程代码如下:

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
  }
})
复制代码

总体流程分为三部分:

    1. 模板编译阶段
    1. 优化阶段
    1. 代码生成阶段 - 即转化为render函数

下面,我们将逐个击破

三. AST

ast 全名:Abstract Syntax Tree,即抽象语法树。是源代码语法结构的一种抽象表示。

在计算机中,任何问题的本质就是 数据结构 + 算法,ast也是一种数据结构,来描述源代码的一种结构化表示。

以我们上面的运行时demo为例:

parse的方法入参,第一个参数是template,是一个字符串, 即:

"<div data-test='这是测试属性'>{{ a }}</div>"
复制代码

抽丝剥茧,我们先看到ast生成阶段parse方法 的核心入口:

// ... 省略一堆函数定义
parseHTML(template, {
  // options
  ...,
  
  start() {
     // ...
  },
  
  end() {
    // ...
  },
  
  chars() {
    // ...
  },
  comment() {
    // ...
  }
})
复制代码

parseHTML 主干如下:

export function parseHTML (html, options) {
  const stack = []
  // ...options
  let index = 0
  let last, lastTag;
  
  while(html) {
    last = html
    
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      
      if(textEnd == 0) {
        
        if(comment.test(html)) {
          const commentEnd = html.indexOf('-->')
          // ...
          if(commentEnd >= 0) {
            // ...
            advance(commentEnd + 3)
            continue
          }
        }
        
        if(conditionalComment.test(html)) {
          // ...
          const conditionalEnd = html.indexOf(']>')
          if (conditionalEnd >= 0) {
            advance(conditionalEnd + 2)
            continue
          }
        }
        
        if(html.match(doctype)) {
          // ...
          advance(doctypeMatch[0].length)
          continue
        }
        
        if(html.match(endTag)) {
          // ...
          const curIndex = index
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }
        
        startTagMatch = parseStartTag()
        if(startTagMatch) {
          // ...
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }
      
      if(textEnd >= 0) {
        // 如果 < 出现在 纯文本中,需要宽容处理,先做为文本处理
      }
      
      if(textEnd < 0) {
        // ...赋值text, advance跳过文本长度
      }
    }
  }else {
    // 处理script,style,textarea的元素
    // 这里我们只处理textarea元素, 其他的两种Vue 会警告,不提倡这么写
    // ...
  }
  
  function advance (n) {
    index += n
    html = html.substring(n)
  }
}
复制代码

我们可以看到parseHTML里面,实际上写了许多正则,去处理字符串。 这个其实不是尤大 从零写起, 尤大是参考 大神 John Resig 之前写的html parse库。

John Resig 何许人也? 正是大名鼎鼎的 JQuery之父。经历过 jquery时代的人, 那个时候jquery是神一般 的存在。

好了,回归正题,template字符串处理,大致流程如下:

    1. while循环 template 字符串
    1. 判断不能是 script, style这些标签,给出对应的警告信息
    1. 通过正则,获取开始标签 < 的字符串位置
    1. 通过正则,判断是否是注释节点,调用advance方法,重新记录index下标,跳过注释长度,截取去注释继续循环
    1. 通过正则,判断是否是条件注释节点。因为我们可能在template中使用条件注释,针对ie做一些事件。同理,调用advance方法,将index下标,跳转到条件注释字符串的尾部,截取掉条件注释,继续循环。
    1. 通过正则,判断是否是 Doctype 节点,同理,调用advance方法,将index下标跳转到 doctype 节点字符串尾部,截取掉 doctype, 继续循环。
    1. 通过正则,判断是否是开始标签,将开始标签的内容提取出来,提取前后对比:
// 匹配开始标签之前
html = "<div data-test='这是测试属性'>{{ a }}</div>"

// 提取之后
html =  "{{ a }}</div>"

// 而此时 startTagMatch 变成这样:
{
  start: 0,
  end: 24,
  tagName: 'div',
  unarySlash: '',
  attrs: [
    "data-test='这是测试属性'",
    // ...
  ]
}
复制代码

开始标签解析完成 后,会调用 parseHTML第二个参数options上的start方法,即上面提到的 parseHTML调用代码:

// ... 省略一堆函数定义
parseHTML(template, {
  // options
  ...,
  
  start(tag, attrs, unary, start, end) {
     // 调用这里的start方法,可以理解成,每次parse一部分html字符串,都会调用本次的 生命周期函数,start, end, chars, comment
     
     // ...
     let element: ASTElement = createASTElement(tag, attrs, currentParent)
     
     // ...
  },
  
  end() {
     
  },
  
  // ...
 
})
复制代码

根据上面的流程,我们已经知道,parseHtml会先 提取出 开始标签相关内容,即:

<div data-test='这是测试属性'>
复制代码

然后根据startTagMatch数据,调用start方法,start方法调用createASTElement 返回astElement。其结构如下:

{
  type: 1,
  tag: 'dev',
  rawAttrsMap: {},
  parent: undefined,
  children: [],
  attrsMap: {
    "data-test": "这是测试属性"
  },
  attrsList: [
    {
      start: 5,
      end: 23,
      name: 'data-test',
      value: "这是测试属性"
    }
  ]
}
复制代码
    1. 开始标签内容处理完成后,去除开始内容后的字符串,变成这样:
html = "{{ a }}</div>"
复制代码

进入下一个while循环,剩下的字符串,继续做为html字段值,再去走一遍以上流程。 此时会进入 :

if(textEnd >= 0) {

}
复制代码

text变量会记录下来,即:

text = "{{ a }}"
复制代码

调用advance,将index调至text字符串的尾部,截取掉{{ a }}

    1. 再次进入下一个while循环,即:
html = "</div>"
复制代码

重复上面的过程,条件匹配到了结束标签,进入:endTagMatch,即"</div>"。 调用advance方法,将index移动到最后。 调用 parseEndTag 方法,触发end钩子。

advance相当于一个下标计算器,每解析完一步,就自动的移动到 之前解析过的尾部,开始下一部分解析

简单的理解就是:parseHTML方法,一边解析不同的内容一边调用对应的钩子函数生成对应的AST节点,最终完成将整个模板字符串转化成AST

总体来说,ast的类型,有3类。

  1. 正常标签节点处理,通过createASTElement 方法创建,其结构如下:
{
  type: 1,
  tag,
  attrsList: [
    // ...
  ],
  attrsMap: {
    // ...
  },
  rawAttrsMap: {},
  parent,
  children: []
}
复制代码
  1. 匹配到字符变量相关,使用parseText解释器,其结构如下:
{
  type: 2,
  text: "{{ a }}",
  expression: "_s(a)",
  tokens: [
    {
      '@binding': 'a'
    }
  ],
  start: 24,
  end: 31
}
复制代码
  1. 纯文本,不包含变量,其结构如下:
{
  type: 3,
  text: "文本内容"
  isComment: true,
  start: xx,
  end: xx
}
复制代码

最终,template字符串,解析出来的ast结构如下:

{
    "type": 1,
    "tag": "div",
    "attrsList": [
        {
           "name": "data-test",
           "value": "这是测试属性",
           "start": 5,
           "end": 23
	}
      ],
      "attrsMap": {
	"data-test": "这是测试属性"
      },
       "rawAttrsMap": {
	  "data-test": {
	    "name": "data-test",
	    "value": "这是测试属性",
	    "start": 5,
	    "end": 23
	  }
	},
	"children": [
          {
	    "type": 2,
	    "expression": "_s(a)",
	    "tokens": [
              {
		"@binding": "a"
	      }
            ],
	    "text": "{{ a }}",
	    "start": 24,
            "end": 31
	  }
        ],
	"start": 0,
	"end": 37,
	"plain": false,
	"attrs": [
          {
            "name": "data-test",
	    "value": "\"这是测试属性\"",
	    "start": 5,
	    "end": 23
	  }
        ]
}
复制代码

需要说明是,我们看到expression 中有个_s, 这个是什么东西呢? 实际上,这个是在vue instance的 render-helpers中定义的。_s = toString

其定义如下:

export function toString (val: any): string {
  return val == null
    ? ''
    : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
      ? JSON.stringify(val, null, 2)
      : String(val)
}
复制代码

ok, 到这里, ast的主干流程就结束了。

四. optimize

获取到ast树后,vue做了一层静态标记优化。给一些不变的节点打上标记,提升后面patch diff的性能。比如,有这样的标签:

<div>
  <div>这是不变的内容1</div>
  <div>这是不变的内容2</div>
  <div>这是不变的内容3</div>
  <div>这是不变的内容4</div>
</div>
复制代码

那么,在进行diff的时候,这种标签都不需要比对,他是纯静态的标签,不会变化。最外层的div,称为:静态根节点。

根据上面生成的ast,我们知道有3种类型的 ast,分别是:

  1. type == 1, 普通元素节点
  2. type == 2, 包含变量的文本
  3. type == 3, 纯文本,不包含变量

由此可见,将type = 3的 ast都加上static = true标识 type = 2的ast,都加上static = false标识 type = 1的,需要进一步判断:

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)
  ))
}
复制代码

即:

    1. 如果节点使用了v-pre指令,那就断定它是静态节点;
    1. 没有pre,需要满足以下条件:
    • 2.1 不能有v-,@, :开头属性
    • 2.2 不能是内置的slot, component
    • 2.3 必须是浏览器保留标签,不能是组件
    • 2.4 不能是v-for的template标签
    • 2.5 判断ast上每个key,是不是只有静态节点才有

标计完成之后,再去ast上,递归每个children进行标记。

在此基础之上,计算出根静态节点,那么diff时候,是根静态节点,那么这个根节点以下的内容都不需要再比较了。

优化完成后,ast的结构变成这样:(多了2个属性)

{
    "type": 1,
    "tag": "div",
    // 这里添加静态标记
    "static": false,
    // 这里添加是否是根静态节点标记
    "staticRoot": false,
    "attrsList": [
        {
           "name": "data-test",
           "value": "这是测试属性",
           "start": 5,
           "end": 23
	}
      ],
      "attrsMap": {
	"data-test": "这是测试属性"
      },
       "rawAttrsMap": {
	  "data-test": {
	    "name": "data-test",
	    "value": "这是测试属性",
	    "start": 5,
	    "end": 23
	  }
	},
	"children": [
          {
            // 这里添加标记
            "static": false,
	    "type": 2,
	    "expression": "_s(a)",
	    "tokens": [
              {
		"@binding": "a"
	      }
            ],
	    "text": "{{ a }}",
	    "start": 24,
            "end": 31
	  }
        ],
	"start": 0,
	"end": 37,
	"plain": false,
	"attrs": [
          {
            "name": "data-test",
	    "value": "\"这是测试属性\"",
	    "start": 5,
	    "end": 23
	  }
        ]
}
复制代码

五. generate

代码生成阶段,通过ast将转化为render函数,其代码如下:

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
  }
}
复制代码

生成代码阶段比较清晰,genElement方法,主要是判断不同的类型,调用不同的生成方法。本质上是一个json转化成另外一个json。值得注意的是,转化json的过程中,我们会看到 _m, _c, _o的这些方法。

这些可以在instance/render-helpers/index.js中可查看到

方法名对应的helper方法
_mrenderStatic
_omarkOnce
_lrenderList
_ecreateEmptyVNode
_trenderSlot
_bbindObjectProps
_vcreateTextVNode
_stoString

这里生成的render函数如下:

with(this) {
    return _c('div', {
        attrs: {
            "data-test": "这是测试属性"
        }
    },
    [_v(_s(a))])
}
复制代码

到这里, compiler过程就结束啦。这个时候,compiler出的render函数,将挂载到 vm.$options上。等待执行updateComponent方法执行时,生成虚拟dom。

注意:compiler只是返回render函数,并未执行render函数,所以这个阶段,还未触发Dep类的依赖收集

六. 总结

    1. compiler是将template字符串转化为render函数的过程
    1. 调用parse方法生成ast
    • 2.1 parseHTML通过正则动态匹配出标签的开始内容,标签内内容,标签结束内容
    • 2.2 不建议template中出现script, style标签,给出警告
    • 2.3 从index = 0开始,匹配开始标签内容,调用advance将index移动至前一次的字符串末尾位置,返回出对应的数据结构描述标签开始内容。另外调用parse的开始生命周期函数,生成对应的 ast
    • 2.4 分别处理 注释节点, 条件注释,Doctype节点,调用advance将index移动到特殊节点字符串的末尾
    • 2.5 while循环计算下一个字符串类型,匹配标签内容
    • 2.6 标签内容调用 parse生命周期的chars方法,生成对应的ast
    • 2.7 匹配结束标签,调用advance将index移动到对应字符串尾部,调用parse的end生命 周期方法,更新对应ast的end标识位
    • 2.8 如此往复调用,直到解析html字符串的最后。
    1. 优化ast,给各个节点的ast打上静态标记,以及静态 根节点,以便patch过程做diff时,去除不必要的对比,提升性能。
    1. 将ast的数据结构,递归遍历每个childrens,将其转化为对应的方法调用。
    1. 返回render函数,将方法挂载至vm.$options上,等待后面执行到updateComponent时生成虚拟DOM

码字不易,多多关注,点赞 Thanks♪(・ω・)ノ

文章分类
前端
文章标签