Vue源码分析(二)-----编译(compile)

3,556 阅读4分钟

compile

大家在使用vue的时候,我们经常编写一些template模板, 举个栗子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue模板编译</title>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

</head>
<body>
 <div id="demo">
    <h1>Vue模板编译</h1>
    <p v-if="txt">xx{{name}}yy</p>
    <div :hhh="name" xxx="123"></div>
    <ul>
        <li v-for="item in names">{{item}}</li>
    </ul>
    <p v-text="txt" @click="addText"></p>
  </div>
  <script>
      // 创建实例
      const app = new Vue({
          el: '#demo',
          data: {foo:'foo', txt: "text"}
      })
  </script>
</body>
</html>

在添加vue.js后,我们在<div id="#demo"></div>里面的内容和我们在真实Dom里面看到的元素是不一样的,查看一下经过Vue的处理后,Dom元素变成了这样: Vue处理后的Dom

在上文中,编写的Vue的双向数据绑定的demo中是通过Compile来完成模板到真实Dom节点的转换。其中使用document.createDocumentFragment()来创建一个文档碎片,即存在内存中的Dom节点。当时我们只是做了一个简单的处理。但是Vue实际的编译比这个复杂的多:我们需要解析v-ifv-for这样的指令,还需要去解析v-bindv-onv-text等。

这本文中去探索一下Vue是如何完成<div id="demo"></div>内部节点的转换。

Vue.prototype.$mount

根据上节的源码分析(一),我们知道new Vue(options)时会进行相应的初始化过程。在上文的关注点主要是对于数据的初始化。在数据进行初始化的过程中,会对数据进行响应式处理,对数据处理完成后,我们还是没有办法再浏览器上看到正确的视图,这个时候需要调用Vue原型上的$mount方法实现挂载操作,从而在浏览器上显示正确的视图。

为了实现挂载这个操作,我们需要去解析options.el或者options.template中编写的模板字符串。什么是模板字符串呢?简单来说就是用字符串表示的包含Vue指令的html内容:

// 在options.el中,我们是指定option.el 该节点内容作为模板
// 例如option.el = 'demo'
<div id="demo">
    <h1>初始化流程</h1>
    <p>{{foo}}</p>
</div>
// 在vue中,会获取该节点转化为如下的形式,template就是模板字符串
let template = '<div id="demo"><h1>初始化流程</h1><p>{{foo}}</p></div>'

在开发项目中会发现,我们在new Vue(options)时其实有时候 是没有调用$mount方法的。在本文上一篇的内容中我们知道Vue的构造函数中执行了_init方法,initMixin()中实现了_init方法,该方法实现了Vue相关的初始化。在_init函数最后判断options是否存在el属性,如果存在则调用vm.$mount方法(这也是为什么在options中编写了el后不用手动调用$mount方法的原因)。在项目开发中,项目的main.js文件中,经常能看到new Vue(options).$mount('#app')这种写法,其实就是将Vue中的内容挂载到根节点'#app'上,然后就可以在浏览器中显示Vue相关的内容。因此可以猜测$mount能够将我们的模板字符串转化成真正的Dom节点显示在浏览器上,并且在遍历的过程中还创建了相应的watcher来进行视图的响应式更新。

再Vue中,当执行$mount方法时才会进行字符串模板编译相关的操作,让我们查看一下该方法在web平台的具体实现:

import { compileToFunctions } from './compiler/index'

// web环境下的扩展$mount
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 获取dom节点
  el = el && query(el)

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    // 没有render才找template,并且将template处理成模板字符串
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        return this
      }
    } else if (el) {
      // 没有render和template的情况下才会去找el,并将el节点的内容转化成模板字符串
      template = getOuterHTML(el)
    }

    // 编译模板字符串,返回render函数
    if (template) {
      // 获取render
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      // 将编译返回的render设置到options.render上
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 执行Vue原型上的mount方法
  return mount.call(this, el, hydrating)
}
// 将el节点转化为模板字符串,即 el => template
function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}

web平台扩展的$mount方法中,进行如下操作:

  1. 获取render函数,并且将其放置到vm实例对象的render上。
    • 当存在options.render时,忽略options.templateoptions.el,跳过本步骤后续所有内容
    • 当存在options.template时忽略options.el,得到template的模板字符串,然后调用compileToFunctions方法,获取template模板字符串对应的render方法,将其放置到vm.options.render
    • 当存在options.el时,得到el对应节点的模板字符串,同样的,也去调用compileToFunctions方法,获取对应的render方法,将其放置到vm.options.render
  2. 调用Vue实例原型上的$mount方法。

从代码可以看出optionseltemplaterender它们三个彼此互斥。优先级为render>template>el。即存在render的时候优先使用render函数,没有render优先template。最后才考虑使用el参数。

