最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Vue2.0源码分析:编译原理(上)

    正文概述 掘金(汪图南)   2020-12-04   529

    编译原理(上)

    如果觉得写得不错,请到GitHub给我一个Star

    上一篇:Vue2.0源码分析:组件化(下)
    下一篇:Vue2.0源码分析:编译原理(下)

    由于掘金文章字数限制,不得不拆分上、下两篇文章。

    介绍

    在之前我们提到过,Vue根据不同的使用场景,提供了不同版本Vue.js打包文件,其中runtime + compiler版本允许我们撰写带template选项的组件,它能够对template进行编译。而runtime + only版本则不允许我们这样做,我们使用Vue-Cli3.0以上版本的脚手架默认创建的项目都是runtime + only版本,其中对于组件的template模板,它依赖于vue-loader来编译成render渲染函数,不再依赖Vue.js

    在编译原理这个大章节,我们为了深入了解其内部实现原理,主要分析runtime + compiler版本的Vue.js。这个版本它的入口文件在src/platforms/web/entry-runtime-with-compiler.js,在这个入口文件我们可以发现,它不仅重新定义了$mount方法,还挂载了一个compile全局API

    import { compileToFunctions } from './compiler/index'
    const mount = Vue.prototype.$mount
    Vue.prototype.$mount = function (
      el?: string | Element,
      hydrating?: boolean
    ): Component {
      el = el && query(el)
    
      /* istanbul ignore if */
      if (el === document.body || el === document.documentElement) {
        process.env.NODE_ENV !== 'production' && warn(
          `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
        )
        return this
      }
    
      const options = this.$options
      // resolve template/el and convert to render function
      if (!options.render) {
        let template = options.template
        if (template) {
          if (typeof template === 'string') {
            if (template.charAt(0) === '#') {
              template = idToTemplate(template)
              /* istanbul ignore if */
              if (process.env.NODE_ENV !== 'production' && !template) {
                warn(
                  `Template element not found or is empty: ${options.template}`,
                  this
                )
              }
            }
          } else if (template.nodeType) {
            template = template.innerHTML
          } else {
            if (process.env.NODE_ENV !== 'production') {
              warn('invalid template option:' + template, this)
            }
            return this
          }
        } else if (el) {
          template = getOuterHTML(el)
        }
        if (template) {
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
            mark('compile')
          }
    
          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
    
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
            mark('compile end')
            measure(`vue ${this._name} compile`, 'compile', 'compile end')
          }
        }
      }
      return mount.call(this, el, hydrating)
    }
    
    Vue.compile = compileToFunctions
    

    其中,$mount方法我们在组件化章节中已经单独介绍过了,在编译原理这一章节,我们将其分为parse模板解析optimize优化codegen代码生成这三个大步骤来深入学习其实现原理,也就是compileToFunctions方法的实现逻辑。

    compileToFunctions

    compile核心方法

    我们知道,在runtime + compiler的版本中,$mount方法和Vue.compile全局API都用到了compileToFunctions方法。在Web平台下,它是从src/platforms/web/compiler/index.js文件中引入的,代码如下:

    import { baseOptions } from './options'
    import { createCompiler } from 'compiler/index'
    const { compile, compileToFunctions } = createCompiler(baseOptions)
    export { compile, compileToFunctions }
    

    在以上代码中,我们可以看到compileToFunctions是从createCompiler方法的调用结果中解构出来的,而createCompiler方法又是从compiler/index.js文件中引入的。根据之前Rollup章节提到过的知识,我们知道compiler是一个别名,真实路径为:src/compiler/index.js,其代码如下:

    import { parse } from './parser/index'
    import { optimize } from './optimizer'
    import { generate } from './codegen/index'
    import { createCompilerCreator } from './create-compiler'
    
    export const createCompiler = createCompilerCreator(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
      }
    })
    

    我们发现createCompiler它又是createCompilerCreator方法的调用结果,在这个方法中,我们可以看到传递给它的baseCompile函数参数的定义。在baseCompile方法中,它的代码量不是很多,但它包含了我们编译最主要的三大步骤:parseoptimizegenerate。这说明,baseCompile才是我们最核心、最基本的编译方法。

    那么,createCompilerCreator又是什么呢?它是如何返回一个函数的?我们来看它的实现代码:

    export function createCompilerCreator (baseCompile: Function): Function {
      return function createCompiler (baseOptions: CompilerOptions) {
        function compile (
          template: string,
          options?: CompilerOptions
        ): CompiledResult {
          const finalOptions = Object.create(baseOptions)
          const errors = []
          const tips = []
    
          let warn = (msg, range, tip) => {
            (tip ? tips : errors).push(msg)
          }
    
          if (options) {
            if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
              // $flow-disable-line
              const leadingSpaceLength = template.match(/^\s*/)[0].length
    
              warn = (msg, range, tip) => {
                const data: WarningMessage = { msg }
                if (range) {
                  if (range.start != null) {
                    data.start = range.start + leadingSpaceLength
                  }
                  if (range.end != null) {
                    data.end = range.end + leadingSpaceLength
                  }
                }
                (tip ? tips : errors).push(data)
              }
            }
            // merge custom modules
            if (options.modules) {
              finalOptions.modules =
                (baseOptions.modules || []).concat(options.modules)
            }
            // merge custom directives
            if (options.directives) {
              finalOptions.directives = extend(
                Object.create(baseOptions.directives || null),
                options.directives
              )
            }
            // copy other options
            for (const key in options) {
              if (key !== 'modules' && key !== 'directives') {
                finalOptions[key] = options[key]
              }
            }
          }
    
          finalOptions.warn = warn
    
          const compiled = baseCompile(template.trim(), finalOptions)
          if (process.env.NODE_ENV !== 'production') {
            detectErrors(compiled.ast, warn)
          }
          compiled.errors = errors
          compiled.tips = tips
          return compiled
        }
    
        return {
          compile,
          compileToFunctions: createCompileToFunctionFn(compile)
        }
      }
    }
    

    虽然,createCompilerCreator方法的代码比较长,但我们适当精简后就会变得非常清晰:

    // 精简代码
    export function createCompilerCreator (baseCompile: Function): Function {
      return function createCompiler (baseOptions: CompilerOptions) {
        function compile () {
          const compiled = baseCompile(template.trim(), finalOptions)
          return compiled
        }
        return {
          compile,
          compileToFunctions: createCompileToFunctionFn(compile)
        }
      }
    }
    

    看到这里,我们把之前介绍的串联起来,无论是$mount方法中的compileToFunctions还是Vue.compile,都是createCompiler方法调用返回结果对象的compileToFunctions属性值,这个属性值它又是createCompileToFunctionFn方法的调用结果,其中参数是在createCompiler中定义的一个compile方法,我们再深入去看createCompileToFunctionFn的代码:

    export function createCompileToFunctionFn (compile: Function): Function {
      const cache = Object.create(null)
    
      return function compileToFunctions (
        template: string,
        options?: CompilerOptions,
        vm?: Component
      ): CompiledFunctionResult {
        options = extend({}, options)
        // ...
        // check cache
        const key = options.delimiters
          ? String(options.delimiters) + template
          : template
        if (cache[key]) {
          return cache[key]
        }
        // ...
        // compile
        const compiled = compile(template, options)
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors)
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
          return createFunction(code, fnGenErrors)
        })
        return (cache[key] = res)
      }
    }
    

    在这个方法中,我们精简一下,只关心以上几段代码。我们可以发现,在createCompileToFunctionFn方法中我们终于找到了compileToFunctions方法的最终定义,其核心代码只有一段:

    const compiled = compile(template, options)
    

    这里的compile就是我们之前提到过的最核心、最基本的编译方法baseCompile它的包裹函数:

    function compile () {
      const compiled = baseCompile(template.trim(), finalOptions)
      return compiled
    }
    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
      }
    }
    

    代码分析

    在介绍完核心compile方法后,我们来分析一下compileToFunctions的实现逻辑:

    • CSP限制CSP是指内容安全策略,我们可以在MDN上看到它对CSP的定义、描述以及一些示例,同时我们可以看到它有下面这样一段描述:

    如果想了解CSP,你可以点击Content Security Policy去深入学习有关CSP方面的知识。

    注意Vue只在1.0+提供了特定的CSP兼容版本,你可以在Vue Github分支仓库去查看这个版本的源代码。

    根据以上描述,如果存在某些CSP限制,那么我们可能无法使用text-to-JavaScript机制,也就是说下面这些代码可能无法正常运行:

    const func = new Function('return 1')
    evel('alert(1)')
    

    compileToFunctions返回函数中,我们使用try/catch尝试检测new Function('return 1')是否存在CSP限制,如果存在就提示相关错误信息。

    'It seems you are using the standalone build of Vue.js in an ' +
    'environment with Content Security Policy that prohibits unsafe-eval. ' +
    'The template compiler cannot work in this environment. Consider ' +
    'relaxing the policy to allow unsafe-eval or pre-compiling your ' +
    'templates into render functions.'
    

    如果不存在,那么代表我们可以安全的使用text-to-JavaScript这种机制,在compileToFunctions中,它有如下核心代码:

    const compiled = compile(template, options)
    

    在以上代码执行后,compiled.render是一段字符串,我们可以举例说明:

    const compiled = {
      render: 'with(this){return 1}'
    }
    

    res返回对象中,compileToFunctions是使用下面这段代码来赋值的:

    const res = {}
    const fnGenErrors = []
    res.render = createFunction(compiled.render, fnGenErrors)
    res.staticRenderFns = compiled.staticRenderFns.map(code => {
      return createFunction(code, fnGenErrors)
    })
    

    我们可以看到,无论是render还是staticRenderFns都使用了createFunction,这个方法的主要作用就是将一段字符串代码,封装成一个函数并返回,其实现代码如下:

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

    如果new Function没有出错,那么我们就返回这个匿名函数,如果出错了就把出错信息pusherrors数组中。借用上面的例子,它封装后如下所示:

    // 封装前
    const compiled = {
      render: 'with(this){return 1}'
    }
    
    // 封装后
    const res = {
      render: function () { with(this){return 1} }
    }
    
    • 核心编译:在之前我们介绍过compileToFunctions方法,它只有一段最核心的代码:
    // 核心代码
    const compiled = compile(template, options)
    
    function compile () {
      const compiled = baseCompile(template.trim(), finalOptions)
      return compiled
    }
    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
      }
    }
    

    当核心编译方法compile开始执行的时候,baseCompile自然而然的跟着一起执行了,当baseCompile执行的时候,也就意味着编译主要三大步骤开始了。由于这三大步骤比较复杂,我们会在后续章节中单独进行介绍。

    • 编译缓存:在组件编译的时候,对于同一个组件而言我们应该只编译一次。当第一次编译完成后,我们应该把编译结果缓存起来,下一次遇到相同组件再次编译的时候先从缓存里面去获取,如果缓存里面有则直接返回,如果没有才会走编译的过程,这就是编译缓存,它属于编译优化的一种手段。其中,编译缓存是靠下面这几段代码来实现的:
    const cache = Object.create(null)
    return function compileToFunctions (
      template: string,
      options?: CompilerOptions,
      vm?: Component
    ): CompiledFunctionResult {
      // ...
      const key = options.delimiters
        ? String(options.delimiters) + template
        : template
      if (cache[key]) {
        return cache[key]
      }
      // ...
      return (cache[key] = res)
    }
    

    我们拿根实例举例说明:

    import Vue from 'vue'
    import App from './App'
    new Vue({
      el: '#app',
      components: { App },
      template: '<App/>'
    })
    

    编译缓存后,cache缓存对象如下:

    const cache = {
      '<App/>': 'with(this) { xxxx }'
    }
    

    当再次编译App组件的时候,发现在cache对象中已经存在这个键,因此直接返回。

    parse模板解析

    在之前提到的baseCompile基础编译方法中,有这样一段代码:

    import { parse } from './parser/index'
    function baseCompile (
      template: string,
      options: CompilerOptions
    ): CompiledResult {
      const ast = parse(template.trim(), options)
      // ...
    }
    

    我们可以发现,它调用了parse方法来对template模板进行编译,编译的结果是一个AST抽象语法树,这个AST抽象语法树会在之后使用到,在这一小节我们的目标是弄清楚parse模板编译的原理。

    AST抽象语法树

    JavaScript中的AST

    ASTAbstract Syntax Tree的缩写,中文翻译成抽象语法树,它是对源代码抽象语法结构的树状表现形式。在很多优秀的开源库中,都有AST的身影,例如:BabelWebpackTypeScriptJSX以及ESlint等等。

    我们不会在这里对AST做过多的介绍,仅举例说明在其JavaScript中的使用案例。假设我们有以下方法定义,我们需要对这段代码进行解析:

    function add (a, b) {
      return a + b
    }
    
    • 对于整段代码而言,它属于一个FunctionDeclaration函数定义,因此我们可以使用一个对象来表示:
    const FunctionDeclaration = {
      type: 'FunctionDeclaration'
    }
    
    • 我们可以将上面这个函数定义分层三个主要部分:函数名函数参数以及函数体,其中它们分别使用idparams以及body来表示,此时函数对象添加这几个属性后如下:
    const FunctionDeclaration = {
      type: 'FunctionDeclaration',
      id: {},
      params: [],
      body: {}
    }
    
    • 对于函数名id而言,我们无法再对它进行拆分,因为它已经是最小的单位了,我们用Identifier来表示:
    const FunctionDeclaration = {
      type: 'FunctionDeclaration',
      id: {
        type: 'Identifier',
        name: 'add'
      },
      ...
    }
    
    • 对于函数参数params而言,我们可以看成是一个Identifier的一个数组:
    const FunctionDeclaration = {
      type: 'FunctionDeclaration',
      params: [
        { type: 'Identifier', name: 'a' },
        { type: 'Identifier', name: 'b' }
      ],
      ...
    }
    
    • 对于函数体而言,也就是花括号以及花括号里面的内容,我们首先用BlockStatement来表示花括号,然后用body来表示花括号里面的内容:
    const FunctionDeclaration = {
      type: 'FunctionDeclaration',
      body: {
        type: 'BlockStatement',
        body: []
      }
    }
    

    在花括号中,我们可以有多段代码,因此它是一个数组形式。在我们的例子中,它使用return返回一个表达式的值,对于return我们可以使用ReturnStatement来表示,而对于a + b这种形式,我们可以使用BinaryExpression。对于BinaryExpression而言,它存在left(a)、operator(+)和right(b)三个属性。在介绍完以上概念后,对于上面的例子它完整的解析对象可以用下面对象来表示:

    const FunctionDeclaration = {
      type: 'FunctionDeclaration',
      id: { type: 'Identifier', name: 'add' },
      params: [
        { type: 'Identifier', name: 'a' },
        { type: 'Identifier', name: 'b' }
      ],
      body: {
        type: 'BlockStatement',
        body: [
          {
            type: 'ReturnStatement',
            argument: {
              type: 'BinaryExpression',
              left: { type: 'Identifier', name: 'a' },
              operator: '+',
              right: { type: 'Identifier', name: 'a' }
            }
          }
        ]
      }
    }
    

    如果你对如何把JavaScript解析成AST有兴趣的话,你可以在AST Explorer网站上看到根据JavaScript代码实时生成的AST

    Vue中的AST

    Vue的模板编译阶段,它使用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: []
      }
    }
    

    我们可以看到,createASTElement方法很简单,仅仅是返回一个ASTElement类型的对象而已,其中ASTElement类型定义在flow/compiler.js中可以找到。他的属性有很多,我们只介绍几种:

    declare type ASTElement = {
      type: 1;                          // 元素类型
      tag: string;                      // 元素标签
      attrsList: Array<ASTAttr>;        // 元素属性数组
      attrsMap: { [key: string]: any }; // 元素属性key-value
      parent: ASTElement | void;        // 父元素
      children: Array<ASTNode>;         // 子元素集合
    }
    

    为了方便我们更好的理解模板编译生成的AST,我们举例说明,假设有以下模板:

    注意:如果你要调试、查看编译生成的AST,你应该使用runtime + compiler版本,如果是runtime + only版本的话,组件会被vue-loader处理,不会进行parse编译。

    new Vue({
      el: '#app',
      data () {
        return {
          list: ['AAA', 'BBB', 'CCC']
        }
      },
      template: `
        <ul v-show="list.length">
          <li v-for="item in list" :key="item" class="list-item">{{item}}</li>
        </ul>
      `
    })
    

    baseCompile方法中,我们对于parse这行代码断点的话,我们可以看到此时生成的AST如下:

    // ul标签AST精简对象
    const ulAST = {
      type: 1,
      tag: 'ul',
      attrsList: [
        { name: 'v-show', value: 'list.length' }
      ],
      attrsMap: {
        'v-show': "list.length"
      },
      parent: undefined,
      directives: [
        { name: 'show', rawName: 'v-show', value: 'list.length' }
      ],
      children: [], // li的AST对象
    }
    // li标签的AST精简对象
    const liAST = {
      type: 1,
      tag: 'li',
      alias: 'item',
      attrsList: [],
      attrsMap: {
        'v-for': 'item in list',
        'class': 'list-item',
        ':key': 'item'
      },
      for: 'list',
      forProcessed: true,
      key: 'item',
      staticClass: '"list-item"',
      parent: {}, // ul的AST对象
      children: [], // 文本节点的AST对象
    }
    // 文本节点的AST精简对象
    const textAST = {
      type: 2,
      expression: "_s(item)",
      text: "{{item}}",
      tokens: [
        { '@binding': 'item' }
      ]
    }
    

    根据以上ulli以及文本节点的AST对象,可以通过parentchildren链接起来构造出一个简单的AST树形结构。

    HTML解析器

    parse模板解析的时候,根据不同的情况分为三种解析器:HTML解析器文本解析器过滤器解析器。其中,HTML解析器是最主要、最核心的解析器。

    整体思想

    parse方法中,我们可以看到它调用了parseHTML方法来编译模板,它是在html-parser.js文件中定义的:

    export function parseHTML (html, options) {
      let index = 0
      let last, lastTag
      while (html) {
        // ...
      }
    }
    

    由于parseHTML的代码极其复杂,我们不必搞清楚每行代码的含义,掌握其整体思想才是最关键的, 其整体思想是通过字符串的substring方法来截取html字符串,直到整个html被解析完毕,也就是html为空时while循环结束。

    为了更好的理解这种while循环,我们举例说明:

    // 变量、方法定义
    let html = `<div class="list-box">{{msg}}</div>`
    let index = 0
    function advance (n) {
      index += n
      html = html.substring(n)
    }
    
    // 第一次截取
    advance(4)
    let html = ` class="list-box">{{msg}}</div>`
    
    // 第二次截取
    advance(17)
    let html = `>{{msg}}</div>`
    
    // ...
    
    // 最后一次截取
    let html = `</div>`
    advance(6)
    let html = ``
    

    在最后一次截取后,html变成了空字符串,此时while循环结束,也就代表整个parse模板解析过程结束了。在while循环的过程中,对于在哪里截取字符串是有讲究的,它实质上是使用正则表达式去匹配,当满足一定条件时,会触发对应的钩子函数,在钩子函数中我们可以做一些事情。

    钩子函数

    我们发现当调用parseHTML方法的时候,它传递了一个对象options,其中这个options包括一些钩子函数,它们会在HTML解析的时候自动触发,这些钩子函数有:

    parseHTML(template, {
      start () {
        // 开始标签钩子函数
      },
      end () {
        // 结束标签钩子函数
      },
      char () {
        // 文本钩子函数
      },
      comment () {
        // 注释钩子函数
      }
    })
    

    为了更好的理解钩子函数,我们举例说明,假设我们有以下template模板:

    <div>文本</div>
    

    解析分析:

    • 开始标签钩子函数:当模板开始解析的时候,会走下面这段代码的逻辑:
    // Start tag:
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      handleStartTag(startTagMatch)
      if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
        advance(1)
      }
      continue
    }
    

    就整段代码逻辑而言,它根据parseStartTag方法的调用结果来判断,如果条件为真则再调用handleStartTag方法,在handleStartTag方法中它会调用了options.start钩子函数。

    function handleStartTag () {
      // ...
      if (options.start) {
        options.start(tagName, attrs, unary, match.start, match.end)
      }
    }
    

    我们回过头来再看parseStartTag方法,它的代码如下:

    import { unicodeRegExp } from 'core/util/lang'
    const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
    const qnameCapture = `((?:${ncname}\\:)?${ncname})`
    const startTagOpen = new RegExp(`^<${qnameCapture}`)
    
    function parseStartTag () {
      const start = html.match(startTagOpen)
      if (start) {
        const match = {
          tagName: start[1],
          attrs: [],
          start: index
        }
        advance(start[0].length)
        let end, attr
        while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
          attr.start = index
          advance(attr[0].length)
          attr.end = index
          match.attrs.push(attr)
        }
        if (end) {
          match.unarySlash = end[1]
          advance(end[0].length)
          match.end = index
          return match
        }
      }
    }
    

    parseStartTag方法的最开始,它使用match方法并传递一个匹配开始标签的正则表达式,如果匹配成功则会返回一个对象。在目前阶段,我们不需要过多的关注parseStartTag方法过多的细节,我们只需要知道两点:

    1. 当匹配开始标签成功时,会返回一个对象。
    2. 在匹配的过程中,会调用advance方法截取掉这个开始标签。
    // 调用前
    let html = '<div>文本</div>'
    
    // 调用
    parseStartTag()
    
    // 调用后
    let html = '文本</div>'
    
    • 文本钩子函数:在截取掉开始标签后,会通过continue走向第二次while循环,此时textEnd会重新求值:
    let html = '文本</div>'
    let textEnd = html.indexOf('<') // 2
    

    因为第二次while循环时,textEnd值为2,因此会走下面这段逻辑:

    let text, rest, next
    if (textEnd >= 0) {
      rest = html.slice(textEnd)
      while (
        !endTag.test(rest) &&
        !startTagOpen.test(rest) &&
        !comment.test(rest) &&
        !conditionalComment.test(rest)
      ) {
        // < in plain text, be forgiving and treat it as text
        next = rest.indexOf('<', 1)
        if (next < 0) break
        textEnd += next
        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)
    }
    

    当以上代码while循环完毕后,text值为文本,然后调用advence以及触发chars钩子函数。

    // 截取前
    let html = '文本<div>'
    
    // 截取
    advence(2)
    
    // 截取后
    let html = '<div>'
    
    • 结束标签钩子函数:在文本被截取之后,开始进入下一轮循环,重新对textEnd进行求值:
    let html = '</div>'
    let textEnd = html.indexOf('<') // 0
    

    textEnd0的时候,会走下面这段逻辑:

    import { unicodeRegExp } from 'core/util/lang'
    const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
    const qnameCapture = `((?:${ncname}\\:)?${ncname})`
    const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
    
    // End tag:
    const endTagMatch = html.match(endTag)
    if (endTagMatch) {
      const curIndex = index
      advance(endTagMatch[0].length)
      parseEndTag(endTagMatch[1], curIndex, index)
      continue
    }
    

    当使用match传入一个匹配结束标签的正则表达式时,如果匹配成功会返回一个对象,然后调用advanceparseEndTag这两个方法。当调用advence后,就把结束标签全部截取掉了,此时html为一个空字符串。在parseEndTag方法中,它会调用options.end钩子函数。

    function parseEndTag () {
      // ...
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
    
    • 注释钩子函数:对于HTML注释节点来说,它会走下面这段代码的逻辑:
    // 注释节点的例子
    let html = '<!-- 注释节点 -->'
    // Comment:
    if (comment.test(html)) {
      const commentEnd = html.indexOf('-->')
    
      if (commentEnd >= 0) {
        if (options.shouldKeepComment) {
          options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
        }
        advance(commentEnd + 3)
        continue
      }
    }
    

    当匹配到注释节点的时候,会先触发options.comment钩子函数,然后调用advence把注释节点截取掉。对于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
        }
        if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
          child.start = start
          child.end = end
        }
        currentParent.children.push(child)
      }
    }
    

    从以上代码可以看出来,当触发此钩子函数的时候,仅仅生成一个注释节点的AST对象,然后把它push到其父级的children数组中即可。

    不同的解析类型

    在介绍钩子函数这个小节,我们已经遇到过几种不同的解析类型了,它们分别是:开始标签结束标签注释标签以及文本标签。在HTML模板解析的时候,前面几种是最常见的,但还有几种解析类型我们同样需要去了解。

    1. 开始标签
    2. 结束标签
    3. 文本标签
    4. 注释标签
    5. DOCTYPE
    6. 条件注释标签

    因为前面四种解析类型我们已经分析过,这里我们来分析一下最后两种解析类型。

    DOCTYPE

    解析DOCTYPE类型时,解析器要做的事情并不复杂,只需要将其截取掉就行,并不需要触发对应的钩子函数或者做其它事情,假设我们有如下html模板:

    let html = `
      <!DOCTYPE html>
      <html>
        <head></head>
        <body></body>
      </html>
    `
    

    HTML模板解析器在解析的时候,会使用正则表达式去匹配DOCTYPE,它会走下面这段代码的逻辑。

    const doctype = /^<!DOCTYPE [^>]+>/i
    // Doctype:
    const doctypeMatch = html.match(doctype)
    if (doctypeMatch) {
      advance(doctypeMatch[0].length)
      continue
    }
    

    如果正则匹配成功,则调用advence将其截取掉,然后continue继续while循环。就以上例子而言,DOCTYPE截取后的值如下:

    let html = `
      <html>
        <head></head>
        <body></body>
      </html>
    `
    
    条件注释标签

    条件注释标签和DOCTYPE一样,我们并不需要做其它额外的事情,只需要截取掉就行,假设我们有如下注释标签的模板:

    let html = `
      <![if !IE]>
        <link href="xxx.css" rel="stylesheet">
      <![endif]>
    `
    

    HTML解析器解析到的时候,会走下面这段代码的逻辑。

    const conditionalComment = /^<!\[/
    if (conditionalComment.test(html)) {
      const conditionalEnd = html.indexOf(']>')
    
      if (conditionalEnd >= 0) {
        advance(conditionalEnd + 2)
        continue
      }
    }
    

    HTML解析器第一次执行的时候,条件注释通过其正则表达式匹配成功,然后调用advence将其截取掉。至于中间的link则走正常的标签解析流程,最后一次解析时遇到条件注释标签的闭合标签,同样满足其正则表达式,然后通过advence将其截取掉,此时html为空字符串,parse模板解析流程结束。

    // 第一次解析后
    let html = `
        <link href="xxx.css" rel="stylesheet">
      <![endif]>
    `
    // ...
    
    // 最后一次解析
    let html = `<![endif]>`
    advence(n)
    let html = ``
    

    注意:通过以上条件注释解析过程分析,我们可以得出一个结论:在Vuetemplate模板中写条件注释语句是没有用的,因为它会被截取掉。

    <!-- 原始tempalte -->
    <template>
      <div>
        <![if !IE]>
          <link href="xxx.css" rel="stylesheet">
        <![endif]>
        <p>{{msg}}</p>
      </div>
    </template>
    
    <!-- 解析后的html -->
    <div>
      <link href="xxx.css" rel="stylesheet">
      <p>xxx</p>
    </div>
    

    DOM层级维护

    我们都知道HTML是一个DOM树形结构,在模板解析的时候,我们要正确维护这种DOM层级关系。在Vue中,它定义了一个stack栈数组来实现。这个stack栈数组不仅能帮我们维护DOM层级关系,还能帮我们做一些其它事情。

    那么,在Vue中是如何通过stack栈数组来维护这种关系的呢?其实,维护这种DOM层级结构,需要和我们之前提到过的两个钩子函数进行配合:start开始标签钩子函数和end结束标签钩子函数。其实现思路是:当触发开始标签钩子函数的时候,把当前节点推入栈数组中;当触发结束标签钩子函数的时候,把栈数组中栈顶元素推出。

    为了更好的理解,我们举例说明,假设有如下template模板和stack栈数组:

    const stack = []
    let html = `
      <div>
        <p></p>
        <span></span>
      </div>
    `
    

    解析流程分析:

    • 当第一次触发开始标签钩子函数的时候,也就是div节点的开始标签钩子函数,这个时候需要把当前节点推入stack栈数组中:
    // 举例使用,实际为AST对象
    const stack = ['div']
    
    • 当第二次触发开始标签钩子函数的时候,也就是p节点的开始标签钩子函数,这个时候需要把当前节点推入stack栈数组中:
    // 举例使用,实际为AST对象
    const stack = ['div', 'p']
    
    • 当第一次触发结束标签钩子函数的时候,也就是p节点的结束标签钩子函数,这个时候需要把栈顶元素推出stack栈数组:
    const stack = ['div']
    
    • 当第三次触发开始标签钩子函数的时候,也就是span节点的开始标签钩子函数,这个时候需要把当前节点推入stack栈数组中:
    // 举例使用,实际为AST对象
    const stack = ['div', 'span']
    
    • 当第二次触发结束标签钩子函数的时候,也就是span节点的结束标签钩子函数,这个时候需要把栈顶元素推出stack栈数组:
    const stack = ['div']
    
    • 当第三次触发结束标签钩子函数的时候,也就是div节点的结束标签钩子函数,这个时候需要把栈顶元素推出stack栈数组:
    const stack = []
    

    在分析完以上解析流程后,我们来看一下在源码的钩子函数中,是如何处理的:

    parseHTML(template, {
      start (tag, attrs, unary, start, end) {
        // ...
        let element: ASTElement = createASTElement(tag, attrs, currentParent)
        // ...
        if (!unary) {
          currentParent = element
          stack.push(element)
        } else {
          closeElement(element)
        }
      },
      end (tag, start, end) {
        const element = stack[stack.length - 1]
        // pop stack
        stack.length -= 1
        // ...
        closeElement(element)
      }
    })
    

    代码分析:

    • start: 首先在start钩子函数的最后,它有一段if/else分支逻辑,在if分支中它直接把element推入到了stack栈数组中,而在else分支逻辑中则调用了closeElement方法。造成存在这种逻辑分支的关键点在于unary参数,那么unary到底是什么?既然它是start钩子函数的参数,我们就在此钩子函数调用的地方去找这个参数是如何传递的,其实它是在handleStartTag方法中定义的一个常量:
    export const isUnaryTag = makeMap(
      'area,base,br,col,embed,frame,hr,img,input,isindex,keygen,' +
      'link,meta,param,source,track,wbr'
    )
    const options.isUnaryTag = isUnaryTag
    const isUnaryTag = options.isUnaryTag || no
    const unary = isUnaryTag(tagName) || !!unarySlash
    

    unary代表一元的意思,我们可以发现isUnaryTag常量在赋值的过程中,给makeMap传递的参数标签全部是自闭合标签。这些自闭合标签,我们能触发其开始标签钩子函数,但无法触发其结束标签钩子函数,因此如果当前标签是自闭合标签的话,我们需要在else分支逻辑中调用closeElement方法手动处理结束标签钩子函数所做的事情,而不需要把其推入stack栈数组中。

    • end: 在触发结束标签钩子函数的时候,它做的事情并不复杂,首先拿到栈顶元素,然后把栈数组的length长度减去1以达到推出栈顶元素的目的,最后调用closeElement方法来处理后续的事情。由于closeElement方法的代码很多,我们并不需要全部理解。在stack栈数组维护DOM层级这一小节我们只需要知道,在closeElement方法中,它会正确处理去AST对象的parentchildren属性即可。

    我们在之前提到过,stack栈数组不仅能帮我们来维护DOM层级关系,还能帮我们来检查元素标签是否正确闭合,如果没有正确闭合则会提示相应错误信息。假设,我们有如下template模板:

    // p标签没有正确闭合
    let html = `<div><p></div>`
    

    当我们提供了以上错误的html模板后,Vue不仅会提示如下错误信息给我们,而且还会自动帮我们把p标签进行闭合:

    tag <p> has no matching end tag.
    

    那么,Vue是如何发现这种错误的呢?又是如何进行闭合的呢?其实,当p节点的开始标签钩子函数触发以后,此时的stack栈数组如下:

    // 举例使用,实际为AST对象
    const stack = ['div', 'p']
    

    因为p标签没有闭合,因此在随后触发div节点的结束标签钩子函数的时候,会执行下面这段代码的逻辑:

    function parseEndTag (tagName, start, end) {
      // ...
      if (tagName) {
        lowerCasedTagName = tagName.toLowerCase()
        for (pos = stack.length - 1; pos >= 0; pos--) {
          if (stack[pos].lowerCasedTag === lowerCasedTagName) {
            break
          }
        }
      } else {
        // If no tag name is provided, clean shop
        pos = 0
      }
      if (pos >= 0) {
        // Close all the open elements, up the stack
        for (let i = stack.length - 1; i >= pos; i--) {
          if (process.env.NODE_ENV !== 'production' &&
            (i > pos || !tagName) &&
            options.warn
          ) {
            options.warn(
              `tag <${stack[i].tag}> has no matching end tag.`,
              { start: stack[i].start, end: stack[i].end }
            )
          }
          if (options.end) {
            options.end(stack[i].tag, start, end)
          }
        }
    
        // Remove the open elements from the stack
        stack.length = pos
        lastTag = pos && stack[pos - 1].tag
      }
      // ...
    }
    

    在第一个for循环中,它要在stack栈数组中找到div节点的位置索引,就前面的例子而言索引pos值为0。然后在第二个for循环的时候,发现栈顶元素到索引为0的位置还有其它元素。这代表中间肯定有元素标签没有正确闭合,因此先提示错误信息,然后触发options.end钩子函数,在end钩子函数中通过closeElement去手动闭合p标签。

    属性解析

    在上面的所有小节中,我们都没有提到parse模板解析的时候是如何解析属性的,在这一小节我们来详细分析一下属性的解析原理。

    为了更好的理解属性解析原理,我们举例说明。假设,我们有以下template模板:

    const boxClass = 'box-red'
    let html = '<div id="box" class="box-class" :class="boxClass">属性解析</div>'
    

    在首次匹配到<div这个开始标签的时候,它走下面这段代码的逻辑:

    // Start tag:
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      handleStartTag(startTagMatch)
      if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
        advance(1)
      }
      continue
    }
    

    在以上代码中,我们需要注意两个方法,一个是parseStartTag,另外一个是handleStartTag。我们先来看parseStartTag方法,在这个方法中它有一个while循环,匹配和处理attrs的过程就在这个while循环中,代码如下:

    function parseStartTag () {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
      advance(start[0].length)
      let end, attr
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
    }
    

    代码分析:

    • 在第一次调用advance的时候,会把<div这段截取掉,截取后html值如下:
    let html = ' id="box" class="box-class" :class="boxClass">属性解析</div>'
    

    随后,在while循环条件的中匹配了两个正则表达式:

    const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
    const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
    

    从命名我们可以看出来,一个是用来匹配动态属性的,一个是用来匹配属性的。

    • while判断条件中,它首先会匹配到id属性,条件判断为真,第一次执行while循环。在while循环中,它不仅调用advance方法把 id="box"这段字符串截取掉,而且还把匹配的结果添加到了match.attrs数组中,第一次while循环执行完毕后,结果如下:
    let html = 'class="box-class" :class="boxClass">属性解析</div>'
    const match = {
      tagName: 'div',
      attrs: [
        [' id="box"', 'id', '=', 'box']
      ]
    }
    
    • 在第二次判断while条件的时候,会同id一样匹配到class属性,第二次while循环执行完毕后,结果如下:
    let html = ' :class="boxClass">属性解析</div>'
    const match = {
      tagName: 'div',
      attrs: [
        [' id="box"', 'id', '=', 'box'],
        [' class="box-class"', 'class', '=', 'box-class']
      ]
    }
    
    • 在第三次判断while条件的时候,它匹配到的是动态属性,这一轮while执行循环完毕后,结果如下:
    let html = '>属性解析</div>'
    const match = {
      tagName: 'div',
      attrs: [
        [' id="box"', 'id', '=', 'box'],
        [' class="box-class"', 'class', '=', 'box-class'],
        [' :class="boxClass"', ':class', '=', 'boxClass']
      ]
    }
    
    • while循环执行完毕后,它判断了end,其中end是在上一次while循环条件判断时使用startTagClose正则表达式匹配的结果。在以上例子中,它成功匹配到>,因此走if分支的逻辑。
    const startTagClose = /^\s*(\/?)>/
    let html = '属性解析</div>'
    

    分析完parseStartTag,我们回过头来看一下handleStartTag方法,在这个方法中使用for循环来遍历match.attrs然后格式化attrs,其代码如下:

    function handleStartTag (match) {
      const l = match.attrs.length
      const attrs = new Array(l)
      for (let i = 0; i < l; i++) {
        const args = match.attrs[i]
        const value = args[3] || args[4] || args[5] || ''
        const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
          ? options.shouldDecodeNewlinesForHref
          : options.shouldDecodeNewlines
        attrs[i] = {
          name: args[1],
          value: decodeAttr(value, shouldDecodeNewlines)
        }
        if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
          attrs[i].start = args.start + args[0].match(/^\s*/).length
          attrs[i].end = args.end
        }
      }
    
      // ...
      if (options.start) {
        options.start(tagName, attrs, unary, match.start, match.end)
      }
    }
    

    handleStartTag方法中对于attrs的处理,主要是规范化attrs,将二维数组规范化为name/value形式的对象数组,在for循环完毕后attrs数组结果如下:

    const attrs = [
      { name: 'id', value: 'box' },
      { name: 'class', value: 'box-class' },
      { name: ':class', value: 'boxClass' }
    ]
    

    规范化完attrs以后,就需要在start钩子函数中创建AST对象了,我们来回顾一下createASTElement方法:

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

    以上代码比较简单,我们唯一值得关注的是makeAttrsMap方法,它的实现代码如下:

    function makeAttrsMap (attrs: Array<Object>): Object {
      const map = {}
      for (let i = 0, l = attrs.length; i < l; i++) {
        if (
          process.env.NODE_ENV !== 'production' &&
          map[attrs[i].name] && !isIE && !isEdge
        ) {
          warn('duplicate attribute: ' + attrs[i].name, attrs[i])
        }
        map[attrs[i].name] = attrs[i].value
      }
      return map
    }
    

    makeAttrsMap方法的主要作用就是把name/value对象数组形式,转换成key/value对象,例如:

    const arr = [
      { name: 'id', value: 'box' },
      { name: 'class', value: 'box-class' }
    ]
    const obj = makeAttrsMap(arr) // { id: 'box', class: 'box-class' }
    

    在介绍完makeAttrsMap方法后,生成的AST对象如下:

    const ast = {
      type: 1,
      tag: 'div',
      attrsList: [
        { name: 'id', value: 'box' },
        { name: 'class', value: 'box-class' },
        { name: ':class', value: 'boxClass' }
      ],
      attrsMap: {
        id: 'box',
        class: 'box-class',
        :class: 'boxClass'
      },
      rawAttrsMap: {},
      parent: undefined,
      children: []
    }
    

    指令解析

    在分析完属性解析原理后,我们来看跟它解析流程非常相似的指令解析流程。在这一小节,我们来看两个非常具有代表性的指令:v-ifv-for

    假设,我们有如下template模板:

    const list = ['AAA', 'BBB', 'CCC']
    let html = `
      <ul v-if="list.length">
        <li v-for="(item, index) in list" :key="index">{{item}}</li>
      </ul>
    `
    

    对于指令的解析,它们同atts解析过程非常相似,因为在dynamicArgAttribute正则表达式中,它是支持匹配指令的:

    const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
    

    parseStartTag方法执行完毕后,ul标签的attrs值如下:

    const match = {
      attrs: [' v-if="list.length"', 'v-if', '=', 'list.length']
    }
    

    handleStartTag方法执行完毕后,ul标签的attrs规范化后的值如下:

    const attrs = [
      { name: 'v-if', value: 'list.length' }
    ]
    

    createASTElement方法调用后,ul标签的AST对象为:

    const ast = {
      type: 1,
      tag: 'ul',
      attrsList: [
        { name: 'v-if', value: 'list.length' }
      ],
      attrsMap: {
        v-if: 'list.length'
      },
      ...
    }
    

    比属性解析多一个步骤,对于v-if指令来说,它在创建AST对象之后调用了processIf方法来处理v-if指令,其代码如下:

    function processIf (el) {
      const exp = getAndRemoveAttr(el, 'v-if')
      if (exp) {
        el.if = exp
        addIfCondition(el, {
          exp: exp,
          block: el
        })
      } else {
        if (getAndRemoveAttr(el, 'v-else') != null) {
          el.else = true
        }
        const elseif = getAndRemoveAttr(el, 'v-else-if')
        if (elseif) {
          el.elseif = elseif
        }
      }
    }
    export function addIfCondition (el: ASTElement, condition: ASTIfCondition) {
      if (!el.ifConditions) {
        el.ifConditions = []
      }
      el.ifConditions.push(condition)
    }
    

    我们可以看到,processIf方法里面,它不仅可以处理v-if指令,还可以对v-else/v-else-if来进行处理。对于v-if而言,它通过调用addIfCondition方法,来给AST对象添加ifConditions属性,当processIf方法执行完毕后,AST对象的最新值为:

    const ast = {
      type: 1,
      tag: 'ul',
      attrsList: [
        { name: 'v-if', value: 'list.length' }
      ],
      attrsMap: {
        v-if: 'list.length'
      },
      if: 'list.length',
      ifConditions: [
        { exp: 'list.length', block: 'ast对象自身', }
      ],
      ...
    }
    

    v-for指令的解析过程跟v-if的基本相同,唯一的区别是v-if使用processIf来处理,v-for使用processFor来处理。

    对于解析li标签来说,在processFor方法调用之前,其AST对象为:

    const ast = {
      type: 1,
      tag: 'li',
      attrsList: [
        { name: 'v-for', value: '(item, index) in list' },
        { name: ':key', value: 'index' }
      ],
      attrsMap: {
        v-for: '(item, index) in list',
        :key: 'index'
      },
      ...
    }
    

    接下来,我们来看看processFor方法,其代码如下:

    export function processFor (el: ASTElement) {
      let exp
      if ((exp = getAndRemoveAttr(el, 'v-for'))) {
        const res = parseFor(exp)
        if (res) {
          extend(el, res)
        } else if (process.env.NODE_ENV !== 'production') {
          warn(
            `Invalid v-for expression: ${exp}`,
            el.rawAttrsMap['v-for']
          )
        }
      }
    }
    

    调用getAndRemoveAttr是为了从ast对象的attrsList属性数组中移除v-for,并且返回其value值,也就是(item, index) in list。然后使用parseFor方法解析这段字符串,其代码如下:

    export function parseFor (exp: string): ?ForParseResult {
      const inMatch = exp.match(forAliasRE)
      if (!inMatch) return
      const res = {}
      res.for = inMatch[2].trim()
      const alias = inMatch[1].trim().replace(stripParensRE, '')
      const iteratorMatch = alias.match(forIteratorRE)
      if (iteratorMatch) {
        res.alias = alias.replace(forIteratorRE, '').trim()
        res.iterator1 = iteratorMatch[1].trim()
        if (iteratorMatch[2]) {
          res.iterator2 = iteratorMatch[2].trim()
        }
      } else {
        res.alias = alias
      }
      return res
    }
    

    就以上例子而言,使用parseFor方法解析value后,res对象值如下:

    const res = {
      alias: 'item',
      iterator1: 'index',
      for: 'list'
    }
    

    随后使用extend方法把这个对象,扩展到AST对象上,extend方法我们之前提到过,这里不在赘述。调用processFor方法后,最新的AST对象的最新值如下:

    const ast = {
      type: 1,
      tag: 'li',
      attrsList: [
        { name: ':key', value: 'index' }
      ],
      attrsMap: {
        v-for: '(item, index) in list',
        :key: 'index'
      },
      alias: 'item',
      iterator1: 'index',
      for: 'list',
      ...
    }
    

    文本解析器

    对于文本而言,我们在开发Vue应用的时候,通常有两种撰写方式:

    // 纯文本
    let html = '<div>纯文本</div>'
    
    // 带变量的文本
    const msg = 'Hello, Vue.js'
    let html = '<div>{{msg}}</div>'
    

    接下来,我们按照这两种方式分开进行介绍。

    纯文本

    在之前我们介绍过,当第一次while循环执行完毕后,此时html的值如下:

    let html = '纯文本</div>'
    

    第二次执行while循环的时候,文本的正则会匹配到,进而触发options.chars钩子函数,在钩子函数中我们只需要关注以下部分代码即可:

    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) {
      if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
        child.start = start
        child.end = end
      }
      children.push(child)
    }
    

    我们可以看到,在if/else分支逻辑中,它根据条件判断的值来创建不同typechild。其中type=2代表带变量文本的ASTtype=3代表纯文本AST。区分创建哪种type的关键逻辑在parseText方法中,其代码如下:

    const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
    export function parseText (
      text: string,
      delimiters?: [string, string]
    ): TextParseResult | void {
      const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
      if (!tagRE.test(text)) {
        return
      }
      // 省略处理带变量的文本逻辑
    }
    

    我们先来说一下delimiters这个参数,如果我们没有传递的话,那么默认就是{{}}双花括号,这个配置可以使用Vue.config.delimiters来指明。很明显,对于纯文本而言它并不匹配,因此直接return结束parseText方法。也就是说,它会走else if分支逻辑,进而创建一个type=3的纯文本AST对象,最后把这个对象push到父级ASTchildren数组中。

    带变量的文本

    带变量的文本解析过程和纯文本类似,差别主要在于parseText方法中,我们来看一下在parseText方法中是如何处理的:

    export function parseText (
      text: string,
      delimiters?: [string, string]
    ): TextParseResult | void {
      const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
    
      // 省略纯文本的逻辑
      
      const tokens = []
      const rawTokens = []
      let lastIndex = tagRE.lastIndex = 0
      let match, index, tokenValue
      while ((match = tagRE.exec(text))) {
        index = match.index
        // push text token
        if (index > lastIndex) {
          rawTokens.push(tokenValue = text.slice(lastIndex, index))
          tokens.push(JSON.stringify(tokenValue))
        }
        // tag token
        const exp = parseFilters(match[1].trim())
        tokens.push(`_s(${exp})`)
        rawTokens.push({ '@binding': exp })
        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
      }
    }
    

    我们可以看到在parseText方法中,它在方法的最后返回了一个对象,并且这个对象包含两个属性:expressiontokens,在return之前的代码主要是为了解析插值文本。

    while循环开始,首先定义了两个关键数组:tokensrawTokens。然后开始执行while循环,并且while循环条件判断的是是否还能匹配到{{}}双花括号或者我们自定义的分隔符,这样做是因为我们可以撰写多个插值文本,例如:

    let html = '<div>{{msg}}{{msg1}}{{msg2}}</div>'
    

    while循环中,我们往tokens数组中push的元素有一个特点:_s(exp),其中_s()toString()方法的简写,exp就是解析出来的变量名。而往rawTokens数组中push的元素就更简单了,它是一个对象,其中固定使用@binding,值就是我们解析出来的exp

    就我们撰写的例子而言,while循环执行完毕后,一起parseText方法返回的对象分别如下:

    // while循环执行完毕后
    const tokens = ['_s(msg)']
    const rawTokens = [{ '@binding': 'msg' }]
    
    // parseText返回对象
    const returnObj = {
      expression: '_s(msg)',
      tokens: [{ '@binding': 'msg' }]
    }
    

    因为parseText返回的是一个对象,因此走if分支的逻辑,创建一个type=2AST对象:

    const ast = {
      type: 2,
      expression: '_s(msg)',
      tokens: [{ '@binding': 'msg' }],
      text: '{{msg}}'
    }
    
    // 添加到父级的children数组中
    parent.children.push(ast)
    

    异常情况

    解析文本的逻辑虽然非常简单,但有时候文本写错了位置也会造成parse模板解析失败。例如,有如下template模板:

    // template为一个纯文本
    let html1 = `
      Hello, Vue.js
    `
    
    // 文本写在了根节点之外
    let html2 = `
      文本1
      <div>文本2</div>
    `
    

    对于这两种情况,它们分别会在控制台抛出如下错误提示:

    'Component template requires a root element, rather than just text.'
    
    'text "xxx" outside root element will be ignored.'
    

    其中,对于第二种错误而言,我们写在根节点之外的文本会被忽略掉。对于这两种错误的处理,在options.chars钩子函数中,代码如下:

    if (process.env.NODE_ENV !== 'production') {
      if (text === template) {
        warnOnce(
          'Component template requires a root element, rather than just text.',
          { start }
        )
      } else if ((text = text.trim())) {
        warnOnce(
          `text "${text}" outside root element will be ignored.`,
          { start }
        )
      }
    }
    return
    

    过滤器解析器

    在撰写插值文本的时候,Vue允许我们可以使用过滤器,例如:

    const reverse = (text) => {
      return text.split('').reverse.join('')
    }
    const toUpperCase = (text) => {
      return text.toLocaleUpperCase()
    }
    let html = '<div>{{ msg | reverse | toUpperCase }}</div>'
    

    你可能会很好奇,在parse编译的时候,它是如何处理过滤器的?其实,对于过滤器的解析它是在parseText方法中,在上一小节我们故意忽略了对parseFilters方法的介绍。在这一小节,我们将会详细介绍过滤器是如何解析的。

    对于文本解析器而言,parseText方法是定义在text-parser.js文件中,而对于过滤器解析器而言,parseFilters方法是定义在跟它同级的filter-parser.js文件中,其代码如下:

    export function parseFilters (exp: string): string {
      let inSingle = false
      let inDouble = false
      let inTemplateString = false
      let inRegex = false
      let curly = 0
      let square = 0
      let paren = 0
      let lastFilterIndex = 0
      let c, prev, i, expression, filters
    
      for (i = 0; i < exp.length; i++) {
        prev = c
        c = exp.charCodeAt(i)
        if (inSingle) {
          if (c === 0x27 && prev !== 0x5C) inSingle = false
        } else if (inDouble) {
          if (c === 0x22 && prev !== 0x5C) inDouble = false
        } else if (inTemplateString) {
          if (c === 0x60 && prev !== 0x5C) inTemplateString = false
        } else if (inRegex) {
          if (c === 0x2f && prev !== 0x5C) inRegex = false
        } else if (
          c === 0x7C && // pipe
          exp.charCodeAt(i + 1) !== 0x7C &&
          exp.charCodeAt(i - 1) !== 0x7C &&
          !curly && !square && !paren
        ) {
          if (expression === undefined) {
            // first filter, end of expression
            lastFilterIndex = i + 1
            expression = exp.slice(0, i).trim()
          } else {
            pushFilter()
          }
        } else {
          switch (c) {
            case 0x22: inDouble = true; break         // "
            case 0x27: inSingle = true; break         // '
            case 0x60: inTemplateString = true; break // `
            case 0x28: paren++; break                 // (
            case 0x29: paren--; break                 // )
            case 0x5B: square++; break                // [
            case 0x5D: square--; break                // ]
            case 0x7B: curly++; break                 // {
            case 0x7D: curly--; break                 // }
          }
          if (c === 0x2f) { // /
            let j = i - 1
            let p
            // find first non-whitespace prev char
            for (; j >= 0; j--) {
              p = exp.charAt(j)
              if (p !== ' ') break
            }
            if (!p || !validDivisionCharRE.test(p)) {
              inRegex = true
            }
          }
        }
      }
    
      if (expression === undefined) {
        expression = exp.slice(0, i).trim()
      } else if (lastFilterIndex !== 0) {
        pushFilter()
      }
    
      function pushFilter () {
        (filters || (filters = [])).push(exp.slice(lastFilterIndex, i).trim())
        lastFilterIndex = i + 1
      }
    
      if (filters) {
        for (i = 0; i < filters.length; i++) {
          expression = wrapFilter(expression, filters[i])
        }
      }
    
      return expression
    }
    

    代码分析:

    • parseFilters被调用的时候,exp参数传递的是整个文本内容,就我们的例子而言它的值为:
    const exp = '{{ msg | reverse | toUpperCase }}'
    

    for循环的目的主要是来处理exp并把处理好的内容赋值到expression变量,就我们的例子而言,处理完毕后它的值为:

    const expression = 'msg | reverse | toUpperCase'
    
    • for循环执行完毕时,此时expression值判断为真,调用pushFilter方法。当执行完pushFilter方法后,filters数组的值如下:
    const filters = ['reverse', 'toUpperCase']
    
    • 最后判断了filters是否为真,为真则遍历filters数组,在每个遍历的过程中调用wrapFilter再次加工expressionwrapFilter方法代码如下:
    function wrapFilter (exp: string, filter: string): string {
      const i = filter.indexOf('(')
      if (i < 0) {
        // _f: resolveFilter
        return `_f("${filter}")(${exp})`
      } else {
        const name = filter.slice(0, i)
        const args = filter.slice(i + 1)
        return `_f("${name}")(${exp}${args !== ')' ? ',' + args : args}`
      }
    }
    

    对于我们的例子而言,在wrapFilter方法中它会走if分支的代码,当我们像下面这样撰写过滤器的时候,它才会走else分支的代码。

    let html = '<div>{{ msg | reverse() | toUpperCase() }}</div>'
    

    wrapFilter方法执行完毕后,expression变量的值如下:

    const expression = '_f("toUpperCase")(_f("reverse")(msg))'
    

    由于过滤器的内容,同样是文本,所以最后差值文本最后会使用_s包裹起来。

    const tokens = ['_s(_f("toUpperCase")(_f("reverse")(msg)))']
    

    注意: 我们会在之后的章节中介绍什么是_f函数。

    如果觉得写得不错,请到GitHub给我一个Star

    上一篇:Vue2.0源码分析:组件化(下)
    下一篇:Vue2.0源码分析:编译原理(下)

    由于掘金文章字数限制,不得不拆分上、下两篇文章。


    起源地下载网 » Vue2.0源码分析:编译原理(上)

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元