Vue源码之模板编译浅析

发布于:2022-11-04 ⋅ 阅读:(609) ⋅ 点赞:(0)

在此前的《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: 'Hello 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, parent: undefined, ... },
        children: [],
    }
]
  • 跟着解析Body这个文本内容,和前面的Header一样,因为是个普通文本节点,所以这个节点不会压栈,但是创建完后会直接设置到currentParent的children中,而我们这里的currentParrent就是这里最后压栈的模板中第三行div对应的节点,也就是栈顶内容。这时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, parent: undefined, ... },
        children: [{ type: 3, text: 'Body', start: 83, end: 87 }],

    }
]
  • 跟着又碰上了结束标签</div>, 处理方法和上面的</h1>一样,调用end回调来处理,end方法会首先让stack顶层节点div,即我们这里的currentParent指向的顶层元素出栈,然后立刻将currentParent指向新的栈顶元素。跟着end方法会将刚才出栈的div节点放到新的currentParent的children下面。至此,div及其子节点解析完成,当前栈内容将如下:
[
    {
        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, parent: undefined, ... },
            children: [{ type: 3, text: 'Body', start: 83, end: 87 }],

        }],
    },
]

到了这里divh1都被收纳到了父节点,在这里也就是root指向的栈顶节点的children中。

  • parseHTML接着解析到最后一个结束标签</section>,调用end回调,将唯一一个顶层节点即root节点出栈。至此,栈为空,template解析完成,root就是整棵AST树,而该树将会被parse方法返回给上层调用。

3.5. 元素节点处理之结束标签

前面我们分析了开始标签的处理,这里我们继续看下parseHTML在分析出结束标签时所调用的end方法是怎么实现的.

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

首先,我们将栈顶元素保存起来到element中,然后开始将该栈顶元素出栈,调整currentParent为新的栈顶节点,即已出栈元素的父节点。

但是,这时父节点还没有保存该出栈子节点的任何信息的。所以end方法紧跟着就是将该出栈的节点给放到其父节点的children上。这就是closeElement方法要做的事情

 function closeElement(element) {
   ...
   if (currentParent && !element.forbidden) {
     if (element.elseif || element.else) {
       ...
     } else {
       ...
       currentParent.children.push(element);
       element.parent = currentParent;
     }
   }
   ...
 }

主要做的就是这两个事情:

  • 将刚才出栈的子节点放到父节点的children数组下面
  • 将子节点的parent设置成父节点

3.6. 文本节点处理

跟着我们要看的就是parseHTML解释出文本时候的处理,比如解释出我们的Hello {{msg}}这个文本的时候的处理。

首先,我们回看下文章前面示例中Hello {{msg}}对应的抽象语法树节点的内容

{
	  type: 2,
	  expression: "\"Hello \"+_s(msg)",
	  text: "Hello {{msg}}",
	  tokens: [ 
		"Hello ",
	  	{@binding: "msg"}
	  ],
	  start: 19,
	  end: 26,
	  static: false,
}

普通文本节点的关键属性:

  • type: 节点类型。2:代表这是个包含了变量引用的文本; 3:代表这是个没有包含变量的文本
  • expression: 解析后的文本表达式,将变量抽出来并写作_s的参数,然后将各个部分通过+号连接起来,比如这里的\"Hello \"+_s(msg)
  • text:原生的文本。比如我们这里的Hello {{msg}}
  • tokens: 以插值字符划分的符号列表。注意解析出来的变量以{@binding: "msg"}:的形式表示

这些内容现在不知道做什么用的没关系,我们下面在渲染函数生成的时候会解析怎么用。当前先要知道我们文本分析的目标就是生成这样的节点就行了。

下面我们看下parseHTML的文本处理回调函数chars

chars(text: string, start: number, end: number) {
  ...
  const children = currentParent.children;
  ...
  if (text) {
    ...
    let res;
    let child: ?ASTNode;
    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);
    }
  }
},

首先看下输入的关键参数:

  • text: parseHTML解析出来的原生的文本内容。即我们这里的Hello {{msg}}

chars函数逻辑简介:

  • 首先将父节点的children数组给找出来
  • 然后通过parseText来对文本进行解析,如果有返回值,代表是包含变量的类型type为2文本,否则代表是没有变量的文本。
  • 如果是包含变量的文本,创建type为2的子节点
  • 如果是不包含变量的纯文本,创建type为3的子节点
  • 将子节点加入到父节点的children数组中