Vue.prototype.$mount的实现实际上对不同的平台做了相应的扩展,目前源码中有weexweb两种平台,本文只讲web平台的具体实现。有兴趣的可以去vue源码查看weex的具体实现。

我们看完web平台对$mount做的扩展,继续查看Vue.prototype.$mount原型方法的实现。web平台下该方法源码中位置:src\platforms\web\runtime\index.js

import { mountComponent } from 'core/instance/lifecycle'
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 获取el元素
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

执行了core/instance/lifecyclemountComponent方法:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 当不存在render时,render为创建一个空节点的函数。
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  // 调用beforeMount生命周期钩子函数
  callHook(vm, 'beforeMount')

  // 传入Watcher的更新视图的函数
  let updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // 为组件创建Watcher,将updateComponent传入第二个参数
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

  // 执行mounted生命周期钩子函数
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

该函数做了如下的事情:

  1. 调用beforeMount生命周期钩子函数
  2. 定义更新Dom的函数updateComponent
  3. 创建watcher,并将得到的updateComponent传入watcher,作为其exprOrFn的参数。
  4. 创建watcher时,会将Dep.target指向自己,然后Wathcer构造函数内部会执行一次传入的updateComponent函数。该函数内部使用options.data中的数据时会触发Object.Object.defineProperty中的get,对其进行依赖收集,updateComponent同时也完成了Dom视图的渲染。
  5. 最后调用mounted这个生命周期钩子函数。

每次解析Vue的字符串模板时,即解析Vue组件时,我们会去创建一个watcher。传入updateComponet函数。毫无疑问,那么该函数就是更新Dom的方法。其中,最重要的无疑时获得更新模板的函数。在上面的源码中,我们可以看到其实该函数做了两件事情:

  1. 调用Vue.prototype._render,返回的结果作为Vue.prototype._update的参数。该方法返回一个虚拟Dom。虚拟Dom就是一个JS对象,用来表示一个真实的Dom节点,后面会详细介绍。
  2. 调用Vue.prototype._update,其中会执行patch方法,实现视图的更新。

看到在调用vm.$mount时Vue创建了一个watcher,回想在上节Vue源码分析(一)中实现的编译器。我们直接通过递归,遍历所有的元素节点和文本节点,当发现存在Vue相关指令时,我们会去解析相关的指令,并且生成一个Watcher实例


那么问题来了,我们编写100个Vue指令的时候,我们会生成100个实例,在实际开发中,我们在template中使用的Vue指令可不止100个,如果为每一个指令生成 一个Watcher指令,会对内存造成十分大的消耗。当我们在做大型的项目时,这个开销就会变得十分庞大。那么Vue2.0中是怎么解决的呢?
其实本文源码分析(一)中编写那个简单的Demo就是vue1.0中的思想:为每个指令创建一个watcher实例来监听。在Vue2.0中,为了解决数据变化追踪粒度太细的问题,引入了虚拟Dom。
在Vue2.0中为每个Vue组件创建一个watcher.即以组件为基本单位创建watcher。将watcher的粒度从元素节点变为组件。那么watcher如何知道组件内部的元素的更新呢? 在这里其实Vue借鉴了React的虚拟Dom.在组件内部通过虚拟Dom的diff算法完成新老节点的比对,完成组件的更新。即在组件内部通过diff来更新Dom视图。

其中,该方法中我们最看重的部分无疑是定义更新模板的函数updateComponent。在上面的源码中,我们可以看到其实该函数做了两件事情

  1. 调用Vue.prototype._render,返回的结果作为Vue.prototype._update的第一个参数。
  2. 调用Vue.prototype._update,其中会执行diff算法,完成视图的更新。

这就是Vue.prototype.$mount的大致流程。总结一下Web平台下的Vue.prototype.$mount干了什么:

  1. 利用compileToFunctions方法,根据Vue构造函数的optionsel或者template转化成相应的render函数
  2. 执行boforemount钩子函数
  3. 创建一个updateComponet函数用于更新组件的视图
  4. 创建一个watcher,触发数据的依赖收集,将updateComponent传入自己的exprOrFn参数,当数据发生变化时进行视图的更新。
  5. 在创建watcher的过程中,构造函数调用一次updateCompont,该函数会将render对应的虚拟Dom转化为真实的Dom节点渲染在浏览器上
  6. 执行mounted钩子函数

编译的重点毫无疑问是compileToFunctions,而更新视图则肯定去查看_update以及_render方法。_update方法在Vue源码分析(三)-----更新策略中会讲到,本文主要讲述编译部分内容。

Compile

