Vue源码之模板编译浅析

Posted 天地会珠海分舵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue源码之模板编译浅析相关的知识,希望对你有一定的参考价值。

在此前的《Vue源码之虚拟DOM来自何方?》文章中,我们学习了虚拟DOM是怎么从页面渲染函数render给生成的。但是,页面渲染函数又是从哪里来的呢?这就是今天想要学习下的内容。

但是整个模板编译的代码过于庞大,如果要面面俱到的话,估计要好几篇甚至上十篇文章才能搞定,我自己估计是没有那么多耐心的了。

故此,这里我会从一个简单的模板例子入手,然后看下它是如何一步步变成我们的页面渲染函数。期间会贯穿模板编译的整个骨干脉络,学习到模板编译的基本原理和实现。

1. 模板编译的终点就是渲染函数

假设有页面如下

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="vue.js"></script>
    </style>
  </head>
  <body>
    <div id="app"></div>
    <template id="demo">
      <section>
        <div>Hello msg</div>
      </section>
    </template>

    <script>
      const vm = new Vue(
        template: '#demo',
        data: 
          msg: 'world'
        
      ).$mount("#app");
    </script>
  </body>
</html>

我们看到里面有页面模板:

<template id="demo">
  <section>
    <div>Hello msg</div>
  </section>
</template>

该模板最终会被编译成渲染函数(注意template里面的才是我们模板的内容):

with (this) 
  return _c('section',[_c('div',[_v("Hello "+_s(msg))])]);


而该函数最终是怎么变成虚拟DOM的,我们在《Vue源码之虚拟DOM来自何方?》已经分析过,这里就不赘述了。我们这里主要关心的是模板到渲染函数的过程。

2. 从模板到渲染函数的三个关键步骤

上面示例代码中,我们会初始化一个Vue实例,然后执行Vue的原型方法$mount

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component 
  el = el && query(el);
  ...
  const options = this.$options;
  // resolve template/el and convert to render function
  if (!options.render) 
    let template = options.template;
    ...
    if (template) 
      ...
      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);
;

我们的示例中提供了template这个配置项,而不是直接提供render方法,所以上面的代码会调用compileToFunctions来将页面编译成render函数。

这里的staticRenderFns在我们这个例子中其实不会用到。这个变量本身是用来存储静态节点生成的静态的渲染函数的。比如我们例子中的插值如果直接写成静态字符串’Hello world’而不是msg,那么最终render将会是:

with(this)return _m(0)

其中_m方法实际上指向的是一个叫做renderStatic的方法,该方法会从staticRenderFns中读取第0个item。而staticRenderFns数组的第一个item将会是:

with(this)return _c('section',[_c('div',[_v("Hello world")])])

好,不岔开太远,回到我们刚才compileToFunctions,最终经过几个函数的调用,回来到我们这里的一个关键函数baseCompile。

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
  

里面调用到的方法虽然简短,但做的事情却是将模板编程render函数非常关键的三个步骤:

  • parse: 将我们的template解析成ast抽象语法树
  • optimize: 将我们上面提到的静态节点在ast上进行标识,以便生成staticRenderFns。我们这个例子中没有用到
  • generate: 最终生成render渲染函数

3. AST抽象语法树的生成

抽象语法树,是我们模板代码的一种javascript抽象表示,其形状是树形结构。一般我们在需要编译或者对代码语法进行分析的时候,都会先将源代码编译成抽象语法树,毕竟,分析javascript对象比分析dom方便多了。

更多的抽象语法树的概念等相关知识,这里就不多聊了,况且聊多了我也很有可能不懂。

3.1. 抽象语法树示例及属性简介

下面先看下我们例子中的模板template被编译成ast后是什么样子的


  tag: 'section',
  type: 1,
  children: [
    tag: 'div',
    type: 1,
    parent: tag: 'section', ...,
    children: [
      type: 2,
      expression: "\\"Hello \\"+_s(msg)",
      text: "Hello msg",
      tokens: [ 
		"Hello ",
      	@binding: "msg"
      ],
      start: 19,
      end: 26,
      static: false,
    ],
    start: 0,
    end: 32,
    ...
  ],
  start: 0,
  end: 45,
  attrsList: [],
  attrsMap: ,
  parent: undefined,
  plain: true,
  rawAttrsMap: ,
  static: false,
  staticRoot: false,

