编译器(二)词法分析

724 阅读24分钟

编译器编译原理大概有这么几块:词法分析、语法分析、语义分析、代码优化、代码生成等等,简单理解可参见《你不知道的JavaScript》阅读笔记Vue的编译器大概就三个步骤词法分析 --> 语法分析 --> 代码生成 本章就是对词法分析部分进行分析,它将模板字符串分解成一个个词法单元(开始标签、结束标签、纯文本、注释节点),在语法分析阶段根据这些会生成ast,然后根据ast生成代码

由上章节可知baseCompile才是真正开始模板编译的地方

import { parse } from './parser/index'
import { optimize } from './optimizer'
import { generate } from './codegen/index'
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
    }
})

ast编译从parse方法而来,从头部引用可知其来自于src/compiler/parser/index.js

parse

这个就是模板解析器函数,它返回解析结果ast

import { parseHTML } from './html-parser'
export function parse(
    template: string,
    options: CompilerOptions
): ASTElement | void {
    // ...
    parseHTML(template, {
        warn,
        expectHTML: options.expectHTML,
        isUnaryTag: options.isUnaryTag,
        canBeLeftOpenTag: options.canBeLeftOpenTag,
        shouldDecodeNewlines: options.shouldDecodeNewlines,
        shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
        shouldKeepComment: options.comments,
        start(tag, attrs, unary) {
            // ...
        },
        end() {
            // ...
        },
        chars(text: string) {
            // ...
        },
        comment(text: string) {
            // ...
        }
    })
    return root
}

可见这还不是实际解析template所在,实际所在是parseHTML,也就是它才是词法分析之处,而rootparse返回值也就是ast,所以parse其实是语法分析所在,它在词法分析基础上进行语法分析从而生成ast 所以本章目标是parseHTML,也就是src/compiler/parser/html-parser.js

/**
 * Not type-checking this file because it's mostly vendor code.
 */

/*!
 * HTML Parser By John Resig (ejohn.org)
 * Modified by Juriy "kangax" Zaytsev
 * Original code by Erik Arvidsson, Mozilla Public License
 * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
 */

文件开头注释可见它是脱胎于John Resigsimplehtmlparser.js 其代码还是很简单的,只是几个正则有点需要稍微细细分解 我们直接看Vue的就好

parseHTML

这就是词法分析阶段,文件头部一堆的变量常量建议暂不管,用到的时候再理解(整理在底部)

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) {
        last = html
        // Make sure we're not in a plaintext content element like script/style
        if (!lastTag || !isPlainTextElement(lastTag)) {
            let textEnd = html.indexOf('<')
            if (textEnd === 0) {
                // ...
            }

            let text, rest, next
            if (textEnd >= 0) {
                // ...
            }

            if (textEnd < 0) {
                // ...
            }

            if (options.chars && text) {
                // ...
            }
        } else {
            // ...
        }

        if (html === last) {
            // ...
        }
    }

    // Clean up any remaining tags
    parseEndTag()

    function advance(n) {
        // ...
    }

    function parseStartTag() {
        // ...
    }

    function handleStartTag(match) {
        // ...
    }

    function parseEndTag(tagName, start, end) {
        // ...
    }
}

可见其主要分为三部分:第一部分和第er部分等用到的时候再理解,建议先看第三部分

  1. 变量定义区域

  2. 工具方法区域

  3. while循环

    我们知道html是模板字符串

last = html

html赋值给last,其实看到后面就会知道每一次循环会把已经处理了的字符串从html里剔除,所以这里last是保存本轮循环开始之时也就是html还没被处理时的值,这样子循环末可以如下判断

if (html === last) {
    // ...
}

这里可以认为html是文本,这是因为到这里html其实已经经过了各种判断却没变化,这就排除了它包含标签、注释等情况 接下来是

if (!lastTag || !isPlainTextElement(lastTag)) {
    // ...
} else {
    // ...
}

这个判断语句我们可以先看else,即lastTag && isPlainTextElement(lastTag),我们先搞懂lastTag是什么,这个其实存的是最近处理的标签,这个啥意思呢?这就引出了个问题,我们如何判断非一元标签

<div>
    <section>
        <br>
    </section>
    <section></section>
</div>

