Vue源码之:模板编译

参考文档:javascript

vue-js.com/learn-vue/html

github.com/answershuto…'vue

前言

在前几篇文章中,咱们介绍了Vue中的虚拟DOM以及虚拟DOMpatch(DOM-Diff)过程,而虚拟DOM存在的必要条件是得先有VNode,那么VNode又是从哪儿来的呢?这就是接下来几篇文章要说的模板编译。你能够这么理解:把用户写的模板进行编译,就会产生VNodejava

了解一下 $mount

/*把本来不带编译的$mount方法保存下来,在最后会调用。*/
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
  /*处理模板templete,编译成render函数,render不存在的时候才会编译template,不然优先使用render*/
  if (!options.render) {
    let template = options.template
    /*template存在的时候取template,不存在的时候取el的outerHTML*/
    if (template) {
      /*当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为DOM节点的时候*/
        template = template.innerHTML
      } else {
        /*报错*/
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      /*获取element的outerHTML*/
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      /*将template编译成render函数,这里会有render以及staticRenderFns两个返回,这是vue的编译时优化,static静态不须要在VNode更新时进行patch,优化性能*/
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        delimiters: options.delimiters
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  /*Github:https://github.com/answershuto*/
  /*调用const mount = Vue.prototype.$mount保存下来的不带编译的mount*/
  return mount.call(this, el, hydrating)
}
复制代码

经过mount代码咱们能够看到,在mount的过程当中,若是render函数不存在(render函数存在会优先使用render)会将template进行compileToFunctions获得render以及staticRenderFns。譬如说手写组件时加入了template的状况都会在运行时进行编译。而render function在运行后会返回VNode节点,供页面的渲染以及在update的时候patch。接下来咱们来看一下template是如何编译的。node

什么是模板编译

咱们把写在<template></template>标签中的相似于原生HTML的内容称之为模板。这时你可能会问了,为何说是“相似于原生HTML的内容”而不是“就是HTML的内容”?由于咱们在开发中,在<template></template>标签中除了写一些原生HTML的标签,咱们还会写一些变量插值,如,或者写一些Vue指令,如v-onv-if等。而这些东西都是在原生HTML语法中不存在的,不被接受的。可是事实上咱们确实这么写了,也被正确识别了,页面也正常显示了,这又是为何呢?git

这就归功于Vue的模板编译了,Vue会把用户在<template></template>标签中写的相似于原生HTML的内容进行编译,把原生HTML的内容找出来,再把非原生HTML找出来,通过一系列的逻辑处理生成渲染函数,也就是render函数,而render函数会将模板内容生成对应的VNode,而VNode再通过前几篇文章介绍的patch过程从而获得将要渲染的视图中的VNode,最后根据VNode建立真实的DOM节点并插入到视图中, 最终完成视图的渲染更新。github

而把用户在template></template>标签中写的相似于原生HTML的内容进行编译,把原生HTML的内容找出来,再把非原生HTML找出来,通过一系列的逻辑处理生成渲染函数,也就是render函数的这一段过程称之为模板编译过程。算法

总体的渲染流程

所谓渲染流程,就是把用户写的相似于原生HTML的模板通过一系列处理最终反应到视图中称之为整个渲染流程。这个流程在上文中其实已经说到了,下面咱们以流程图的形式宏观的了解一下,流程图以下:express

从图中咱们也能够看到,模板编译过程就是把用户写的模板通过一系列处理最终生成render函数的过程。编程

模板编译内部流程

那么模板编译内部是怎么把用户写的模板通过处理最终生成render函数的呢?这内部的过程是怎样的呢?

抽象语法树AST

Vue如何从<template></template>标签中写的模板字符串中提取出元素的标签,属性,变量等,就要借助一个叫作抽象语法树的东西

所谓抽象语法树,在计算机科学中,抽象语法树AbstractSyntaxTree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每一个节点都表示源代码中的一种结构。之因此说语法是“抽象”的,是由于这里的语法并不会表示出真实语法中出现的每一个细节。好比,嵌套括号被隐含在树的结构中,并无以节点的形式呈现;而相似于if-condition-then这样的条件跳转语句,可使用带有两个分支的节点来表示。——来自百度百科

astexplorer.net/

具体流程

将一堆字符串模板解析成抽象语法树AST后,咱们就能够对其进行各类操做处理了,处理完后用处理后的AST来生成render函数。其具体流程可大体分为三个阶段

一、模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST

二、优化阶段:遍历AST,找出其中的静态节点,并打上标记;

三、代码生成阶段:将AST转换成渲染函数;

这三个阶段在源码中分别对应三个模块,下面给出三个模块的源代码在源码中的路径:

一、模板解析阶段——解析器——源码路径:src/compiler/parser/index.js`;

二、优化阶段——优化器——源码路径:src/compiler/optimizer.js;

三、代码生成阶段——代码生成器——源码路径:src/compiler/codegen/index.js; 其对应的源码以下:

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

function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  /*parse解析获得ast树*/
  const ast = parse(template.trim(), options)
  /*
    将AST树进行优化
    优化的目标:生成模板AST树,检测不须要进行DOM改变的静态子树。
    一旦检测到这些静态树,咱们就能作如下这些事情:
    1.把它们变成常数,这样咱们就不再须要每次从新渲染时建立新的节点了。
    2.在patch的过程当中直接跳过。
 */
  optimize(ast, options)
  /*根据ast树生成所需的code(内部包含render与staticRenderFns)*/
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

复制代码

能够看到 baseCompile的代码很是的简短主要核心代码。

一、const ast =parse(template.trim(), options):parse 会用正则等方式解析 template 模板中的指令、classstyle等数据,造成AST

二、optimize(ast, options): optimize的主要做用是标记静态节点,这是 Vue 在编译过程当中的一处优化,挡在进行patch 的过程当中,DOM-Diff 算法会直接跳过静态节点,从而减小了比较的过程,优化了 patch 的性能。

三、const code =generate(ast, options): 将 AST 转化成render函数字符串的过程,获得结果是render函数的字符串以及staticRenderFns 字符串。

最终baseCompile的返回值

{
 	ast: ast,
 	render: code.render,
 	staticRenderFns: code.staticRenderFns
 }
复制代码

最终返回了抽象语法树( ast),渲染函数( render ),静态渲染函数( staticRenderFns ),且render 的值为code.renderstaticRenderFns 的值为code.staticRenderFns,也就是说经过 generate处理 ast以后获得的返回值 code 是一个对象。

下面再给出模板编译内部具体流程图,便于理解。流程图以下:

模板解析阶段

在解析整个模板的时候它的流程应该是这样子的:HTML解析器是主线,先用HTML解析器进行解析整个模板,在解析过程当中若是碰到文本内容,那就调用文本解析器来解析文本,若是碰到文本中包含过滤器那就调用过滤器解析器来解析。以下图所示:

回到源码

解析器的源码位于/src/complier/parser文件夹下,其主线代码以下:

// 代码位置:/src/complier/parser/index.js

/**
 * Convert HTML string to AST.
 */
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) {
        if (inVPre) {
        ...
        } else {
             /*处理属性*/
            processAttrs(element)
        }
    },
    end () {

    },
    //这个地方处理 parseText
    chars (text: string) {
        if (text) {
        let expression
            if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
              children.push({
                type: 2,
                expression,
                text
              })
            }
        }
    },
    comment (text: string) {

    }
  })
  return root
}

/*处理属性*/
function processAttrs (el) {
  /*获取元素属性列表*/
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, isProp
  for (i = 0, l = list.length; i < l; i++) {
     .....
      /*若是属性是v-bind的*/
      if (bindRE.test(name)) { // v-bind
        /*这样处理之后v-bind:aaa获得aaa*/
        name = name.replace(bindRE, '')
        .....
        /*解析过滤器*/
        value = parseFilters(value)
        ....
      }
    } else {
      /*处理常规的字符串属性*/
      // literal attribute
      if (process.env.NODE_ENV !== 'production') {
        const expression = parseText(value, delimiters)
        ....
        }
    }
  }
}
复制代码

从上面代码中能够看到,parse 函数就是解析器的主函数,在parse 函数内调用了parseHTML函数对模板字符串进行解析,在parseHTML

函数解析模板字符串的过程当中,若是遇到文本信息,就会调用文本解析器parseText函数进行文本解析;若是遇到文本中包含过滤器,就会调用过滤器解析器parseFilters函数进行解析。

模板解析阶段 parseHTML

HTML解析器内部运行流程

在源码中,HTML解析器就是parseHTML函数,在模板解析主线函数parse中调用了该函数,并传入两个参数,代码如上: 从代码中咱们能够看到,调用parseHTML函数时为其传入的两个参数分别是:

一、template:待转换的模板字符串;

二、options:转换时所需的选项;

第一个参数是待转换的模板字符串,无需多言;重点看第二个参数,第二个参数提供了一些解析HTML模板时的一些参数,同时还定义了4个钩子函数。这4个钩子函数有什么做用呢?咱们说了模板编译阶段主线函数parse会将HTML模板字符串转化成AST,而parseHTML是用来解析模板字符串的,把模板字符串中不一样的内容出来以后,那么谁来把提取出来的内容生成对应的AST呢?答案就是这4个钩子函数

把这4个钩子函数做为参数传给解析器parseHTML,当解析器解析出不一样的内容时调用不一样的钩子函数从而生成不一样的AST

paseHTML 源码以下:

function parseHTML(html, options) {
    const stack = []       // 维护AST节点层级的栈
    const expectHTML = options.expectHTML
    const isUnaryTag = options.isUnaryTag || no
    const canBeLeftOpenTag = options.canBeLeftOpenTag || no   //用来检测一个标签是不是能够省略闭合标签的非自闭合标签
    let index = 0   //解析游标,标识当前从何处开始解析模板字符串
    let last,   // 存储剩余还未解析的模板字符串
        lastTag  // 存储着位于 stack 栈顶的元素

	// 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
	while (html) {
		last = html;
		// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
		if (!lastTag || !isPlainTextElement(lastTag)) {
		   let textEnd = html.indexOf('<')
              /**
               * 若是html字符串是以'<'开头,则有如下几种可能
               * 开始标签:<div>
               * 结束标签:</div>
               * 注释:<!-- 我是注释 -->
               * 条件注释:<!-- [if !IE] --> <!-- [endif] -->
               * DOCTYPE:<!DOCTYPE html>
               * 须要一一去匹配尝试
               */
            if (textEnd === 0) {
                // 解析是不是注释
        		if (comment.test(html)) {

                }
                // 解析是不是条件注释
                if (conditionalComment.test(html)) {

                }
                // 解析是不是DOCTYPE
                const doctypeMatch = html.match(doctype)
                if (doctypeMatch) {

                }
                // 解析是不是结束标签
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {

                }
                // 匹配是不是开始标签
                const startTagMatch = parseStartTag()
                if (startTagMatch) {

                }
            }
            // 若是html字符串不是以'<'开头,则解析文本类型
            let text, rest, next
            if (textEnd >= 0) {

            }
            // 若是在html字符串中没有找到'<',表示这一段html字符串都是纯文本
            if (textEnd < 0) {
                text = html
                html = ''
            }
            // 把截取出来的text转化成textAST
            if (options.chars && text) {
                options.chars(text)
            }
		} else {
			// 父元素为script、style、textarea时,其内部的内容所有当作纯文本处理
		}

		//将整个字符串做为文本对待
		if (html === last) {
			options.chars && options.chars(html);
			if (!stack.length && options.warn) {
				options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
			}
			break
		}
	}

	// Clean up any remaining tags
	parseEndTag();
	//parse 开始标签
	function parseStartTag() {

	}
	//处理 parseStartTag 的结果
	function handleStartTag(match) {

	}
	//parse 结束标签
	function parseEndTag(tagName, start, end) {

	}
}
复制代码
当解析到开始标签时调用start函数生成元素类型的AST节点,代码以下;
// 当解析到标签的开始位置时,触发start
start (tag, attrs, unary) {
	const element: ASTElement = {
        type: 1,
        tag,
        attrsList: attrs,
        attrsMap: makeAttrsMap(attrs),
        parent: currentParent,
        children: []
    }
}

复制代码

从上面代码中咱们能够看到,start函数接收三个参数,分别是标签名tag、标签属性attrs、标签是否自闭合unary。当调用该钩子函数时,内部会调用createASTElement函数来建立元素类型的AST节点

当解析到结束标签时调用end函数;
当解析到文本时调用chars函数生成文本类型的AST节点;
// 当解析到标签的文本时,触发chars
chars (text) {
  if (text) {
    let expression
    if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
      children.push({
        type: 2,
        expression,
        text
      })
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      children.push({
        type: 3,
        text
      })
    }
  }
}
复制代码

当解析到标签的文本时,触发chars钩子函数,在该钩子函数内部,首先会判断文本是否是一个带变量的动态文本,如“hello ”。若是是动态文本,则建立动态文本类型的AST节点;若是不是动态文本,则建立纯静态文本类型的AST节点。

当解析到注释时调用comment函数生成注释类型的AST节点;
comment (text: string, start, end) {
      // adding anyting 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)
      }
    }
复制代码

当解析到标签的注释时,触发comment钩子函数,该钩子函数会建立一个注释类型的AST节点。

一边解析不一样的内容一边调用对应的钩子函数生成对应的AST节点,最终完成将整个模板字符串转化成AST,这就是HTML解析器所要作的工做。

如何解析不一样的内容

要从模板字符串中解析出不一样的内容,那首先要知道模板字符串中都会包含哪些内容。那么一般咱们所写的模板字符串中都会包含哪些内容呢?通过整理,一般模板内会包含以下内容:

  • 文本,例如“难凉热血”
  • HTML注释,例如
  • 条件注释,例如我是注释
  • DOCTYPE,例如
  • 开始标签,例如
  • 结束标签,例如
解析HTML注释

解析注释比较简单,咱们知道HTML注释是以<!--开头,以-->结尾,这二者中间的内容就是注释内容,那么咱们只需用正则判断待解析的模板字符串html是否以<!--开头,如果,那就继续向后寻找-->,若是找到了,OK,注释就被解析出来了。代码以下:

const comment = /^<!\--/
if (comment.test(html)) {
  // 若为注释,则继续查找是否存在'-->'
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    // 若存在 '-->',继续判断options中是否保留注释
    if (options.shouldKeepComment) {
      // 若保留注释,则把注释截取出来传给options.comment,建立注释类型的AST节点
      options.comment(html.substring(4, commentEnd))
    }
    // 若不保留注释,则将游标移动到'-->'以后,继续向后解析
    advance(commentEnd + 3)
    continue
  }
}

function advance (n) {
  index += n   // index为解析游标
  html = html.substring(n)
}
复制代码

在上面代码中,若是模板字符串html符合注释开始的正则,那么就继续向后查找是否存在-->,若存在,则把html从第4位("<!--"长度为4)开始截取,直到-->处,截取获得的内容就是注释的真实内容,而后调用4个钩子函数中的comment函数,将真实的注释内容传进去,建立注释类型的AST节点。

上面代码中有一处值得注意的地方,那就是咱们日常在模板中能够在<template></template>标签上配置comments选项来决定在渲染模板时是否保留注释,对应到上面代码中就是options.shouldKeepComment,若是用户配置了comments选项为true,则shouldKeepCommenttrue,则建立注释类型的AST节点,如不保留注释,则将游标移动到'-->'以后,继续向后解析。

解析条件注释

解析条件注释也比较简单,其原理跟解析注释相同,都是先用正则判断是不是以条件注释特有的开头标识开始,而后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来便可,因为条件注释不存在于真正的DOM树中,因此不须要调用钩子函数建立AST节点。代码以下:

// 解析是不是条件注释
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
  // 若为条件注释,则继续查找是否存在']>'
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    // 若存在 ']>',则从本来的html字符串中把条件注释截掉,
    // 把剩下的内容从新赋给html,继续向后匹配
    advance(conditionalEnd + 2)
    continue
  }
}
复制代码
解析开始标签

相较于前三种内容的解析,解析开始标签会稍微复杂一点,可是万变不离其宗,它的原理仍是相通的,都是使用正则去匹配提取。

首先使用开始标签的正则去匹配模板字符串,看模板字符串是否具备开始标签的特征,以下:

/**
 * 匹配开始标签的正则
 */
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

const start = html.match(startTagOpen)
if (start) {
  const match = {
    tagName: start[1],
    attrs: [],
    start: index
  }
}

// 以开始标签开始的模板:
'<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
// 以结束标签开始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本</p>'.match(startTagOpen) => null
复制代码

在上面代码中,咱们用不一样类型的内容去匹配开始标签的正则,发现只有<div></div>的字符串能够正确匹配,而且返回一个数组。

在前文中咱们说到,当解析到开始标签时,会调用4个钩子函数中的start函数,而start函数须要传递3个参数,分别是标签名tag、标签属性attrs、标签是否自闭合unary。标签名经过正则匹配的结果就能够拿到,即上面代码中的start[1],而标签属性attrs以及标签是否自闭合unary须要进一步解析。

一、解析标签属性

咱们知道,标签属性通常是写在开始标签的标签名以后的,以下:

<div class="a" id="b"></div>
复制代码

另外,咱们在上面匹配是不是开始标签的正则中已经能够拿到开始标签的标签名,即上面代码中的start[0],那么咱们能够将这一部分先从模板字符串中截掉,则剩下的部分以下:

class="a" id="b"></div>
复制代码

那么咱们只需用剩下的这部分去匹配标签属性的正则,就能够将标签属性提取出来了,以下:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
复制代码

能够看到,第一个标签属性class="a"已经被拿到了。另外,标签属性有可能有多个也有可能没有,若是没有的话那好办,匹配标签属性的正则就会匹配失败,标签属性就为空数组;而若是标签属性有多个的话,那就须要循环匹配了,匹配出第一个标签属性后,就把该属性截掉,用剩下的字符串继续匹配,直到再也不知足正则为止,代码以下:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
 tagName: start[1],
 attrs: [],
 start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
 advance(attr[0].length)
 match.attrs.push(attr)
}
复制代码

在上面代码的while循环中,若是剩下的字符串不符合开始标签的结束特征(startTagClose)而且符合标签属性的特征的话,那就说明还有未提取出的标签属性,那就进入循环,继续提取,直到把全部标签属性都提取完毕。

所谓不符合开始标签的结束特征是指当前剩下的字符串不是以开始标签结束符开头的,咱们知道一个开始标签的结束符有多是一个>(非自闭合标签),也有多是/>(自闭合标签),若是剩下的字符串(如></div>)以开始标签的结束符开头,那么就表示标签属性已经被提取完毕了。

二、解析标签是不是自闭合

在HTML中,有自闭合标签(如<img src=""/>)也有非自闭合标签(如<div></div>),这两种类型的标签在建立AST节点是处理方式是有区别的,因此咱们须要解析出当前标签是不是自闭合标签。

解析的方式很简单,咱们知道,通过标签属性提取以后,那么剩下的字符串无非就两种,以下: `

<!--非自闭合标签-->
></div>
复制代码
<!--自闭合标签-->
/>
复制代码

因此咱们能够用剩下的字符串去匹配开始标签结束符正则,以下:

const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
复制代码

能够看到,非自闭合标签匹配结果中的end[1]"",而自闭合标签匹配结果中的end[1]"/"。因此根据匹配结果的

  • end[1]是不是""咱们便可判断出当前标签是否为自闭合标签,源码以下:
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
if (end) {
 match.unarySlash = end[1]
 advance(end[0].length)
 match.end = index
 return match
}
复制代码

通过以上两步,开始标签就已经解析完毕了,完整源码以下:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/


function parseStartTag () {
  const start = html.match(startTagOpen)
  // '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index
    }
    advance(start[0].length)
    let end, attr
    /**
     * <div a=1 b=2 c=3></div>
     * 从<div以后到开始标签的结束符号'>'以前,一直匹配属性attrs
     * 全部属性匹配完以后,html字符串还剩下
     * 自闭合标签剩下:'/>'
     * 非自闭合标签剩下:'></div>'
     */
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }

    /**
     * 这里判断了该标签是否为自闭合标签
     * 自闭合标签如:<input type='text' />
     * 非自闭合标签如:<div></div>
     * '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
     * '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
     * 所以,咱们能够经过end[1]是不是"/"来判断该标签是不是自闭合标签
     */
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}
复制代码

