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


    本文我们一起通过学习Vue模板编译原理(一)-Template生成AST来分析Vue源码。预计接下来会围绕Vue源码来整理一些文章,如下。

    这些文章统一放在我的git仓库:https://github.com/yzsunlei/javascript-series-code-analyzing。觉得有用记得star收藏。

    编译过程

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

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

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

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

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

    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),对应的源码实现我们通常称之为解析器。

    解析器运行过程

    在分析解析器的原理前,我们先举例看下解析器的具体作用。

    来一个最简单的实例:

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

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

    {
      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>
    

    整个解析运行过程就是:解析到

    时,会触发一个标签开始的钩子函数start,处理匹配片段,生成一个标签节点添加到AST上;然后解析到

    时,又触发一次钩子函数start,处理匹配片段,又生成一个标签节点并作为上一个节点的子节点添加到AST上;接着解析到{{name}}这行文本,此时触发了文本钩子函数chars,处理匹配片段,生成一个带变量文本(变量文本下面会讲到)标签节点并作为上一个节点的子节点添加到AST上;然后解析到

    ,触发了标签结束的钩子函数end;接着继续解析到
    ,此时又触发一次标签结束的钩子函数end,解析结束。

    正则匹配

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

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

    export const onRE = /^@|^v-on:/
    export const dirRE = process.env.VBIND_PROP_SHORTHAND
      ? /^v-|^@|^:|^.|^#/
      : /^v-|^@|^:|^#/
    export const forAliasRE = /([sS]*?)s+(?:in|of)s+([sS]*)/
    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 = /[
    ]/
    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开始标签并且触发钩子函数start,截取后的结果为:

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

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

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

    第三轮循环时,截取出一段字符串

    ,解析出是p开始标签并且触发钩子函数start,截取后的结果为:

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

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

      </p>
    </div>
    

    第五轮循环时,截取出一段字符串

    ,解析出是p闭合标签并且触发钩子函数end,截取后的结果为:

    
    </div>
    

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

    </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解析器解析出来的文本进行二次加工,主要是为了处理带变量的文本。

    相关

  • 相关阅读:
    XStream教程
    Log4j教程
    Java.io包
    Java输入/输出教程
    Java.math.BigDecimal.abs()方法
    数据类型转换
    JUnit教程
    java.lang
    标识符
    PHP面向对象笔记解析
  • 原文地址:https://www.cnblogs.com/yzsunlei/p/12118330.html
Copyright © 2020-2023  润新知