下面看下parseText方法是怎么生成expression和tokens的。

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
export function parseText(
  text: string,
  delimiters?: [string, string]
): TextParseResult | void {
  // delimiters在我们这个示例中不会定义,所以我们会用defaultTagRE这个正则表达式去匹配插值{{xxx}}语法
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE;

  // 如果没有找到插值的地方,代表这个文本没有用到变量,直接返回,注意没有提供任何返回值。
  if (!tagRE.test(text)) {
    return;
  }
  const tokens = [];
  const rawTokens = [];

  // 注意这了的正则表达式的lastIndex的作用,我们这里的去全局匹配,在没匹配一个插值之后,就会调整lastIndex,以便
  // 下面的while循环能开始匹配下一个插值。因为我们的文本可能有多个插值的地方,比如:"My name is {{name}}, I am {{age}} years old"
  let lastIndex = (tagRE.lastIndex = 0);
  let match, index, tokenValue;
  while ((match = tagRE.exec(text))) {
    /*在我们的例子中,因为我们只有一个{{msg}}的插值,所以while只会循环一次,这时match的内容将会是:
     match: {
      0: "{{msg}}", 
      1: "msg, 
      index: 6, 
      input: "Hello {{msg}}"
    }
    */
    index = match.index;
    // push text token
    if (index > lastIndex) {
      //将lastIndex到index,即我们这里的0到6之间的字符,也就是插值语法{{之前的字符给推入到rawTokens, 在我们示例中是"Hello "
      rawTokens.push((tokenValue = text.slice(lastIndex, index)));
      // 同时将上面的字符串stringify后push到tokens中
      tokens.push(JSON.stringify(tokenValue));
    }
    // tag token
    // parseFilters本身是用来解析过滤器的,这里我们没有用到,所以会直接返回输入。而我们这里输入match[1]是"msg"
    const exp = parseFilters(match[1].trim());
    tokens.push(`_s(${exp})`);
    rawTokens.push({ "@binding": exp });

    // 调整lastIndex,开始下一个插值的分析。
    // 在我们示例中只有一个插值,所以{{开始前的下标加上{{msg}}的长度,就已经去到文本的末尾了
    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,
  };
}

因为这里要解析的地方比较多,所以干脆直接写到代码注释里面去了,这样应该更方便参考和理解。

最终返回的结果就是

{
	  expression: "\"Hello \"+_s(msg)",
	  tokens: [ 
		"Hello ",
	  	{@binding: "msg"}
	  ]
}

有了这两个返回结果,chars回调就会创建出对应的子节点

{
	  type: 2,
	  expression: "\"Hello \"+_s(msg)",
	  text: "Hello {{msg}}",
	  tokens: [ 
		"Hello ",
	  	{@binding: "msg"}
	  ],
	  start: 19,
	  end: 26,
	  static: false,
}

3.7. 注释节点处理

跟着我们看下注释节点的处理。parseHTML方法解析到模板中的注释标签之后,将会调用comment回调

comment(text: string, start, end) {
  // adding anything as a sibling to the root node is forbidden
  // comments should still be allowed, but ignored
  if (currentParent) {
    const child: ASTText = {
      type: 3,
      text,
      isComment: true,
    };
    ...
    currentParent.children.push(child);
  }
}

代码很简单,创建个type为3的注释节点,然后加入到父节点就完了。

3.8. parseHTML解析模板字符串

下面我们看下parseHTML的关键代码。这里为了分析简单,我们省略掉了注释代码解析等不是很关键的代码。

export function parseHTML (html, options) {
  const stack = []
  const expectHTML = options.expectHTML
  const isUnaryTag = options.isUnaryTag || no
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    // Make sure we're not in a plaintext content element like script/style
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        // Comment:
        ...

        // Doctype:
        ...

        // End tag:
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // Start tag:
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          handleStartTag(startTagMatch)
          ...
          continue
        }
      }

      let text, rest, next
      if (textEnd >= 0) {
        rest = html.slice(textEnd)
        ...
        text = html.substring(0, textEnd)
      }

      if (textEnd < 0) {
        text = html
      }

      if (text) {
        advance(text.length)
      }

      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    } else {
      ...
    }

    ...
  }