也就是我们如何知道section、div是一对的,这就是stack的的用处了,当解析到非一元标签时就将其入栈,也就是解析到第一个<section>stack = [div, section1],之后解析第一个</section>,发现栈顶是section也就是最近一个开始标签和这个结束标签匹配上了,这就说明它们是一对,如何将其出栈即可,而这个lastTag就是最近入栈的标签 如此一来我们就知道

lastTag && isPlainTextElement(lastTag)

这个意思是最近入栈的非一元标签是纯文本标签,然后我们再看if,也就是非纯文本标签

let textEnd = html.indexOf('<')
if (textEnd === 0) {
    // ...
}
let text, rest, next
if (textEnd >= 0) {
    // ...
}
if (textEnd < 0) {
    // ...
}
if (options.chars && text) {
    // 文本
}

首先定义变量textEnd存储<html中第一次匹配上的索引,根据textEnd就又可以划分:

  • textEnd === 0:这表示这段字符串可能是这几种情况:

    • 注释节点:

      <!-- -->
      
      • 条件注释节点:<![ ]>

      • DOCTYPE:<!DOCTYPE >

      • 结束标签:</div>

      • 开始标签:<div>

      • 单纯的字符串:<abc<123

  • textEnd >= 0:这个其实是匹配

    • 包含有<但并非以它开始的字符串:pre<div
    • <开始却紧接的不是规范标签名的字符串:<123
  • textEnd < 0:这个就肯定是纯文本咯 现在我们再来看下这个while

while (html) {
    if (!lastTag || !isPlainTextElement(lastTag)) {
        let textEnd = html.indexOf('<')
        if (textEnd === 0) {
            // ...
        }
        let text, rest, next
        if (textEnd >= 0) {
            // ...
        }
        if (textEnd < 0) {
            // ...
        }
        if (options.chars && text) {
            // 文本
        }
    } else {
        // 文本
    }
    if (html === last) {
        // 文本
    }
}

如此一来我们可以将while内部划分出这么几块:

  • textEnd = 0
  • textEnd >= 0
  • textEnd < 0
  • 一元纯文本标签
  • 纯文本

1. textEnd = 0

由上分析可知分下面这几种情况,我们一一讲解

注释节点

if (comment.test(html)) {
    const commentEnd = html.indexOf('-->')
    if (commentEnd >= 0) {
        if (options.shouldKeepComment) {
            options.comment(html.substring(4, commentEnd))
        }
        advance(commentEnd + 3)
        continue
    }
}

首先检测下是不是注释开头,若是的话就说明可能是注释,因为也可能是如下

<!--abc

也就是没有-->这个结尾。所以立马就是声明commentEnd变量存储-->在字符串中的索引,若是在的话说明注释节点前后标识都有那必然是注释了,判断下options.shouldKeepComment,是的话就调用传入的options.comment钩子,它接收一个参数就是注释内容 然后调用advance舍弃已经处理过的这部分字符串且移动索引,最后continue进行下一次循环

条件注释节点

// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
    const conditionalEnd = html.indexOf(']>')
    if (conditionalEnd >= 0) {
        advance(conditionalEnd + 2)
        continue
    }
}

这个也类似就是注释节点,只是检测的规则变了而已,其实它只是为了匹配然后舍弃这段字符串,因为即使匹配上了也没有对应的钩子来创建这个节点,查了下只有createComment,应该是没有创建条件注释的API

DOCTYPE节点

// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
    advance(doctypeMatch[0].length)
    continue
}

这个也很简单,就是匹配<!DOCTYPE html>这种节点,移动的索引就是这个节点字符串长度,它也被丢弃了,这个倒很好理解,Vue模板里怎么可能有DOCTYPE节点呢

结束标签

// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
}

这个也很简单就是匹配下endTag然后把结果存储在endTagMatch,而且endTag有个捕获组标签名

<div></div>

这个标签解析到这的话html就是<div>,所以endTagMatch如下

["</div>", "div"]

匹配到的话就先存储当前的索引到curIndex,然后将索引右移这个结束标签</div>的长度(也就是舍弃这个已处理过的字符串),然后调用parseEndTag进一步处理,这里传入三个参数:标签名、结束标签在html上的起始索引、结束标签在html上的结束索引,最后continue

开始标签

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

调用parseStartTag解析开始标签,它返回的是形如

{
    "tagName": "div",
    "attrs": [
        [" id='app'", "id", "=", "app", null, null],
        [" v-if='flag'", "v-if", "=", "flag", null, null]
    ],
    "start": 0,
    "unarySlash": "",
    "end": 26
}