在上面的方法中,我们知道compileToFunctions是将传入的模板字符串解析成render函数。为了达到这个要求,Vue中单独建立了一个文件夹Compile来做这个事情。我们可以将其对字符串模板的解析主要分为三部分:

  1. parse 解析:解析字符串模板,生成AST。
  2. optimize 优化:遍历AST标记静态节点。
  3. generate 代码生成:根据AST生成渲染函数。

这里有一个参考网站,你在左侧编写的Vue模板,在右侧会转化成相应的render函数,有兴趣可以去编写自己的小Demo: Vue模板编译

compileToFunctions

1. parse

上面我们知道,parse主要是将模板字符串解析为AST(abstract syntax tree)。AST中文名叫做抽象语法树:是指源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。听起来很高大上的样子,那么在Vue中的AST长什么样子呢?

举个栗子,假设我们创建了一个<div id="demo"></div>元素:

<div id="demo">
    <h1>Vue模板编译</h1>
    <p v-if="txt">xx{{name}}yy</p>
    <div :hhh="name" xxx="123"></div>
    <ul>
        <li v-for="item in names">{{item}}</li>
    </ul>
    <p v-text="txt" @click="addText"></p>
</div>

Vue中生成的AST部分属性如下:

{
	type: 1,
    tag: "div",
    parent: undefined;
    attrsMap: { id: "demo" },
    attrList: [{name: "id", value: "demo"}],
    children: [
    	{
        	type: 1,
            tag: "h1",
            parent: {type:1,tag: "div"....},
            attrsMap: {},
            attrsList: []
            children: [{type: 3, text: "Vue模板"}]
            
        },{
        	type: 3,
            text: ""
        },{
        	type: 1,
            tag: "p",
        	attrsMap: {v-if: "txt"},
            children: [
             {
             	type:2, 
                expression: ""xx"+_s(name)+"yy"", 
                text: "xx{{name}}yy", 
                tokens: ["xx",{@binding:"name"}, "yy"]
              }
            ],
            if: "txt"
            ifConditions: [{exp:"txt", block:{type: 1, tag:"p"...} }],
            parent: {type:1, tag:"div"....}
        },{
        	type:3,
            text: ""
        },{
        	type: 1,
            tag: "div",
            attrs: [{name: "hhh",  value: "name"}, {name: "xxx", value: "123"}],
            attrsList: [{name: ":hhh", value: "name"}, {name: "xxx", value: "123"}],
            attrsMap:{:hhh: "name", xxx: "123"},
            hasBindings: true,
            parent: {type: 1, tag: "div", attrsList:....}
        },{
        	type:3,
            text: " "
        },{
        	type: 1,
            tag: "ul",
            parent:{type: 1, tag: div...},
            children: [{
            	type: 1,
                tag: "li",
                attrsMap: {v-for: "item in names"},
                for: "names",
                alias: "item",
                children: [{
                	type: 2,
                    expression: "_s(item)", 
                    tokens: [{@binding: "item"}],
                    text: "{{item}}",
                }],
                parent: {type: 1, tag: "ul"...}
            
            }]
        },{
          type: 3,
          text: " "
        },{
          type: 1,
          tag: "p",
          hasBingding: true,
          directives: {name: "text", rawName: "v-text", value: "txt"},
          events: {click: {value: "addText"}},
          parent: {type: 1, tag: "div"...}
        }
    ]
}

最终通过一个树形结构来表示我们的根节点,其中能够清晰的描述标签的相关属性和标签之间的依赖关系。如何将一个模板字符串解析成AST呢?用一副图来表示其大致过程:

AST解析

当我们匹配<开头的字符串时,说明我们遇到了开始标签,我们匹配到</开头的字符串时,说明是结束标签。其中,我们匹配到开始和结束标签后,就字符串进行截取。截取到我们遇到的第一个><>中的所有字符串从整个html中截取出来,其中开始标签会存在属性,我们对其进行解析。

当我们的字符串不是以<开头时,在整个字符串中找到第一个<的下标,讲开头到<下标的所有字符截取出来,当作文本处理(暂不考虑字符中存在<的情况)。

根据图的流程,我们需要去匹配开始标签,结束标签和文本内容。其中,当我们匹配到开始标签的时候我们需要去解析开始标签的属性,解析到文本内容和结束标签时,我们需要对其制作相应的处理。我们需要首先一定能想到去用正则去匹配字符串,那么我们应该至少会需要如下的正则

const ncname = '[a-zA-Z_][\\w\\-\\.]*' 
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)// 匹配开始标签开头 即`<tagName`
const startTagClose = /^\s*(\/?)>/   //匹配开始标签结束 即 >或者 /> 结尾
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配结束标签
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/   // 匹配属性
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配形如{{xxx}}的文本
const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/  // 匹配v-for表达式中的内容,例如匹配`item in arr`
const dirRE = /^v-|^@|^:|^#/ //匹配'v-','@'或者 ':'开头的属性