parseHTML工作的原理,简单来说就是用一个index指针指向模板字符串的头部,然后循环将其不断后移来对其内容进行解析。大概流程如下:

  • 将index指向模板字符串开始位置
  • 将textEnd指针指向从index开始的下个<
  • 如果textEnd为0,代表当前字符串是以<开始的,那么接下来要分析的内容可能将会是一个开始标签如<div>,结束标签</div>,Doctype如<!DOCTYPE html>,注释如<!-- 我是注释 -->。至于是哪一种,vue是通过正则来判断的。一旦判断出是某种类型,即会做相应的处理
  • 假如这里解析开始标签,解析过程是通过parseStartTag这个方法来做正则判断的。一旦发现是开始标签,该方法会调用advance函数来将处理过的内容删除掉,代表下一次循环将会从新的位置开始进行分析。跟着就会调用handleStartTag方法来做进一步处理,该函数其中重要的一步就是调用我们前面分析的start回调方法来建立抽象语法树元素节点。
  • 如果上面的textEnd不是0,那么就把当前index到下个<之间的内容给取出来作为文本text来处理。同样会先将index指针移到text文本之后,然后将之前的内容删掉再保存会html中,代码最后会调用我们前面分析过的回调chars来处理文本节点
  • 这时代码将会使用这个已经移除掉处理过内容的新的html来进行下一次的while循环,去做下一论的解析,如此往复,直到所有模板字符串都处理完毕

下面是advance的代码,大家可以参照着阅读

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

至此,我们就已经分析完了我们示例中的模板字符串是怎么被解析成抽象语法树的。Vue代码中的下一步就是调用optimize方法来进一步对ast抽象语法树进行优化处理。该优化主要是将那些不会因为数据状态改变而改变的节点给标识出来,以便今后在将ast生成虚拟DOM做diff的时候能够提升性能用。

4. 抽象语法树优化

其实在本文的例子中,optimize其实用处不大,因为我们这个简单的例子中唯一的文本节点是有插值语法去引用变量的,所以不会有静态节点的出现。不过我们这里还是会分析下optimize的作用。

4.1. 什么是静态节点和静态根节点

开始之前,我们需要先知道ast经过optimize之后,会在我们对应节点上加入static以及staticRoot等这些信息,其中:

  • static: 是否是静态节点。如果一个节点是个没有变量的纯文本节点,那么就是个静态节点。如果一个元素节点的所有子节点都是静态节点,该元素节点也是个静态节点。更多静态节点的判断会由isStatic函数来做判断
  • staticRoot:是否是静态根节点。如果一个元素节点所有的子节点都是静态节点,那么这个节点就被称作根节点。

下面就是判断一个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)
  ))
}

我们前面已经说过节点的类型,在这里会用来判断是否是静态节点:

  • 节点type是2代表的是含有变量的动态文本节点,所以这里返回false,代表这不是个静态节点
  • 节点type是3代表这是个纯文本的静态文本节点,所以返回true,代表这是个静态节点

如果节点类型不是2和3,那应该就是元素节点了,这时就要走代码最后的那个判断,如果元素节点存在v-if,v-for,v-model等动态绑定之类的语句,也会被判定为不是个静态节点。

为了加深理解,下面我们对照两个模板和其对应的optimize后的ast内容,加深下对static和staticRoot的理解。

首先我们看下我们的示例

<section>
  <div>Hello {{msg}}</div>
</section>

对应的optimized后的ast

{
  tag: 'section',
  type: 1,
  parent: undefined,
  static: false,
  staticRoot: false,
  children: [{
    tag: 'div',
    type: 1,
    parent: {tag: 'section', ...},
    static: false,
  	staticRoot: false,
    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,
 
}

对于ast的每个节点:

  • 最内层的Hello {{msg}}为为带有插值语法的动态文本节点,所以其static是false
  • 上层div为元素节点,切其子节点不是静态节点,所以它的static也是为false,既然不是staic节点,当然也不会是staticRoot节点
  • 同理,顶层section是元素节点,但是子节点有非静态节点,所以它的static和staticRoot也都是false

下面我们再看下上面分析抽象语法树层级关系时用到的例子

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

对应的优化过后的ast:

{
    type: 1,
    tag: 'section',
    start: 0,
    end: 9,
    parent: undefined,
    static: true,
    staticRoot: true,
    children: [{
        type: 1,
        tag: 'h1',
        start: 56,
        end: 60,
        static: true,
        parent: { type: 1, tag: 'section', start: 0, end: 9, parent: undefined, ... },
        children: [{ type: 3, text: 'Header', start: 60 end: 66, static: true }],

    }, {
        type: 1,
        tag: 'div',
        start: 78,
        end: 83,
        static: true,
        parent: { type: 1, tag: 'section', start: 0, end: 9, parent: undefined, ... },
        children: [{ type: 3, text: 'Body', start: 83, end: 87, static: true, }],
    }],
}

对于ast的每个节点:

  • 最内层的Headerbody为内容的节点为纯文本节点,所以其static是true
  • 父节点分别是元素节点h1div,因为其各自的children,即上面的Headerbody都是静态节点,所以她们自身也会被定义成是静态节点。但是,这里有一点需要注意的是,这两个元素都没有被标志成staticRoot节点,为什么呢?其原因就是静态根节点有个条件是其不能只有一个纯文本的子节点!
  • 同时,祖父节点section已经是顶层节点,其下的子节点都是静态节点,所以该父节点被定义成既是静态static节点,也是staticRoot静态根节点

4.2. optimize源码分析

下面我们开始看下vue中的optimze是怎么做的

 function optimize (root, options) {
    if (!root) { return }
    ...
    markStatic(root);
    // second pass: mark static roots.
    markStaticRoots(root, false);
  }

第一步,调用markStatic从抽象语法树根开始一个个节点往下分析来设计static标志; 第二步,调用markStaticoots从抽象语法树根节点开始找到各个静态根节点并设置成staticRoot。

先看markStatic

function markStatic (node) {
    node.static = isStatic(node);
    if (node.type === 1) {
      // do not make component slot content static. this avoids
      // 1. components not able to mutate slot nodes
      // 2. static slot content fails for hot-reloading
      ...
      for (var i = 0, l = node.children.length; i < l; i++) {
        var child = node.children[i];
        markStatic(child);
        if (!child.static) {
          node.static = false;
        }
      }
      ...
    }
  }

逻辑简单来说就是:如果是元素节点且有children,则递归将每个节点markStatic。递归出口就是到了底层非元素节点的时候。最后逐层返回,每层都调用一次isStatic方法来判断本节点是不是静态节点,设置节点的static属性。

这里需要注意的是!child.static这个判断,只要子节点中有一个不是静态节点,则该元素节点也会被设置成非静态节点,即node.statick = false

跟着我们看下markStaticRoot方法

function markStaticRoots (node, isInFor) {
  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 (var i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for);
      }
    }
    ...
  }
}