这么个对象,我们假设这个parseStartTag有值,也就是这个是个完整的开始标签那么就会调用handleStartTag方法来继续处理,它接收startTagMatch为参数,这里才会真正调用options.start钩子函数 然后根据shouldIgnoreFirstNewline判断是否需要忽略元素内容第一个换行符,是的话就右移一个字符,最后continue即可

2. textEnd >= 0

到这一步可知它并非是结束标签、开始标签、注释节点、条件注释节点,但是却包含了<字符,即

  • 包含有<但并非以它开始的字符串:pre<div
  • <开始却紧接的不是规范标签名的字符串:<123 假设<div>1<2<3<4<</div>是我们模板
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)
    advance(textEnd)
}

首先截取匹配上的这个<之后(包括<)的字符串存到rest变量(也就是<2<3<4</div>),紧接着就是个while循环,它满足四个条件才继续循环即rest(剩下的字符串)不是结束标签、开始标签、注释节点、条件注释节点(其实是类似,就像<div</div>也算符合,因为<div符合开始标签开始部分,类似即可),我们看while内部

next = rest.indexOf('<', 1)
if (next < 0) break

这个就是查找rest第二个<的索引(indexOf第二参数为1),没找见的话(就是没有可能是开始标签等四大节点)就跳出循环,因为余下的都是文本了其实,找见的话就说明rest(剩下的字符串)可能有这四大节点,就更新textEnd为下一个<的索引(这其实说明这个<之前的都是文本),然后重新截取rest,继续循环直到解析到真正的四大标签或者最后一个<

text = html.substring(0, textEnd)
advance(textEnd)

这时候循环结束,此时textEnd === 7(解析到</div>这个<结束循环) 结束循环之后就是截取解析到最后一个<之前的字符串且赋值给texttext = '1<2<3<4',它其实是文本),然后右移textEnd单位,代表这个<之前都已经处理过了 接下来是这段代码,我们先不管textEnd < 0,其实如上这种情况也进不去,因为textEnd > 0

if (textEnd < 0) {
    text = html
    html = ''
}

if (options.chars && text) {
    options.chars(text)
}

所以最终会走到下面这个if,因为text是文本所以调用options.chars钩子处理即可

解析包含<却非结束标签、开始标签、注释节点、条件注释节点的字符串(pre<div<123),匹配直到这四个节点或者模板字符串末

3. textEnd < 0

if (textEnd < 0) {
    text = html
    html = ''
}

这个很简单就是若是找不到<,那么这模板字符串必然是文本,直接将html置空,表明处理完毕

4. 纯文本标签

这个其实是处理script,style,textarea这三个纯文本标签

let endTagLength = 0
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
const rest = html.replace(reStackedTag, function (all, text, endTag) {
    endTagLength = endTag.length
    if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
        text = text
            .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
    }
    if (shouldIgnoreFirstNewline(stackedTag, text)) {
        text = text.slice(1)
    }
    if (options.chars) {
        options.chars(text)
    }
    return ''
})
index += html.length - rest.length
html = rest
parseEndTag(stackedTag, index - endTagLength, index)

这段关键在于reStackedTag这个正则,这里他做了个小优化,就是使用reCache来缓存了这个stackedTag对应的正则。假设我们处理的标签是script,那么reStackedTag = new RegExp('([\\s\\S]*?)(</script[^>]*>)', 'i') = /([\s\S]*?)(</script[^>]*>)/i,它有俩个捕获组,第一个分组是懒惰模式,匹配字符全集,第二个捕获组是匹配对应的结束标签,该正则大小写不敏感 然后使用reStackedTag匹配html并且将其替换为空串,它接收三个参数all(匹配到的串), text(第一个捕获组,即该标签内容), endTag(第二个捕获组,即改结束标签) 进入replace第二参数函数体 首先使用endTagLength存储结束标签体长度

if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
    text = text
        .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
        .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
}

其实这段有点奇怪,首先这个stackedTag必然是script,style,textarea三个之一,也就是!isPlainTextElement(stackedTag)必然是为假,所以这个if语句必然进不了

if (shouldIgnoreFirstNewline(stackedTag, text)) {
    text = text.slice(1)
}
if (options.chars) {
    options.chars(text)
}
return ''