这里抽象语法树有以下一些关键的属性:

  • tag: 标签名。这应该很容易理解,比如我们例子中第一层就是个section标签,第二层也是个div标签
  • type:节点类型。其中1代表元素节点,即<div></div>之类;2代表有引用变量的文本节点,比如我们这里的Hello msg; 3代表普通文本节点,比如Hello world之类的。
  • parent: 当前节点的父节点
  • children: 当前节点的子节点
  • 其他: 还有其他一些在我们这个场景中不是很重要的属性

如果节点类型是2的话,其抽象语法树中对应的属性会有点特别:

  • expression: 解析后的文本表达式。比如这里的Hello +_s(msg)
  • text:原生的文本。比如我们这里的Hello msg
  • tokens: 以插值字符划分的符号列表。注意解析出来的变量以@binding: "msg":的形式表示

3.2. 模板解析工作流概览

下面我们开始看从template到render的第一个阶段,parse阶段,即模板解析阶段。

/**
 * Convert HTML string to AST.
 */
export function parse(
  template: string,
  options: CompilerOptions
): ASTElement | void 
  ...
  const stack = [];
  let root;
  let currentParent;
  ...

  function closeElement(element) 
    ... // 关闭标签并设置当前节点的父节点关系
  

  ...

  parseHTML(template, 
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    outputSourceRange: options.outputSourceRange,
    start(tag, attrs, unary, start, end) 
      ... // 处理开始标签
    ,

    end(tag, start, end) 
      ... // 处理结束标签
    ,

    chars(text: string, start: number, end: number) 
      ... // 处理文本节点
    ,
    comment(text: string, start, end) 
      ...//处理注释节点
    ,
  );
  return root;


模板解析阶段主要就是通过调用parseHTML这个方法来完成的。其函数内部会对template的字符串内容进行逐字分析,根据一定的逻辑和算法来分析出开始标签、结束标签、普通文本、注释文本等,一旦解析出这些元素内容,我们就需要将这些内容转换成组成抽象语法树的节点,而这,也就是parseHTML方法参数中的四个回调所要做的事情。

现在我们先不对parseHTML怎么分析出这些标签和普通文本等的代码作分析,而是先做些假设,假设parseHTML函数内部已经解析出了开始标签<div>,结束标签</div>等,普通文本等,然后看下对应的这些回调是怎么工作的。

3.3. 元素节点处理之开始标签

首先我们看下开始标签,在parseHTML解析出开始标签如<div>,那么将会回调到参数传入的start方法。下面就是根据我们例子的情况,省略掉一些枝叶之后的start源码:

 start(tag, attrs, unary, start, end) 
      ...
      let element: ASTElement = createASTElement(tag, attrs, currentParent);
      
      ... // 属性处理等工作

      if (!root) 
        root = element;
        ...
      

      if (!unary) 
        currentParent = element;
        stack.push(element);
       else 
        closeElement(element);
      
    ,

该方法传入了四个参数:

  • tag: 标签名,比如我们template中第一个节点中的标签section
  • unary: 自闭合标签标识位,比如</br>这种。这里不是我们分析的重点,我们的template中也没有这种标签,所以这里为false
  • attrs: 分析出的属性内容。假如我们某个div中有class或者id属性的话,这里将有对应解析出来的内容。在我们这个例子中,主要是想通过最简单的流程来了解模板解析的脉络,所以不会设置这些属性,今后看情况再另外起文章分析。所以这里的内容将会是一个空数组 []
  • start: 开始标签在template中的开始位置
  • end: 开始标签在template中的结束位置

start方法首先会调用createASTElement方法来创建一个AST的元素节点

export function createASTElement(
  tag: string,
  attrs: Array<ASTAttr>,
  parent: ASTElement | void
): ASTElement 
  return 
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    rawAttrsMap: ,
    parent,
    children: []
  

其中基本上都是一些直接赋值的工作。

一旦节点创建好之后,如果此前还没有设置过root节点,那么代表当前节点就是顶层节点,所以直接赋值给root。

这里需要注意的是createASTElement中parent这个形参,对应的是parse方法中传入的currentParent这个变量:

  • currentParent: 保存的是当前正在处理节点的父节点。

parse跟着要做的事情就是将这个新的节点设置成当前父节点currentParent,然后将其推入到stack这个栈中。

为什么要这么做呢?

其实这里涉及到我们怎么保证各个在解析过程中建立起来的抽象语法树节点的层级关系的精髓。在继续往下分析之前,我觉得很有必要通过一个例子来说明白这个事情。其中估计会涉及一些还没有分析到的代码,这个我也尽量会通过文字来说清楚,然后在后面分析到对应的源码的时候我们再反过来对照下。

3.4. 插曲:通过栈保证抽象语法树中节点的层级关系