逻辑大致如下:

  • 首先,静态根节点必须是个元素节点。这也很容易理解,只有元素节点才会有子节点
  • 如果该节点是static节点,有children子节点,且子节点不是只有一个静态文本节点,那么将该节点设置成静态根节点,否则设置成非静态节点。按照注释中的说法,之所以这样判断是因为做这种优化的结果带来的收益高于成本
  • 然后针对每个子节点调用markStaticRoots进行递归来设置静态根节点属性

支持,抽象语法树优化阶段就说完了。下一步我们就来看下这个ast是怎么转换成render渲染函数的。

5. 生成render函数

通过此前的文章《Vue源码之虚拟DOM来自何方?》的学习,我们知道虚拟DOM是通过渲染函数来生成的。那么渲染函数又是从哪里来的呢?答案就是通过ast生成的。

这一章节我们就来看下模板的抽象语法树是怎么转换成渲染函数的。

5.1. render函数生成流程简介

开始之前,我们再次把文章开始的时候给出的示例和最终生成的AST以及render函数给出来。

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

该模板最终会被编译成抽象语法树ast

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

该模板最终会被编译成渲染函数:

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

如果我们有看过此前的文章《Vue源码之虚拟DOM来自何方?》,我们应该知道这里的:

  • _c: 其实就是我们常说的h函数,也就是我们vue中的$createElement方法,其作用是用来创建一个元素节点。该方法接受三个参数,分别是标签名tag,属性数据对象data,子节点children
  • _v: 就是vue中的createTextVNode,创建文本虚拟节点的快速方法
  • _s:就是toString的简写

ast转换成render函数的流程,其实就是一个递归处理的过程,从根节点到子节点进行递归,将每个节点转换成可以生成对应虚拟节点的h函数_c或者_v等。下面我们简单看下例子中的ast到render函数的流程:

  • ast根节点section是个元素节点,且模板中该节点没有使用class等属性,所以会直接生成h函数: _c('section', {}, [/* 由递归生成的子节点*/]),因为第二个参数属性对象为空,h函数支持重载,所以可以省略空数据属性,写成 _c('section', [/* 由递归生成的子节点*/]`)
  • 跟着进入section的子节点div,同样也是个元素节点,所以同样会直接生成h函数: _c('div', [/* 由递归生成的子节点*/]`)
  • 跟着到最下层的节点,因为是个文本节点,所以直接用_v将其包裹: _v("Hello "+_s(msg))
  • 然后递归组成返回,填充上层的子节点内容。最终得到 _c('section',[_c('div',[_v("Hello "+_s(msg))])])

最后将这部分代码用with包装下,最终生成上面的代码

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

5.2. render函数生成源码分析

下面我们看下在vue中,从ast到render函数的生成是怎么实现的。首先我们看下generate方法

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  // fix #11483, Root level <script> tags should not be rendered.
  const code = ast ? (ast.tag === 'script' ? 'null' : genElement(ast, state)) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