经过源码能够看到,调用parseStartTag函数,若是模板字符串符合开始标签的特征,则解析开始标签,并将解析结果返回,若是不符合开始标签的特征,则返回undefined。

解析完毕后,就能够用解析获得的结果去调用start钩子函数去建立元素型的AST节点了。

在源码中,Vue并无直接去调start钩子函数去建立AST节点,而是调用了handleStartTag函数,在该函数内部才去调的start钩子函数,为何要这样作呢?这是由于虽然通过parseStartTag函数已经把建立AST节点必要信息提取出来了,可是提取出来的标签属性数组仍是须要处理一下,下面咱们就来看一下handleStartTag函数都作了些什么事。handleStartTag函数源码以下:

function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    if (expectHTML) {
      // ...
    }

    const unary = isUnaryTag(tagName) || !!unarySlash

    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 (!unary) {
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
      lastTag = tagName
    }

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

复制代码

handleStartTag函数用来对parseStartTag函数的解析结果进行进一步处理,它接收parseStartTag函数的返回值做为参数。

handleStartTag函数的开始定义几个常量:

const tagName = match.tagName       // 开始标签的标签名
const unarySlash = match.unarySlash  // 是否为自闭合标签的标志,自闭合为"",非自闭合为"/"
const unary = isUnaryTag(tagName) || !!unarySlash  // 布尔值,标志是否为自闭合标签
const l = match.attrs.length    // match.attrs 数组的长度
const attrs = new Array(l)  // 一个与match.attrs数组长度相等的数组
复制代码
解析结束标签

