Vue2.0源码阅读计划(五)——模板编译

——过去属于死神,未来属于你自己

前言

正是问题激发我们去学习,去实践,去观察。对于问题,我习惯于遵循是什么?为什么?怎么做的思路来探索。

模板编译,在不了解之前,起码我看到编译两字,就觉得很厉害样子。词条上是这样来描述编译原理的:

编译原理是计算机专业的一门重要专业课,旨在介绍编译程序构造的一般原理和基本方法。内容包括语言和文法、词法分析、语法分析、语法制导翻译、中间代码生成、存储管理、代码优化和目标代码生成。 编译原理是计算机专业设置的一门重要的专业课程。编译原理课程是计算机相关专业学生的必修课程和高等学校培养计算机专业人才的基础及核心课程,同时也是计算机专业课程中最难及最挑战学习能力的课程之一。编译原理课程内容主要是原理性质,高度抽象。

?额。。。为什么我的大学课程没有这门课,算了,就算有,我这么淘气,肯定也不会学。简单读了一遍,让我眼前一亮?的就是最难及最挑战学习能力这几个关键字了,我就是个普通人,大家大多数也都是普通人,懂我意思吧。嘿嘿,放弃,我要回我的床躺平。

不行,兔兔是个有理想的人,立志要做到比大多数人优秀,比剩下的少数人还优秀,那就不是人干的事了。挣扎,爬起来,我还能学?。我们在上面有看到编译原理的内容包括词法分析、语法分析、代码优化、目标代码生成等,所以在vue中,模板编译的流程在这个的基础上,大致划分为三个阶段,分别是模板解析阶段、优化阶段和代码生成阶段。

正文

vue的官网API上我们会在有些地方,看到有如下的说明:

image.png
那么什么是完整版呢?所以在开始之前,我们先来了解下vue的构建版本。

构建版本

完整版本

完整版本即runtime + compiler版本,指的是一个同时包含编译器和运行时的完整版本。完整版本包含了编译器,编译器会自动将template选项中的模板字符串编译成渲染函数(render)的代码。完整版本我们一般只在以CDN形式引入的时候会用到:

new Vue({
  template: '<div>{{ hi }}</div>'
})
复制代码

我们在每一个html文件对应的js中都会这样去写。编译是个耗费性能的过程,完整版本也因为加入了编译的流程代码,体积会比较庞大(相比运行时版本大了约30%)。当采用CDN链入的形式的时候,浏览器在解析到script标签时会去下载资源,资源庞大就比较耗时,资源下载完毕还要进行模板编译,所以会导致性能大打折扣。

运行时版本

运行时版本即runtime版本,除去了编译流程的代码。那么我们在使用运行时版本的时候,除去自己手写render函数的情况,我们写的模板就不需要编译了吗?任何时候都需要编译。我们平时更多的工作场景是使用单文件组件(*.vue)的形式进行快速开发,在这种工程化开发模式下,vue-loader会对*.vue文件中的template模板进行编译(也会编译style),也就是编译这部分vue-loader插件帮助我们去做了。

运行时版本将Vue对模板的编译阶段合并到webpack的构建流程中,这样不仅减小了生产环境代码的体积,也大大提高了运行时的性能,一举两得。

所以为了完整的学习源码,我们还是要阅读runtime + compiler完整版本的代码。

什么是模板编译

单文件组件中模板即写在标签中的类似于原生HTML的内容,模板编译就是将模板编译生成render函数的过程。

为什么需要模板编译

我们的模板在上面被称之为类似于原生HTML的内容,类似是因为我们在开发中会使用模板插值、指令、别的组件等。模板编译需要处理掉这些不属于原生HTML的内容,并最终生成一个可以被调用的render函数,执行render函数会生成模板对应的虚拟DOM,这样后面才能进行DOM Diff,从而更新渲染视图。

模板编译流程

image.png

模板编译阶段只存在于完整版本中,发生在生命周期的createdbeforeMount之间。这里在对el是否存在进行判断后,接着判断了template选项是否定义,在这中间未列出对于自定义render函数的判断,如果render函数存在,这里是不会进行模板编译的。如果你的render是如下方式定义的:

import App from './App'

new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})
复制代码

那么模板编译发生在patch阶段Vue子类实例化的时候(详见上篇Vue2.0源码阅读计划(四)——虚拟DOM),编译无论何时都是一定且必须要执行的过程。

模板编译的具体流程可大致分为三个阶段:

  1. 模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST解析器);
  2. 优化阶段:遍历AST,找出其中的静态节点,并打上标记(优化器);
  3. 代码生成阶段:将AST转换成渲染函数(代码生成器);

从源码中可以看出:

// 源码位置: /src/complier/index.js

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    // 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
    optimize(ast, options)
  }
  // 代码生成阶段:将AST转换成渲染函数;
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})
复制代码

模板解析阶段

此阶段是将模板解析成AST,那么AST又是什么?

在计算机科学中,抽象语法树(AbstractSyntaxTree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

通俗来讲,vue中的AST就是一个包含了模板中关键有效信息属性的树状JS对象。我们通过这个在线网站来直观感受一下:

image.png

解析阶段还是比较复杂的,我这里只梳理下主体实现过程:

与编译相关的代码全部在/src/complier下,解析器定义在/src/complier/parser/index.js中:

// 伪代码
export function parse(template, options) {
   // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    // 当解析到开始标签时,调用该函数
    start (tag, attrs, unary) {
    
    },
    // 当解析到结束标签时,调用该函数
    end () {
    
    },
    // 当解析到文本时,调用该函数
    chars (text) {
        if (text) parseText(text, delimiters)
    },
    // 当解析到注释时,调用该函数
    comment (text) {

    }
  })
  return root
}
复制代码