这个就是判断下是不是需要舍弃text(内容)第一个换行符,然后将调用options.chars钩子函数,最后返回空字符串,这就是把匹配到的字符串替换成空串 从这个替换函数出来,这个html.replace的返回值存储在rest变量,这个什么意思呢?其实就是剩余的字符串,假设html = 'content</script>last',这个替换完了rest = 'last'

index += html.length - rest.length
html = rest

更新索引index,也就是右移匹配到的串的长度(如此例的content</script>的长度),最后将rest作为值更新html,正常情况下rest应该是'',这也就while遍历完毕

parseEndTag(stackedTag, index - endTagLength, index)

最后闭合此标签,后俩参数没啥用就懒得解释了,其实也简单的无需解释

这个就是处理script,style,textarea这三个纯文本标签,它们内部内容只能是文本,所以可以简单处理

5. 纯文本

if (html === last) {
    options.chars && options.chars(html)
    if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`)
    }
    break
}

我们知道html是还没有处理的字符串,last是处理之前的html,到这里之前可是历经各种判断。所以这俩个要是一样的话说明之前的各种判断都没匹配上也就是不是开始标签、结束标签、注释、DOCTYPE,那么html自然也就是纯文本了 要这个if语句成立html = '<div></div><123'就可以,它会闭合div,然后就剩下<123 进入if内部,然后又是个if语句,这时候!stack.length === true,就报警告模板结尾不合法

至此while解析完毕

// Clean up any remaining tags
parseEndTag()

这句很重要,其实就是逐个提示未闭合的标签

options参数

parseHTML函数的第二参数options

  • warn:传入的警告函数,接收一个参数msg
  • expectHTML:期望和HTML表现一致,浏览器有一些奇怪的表现,比如p允许的内容是Phrasing content,那么<p><div></div></p>这个会渲染成<p></p><div></div><p></p>有关元素分类详见
  • isUnaryTag:用于判断是否是一元标签的函数,它接收标签名为参来返回true\false
  • canBeLeftOpenTag:这个就是用于检测标签是否可以省略闭合标签的方法,它接收标签名为参来返回true\false
  • shouldDecodeNewlines:用于判断是否需要对属性值的换行符编码进行解码处理
  • shouldDecodeNewlinesForHref:用于判断是否需要对a标签的href属性值的换行符编码进行解码处理
  • shouldKeepComment:这个其实就是Vue传入的选项里的comments,当设为true时,将会保留且渲染模板中的HTML注释

常量、变量

parseHTML内部的一些常量、变量

  • stack

    const stack = []
    

    这个是用于存储非一元标签,它可以用于检测非一元标签是否缺少对应的结束标签(while循环完毕的话若是stack还有值就说明该标签未闭合),初始化为空数组

  • expectHTML:就是options.expectHTML的引用

    const expectHTML = options.expectHTML
    
  • isUnaryTag:就是options.isUnaryTag的引用

    const isUnaryTag = options.isUnaryTag || no
    

    做了下默认值处理,即no也就是返回false

  • canBeLeftOpenTag:就是options.canBeLeftOpenTag的引用

    const canBeLeftOpenTag = options.canBeLeftOpenTag || no
    

    做了下默认值处理,即no也就是返回false

  • index:就是当前模板字符读取的索引位置

    let index = 0
    
  • last:存储while每一轮开始时的html(模板字符串),这样子可用于每轮循环末判断是否已经被处理过了,若是未被处理过(html === last)那么剩下的必然是文本了

  • lastTag:用于存储最近处理的开始标签,它其实始终是stack的栈顶元素

方法

parseHTML内部的一些工具方法

advance

function advance(n) {
    index += n
    html = html.substring(n)
}

传入索引,根据传入的索引设置当前处理的索引,然后将html赋值为还未处理的那部分

parseEndTag

function parseEndTag(tagName, start, end) {
    let pos, lowerCasedTagName
    if (start == null) start = index
    if (end == null) end = index

    if (tagName) {
        lowerCasedTagName = tagName.toLowerCase()
    }
    // Find the closest opened tag of the same type
    if (tagName) {
        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.`
                )
            }
            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
    } else if (lowerCasedTagName === 'br') {
        if (options.start) {
            options.start(tagName, [], true, start, end)
        }
    } else if (lowerCasedTagName === 'p') {
        if (options.start) {
            options.start(tagName, [], false, start, end)
        }
        if (options.end) {
            options.end(tagName, start, end)
        }
    }
}