从前面的抽象语法树例子中我们可以看到,和我们的DOM结构一样,我们里面的节点都是有层级关系的,比如例子中的第一个section对应的节点会有children,但是不会有parent,因为它是底层节点,而其下的div子节点就会既有children也有parent,parent就是顶层父节点,children就是文本节点。而这,也正是为什么抽象语法树之所以叫做树的原因了。

那么这个层级关系是怎么保证的呢?

这里关键点就是parse中的stack这个栈结构结合这里的currentParent来实现的。首先我们假设有template的内容如下:

  <section>
    <h1>Header</h1>
    <div>Body</div>
  </section>

就我们的例子来说,大概的流程将会是这样的:

  • parseHTML解析出第一个开始标签<div>,然后调用start来创建一个AST节点,然后将currentParent指向这个节点,同时将这个节点压栈。此时的stack是现在这个样子的:
[
	
        type: 1,
        tag: 'section',
        start: 0, 
		end: 9,
        parent: undefined,
        children: [],
    ,
]
  • 然后解析出第二个开始标签,即第二行的<div>, 同样调用start创建新节点,currentParent指向新节点,压栈。此时stack是如下这样的:
[
    
        type: 1,
        tag: 'section',
        start: 0, 
        end: 9,
        parent: undefined,
        children: [],
    ,
    
       type: 1,
       tag: 'h1',
       start: 56,
       end: 60,
       parent:  type: 1, tag: 'div', start: 0, end: 9, parent: undefined, ... ,
       children: [],
    
]
  • 跟着解析Header这个文本内容,因为是个普通文本节点,所以这个节点不会压栈,但是创建完后会直接设置到currentParent的children中,而我们这里的currentParrent就是这里最后压栈的第二行h1对应的节点,也就是栈顶内容。注意我们这里是通过javascript的数组来实现的栈,这里压栈是从下往上压,即栈顶会在stack.length - 1这个下标的位置。这时stack内容将如下
[
    
        type: 1,
        tag: 'section',
        start: 0, end: 9,
        parent: undefined,
        children: [],
    ,
    
        type: 1,
        tag: 'h1',
        start: 56,
        end: 60,
        parent:  type: 1, tag: 'section', start: 0, end: 9, parent: undefined, ... ,
        children: [ type: 3, text: 'Header', start: 60 end: 66 ],

    
]
  • 跟着,parseHTML将会解析出</h1>这个结束标签。这时将会调用end回调来处理,end方法会首先让stack顶层节点h1,即我们这里的currentParent指向的顶层元素出栈,然后立刻将currentParent指向新的栈顶元素。跟着end方法会将刚才出栈的h1节点放到新的currentParent的children下面。至此,h1及其子节点解析完成,当前栈内容将如下:
[
    
        type: 1,
        tag: 'section',
        start: 0, end: 9,
        parent: undefined,
        children: [
            type: 1,
            tag: 'h1',
            start: 56,
            end: 60,
            parent:  type: 1, tag: 'section', start: 0, end: 9, parent: undefined, ... ,
            children: [ type: 3, text: 'Header', start: 60 end: 66 ],

        ],
    ,

]

也就是说,现在栈上面剩下了一个节点,也就是我们最早加进去的由root指向的节点。该节点经过上面的一番操作后,将呈现为树状结构,和我们的模板的层级对应。

接着我们把剩下的分析完。

  • parseHTML跟着会解析出<div>这个标签,然后调用start来进行节点创建,然后将currentParent指向这个节点,同时将这个节点压栈。此时的stack是现在这个样子的:
[
    
        type: 1,
        tag: 'section',
        start: 0, end: 9,
        parent: undefined,
        children: [
            type: 1,
            tag: 'h1',
            start: 56,
            end: 60,
            parent:  type: 1, tag: 'section', start: 0, end: 9, parent: undefined, ... ,
            children: [ type: 3, text: 'Header', start: 60 end: 66 ],
        ],
    ,
    
        type: 1,
        tag: 'div',
        start: 78,
        end: 83,
        parent:  type: 1, tag: 'section', start: 0, end: 9以上是关于Vue源码之模板编译浅析的主要内容,如果未能解决你的问题,请参考以下文章

Vue源码之模板编译浅析

浅析 Vue.js 中那些空间换时间的操作

React Native Android 源码框架浅析(主流程及 Java 与 JS 双边通信)

React Native Android 源码框架浅析(主流程及 Java 与 JS 双边通信)

[Vue源码]一起来学Vue模板编译原理-Template生成AST

[Vue源码]一起来学Vue模板编译原理-AST生成Render字符串