这里有一个便于理解正则表达式的网站:正则解析

我们解析template的时候采用循环的方式进行,因此每次匹配并且解析出来一段内容时,我们需要将已经匹配的内容去掉,然后去匹配剩下的内容。因此我们需要一个截取字符串模板字符串的函数:

function advance (n) {
  index += n
  html = html.substring(n)
}

然后尝试实现一下解析模板字符串的函数:

export function parseHtml (html, options){
  let index = 0;
  while(html) {
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
      if (html.match(endTag)) {
      	advance(endTagMatch[0].length);
        // todo: 处理开始标签
        continue
      }
      if (html.match(startTagOpen)) {
        advance(endTagMatch[0].length);
        // todo: 处理结束标签
        continue
      }
    } else {
      if(textEnd > 0){
      	// 截取文本
      	text = html.substring(0, textEnd);
      }else{
      	// 整个模板都是文本节点(没有匹配到'<')
      	text = html
      }
      if(text){
      	advance(text.length)
      }
      // todo: 对文本节点进行处理
      continue
    }
  }
}

在上面的例子中对Vue的编译过程进行了简化, 在这里我们仅对标签,文本进行处理。其中:在while每次循环中,首先找到<字符开头的位置。如果html<开头(即textEnd为0),则我们当作元素节点去处理,否则当成文本节点处理。处理元素节点时首先匹配结束标签,比如:</p></div>,如果匹配不到则尝试匹配开始标签,比如:<p><div>。每当匹配到标签时,我们需要对匹配到的内容进行相应的处理.

我们知道最后解析成的AST是具有树结构的JS对象,因此我们需要添加一个栈(先进后出) 来建立父子关系。当我们匹配到startTagOpen时,将解析到的内容以对象的形式放置到栈中。匹配到startTagClose时,进行退栈操作并且建立标签之间的父子关系。解析到文本节点时,直接建立文本节点和当前父亲节点之间的关系。

因此我们在传入parseHtmloptions中定义三个函数startendchars来帮助我们进行栈的维护,同时帮助我们生成AST的树形结构:

  • start:当解析到开始标签时调用此函数,保存标签数据,并且将节点对象入栈。
  • end:当解析到的结束标签时调用此函数,进行退栈操作,并且进行绑定父节点操作。
  • chars:当解析到的文本节点调用此函数,将文本节点添加到父节点上。

并且我们还需要在parseHtml外部定义三个变量进行栈的维护

  • stack: 用于保存解析好的标签(以JS对象保存),是一个栈(先进后出,在js中可以看作一个只能使用poppush方法的数组)
  • currentParent: 存放当前标签父标签节点引用
  • root:存放根节点标签 由此,我们可以编写下面的代码:
options:
{
  start: (startTagMatch) =>{
    // todo: 用js对象保存标签的内容,并将js对象入栈
  	// 进行入栈操作
    // 对currentParent 和 `root`变量进行更新
  },
  end () {
   // todo: 进行退栈,并且建立父子节点直接的关系
  },
  chars (text) {
  	// todo: 对字符串进行处理
  }
}

<input /><br />这种没有结束标签的属于自闭合标签;<div></div>,<p></p>属于非自闭合标签.

start方法: 当解析到开始标签时调用此方法。该方法参数为解析到的属性,标签名等内容。根据参数的内容创建一个js对象,用于保存标签信息。 end方法: 当解析到结束标签时调用此方法。该方法在栈中进行退栈操作,保存节点之间父子关系。 chars方法:当解析到文本节点时调用此方法。该方法参数为文本内容,该方法首先尝试文本是否匹配defaultTagRE,如果匹配说明文本不是静态内容,否则当做静态的文本内容来处理。最后建立文本节点和父节点之间的引用。

知道了options中的三个方法,可以对parseHtml进行扩展。

const stack = [];
const currentParent = root = null;
export function parseHtml (html, options){
  let index = 0;
  while(html) {
    let textEnd = html.indexOf('<');
    if (textEnd === 0) {
      // 解析html节点
      const endTagMatch = html.match(endTag);
      if (endTagMatch) {
      	advance(endTagMatch[0].length);
        parseEndTag(endTagMatch[1]);
        continue;
      }
      if (html.match(startTagOpen)) {
      	const start = html.match(startTagOpen)
      	advance(start[0].length);
        const startTagMatch = parseStartTag(start);
        options.start(startTagMatch);
        continue;
      }
    } else {
      // 解析文本节点
      let text;
      if (textEnd >= 0) {
        text = html.substring(0, textEnd);
      }

      if (textEnd < 0) {
        text = html;
      }

      if (text) {
        advance(text.length);
      }
      options.chars(text);
      continue;
    }
  }
}