从它参数上来看它能取到结束标签名以及在模板上的起止索引,但实际上对于options.end钩子而言这几个参数并没有什么用而且对于本函数而言后俩个参数也没什么用,试想如下这个模板:

<div>
    <section>
        <br>
    </section>
    <section></section>
</div>

当我们解析到第一个</section>,这时候ast该生成的(div、section、br)其实已经生成了,如下

{
    "type": 1,
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "children": [{
        "type": 1,
        "tag": "section",
        "attrsList": [],
        "attrsMap": {},
        "children": [{
            "type": 1,
            "tag": "br",
            "attrsList": [],
            "attrsMap": {},
            "children": [],
            "plain": true
        }],
        "plain": true
    }],
    "plain": true
}

首先我们想下这个为什么能有这种层级,这个其实也很简单,我们匹配到开始非一元标签的时候让之后匹配到的节点都算其子节点(这就需要一个标识符来记录当前父节点,也就是currentParent赋值是此标签),当然若是匹配到结束标签的时候就把currentParent给置为它的父节点,这就是匹配到这个结束标签的时候options.end钩子所要干的事 回到parseEndTag,我们看看它干了些什么 首先我们可见parseEndTag调用有三种传参:

  • parseEndTag(tagName, start, end)
  • parseEndTag(tagName)
  • parseEndTag() 我们可以认为第一种和第二种一样(后俩参数没啥用),第三种我们先不管
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index

if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
}

首先就是定义一堆变量,pos用于纪录匹配到的stack里的和此结束标签同名的开始标签索引,lowerCasedTagName是将tagName小写版 然后就是start、end的默认值处理(其实也没什么用)以及tagName小写化

// Find the closest opened tag of the same type
if (tagName) {
    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
}

这个就如注释所示寻找最近的同名开始标签取其索引,没找见的话就算0 接下来就是个if else结构,我们先看第一个if

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.`
            )
        }
        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
}

若是pos >= 0的话也就是能找见这个tagName对应的开始标签或者tagName不存在,我们先看前者 进入if内部,倒序遍历stack

(i > pos || !tagName)

我们知道pos是开始标签的索引,一般而言它就是栈顶那个,也就是i === pos,但是也有例外,如下:

<div>
    <section>
        <span>
    </section>
    <a>
</div>

当解析到</section>stack = [div, section, span],这时候pos = 1、i = 2,也就是pos到栈顶之间有未闭合的标签(在这里就是span),所以提示,接下来就是关键

if (options.end) {
    options.end(stack[i].tag, start, end)
}

把这个未闭合的标签(span)给闭合,不然就会有问题 假设我们未闭合这个没有闭合的标签span,也就是如下改动:

if (process.env.NODE_ENV !== 'production' &&
    (i > pos || !tagName) &&
    options.warn
) {
    // ...
} else {
    if (options.end) {
        options.end(stack[i].tag, start, end)
    }
}

那么下一次循环也就是i === 1的时候也就是闭合section后,这时候currentParent还是section。 因为在解析到<span>的时候currentParentspan,这是毋庸置疑的(代表下一个元素是它子节点),然后解析到</section>本来会闭合span(也就是currentParent会变成section,但是没有闭合所以还是span),闭合section后这时候currentParentsection 接着解析到<a>,这时候就会将其挂载到section下面,也就是最终会变成这种结果

<div>
    <section>
        <span></span>
        <a></a>
    </section>
</div>

回到函数主流程

// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag

如注释所示就是将处理过的标签从栈移除并且重新赋值lastTag

这里有个需要注意的是若是pos不为真的话(pos === 0)那么lastTag === 0

接下来是第二个else if

else if (lowerCasedTagName === 'br') {
    if (options.start) {
        options.start(tagName, [], true, start, end)
    }
}

这个就是发现这个结束标签是br的且pos < 0。我们知道只有找不到对应的开始标签才会pos < 0pos === -1,也就是如下

<div>
    </br>
</div>

这个其实在浏览器里会将其解析成<br>,所以为了和浏览器一致就将其传给options.start钩子,注意其第三参数传的是true,其实代表这是一元标签,之后讲到的时候详述 最后是第三个else if

else if (lowerCasedTagName === 'p') {
    if (options.start) {
        options.start(tagName, [], false, start, end)
    }
    if (options.end) {
        options.end(tagName, start, end)
    }
}

和上一个br类似,也就是如下结构

<div>
    </p>
</div>

</p>在浏览器也会正确解析成<p></p>,所以为了表现一致就调用options.start、options.end俩个钩子分别创建和闭合标签 其实以上这些都是在tagName存在的时候的表现,还有parseEndTag(),这时候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.`
            )
        }
        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
}