结束标签的解析要比解析开始标签容易多了,由于它不须要解析什么属性,只须要判断剩下的模板字符串是否符合结束标签的特征,若是是,就将结束标签名提取出来,再调用4个钩子函数中的end函数就行了。

首先判断剩余的模板字符串是否符合结束标签的特征,以下:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)

'</div>'.match(endTag)  // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag)  // null
复制代码

上面代码中,若是模板字符串符合结束标签的特征,则会得到匹配结果数组;若是不合符,则获得null。

接着再调用end钩子函数,以下:

if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
}
复制代码
解析文本

解析文本也比较容易,在解析模板字符串以前,咱们先查找一下第一个<出如今什么位置,若是第一个<在第一个位置,那么说明模板字符串是以其它5种类型开始的;若是第一个<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了;若是在整个模板字符串里没有找到<,那说明整个模板字符串都是文本。这就是解析思路,接下来咱们对照源码来了解一下实际的解析过程,源码以下:

et textEnd = html.indexOf('<')
// '<' 在第一个位置,为其他5种类型
if (textEnd === 0) {
    // ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
    // 若是html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
    // 那就把'<'之后的内容拿出来赋给rest
    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
        /**
           * 用'<'之后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
           * 若是都匹配不上,表示'<'是属于文本自己的内容
           */
        // 在'<'以后查找是否还有'<'
        next = rest.indexOf('<', 1)
        // 若是没有了,表示'<'后面也是文本
        if (next < 0) break
        // 若是还有,表示'<'是文本中的一个字符
        textEnd += next
        // 那就把next以后的内容截出来继续下一轮循环匹配
        rest = html.slice(textEnd)
    }
    // '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
    text = html.substring(0, textEnd)
    advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
    text = html
    html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
    options.chars(text)
}
复制代码