先实现一个解析开始标签的方法parseStartTag

function parseStartTag (start) {
  const match = {
    tagName: start[1],
    attrs: [],
    start: index
  };
  let end, attr;
  // 循环匹配开始标签中的属性,直到遇到'>'或者'/>'
  while (!(end = html.match(startTagClose)) && html.match(attribute))) {
    attr.start = index
    advance(attr[0].length)
    attr.end = index
    match.attrs.push(attr)
  }
  // 对属性数组进行处理
  match.attrs = match.attrs.map(args =>{
    const value = args[3] || args[4] || args[5] || ''
    return {
      name: args[1],
      value
    }
  })
  if (end) {
    match.unarySlash = end[1]
    advance(end[0].length)
    match.end = index
    return match
  }
}

在该方法中,我们定义了一个match对象来保存我们解析出来的结果,其中包括:

  • tagName:标签的名称
  • attrs: 用数组保存解析出来的一系列属性
  • start: 标签在模板字符串中的开始下标
  • end: 标签在模板字符串中结束的下标
  • unarySlash:表示该标签是否是自闭合标签

在方法内部定义一个循环,当匹配不到标签的结束并且可以匹配到标签的属性时,进入循环体,在循环体内部,将解析到的属性存到match.attrs上。

最后跳出循环时,我们解析完了元素节点的所有标签,解析完成,最后在match加入unarySlashend属性。然后将解析到match返回。最后在外层parseHtml调用options.start函数将match传入。尝试编写options.start方法。

start(startTagMatch) {
  const element = {
    type: 1,
    tag: startTagMatch.tagName,
    lowerCasedTag: startTagMatch.tagName.toLowerCase(),
    attrsList: startTagMatch.attrs,
    attrsMap: makeAttrsMap(startTagMatch.attrs),
    parent: currentParent,
    children: []
  }
  if (!root) {
    root = element
  }
  if(!match.unarySlash){
    // 非自闭合标签
    currentParent = element
    stack.push(element)
  }else{
    // 自闭合标签
    // 直接保存该标签和父节点之间的关系
    currentParent.children.push(element)
    element.parent = currentParent
  }
}
// 将attrs数组转化成map(key-value键值对)
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方法中定义了一个js对象element来保存标签的信息:

  • type:节点的类型
  • tag:标签的名称
  • lowerCasedTag:标签名称的小写
  • attrsList:以数组保存属性列表
  • attrsMap:以key-value保存属性(JS对象)
  • parent:当前标签的父标签节点对象引用
  • children: 保存当前标签的孩子节点引用

存在标签的节点type值均为1.在该函数中首先查看root是否存在。如果不存在,进入该函数的root设置为element,该判断用于第一次进入该函数时设置根节点。然后去判断该标签是否是自闭合标签,如果是自闭合标签则不存在孩子节点,直接将自己(element)加入到currentParent.children(当前父节点的孩子数组)中;如果不是自闭合标签则将当前element对象加入到栈(stack)中,并将currentParent设置为自己(element)。

同理,我们继续完善parseHtml中的parseEndTag

function parseEndTag(tagName){
	let pos, lowerCasedTagName
    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      pos = 0
    }
    for (let i = stack.length - 1; i >= pos; i--) {
      options.end(stack[i].tag, start, end)
    }
}

parseHtml中,我们将闭合标签的标签名作为参数传入parseEndTag,然后用toLowerCase获取到标签名的小写lowerCasedTagName.从栈顶(数组的最后一个元素)到栈底(数组的最后一个元素)遍历栈.找到和该标签名相同的节点元素的下标(如果没找到则将下标赋值为0).然后循环栈顶到该节点元素下标,执行options.end进行退栈并且绑定父子节点的操作.

end () {
  // 建立父子关系,并且维护栈
  const element = stack[stack.length - 1]
  stack.length -= 1
  currentParent = stack[stack.length - 1]
  currentParent.children.push(element)
  element.parent = currentParent
}

end方法:将当前栈顶元素退栈,退出的栈顶的元素赋值给element,改变栈顶指向(stack.length-=1),将element加入当前栈顶元素的children数组,最后将elementparent属性指向当前栈顶元素的JS对象.

有人可能会问了:parseEndTag时直接进行退栈操作不就行了,为什么要写一个循环呢? 一般情况下是可以直接退栈的,但是编写代码可能存在写了<span>而忘记写</span>的情况,因此我们要找到栈中离自己最近的相同标签名.这样,即使忘记写结束标签也能完成层级关系的建立.

最后我们来实现一下parseText以及options.chars

options.chars