parse主函数内调用了parseHTML函数,顾名思义,parseHTML 函数是对模板字符串进行解析的。我们在/src/complier/parser下看到有html-parser.jstext-parser.jsfilter-parser.js这3个文件,它们分别对应HTML解析器(parseHTML)文本解析器(parseText)过滤器解析器(parseFilters)

解析的整个流程是:以HTML解析器为主线,先用HTML解析器进行解析整个模板,在解析过程中如果碰到文本内容,那就调用文本解析器来解析文本,如果碰到文本中包含过滤器那就调用过滤器解析器来解析。

对应源码来看,你会在parseHTML中看到当触发chars钩子函数时parseText被调用,在parseText的定义中会看到parseFilters被调用。

HTML解析器内部运行流程: 通过循环解析模板,用不同的正则表达式将文本、HTML注释、条件注释、DOCTYPE、开始标签、结束标签等不同内容从模板字符串中一一解析出来,对于不同情况分别进行不同的处理,直到整个模板被解析完毕。

在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾:

function advance (n) {
    index += n
    html = html.substring(n)
}
复制代码
  • 当解析到开始标签时调用start函数生成元素类型的AST节点,代码如下:
start (tag, attrs, unary) {
	let element = createASTElement(tag, attrs, currentParent)
}

export function createASTElement (tag,attrs,parent) {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    parent,
    children: []
  }
}
复制代码
  • 当解析到结束标签时调用end函数:
end (tag, start, end) {
  const element = stack[stack.length - 1]
  closeElement(element) // 暂不做了解
}
复制代码
  • 当解析到文本时调用chars函数生成文本类型的AST节点:
chars (text) {
  if(text是带变量的动态文本){
    let element = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  } else {
    let element = {
      type: 3,
      text
    }
  }
}
复制代码

-当解析到注释时调用comment函数生成注释类型的AST节点:

comment (text: string) {
  let element = {
    type: 3,
    text,
    isComment: true
  }
}
复制代码

从伪代码我们可以看出:AST 元素节点总共有 3 种类型,type1 表示是普通元素,为 2 表示是表达式,为 3 表示是纯文本。

解析时需要注意的地方:我们上面创建的AST节点都是单独创建且分散的,而真正的DOM节点都是有层级关系的,如何保证AST节点层级关系,也就是我们如何构建出树形的AST节点来?

针对此问题,解决办法是在HTML解析器一开始就定义了一个栈stack,在解析到开始标签时,调用start钩子函数,将解析得到的开始标签推入栈中;在解析闭合标签的时候,调用end钩子函数,就将解析得到的结束标签所对应的开始标签从栈顶弹出;弹出后此时留在栈顶的标签就是弹出标签的父节点。

对于有未正确闭合的标签的处理流程:

<div><p><span></p></div>
复制代码

解析到<div>入栈,解析到<p>入栈,解析到<span>入栈,解析到</p>出栈,但栈顶此时是<span>,那么久说明span标签没有被正确闭合,此时控制台就会抛出警告:tag has no matching end tag.

优化阶段

AST构建好后,vue还进行了优化,即标记静态节点(<p>我是静态节点</P>),在patch阶段对于静态节点的比对会直接跳过,从而提高性能。

标记静态节点的原理很简单:从根节点开始,先标记根节点为静态节点,然后看根节点如果是元素节点,那么就去向下递归它的子节点,子节点如果还有子节点那就继续向下递归,直到标记完所有节点。递归过程中,一旦子节点有不是 static 的情况,则它的父节点的 static 均变成 false

标记静态根节点的前提是:

  • 节点本身必须是静态节点;
  • 必须拥有子节点 children
  • 子节点不能只是只有一个文本节点;

否则的话,对它的优化成本将大于优化后带来的收益。标记的原理与寻找静态节点相同。

代码生成阶段

此阶段要做的事就是如何根据优化后的AST生成render函数。这也是一个递归的过程,从顶向下依次递归AST中的每一个节点,根据不同的AST节点类型创建不同的VNode类型。实际还是调用createElementrender函数的第一参数)去生成的VNode

AST遍历完毕后用with包装,输出一个字符串:

`
with(this){
    reurn _c(
        'div',
        {
            attrs:{"id":"NLRX"},
        }
        [
            _c('p'),
            [
                _v("Hello "+_s(name))
            ]
        ])
}
`
复制代码

最后将这个函数字符串作为参数放入new Function(code)中:

res.render = createFunction(compiled.render, fnGenErrors)

function createFunction (code, errors) {
  try {
    return new Function(code)
  } catch (err) {
    errors.push({ err, code })
    return noop
  }
}
复制代码

当我们调用render的时候就会生成VNode了。

至此,模板编译的整个流程就走完了。个人认为,此过程较为复杂,理解大体思想就可以了,死磕代码没啥用,框架学的是思想。

结语

本文更多是偏向于笔记、总结,并加入了自己学习时的一些理解,大家一起加油啊!!!

参考:
Vue源码系列-Vue中文社区
Vue.js 技术揭秘

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享