值得深究的是若是<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了,接着咱们还要从第一个<的位置继续向后判断,由于还存在这样一种状况,那就是若是文本里面原本就包含一个<,例如1<2。为了处理这种状况,咱们把从第一个<的位置直到模板字符串结束都截取出来记做rest,以下:

while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
) {
    // < in plain text, be forgiving and treat it as text
    /**
    * 用'<'之后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
    * 若是都匹配不上,表示'<'是属于文本自己的内容
    */
    // 在'<'以后查找是否还有'<'
    next = rest.indexOf('<', 1)
    // 若是没有了,表示'<'后面也是文本
    if (next < 0) break
    // 若是还有,表示'<'是文本中的一个字符
    textEnd += next
    // 那就把next以后的内容截出来继续下一轮循环匹配
    rest = html.slice(textEnd)
}
复制代码

如何保证AST节点层级关系

上一章节咱们介绍了HTML解析器是如何解析各类不一样类型的内容而且调用钩子函数建立不一样类型的AST节点。此时你可能会有个疑问,咱们上面建立的AST节点都是单首创建且分散的,而真正的DOM节点都是有层级关系的,那如何来保证AST节点的层级关系与真正的DOM节点相同呢?

关于这个问题,Vue也注意到了。Vue在HTML解析器的开头定义了一个栈stack,这个栈的做用就是用来维护AST节点层级的,那么它是怎么维护的呢?经过前文咱们知道,HTML解析器在从前向后解析模板字符串时,每当遇到开始标签时就会调用start钩子函数,那么在start钩子函数内部咱们能够将解析获得的开始标签推入栈中,而每当遇到结束标签时就会调用end钩子函数,那么咱们也能够在end钩子函数内部将解析获得的结束标签所对应的开始标签从栈中弹出。请看以下例子:

假若有以下模板字符串:

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

当解析到开始标签<div>时,就把div推入栈中,而后继续解析,当解析到<p>时,再把p推入栈中,同理,再把span推入栈中,当解析到结束标签</span>时,此时栈顶的标签恰好是span的开始标签,那么就用span的开始标签和结束标签构建AST节点,而且从栈中把span的开始标签弹出,那么此时栈中的栈顶标签p就是构建好的spanAST节点的父节点,以下图:

模板解析阶段 parseText

文本解析器的源码位于src/compiler/parser/text-parsre.js中,代码以下:

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const buildRegex = cached(delimiters => {
  const open = delimiters[0].replace(regexEscapeRE, '\\$&')
  const close = delimiters[1].replace(regexEscapeRE, '\\$&')
  return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  /**
   * let lastIndex = tagRE.lastIndex = 0
   * 上面这行代码等同于下面这两行代码:
   * tagRE.lastIndex = 0
   * let lastIndex = tagRE.lastIndex
   */
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    if (index > lastIndex) {
      // 先把'{{'前面的文本放入tokens中
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    // 取出'{{ }}'中间的变量exp
    const exp = parseFilters(match[1].trim())
    // 把变量exp改为_s(exp)形式也放入tokens中
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    // 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
    lastIndex = index + match[0].length
  }
  // 当剩下的text再也不被正则匹配上时,表示全部变量已经处理完毕
  // 此时若是lastIndex < text.length,表示在最后一个变量后面还有文本
  // 最后将后面的文本再加入到tokens中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }

  // 最后把数组tokens中的全部元素用'+'拼接起来
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

复制代码

咱们看到,除开咱们本身加的注释,代码其实不复杂

相关文章
相关标签/搜索