chars(text){
  let element = {}
  if(defaultTagRE.test(text)){
    let res = parseText(text)
    element = {
      type: 2, 
      text, 
      parent: currentParent
      ...res
    }
  }else{
    element = { 
        type: 3,
        text,
        parent: currentParent
    }
  }
  currentParent.children.push(element)
}

parseHtml中调用option.charts传入文本字符串text.如果用defaultTagRE能够匹配到text的内容,即text中存在{{xxx}} 的内容则说明该文本节点不是静态的文本,去调用parseText(text)解析字符串,将element对象的type设置为2,将解析内容保存在element上;如果不存在{{xx}}的内容,则说明该文本节点的内容是静态的,不会发生改变,将element.type设置为3,将text放置到element上.最后将element加入到当前父节点currentParent.children数组中.

parseText解析动态文本节点:

function parseText(text){
  // 如果存在`{{}}`则将文本内容拆分成多个部分保存在数组中
  const tokens = []
  let lastIndex = defaultTagRE.lastIndex = 0
  let match, index, tokenValue
  // 对文本中所有符合defaultTagRE的内容进行处理,将文本进行切割放置到数组中
  while ((match = defaultTagRE.exec(text))) {
    index = match.index
    // 将xxx{{yyy}}zzz切分成['xxx','yyy','zzz']
    if (index > lastIndex) {
      tokenValue = text.slice(lastIndex, index)
      tokens.push(JSON.stringify(tokenValue))
    }
    // 获取{{xxx}}中的xxx
    const exp = match[1].trim()
    tokens.push(`_s(${exp})`)
    lastIndex = index + match[0].length
  }
  // 处理字符串中最后一个'}}'到字符串的内容(如果存在)
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.slice(lastIndex)))
  }
  return {
    expression: tokens.join('+'),
    tokens
  }
}

如果我们有这样一个文本:

// 假设name的值为'Evan'
hello {{name}}!welcome back

我们最后返回的内容为:

{
	expression: 'hello Evan! welcome back',
    tokens: ['hello', '_s(name)', "!welcome back" ]
}

_stoString()的简写.在实际运行_s(name)的时候,name会根据上文找到vm.$data.name上.

最后介绍一下如何实现v-textv-if以及v-for: 我们需要在options.start添加如下内容

start(startTagMatch) {
  const element = {
    type: 1,
    tag: startTagMatch.tagName,
    lowerCasedTag: startTagMatch.tagName.toLowerCase(),
    attrsList: startTagMatch.attrs,
    attrsMap: makeAttrsMap(startTagMatch.attrs),
    parent: currentParent,
    children: []
  }
  // ---- 添加内容
  processIf(element); // 尝试解析v-if
  processFor(element); // 尝试解析v-for
  processAttr(element); //尝试解析v-text,v-html...
  // ---- 
  // ....
}

由于Vue中对有v-if节点中添加ififConditions来保存判断条件和影响范围,并将v-if属性从attrListattrMap中删除。 因此我们需要一个删除函数getAndRemoveAttr

function getAndRemoveAttr(element, name){
	var val;
    if ((val = el.attrsMap[name]) != null) {
      var list = el.attrsList;
      for (var i = 0, l = list.length; i < l; i++) {
        if (list[i].name === name) {
          list.splice(i, 1);
          delete el.attrsMap[name];
          break
        }
      }
    }
    return val
}

我们定义一个getAndRemoveAttr来尝试从节点的属性数组中找出为name的属性,并将该属性从数组中删除,最后返回name属性对应的值或者返回undefined

有了这个函数我们尝试实现一下processIf函数:

function processIf (el) {
    const exp = getAndRemoveAttr(el, 'v-if'); 
    if (exp) {
        el.if = exp;
        if (!el.ifConditions) {
            el.ifConditions = [];
        }
        el.ifConditions.push({
            exp: exp,
            block: el
        });
    }
}

同样的道理,v-for指令最后保存在对象的foralias属性上,分别是循环的数组,循环数组中元素名称. 按照v-f的逻辑,我们实现以下v-for

function processFor (el) {
    let exp = getAndRemoveAttr(el, 'v-for');
    if (exp) {
        const inMatch = exp.match(forAliasRE);
        el.for = inMatch[2].trim();
        el.alias = inMatch[1].trim();
    }
}

在实现完processIfprocessFor后,属性数组中可能还存在v-textv-html这样的内容,我们用processAttrs来解析其他的指令。简单起见我们只实现一下v-textv-onv-bind。其他的实现读者有兴趣和精力可以去阅读源码。 实现v-text我们需要需要一个函数addDirective,将节点使用的指令相关内容保存到AST节点的directive属性上

export function addDirective (el, name, rawName, value) {
  (el.directives || (el.directives = [])).push({
    name,
    rawName,
    value,
  })
}

然后我们尝试编写processAttrs