我们可以发现parseEndTag()是在while循环完了之后调用的,这时候若是stack还有值的话那是什么样子的模板呢?

<div>
</div>
<span>

就是这么个情况,之前的节点该闭合的都闭合了就剩下最后span这个开始标签,那么stack = [span],所以这个循环必然能进,循环内的if也必然成立,这就把剩下未闭合的标签都给挨个警告了

parseEndTag函数主要干了三件事:

  • 检查是否缺少对应的闭合标签
  • 解析</br>、</p>标签,使之与浏览器的表现一致
  • 处理栈内多余的未闭合标签

parseStartTag

这个是用于解析开始标签的只有符合开始标签才能有返回值

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(attribute))) {
            advance(attr[0].length)
            match.attrs.push(attr)
        }
        if (end) {
            match.unarySlash = end[1]
            advance(end[0].length)
            match.end = index
            return match
        }
    }
}

首先会调用html匹配startTagOpen正则,若是有值的话那必然是开始标签,而且startTagOpen也有个捕获组,若是html = '<div></div>',start其实是这样子的类型

["<div", "div"]

进入if内部

const match = {
    tagName: start[1],
    attrs: [],
    start: index
}

首先定义了这么个对象,它其实是最终返回值,我们分析下这三个属性:

  • tagNamestart[1],自然就是匹配到的标签名
  • attrs:它其实是用来存储匹配到的属性
  • startindex,也就是这个开始标签在整个模板字符串中的索引位置
advance(start[0].length)

这句很好理解,就是我已经处理了start[0],那么自然也得把这个给舍弃了

let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
    advance(attr[0].length)
    match.attrs.push(attr)
}

首先end = html.match(startTagClose),这个就是匹配开始标签结束部分 然后attr = html.match(attribute),这个就是匹配这个标签属性部分 所以这个while执行下去的条件就是没有匹配到开始标签结束部分且匹配到了属性,可见只有匹配到开始标签结束部分(其实也是匹配到没属性了)就会结束 循环体内也很好理解,先调用advance函数右移attr[0].length,然后将匹配到的属性pushmatch.attrs

if (end) {
    match.unarySlash = end[1]
    advance(end[0].length)
    match.end = index
    return match
}

到此时我们已经处理完了开始标签的开始部分、属性部分,还差个结束部分,这个end就是匹配到的结束部分 若是end存在的话说明这是个完整的开始标签。匹配到的end如下

// <div></div>
['>', '']
// <br />
['/>', '/']

可见可以用end[1]来判断是否是一元标签,然后调用advance右移end[0].length,然后赋值match.endindex,代表开始标签结束部分下一字符在整个模板字符串中的索引位置,就像<div></div>match = { tagName: 'div', start: 0, end: 5 }

parseStartTag函数是匹配开始标签且返回有关其特性的对象match形如下所示

// <div id="app" v-if="flag"></div>
{
    "tagName": "div",
    "attrs": [
        [" id='app'", "id", "=", "app", null, null],
        [" v-if='flag'", "v-if", "=", "flag", null, null]
    ],
    "start": 0,
    "unarySlash": "",
    "end": 26
}

handleStartTag

这个是用来进一步处理parseStartTag的结果

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

    if (expectHTML) {
        if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
            parseEndTag(lastTag)
        }
        if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
            parseEndTag(tagName)
        }
    }

    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]
        // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
        if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
            if (args[3] === '') { delete args[3] }
            if (args[4] === '') { delete args[4] }
            if (args[5] === '') { delete args[5] }
        }
        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)
    }
}

首先会判断下expectHTML这个参数是否为真,从字面上理解是期望和HTML表现一致,什么意思呢?浏览器有如下俩个表现:

  1. p标签只允许Phrasing contentdeveloper.mozilla.org/zh-CN/docs/…
<p><div></div></p>

divFlow contentchenhaizhou.github.io/2016/01/19/… ),所以这个在浏览器表现会是下面这样子

<p></p>
<div></div>
<p></p>
  1. 一般而言标签必须开始结束标签都写,但是有些在某种情况下是可以只写开始标签的,比如p标签developer.mozilla.org/zh-CN/docs/…

