[Vue源码]一块儿来学Vue模板编译原理(一)-Template生成AST

本文咱们一块儿经过学习Vue模板编译原理(一)-Template生成AST来分析Vue源码。预计接下来会围绕Vue源码来整理一些文章,以下。javascript

  • [一块儿来学Vue双向绑定原理-数据劫持和发布订阅]()
  • [一块儿来学Vue模板编译原理(一)-Template生成AST]()
  • [一块儿来学Vue模板编译原理(二)-AST生成Render字符串]()
  • [一块儿来学Vue虚拟DOM解析-Virtual Dom实现和Dom-diff算法]()

这些文章统一放在个人git仓库:https://github.com/yzsunlei/javascript-series-code-analyzing。以为有用记得star收藏。html

编译过程

模板编译是Vue中比较核心的一部分。关于Vue编译原理这块的总体逻辑主要分三个部分,也能够说是分三步,先后关系以下:vue

第一步:将模板字符串转换成element ASTs(解析器)

第二步:对 AST 进行静态节点标记,主要用来作虚拟DOM的渲染优化(优化器) java

第三步:使用element ASTs生成render函数代码字符串(代码生成器)git

对应的Vue源码以下,源码位置在src/compiler/index.jsgithub

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 1.parse,模板字符串 转换成 抽象语法树(AST)
  const ast = parse(template.trim(), options)
  // 2.optimize,对 AST 进行静态节点标记
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 3.generate,抽象语法树(AST) 生成 render函数代码字符串
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

这篇文档主要讲第一步将模板字符串转换成对象语法树(element ASTs),对应的源码实现咱们一般称之为解析器。正则表达式

解析器运行过程

在分析解析器的原理前,咱们先举例看下解析器的具体做用。 算法

来一个最简单的实例:express

<div>
  <p>{{name}}</p>
</div>

上面的代码是一个比较简单的模板,它转换成AST后的样子以下:segmentfault

{
  tag: "div"
  type: 1,
  staticRoot: false,
  static: false,
  plain: true,
  parent: undefined,
  attrsList: [],
  attrsMap: {},
  children: [
    {
      tag: "p"
      type: 1,
      staticRoot: false,
      static: false,
      plain: true,
      parent: {tag: "div", ...},
      attrsList: [],
      attrsMap: {},
      children: [{
        type: 2,
        text: "{{name}}",
        static: false,
        expression: "_s(name)"
      }]
    }
  ]
}

其实AST并非什么很神奇的东西,不要被它的名字吓倒。它只是用JS中的对象来描述一个节点,一个对象表明一个节点,对象中的属性用来保存节点所需的各类数据。

事实上,解析器内部也分了好几个子解析器,好比HTML解析器、文本解析器以及过滤器解析器,其中最主要的是HTML解析器。顾名思义,HTML解析器的做用是解析HTML,它在解析HTML的过程当中会不断触发各类钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数。

咱们先看下解析器总体的代码结构,源码位置src/compiler/parser/index.js

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) {
    //...
  }
})

实际上,模板解析的过程就是不断调用钩子函数的处理过程。整个过程,读取template字符串,使用不一样的正则表达式,匹配到不一样的内容,而后触发对应不一样的钩子函数处理匹配到的截取片断,好比开始标签正则匹配到开始标签,触发start钩子函数,钩子函数处理匹配到的开始标签片断,生成一个标签节点添加到抽象语法树上。

还举上面那个例子来讲:

<div>
  <p>{{name}}</p>
</div>

整个解析运行过程就是:解析到<div>时,会触发一个标签开始的钩子函数start,处理匹配片断,生成一个标签节点添加到AST上;而后解析到<p>时,又触发一次钩子函数start,处理匹配片断,又生成一个标签节点并做为上一个节点的子节点添加到AST上;接着解析到{{name}}这行文本,此时触发了文本钩子函数chars,处理匹配片断,生成一个带变量文本(变量文本下面会讲到)标签节点并做为上一个节点的子节点添加到AST上;而后解析到</p>,触发了标签结束的钩子函数end;接着继续解析到</div>,此时又触发一次标签结束的钩子函数end,解析结束。

正则匹配

模板解析过程会涉及到许许多多的正则匹配,知道每一个正则有什么用途,会更加方便以后的分析。

那咱们先来看看这些正则表达式,源码位置在src/compiler/parser/index.js

export const onRE = /^@|^v-on:/
export const dirRE = process.env.VBIND_PROP_SHORTHAND
  ? /^v-|^@|^:|^\.|^#/
  : /^v-|^@|^:|^#/
export const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
const stripParensRE = /^\(|\)$/g
const dynamicArgRE = /^\[.*\]$/

const argRE = /:(.*)$/
export const bindRE = /^:|^\.|^v-bind:/
const propBindRE = /^\./
const modifierRE = /\.[^.\]]+(?=[^\]]*$)/g

const slotRE = /^v-slot(:|$)|^#/

const lineBreakRE = /[\r\n]/
const whitespaceRE = /\s+/g