function processAttrs (el) {
  const list = el.attrsList
  let i, l, name, rawName, value
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name
    value = list[i].value
    if (dirRE.test(name)) {
    	el.hasBindings = true
      	name = name.replace(dirRE, '')
      	const argMatch = name.match(argRE)
      	addDirective(el, name, rawName, value)
      }
     }
   }
}

对于v-bindv-on我们需要做特殊处理

  • v-on在AST节点创建一个event属性保存相关内容.编写addHandler协助完成
  • v-bind在AST节点创建一个attr属性保存绑定的相关内容.编写addAttr协助完成

实现一下addHandleraddAttr函数:

addAttr(el, name, value){
	const attrs = el.attrs || (el.attrs = [])
	attrs.push({name, value})
}
addHandler(el, name, value){
	const event = el.event || (el.event = {})
    event[name] = {value}
}

因此对processAttrs进行扩展:

function processAttrs (el) {
  const list = el.attrsList
  let i, l, name, rawName, value
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name
    value = list[i].value
    if (dirRE.test(name)) {
      el.hasBindings = true
      if (/^:|^v-bind:/.test(name)) { // v-bind
        name = name.replace(/^:|^v-bind:/, '')
        addAttr(el, name, value, list[i], isDynamic)
      }else if(/^@|^v-on:/.test(name)){ // v-on
      	name = name.replace(/^@|^v-on:/, '')
      	addHandler(el, name, value)
      }else{ // v-text, v-html
      	name = name.replace(dirRE, '')
      	const argMatch = name.match(argRE)
      	addDirective(el, name, rawName, value)
      }
     }
   }
}

一个简单的解析过程我们便实现了。

2. optimize

优化器的作用在是在parse完成后生成的AST树中,找到静态子树并且标记。这个步骤涉及到后面的patch函数,在后续通过虚拟Dom比对更新视图的时候,某些静态节点(即内容不会发生变化的节点)我们可以不去进行比对,这样我们可以节省一些性能消耗。 因此这样做的好处:

  • 每次对Dom进行渲染时,不需要为静态子树创建新的节点。
  • 在虚拟Dom进行Diff算法时,我们不需要去比对静态节点。

经过optimize我们要将parse过程得到的AST树每个节点加上static 来标志该节点是否为静态节点.

在上面的parse解析过程中,不同的节点会生成基于不同的type值。

type说明
1元素节点
2带变量的动态文本节点
3不带变量的纯文本节点

很明显当type为2时,我们能够确定它不可能时动态节点,当type为3时,它一定为静态节点。而当type为1时,需要同时满足以下所有条件,才能判断它是一个静态节点。

  • 不能有v-@:开头的属性
  • 不能使用 v-ifv-for或者v-else的指令
  • 标签名不能是slot 或者component等Vue内置标签
  • 标签名必须是Vue的保留标签(分为HTML保留标签和SVG保留标签)
  • 当前节点的父节点不能是v-for指令的标签
  • 节点上不存在动态节点才会有的属性,看到时代码在讨论.

在这里我们简化判断,只判断元素是否存在v-开头的属性.因此我们可以编写一个函数isStatic来判断某个节点是否为静态节点.

function isStatic (astNode) {
  if (astNode.type === 2) { 
    return false
  }
  if (astNode.type === 3) { 
    return true
  }
  return (!astNode.if && !astNode.for && !astNode.hasBindings);
}

Vue中optimize核心实现只有两个函数markStatic 以及markStaticRoots

function optimize (root) {
  if (!root) return
  markStatic(root)
  markStaticRoots(root, false)
}

由于我们知道,AST是树结构,因此我们标记静态节点要分为两步

  1. 标记所有的静态节点并打标记,即markStatic(root)
  2. 根据标记所有的静态节点,找出所有的静态根节点并打上标记,即(root, false)

标记所有的静态节点,那么我们就需要去遍历AST的树结构。遍历树结构一般都是采用递归的方法。

function markStatic (node: ASTNode) {
  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
      }
    }
  }
}

然后是markStaticRoots

function markStaticRoots (node) {
    if (node.type === 1) {
        if (node.static && node.children.length && !(
        node.children.length === 1 &&
        node.children[0].type === 3
        )) {
            node.staticRoot = true;
            return;
        } else {
            node.staticRoot = false;
        }
    }
}

当某个元素节点是一个静态节点,同时存在孩子节点,并且满足该节点不是只有一个文本类型的静态节点(在这种情况,可能是尤大大认为优化的消耗大于后面diff的消耗),则将节点的staticRoot设置为ture;其他情况将staticRoot设置为false

3. generate