其实关于标签省略Vue并不是和浏览器表现一致只是简单适配了几条。这点其实作者有解释github.com/vuejs/vue/p…,大致意思是这其实是HTML的设计缺陷,我们应该规避它而不是想尽办法去适配从而导致一些意想不到的错误

现在回到源码上来

  • 首先是第一种情况
// <p><div></div></p>
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
    parseEndTag(lastTag)
}

我们解析到<div>时就会匹配上这个条件从而调用parseEndTag(lastTag),这就会闭合p标签,这时候就变成<p></p><div></div></p>,然后div自然是正常解析,后面的</p>也会被解析成<p></p>,这样子就会解析成<p></p><div></div><p></p>

  • 然后是第二种情况
// <p>12<p>34
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
    parseEndTag(tagName)
}

这个其实只简单适配了<p>12<p>34这种情况,也就是可以省略结束标签的标签比如p后面紧跟着的标签还是这个标签就会进入这个语句 我们解析到第二个p时就会闭合第二个p,这样子第一个p就没闭合,就会报提示,也就是不提倡这种写法 接下来就是处理属性

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

这里混入了这句,这个其实不是用在处理属性上的,它用于判断该标签是否是一元标签,首先使用options.isUnaryTag来判断,毕竟input之类就可以通过标签名来判断是否是一元标签,但是一些自定义组件这个就不行了,如下

<div>
    <hello />
</div>

所以就需要unarySlashparseStartTag解析来的match.unarySlash,它是根据/>这个开始标签结束部分来判断的 然后就是真正的处理属性了

/**
 * 
 * "attrs": [
        [" id='app'", "id", "=", "app", null, null],
        [" v-if='flag'", "v-if", "=", "flag", null, null]
    ]
*/
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
    if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
        if (args[3] === '') { delete args[3] }
        if (args[4] === '') { delete args[4] }
        if (args[5] === '') { delete args[5] }
    }
    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)
    }
}

首先我们定义l变量存储match.attrs.length,接着就定义attrs来存储属性 然后对match.attrs遍历

if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
    if (args[3] === '') { delete args[3] }
    if (args[4] === '') { delete args[4] }
    if (args[5] === '') { delete args[5] }
}

IS_REGEX_CAPTURING_BROKEN我们知道这个是关FF bug相也就是这个是用于判断当前环境是否有这个bug(捕获组匹配不到的话应该是undefined而不是'') 我们知道match.attrs子项的3、4、5有一项是属性值(是捕获组捕获),而若是有这个bug的话且当前属性存在的话这三项里的其余两项就会是'',我们把它delete即可。其实不处理也没什么关系,毕竟只是空串

const value = args[3] || args[4] || args[5] || ''

定义变量value存储这个属性值

const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
    ? options.shouldDecodeNewlinesForHref
    : options.shouldDecodeNewlines

这个是用于解决浏览器一个奇怪问题github.com/vuejs/vue/i…。我们有时候挂载是只传el不传template,这样子取innerHTML的时候

<a href="http://google.com
"></a>

这个取到的innerHTML会是<a href="http://google.com&#10;"></a>,而有些浏览器不止a标签,其他标签也会出现,所以需要做下处理,不然的话访问的链接后面加了&#10;不就访问不了了 所以这段意思就是若是当前标签是a且正在处理的属性是href那我就对a标签属性做下处理,否则的话就对所有标签的属性值做下处理

attrs[i] = {
    name: args[1],
    value: decodeAttr(value, shouldDecodeNewlines)
}

这里就是组装attrs,它关键在于decodeAttr对属性值做的处理,即对属性值里的编码进行解码,就像http://google.com&#10;转成http://google.com\n 最终attrs是个如下的样子

[{
    name: 'id',
    value: 'app'
}, {
    name: 'v-if',
    value: 'flag'
}]

接下来是处理lastTag以及stack入栈

if (!unary) {
    stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
    lastTag = tagName
}

若是一元标签的话是没有子节点的也就没有结束标签去处理,但是非一元标签的话就得处理,将该开始标签入栈且将lastTag即最近处理的开始标签置为此开始标签 最后是调用start钩子函数

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

传入5个参数:标签名、属性数组、一元标签标识符、开始标签起始索引、开始标签结束部分后置索引。实际上后俩参数没啥用

文头变量区

集中解释下src/compiler/parser/html-parser.js头部定义的一些常量变量和方法