const invalidAttributeRE = /[\s"'<>\/=]/

上面这些正则相对来讲比较简单,基本上都是用来匹配Vue中自定义的一些语法格式,如onRE匹配 @ 或 v-on 开头的属性,forAliasRE匹配v-for中的属性值,好比item in items、(item, index) of items。

下面这些就是专门针对html的一些正则匹配,源码位置在src/compiler/parser/html-parser.js

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/

这些正则表达式相对来讲就复杂一些,如attribute用来匹配标签的属性,startTagOpen、startTagClose用于匹配标签的开始、结束部分等。这些正则表达式的写法就很少说了,有兴趣的朋友能够针对这些正则一个一个的去测试一下。

HTML解析器

这里咱们来看看HTMl解析器。

事实上,解析HTML模板的过程就是循环的过程,简单来讲就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,而后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。

咱们经过源码,就能够看到整个函数逻辑就是被一个while循环包裹着。源码位置在:src/compiler/parser/html-parser.js

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) {
    //...
  }
  
  parseEndTag()
  
  //...
}

下面我用一个简单的模板,模拟一下HTML解析的过程,以便于更好的理解。

<div>
  <p>{{text}}</p>
</div>

最初的HTML模板:

<div>
  <p>{{text}}</p>
</div>

第一轮循环时,截取出一段字符串<div>,解析出是div开始标签而且触发钩子函数start,截取后的结果为:

<p>{{text}}</p>
</div>

第二轮循环时,截取出一段换行空字符串,会触发钩子函数chars,截取后的结果为:

<p>{{text}}</p>
</div>

第三轮循环时,截取出一段字符串<p>,解析出是p开始标签而且触发钩子函数start,截取后的结果为:

{{text}}</p>
</div>

第四轮循环时,截取出一段字符串{{name}},解析出是变量字符串而且触发钩子函数chars,截取后的结果为:

</p>
</div>

第五轮循环时,截取出一段字符串</p>,解析出是p闭合标签而且触发钩子函数end,截取后的结果为:

</div>

第六轮循环时,截取出一段换行空字符串,会触发钩子函数chars,截取后的结果为:

</div>

第七轮循环时,截取出一段字符串</div>,解析出是div闭合标签而且触发钩子函数end,截取后的结果为:

第八轮循环时,发现只有一个空字符串,解析完毕,循环结束。

如今,是否是就对HTML解析过程很清楚了。其实循环过程对每次匹配到的片断进行分析记录仍是很复杂的,由于被截取的片断分不少种类型,好比:

开始标签,例如 <div>

结束标签,例如</div>

HTML注释,例如<!-- 注释 -->

DOCTYPE,例如<!DOCTYPE html>

条件注释,例如<!--[if !IE]>-->注释<!--<![endif]-->

文本,例如'字符串'

对每一个片断的具体处理这里就不说了,有兴趣的直接看源码去。

文本解析器

文本解析器是对HTML解析器解析出来的文本进行二次加工。文本其实分两种类型,一种是纯文本,另外一种是带变量的文本。以下:

这种就是纯文本:

这里有段文本

这种就是带变量的文本:

文本内容:{{text}}

上面HTML解析器在解析文本时,并不会区分文本是不是带变量的文本。若是是纯文本,不须要进行任何处理;但若是是带变量的文本,那么须要使用文本解析器进一步解析。由于带变量的文本在使用虚拟DOM进行渲染时,须要将变量替换成变量中的值。

咱们知道,HTML解析器在碰到文本时,会触发chars钩子函数,咱们先来看看钩子函数里面是怎么区分普通文本和变量文本的。

源码位置在:src/compiler/parser/html-parser.js

chars (text: string, start: number, end: number) {
  //...
  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
    }
  }
  //...
  children.push(child)
}

咱们重点看res = parseText(text,delimiters)这一行,经过条件判断设置不一样的类型。事实上type=2表示表达式类型,type=3表示普通文本类型。

咱们再来看看parseText函数具体作了什么

export function parseText (
  text: string,
  delimiters?: [string, string]
): TextParseResult | void {
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  // 匹配不到带变量时直接返回了
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  // 对匹配到的变量循环处理成表达式
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    // 先把 { { 前边的文本添加到tokens中
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    const exp = parseFilters(match[1].trim())
    // 使用_s对变量进行包装
    // 把变量改为`_s(x)`这样的形式也添加到数组中
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    // 设置lastIndex来保证下一轮循环时,正则表达式再也不重复匹配已经解析过的文本
    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
  }
}

实际上这个函数就是处理带变量的文本,首先若是是纯文本,直接return。若是是带变量的文本,使用正则表达式匹配出文本中的变量,先把变量左边的文本添加到数组中,而后把变量改为_s(x)这样的形式也添加到数组中。若是变量后面还有变量,则重复以上动做,直到全部变量都添加到数组中。若是最后一个变量的后面有文本,就将它添加到数组中。

那么对于上面示例处理结果以下:

parseText('这里有段文本')
// undefined

parseText('文本内容:{{text}}')
// '"文本内容:" + _s(text)'

好了,对于文本解析器就这么多内容。

总结一下

模板解析是Vue模板编译的第一步,即经过模板获得AST(抽象语法树)。

生成AST的过程核心就是借助HTML解析器,当HTML解析器经过正则匹配到不一样的片断时会触发对应不一样的钩子函数,经过钩子函数对匹配片断进行解析咱们能够构建出不一样的节点。

文本解析器是对HTML解析器解析出来的文本进行二次加工,主要是为了处理带变量的文本。

相关

相关文章
相关标签/搜索