generate将前面我们得到的AST转化为render函数,执行该函数即可得到vDom(虚拟Dom节点).在前面讲述的Vue Template Explorer,我们可以看到编辑后的生成的render函数 模板编译 其中_c_v等是其他方法名的简写:

类型创建方法别名参数
元素节点createElement_ctag(标签名), data(指令,属性,事件等), children(孩子节点)
文本节点createTextVNode_vval(文本内容)
创建列表renderList_lval(遍历的内容), render(渲染列表的函数)
创建空节点createEmptyVNode_e无参数
这里我们不详细讲解这些函数,在Vue源码分析(三)中会详细介绍。

我们现在需要做是利用字符串,根据表格中的函数拼接成一个可执行的函数内部代码code,然后使用new Function(code)生成render函数。

在前面我们知道AST大概长什么样子了,我们如何把AST再次转化为render函数呢?在经过1,2步之后,我们得到了一个标记了静态节点的AST树。还差最后一步,将AST转化成render函数,当执行render函数的时候,我们可以得到虚拟Dom。那么如何去实现呢?

首先一定要有去处理元素节点的函数genElement,文本节点的函数genText。在处理元素节点的函数中,我们需要对v-ifv-for做处理的函数genForgenIf.

然后我们需要对v-on,v-bind这些指令叫你下处理。genData进行处理最后我们需要去处理孩子节点,因此我们还需要genChildren

然后我们开始尝试编写genElement:

function genElement (el) {
  if (el.for && !el.forProcessed) {
      return genFor(el)
  } else if (el.if && !el.ifProcessed) {
      return genIf(el)
  } else {
      data = genDate(el)
      const children = genChildren(el)
      let code
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
      return code
  }
 }

然后依次完善函数,先完善一下genForgenIf

function genFor (el) {
  el.forProcessed = true
  const exp = el.for
  const alias = el.alias
  const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
  const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

  return `_l((${exp}),` +
    `function(${alias}${iterator1}${iterator2}){` +
      `return ${genElement(el)}` +
    '})'
}

function genIf (el) {
    el.ifProcessed = true
    if (!el.ifConditions.length) {
        return '_e()'
    }
    return `(${el.ifConditions[0].exp})?${genElement(el.ifConditions[0].block)}: _e()`
}

完善genData:

// 处理指令
function genDirectives(el){
  const dirs = el.directives
  if (!dirs) return
  let res = 'directives:['
  for (i = 0, l = dirs.length; i < l; i++) {
 	res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
        dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
      }},`
  }
  return res.slice(0, -1) + ']'
}

function genData (el) {
  let data = '{'
  // 添加指令
  const dirs = genDirectives(el)
  if (dirs) data += dirs + ','

  // 添加属性
  if (el.attrsMap) {
    data += `attrs:${JSON.stringify(el.attrsMap)},`
  }
  // 添加事件
  if (el.events) {
    data += `on:${JSON.stringify(el.events)},`
  }

  data = data.replace(/,$/, '') + '}' // 如果存在','结尾则删除逗号并添加'}'
  return data
}

最后就是genChildren:

// 该方法调用了genNode
function genChildren (el) {
  const children = el.children;

  if (children && children.length > 0) {
      return `${children.map(genNode).join(',')}`;
  }
}

function genNode(el){
	if (el.type === 1) {
        return genElement(el);
    } else {
        return genText(el);
    }
}
function genText(el){
	if(el.text){ // 静态文本节点
    	reutrn `_v(${el.text})`
    }
    if(el.expression){ // 动态文本节点
    	return `_v(${el.expression})`
    }
}

至此,我们编写compileToFunctions函数,完成Vue中模板字符串到render函数的转换。

function compile(){
  const ast = parse(template.trim(), options)
  optimize(ast, options)

  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
  }
}

总结

在这篇文章中,主要讲述了如何将我们编写的字符串转换成render,执行render函数,便可以得到虚拟Dom。有了虚拟Dom便可以在视图更新编写patch函数比对新老虚拟Dom,以最小的操作步骤去更新Dom视图。

后续我会将本文的内容整合成一个小Demo放到我的github上,有兴趣可以关注我哟~ 代码地址点我

在下篇文章中,我们会讲述关于虚拟Dom,patch比对算法,Vue的异步批量更新策略。敬请期待Vue源码分析(三)-----Vue更新策略

参考文档:

  vue.js源码         github.com/vuejs/vue


《深入浅出vue.js》         ----刘博文
《剖析 Vue.js 内部运行机制》      ----掘金小册(作者:染陌同学)

如果觉得写的还不错,记得点个赞哟~,作者每个月至少编写一篇技术博客,球球各位小姐姐,小哥哥们关注~

Faith is the bird that feels the light and sings when the dawn is still dark.


信念是一只鸟,在黎明昏暗之时,就受光明引领而歌唱

——泰戈尔