参数

  • attribute:顾名思义是用于匹配属性的
// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 这个正则有点复杂我们分开来看:
// 1.     ^\s*([^\s"'<>\/=]+)                                       捕获属性名
// 2.     (?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?
// 2.1    \s*(=)\s*                                                 捕获=,包括其前后空格
// 2.2    (?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+))                  
// 2.2.1  "([^"]*)"+|                                               捕获双引号包裹的字符串
// 2.2.2  '([^']*)'+|                                               捕获单引号包裹的字符串
// 2.2.3  ([^\s"'=<>`]+)                                            捕获未被引号包裹的字符串

这样子其实还是有点迷,我们整个例子匹配下看看结果

// <div id='app'></div>
// ["id='app'","id","=",null,"app",null]

其实从这也可以看出来这五个捕获组分别是:属性名、=、双引号包裹的属性值、单引号包裹的属性值、未包裹的属性值

const ncname = '[a-zA-Z_][\\w\\-\\.]*'

NCName以字母或下划线_字符开头,后接XML规范中允许的任意字母、数字、重音字符、变音符号、句点.、连字符-和下划线_的组合,其实就是个正常的标签名就是了,一般不会写错

  • qnameCapture:就是用来捕获qname
// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const qnameCapture = `((?:${ncname}\\:)?${ncname})`

如所给的链接所示,qname其实是个合法的XML name,它是包括可选的前缀、冒号以及必选的名称组成 qnameCapture就是用于捕获这个qname,它有一个捕获组捕获整个qname

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

可见和qnameCapture一样有捕获组捕获标签名

  • startTagClose:用于检测开始标签结束部分
const startTagClose = /^\s*(\/?)>/

它有一个捕获组用于捕获/,这个可以用于判断是否是一元标签

  • endTag:匹配结束标签
// /^<\/((?:[a-zA-Z_][\w\-\.]*\:)?[a-zA-Z_][\w\-\.]*)[^>]*>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

可见捕获</div>这种结束标签,同样也有个捕获组捕获标签名

  • doctype:用于检测DOCTYPE标签
const doctype = /^<!DOCTYPE [^>]+>/i
  • comment、conditionalComment:这俩个分别是用于检测注释节点和条件注释节点
// #7298: escape - to avoid being pased as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
  • IS_REGEX_CAPTURING_BROKEN:用于检测是否存在正则捕获组未捕获到结果却是''空字符串的bug
let IS_REGEX_CAPTURING_BROKEN = false
'x'.replace(/x(.)?/g, function (m, g) {
    IS_REGEX_CAPTURING_BROKEN = g === ''
})

这是个FF bug,我们只需要用x匹配/x(.)?/g,这个捕获组必然是捕获不到的 在这个replace第二参数(函数)里,g就是这个捕获组捕获的结果,若是空字符串那说明有这个bug

方法

  • isPlainTextElement:用于判断是否是纯文本标签
export const isPlainTextElement = makeMap('script,style,textarea', true)

script,style,textarea这三个标签内部不可能有标签,所以可以当做都是文本

  • isIgnoreNewlineTag、shouldIgnoreFirstNewline:用于判断是否是需要忽略标签内容第一个换行符的标签
// #5992
const isIgnoreNewlineTag = makeMap('pre,textarea', true)
const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n'

pre、textarea这俩个标签在浏览器渲染时会忽略第一个换行符,如下

<pre>
content</pre>
<!-- 相当于 -->
<pre>content</pre>

所以我们需要判断下这个标签是否是pre、textarea且其内容第一个字符是否是换行符

  • decodeAttr:这个就是用于属性解码的
const decodingMap = {
    '&lt;': '<',
    '&gt;': '>',
    '&quot;': '"',
    '&amp;': '&',
    '&#10;': '\n',
    '&#9;': '\t'
}
const encodedAttr = /&(?:lt|gt|quot|amp);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10|#9);/g
function decodeAttr(value, shouldDecodeNewlines) {
    const re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr
    return value.replace(re, match => decodingMap[match])
}

它接收俩个参数,value是属性值,shouldDecodeNewlines是是否需要对换行符、制表符解码 首先判断下shouldDecodeNewlines真假与否,真的就取encodedAttrWithNewLines,反之取encodedAttr,俩者区别在于是否匹配&#10;(换行符)、&#9;(制表符) 然后就是把value匹配正则re的结果替换成对应的解码后的字符,decodingMap就是对应表