这里关键代码就是通过genElement方法来生成code

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 {
    // component or element
    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
      })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

这里我们的例子中既不是静态节点,也没有用到v-for等会让我们分析过程变复杂的选项,所以前面的一堆if else的可以跳过。

其实这里核心的代码是这几行

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

data就是我们说的class等属性数据,我们的例子中是空。

children那一行就是开始对节点的children进行递归处理,我们在下面会进一步分析。

我们上面说了h函数接受三个参数:

  • 标签名tag
  • 属性数据对象data
  • 子节点children

所以我们上面在这些数据都准备好后,就会将这个节点标签tag,属性数据data,子节点children组合起来生成最终的代码code

跟着我们看下genChildren的关键代码

export function genChildren (
  el: ASTElement,
  state: CodegenState,
  checkSkip?: boolean,
  altGenElement?: Function,
  altGenNode?: Function
): string | void {
  const children = el.children
  if (children.length) {
    const el: any = children[0]
    // optimize single v-for
    ...
    const gen = altGenNode || genNode
    return `[${children.map(c => gen(c, state)).join(',')}]${
      normalizationType ? `,${normalizationType}` : ''
    }`
  }
}

主要做的事情就是取出子节点children,然后遍历各个子节点,针对每个子节点调用genNode方法来生成可以生成虚拟节点的h函数调用。而这里genNode又可能会调用前面的genElement,故此形成递归。

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

如果上面分析的元素的子节点也是个元素类型的节点,代表其下可能还有子节点,所以这里调用genElement来形成递归处理。

如果是纯文本节点或者注释节点,那么就调用genComment来生成注释的h函数调用代码,如果都不是的话,那应该是个带有变量插值的文本节点,需要调用genText来进行处理。

生成注释节点代码的genComment函数源码如下

export function genComment (comment: ASTText): string {
  return `_e(${JSON.stringify(comment.text)})`
}

通过用_e来将我们的注释文本包裹自来就完了。事实上,_e 对应的是createEmptyVNode方法,该方法的调用就会生成注释类型的虚拟节点。

export const createEmptyVNode = (text: string = "") => {
  const node = new VNode();
  node.text = text;
  node.isComment = true;
  return node;
};

但是我们这里只是生成代码,而不是真实的调用。

同时,我们也看下genText的源码

export function genText (text: ASTText | ASTExpression): string {
  return `_v(${text.type === 2
    ? text.expression // no need for () because already wrapped in _s()
    : transformSpecialNewlines(JSON.stringify(text.text))
  })`
}

作用就是用_v来将文本包裹起来,这个我们在前面已经见识到了。

至此,我们的render函数代码就生成了,但是,如我们所见,这些代码都还是一堆字符串而已,要让其真正成为可以被调用的真实方法,我们还需要调用createFunction方法

function createFunction(code, errors) {
  try {
    return new Function(code);
  } catch (err) {
    errors.push({ err, code });
    return noop;
  }

实现的方式很简单,直接调用ES6给我们带来的Function够着函数来将我们的code字符串转换成真实的可以调用的render方法。

6. 小结

至此,我们的vue模板编译源码分析浅析就算完成了,稍微总结下:模板编译分成了三个主要的阶段:

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

在解析成抽象语法树的过程中,vue通过一个栈来保证语法树中节点的层级关系,简单来说就是碰到开始标签时节点压栈,碰到结束标签时出栈,并把出栈的节点放到当前最新栈顶也就是父节点的children列表中。

解析template字符串的时候parseHTML接受四个回调来处理解析后的内容

  • start: 解析出开始标签如<div>后的回调。主要就是创建对应的ast节点,并将其压倒栈顶
  • end: 解释出结束标签如</div>后的回调。主要就是弹栈子节点,并将其设置到栈顶即父节点的children列表。
  • chars: 解析出文本后的回调。主要就是根据是否是有变量插值来创建type为2的节点和type为3的纯文本节点
  • comment: 解释出注释后的回调。主要就是创建type为3且isComment为true的注释节点

到了代码优化阶段,主要做的事情就是找出静态节点和静态根节点,并标志其static和staticRoot属性。

最后就是生成对应的render函数代码,主要逻辑就是通过递归从抽象语法树的顶端到子节点逐个创建对应的h函数(即_c或者$createElement)代码字符串code。

最后通过es6语法的new Function(code)来生成真正的render方法。

ps: 这篇文章是我每天晚上回家抽时间写的,耗时5个晚上,所以转载的时候请保留出处连接,算是对我劳动成果的那么一点点尊重吧。

我是@天地会珠海分舵,「青葱日历」和「三日清单」作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到