php获取vue的html源码,Vue.js 源码学习八 —— HTML解析细节学习

从上一篇博客中,我们知道了template编译的整体逻辑和template编译后用在了哪里。本文着重讲下HTML的解析过程。

parse 方法

所有解析的起点就在 parse 方法中,parse方法最终将返回为一个 AST 语法树元素。

// src/core/compiler/parser/index.js

export function parse (

template: string,

options: CompilerOptions

): ASTElement | void {

warn = options.warn || baseWarn

platformIsPreTag = options.isPreTag || no

platformMustUseProp = options.mustUseProp || no

platformGetTagNamespace = options.getTagNamespace || no

transforms = pluckModuleFunction(options.modules, 'transformNode')

preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')

postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')

delimiters = options.delimiters

const stack = []

const preserveWhitespace = options.preserveWhitespace !== false

let root

let currentParent

let inVPre = false

let inPre = false

let warned = false

function warnOnce(msg){...}

function closeElement(element){...}

parseHTML(...)

return root

}

可以看到,除了 parseHTML 方法外,其他都是定义变量、方法的行为。因此只需深入看 parseHTML 行为就好。

于是我们在 src/core/compiler/parser/html-parser.js 文件中找到 parseHTML 方法。

parseHTML 中的几个方法

在源码中可以看到,parseHTML 中有四个方法,我们来一一解读。

advance

// 推进。向前推进n个字符

function advance (n) {

index += n

html = html.substring(n)

}

将index的值向后移动n位,然后从第n个字符开始截取 HTML 内容字符串。

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开始标签,并且将开始标签中的属性都保存到一个数组中。最终返回标签结果:标签名、标签属性和标签起始结束位置。例如标签为 返回结果如下:

{

"attrs": [

[

" v-on:click='hey'",

"v-on:click",

"=",

"hey",

"undefined",

"undefined",

]

],

"end": 48,

"start": 23,

"tagName": "button",

"unarySlash": ""

}

handleStartTag

// 处理开始标签,将开始标签中的属性提取出来。

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

}

// 触发 options.start 方法。

if (options.start) {

options.start(tagName, attrs, unary, match.start, match.end)

}

}

该方法用于处理开始标签。如果是可以直接结束的标签,直接解析结束标签;然后遍历查找属性的属性值 value 传入数组;将开始标签的标签名、小写标签名、属性值传入堆栈中;将当前标签变为最后标签;最后触发 options.start 方法。

最后推入堆栈的数据如下

{

"tag": "button",

"lowerCasedTag": "button",

"attrs": [

{

"name": "v-on:click",

"value": "hey"

}

]

}

parseEndTag

// 解析结束TAG

function parseEndTag (tagName, start, end) {

let pos, lowerCasedTagName

if (start == null) start = index

if (end == null) end = index

if (tagName) {

lowerCasedTagName = tagName.toLowerCase()

}

// 找到同类的开始 TAG 在堆栈中的位置

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

}

// 对堆栈中的大于等于 pos 的开始标签使用 options.end 方法。

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 has no matching end tag.`

)

}

if (options.end) {

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

}

}

// Remove the open elements from the stack

// 从栈中移除元素,并标记为 lastTag

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)

}

}

}

解析结束标签。先是获取开始结束位置、小写标签名;然后遍历堆栈找到同类开始 TAG 的位置;对找到的 TAG 位置后的所有标签都执行 options.end 方法;将 pos 后的所有标签从堆栈中移除,并修改最后标签为当前堆栈最后一个标签的标签名;如果是br标签,执行 option.start 方法;如果是 p 标签,执行 options.start 和options.end 方法。(最后两个操作让我猜想 start 和 end 方法用于标签的开始和结束行为中。)

parseHTML 的整体逻辑

之前所说的 options.start 等方法,其实在 parseHTML 的传参中传入的 start、end、chars、comment 这四个方法,这些方法会在parseHTML 方法特定的地方被使用,而这些方法中的逻辑下一节再讲。

这里先来看看在 parseHTML 方法的整体逻辑:

// src/core/compiler/parser/html-parser.js

export function parseHTML (html, options) {

const stack = []

const expectHTML = options.expectHTML

const isUnaryTag = options.isUnaryTag || no

const canBeLeftOpenTag = options.canBeLeftOpenTag || no

let index = 0

let last, lastTag

while (html) {

last = html

// 如果没有lastTag,并确保我们不是在一个纯文本内容元素中:script、style、textarea

if (!lastTag || !isPlainTextElement(lastTag)) {

// 文本结束,通过

let textEnd = html.indexOf('

// 文本结束位置在第一个字符,即第一个标签为<

if (textEnd === 0) {

// 注释匹配

if (comment.test(html)) {

const commentEnd = html.indexOf('-->')

if (commentEnd >= 0) {

// 如果需要保留注释,执行 option.comment 方法

if (options.shouldKeepComment) {

options.comment(html.substring(4, commentEnd))

}

advance(commentEnd + 3)

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

}

}

// Doctype:

const doctypeMatch = html.match(doctype)

if (doctypeMatch) {

advance(doctypeMatch[0].length)

continue

}

// End tag: 结束标签

const endTagMatch = html.match(endTag)

if (endTagMatch) {

const curIndex = index

advance(endTagMatch[0].length)

// 解析结束标签

parseEndTag(endTagMatch[1], curIndex, index)

continue

}

// Start tag: 开始标签

const startTagMatch = parseStartTag()

if (startTagMatch) {

handleStartTag(startTagMatch)

if (shouldIgnoreFirstNewline(lastTag, html)) {

advance(1)

}

continue

}

}

// < 标签位置大于等于0,即标签中有内容

let text, rest, next

if (textEnd >= 0) {

// 截取从 0 - textEnd 的字符串

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('

if (next < 0) break

textEnd += next

rest = html.slice(textEnd)

}

// 最终截取字符串内容

text = html.substring(0, textEnd)

advance(textEnd)

}

if (textEnd < 0) {

text = html

html = ''

}

// 绘制文本内容,使用 options.char 方法。

if (options.chars && text) {

options.chars(text)

}

} else {

// 如果lastTag 为 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(//g, '$1') //

.replace(//g, '$1') //

}

if (shouldIgnoreFirstNewline(stackedTag, text)) {

text = text.slice(1)

}

// 处理文本内容,并使用 options.char 方法。

if (options.chars) {

options.chars(text)

}

return ''

})

index += html.length - rest.length

html = rest

// 解析结束tag

parseEndTag(stackedTag, index - endTagLength, index)

}

// html文本到最后

if (html === last) {

// 执行 options.chars

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

}

}

// 清理所有残留标签

parseEndTag()

...

}

具体的解析都写在注释里面了。

其实就是利用正则循环处理 html 文本内容,最后使用 advance 方法来截取后一段 html 文本。在解析过程中执行了 options 中的一些方法。

下面我们来看看传入的方法都做了些什么?

parseHTML 传参的几个方法

warn

// src/core/compiler/parser/index.js

warn = options.warn || baseWarn

如果options中有 warn 方法,使用该方法。否则调用 baseWarn 方法。

start

start (tag, attrs, unary) {

// 确定命名空间

const ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag)

// 处理 IE 的 SVG bug

if (isIE && ns === 'svg') {

attrs = guardIESVGBug(attrs)

}

// 获取AST元素

let element: ASTElement = createASTElement(tag, attrs, currentParent)

if (ns) {

element.ns = ns

}

if (isForbiddenTag(element) && !isServerRendering()) {

element.forbidden = true

}

// 遍历执行 preTransforms 方法

for (let i = 0; i < preTransforms.length; i++) {

element = preTransforms[i](element, options) || element

}

// 处理各种方法

if (!inVPre) {

// v-pre

processPre(element)

if (element.pre) {

inVPre = true

}

}

if (platformIsPreTag(element.tag)) {

inPre = true

}

if (inVPre) {

// 处理原始属性

processRawAttrs(element)

} else if (!element.processed) {

// v-for v-if v-once

processFor(element)

processIf(element)

processOnce(element)

// 元素填充?

processElement(element, options)

}

// 检查根节点约束

function checkRootConstraints (el) {

if (process.env.NODE_ENV !== 'production') {

if (el.tag === 'slot' || el.tag === 'template') {

warnOnce(

`Cannot use as component root element because it may ` +

'contain multiple nodes.'

)

}

if (el.attrsMap.hasOwnProperty('v-for')) {

warnOnce(

'Cannot use v-for on stateful component root element because ' +

'it renders multiple elements.'

)

}

}

}

// 语法树树管理

if (!root) {

// 无root

root = element

checkRootConstraints(root)

} else if (!stack.length) {

// 允许有 v-if, v-else-if 和 v-else 的根元素

if (root.if && (element.elseif || element.else)) {

checkRootConstraints(element)

// 添加 if 条件

addIfCondition(root, {

exp: element.elseif,

block: element

})

} else if (process.env.NODE_ENV !== 'production') {

warnOnce(

`Component template should contain exactly one root element. ` +

`If you are using v-if on multiple elements, ` +

`use v-else-if to chain them instead.`

)

}

}

if (currentParent && !element.forbidden) {

// v-else-if v-else

if (element.elseif || element.else) {

// 处理 if 条件

processIfConditions(element, currentParent)

} else if (element.slotScope) { // slot-scope

currentParent.plain = false

const name = element.slotTarget || '"default"'

;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element

} else {

// 将元素插入 children 数组中

currentParent.children.push(element)

element.parent = currentParent

}

}

if (!unary) {

currentParent = element

stack.push(element)

} else {

// 关闭元素

closeElement(element)

}

},

其实start方法就是处理 element 元素的过程。确定命名空间;创建AST元素 element;执行预处理;定义root;处理各类 v- 标签的逻辑;最后更新 root、currentParent、stack 的结果。

其中关键点在于 createASTElement 方法。可以看到该方法传递了 tag、attrs和currentParent。其中前两个参数是不是很熟悉?就是我们在 parseHTML 的 handleStartTag 方法中传给堆栈数组中的数据对象。

{

"tag": "button",

"lowerCasedTag": "button",

"attrs": [

{

"name": "v-on:click",

"value": "hey"

}

]

}

最终通过 createASTElement 方法定义了一个新的 AST 对象。

// 创建AST元素

export function createASTElement (

tag: string,

attrs: Array,

parent: ASTElement | void

): ASTElement {

return {

type: 1,

tag,

attrsList: attrs,

attrsMap: makeAttrsMap(attrs),

parent,

children: []

}

}

end

end () {

// 删除尾随空格

const element = stack[stack.length - 1]

const lastNode = element.children[element.children.length - 1]

if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {

element.children.pop()

}

// 退栈

stack.length -= 1

currentParent = stack[stack.length - 1]

// 关闭元素

closeElement(element)

},

end方法就很简单了,就是一个清理结束的过程。

从这里可以看到,stack中存的是个有序的数组,数组最后一个值永远是父级元素;currentParent表示当前的父级元素。其实也很好理解,收集HTML元素的时候是从最外层元素向内收集的,处理HTML内容的时候是从最内部元素向外处理的。所以,当最内部元素处理完后,将元素从对线中移除,开始处理当前最内部的元素。

chars

chars (text: string) {

if (!currentParent) {

return

}

// IE textarea placeholder bug

if (isIE &&

currentParent.tag === 'textarea' &&

currentParent.attrsMap.placeholder === text

) {

return

}

// 获取元素 children

const children = currentParent.children

// 获取文本内容

text = inPre || text.trim()

? isTextTag(currentParent) ? text : decodeHTMLCached(text)

// only preserve whitespace if its not right after a starting tag

: preserveWhitespace && children.length ? ' ' : ''

if (text) {

let res

// inVPre 是判断 v-pre 的

if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {

// 表达式,会转为 _s(message) 表达式

children.push({

type: 2,

expression: res.expression,

tokens: res.tokens,

text

})

} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {

// 纯文本内容

children.push({

type: 3,

text

})

}

}

},

chars方法用来处理非HTML标签的文本。如果是表达式,通过 parseText 方法解析文本内容并传递给当前元素的 children;如果是普通文本直接传递给当前元素的 children。

comment

comment (text: string) {

currentParent.children.push({

type: 3,

text,

isComment: true

})

}

comment方法用来保存需要保存在语法树中的注释。它与保存普通文本类似,只是多了 isComment: true。

生成语法树

我这里写了个demo,并且抓取了AST元素最后生成结果。

Hey

{{ message }}

你好!

new Vue({

el: "#app",

data: {

message: "Hey Vue.js"

},

methods: {

hey() {

this.message = "Hey Button"

}

}

})

结果如下:

1460000013592122

最后

最后整理理一下思路:

parseHTML 中的方法用于处理HTML开始和结束标签。

parseHTML 方法的整体逻辑是用正则判断各种情况,进行不同的处理。其中调用到了 options 中的自定义方法。

options 中的自定义方法用于处理AST语法树,最终返回出整个AST语法树对象。

可以这么说,parseHTML 方法中仅仅是使用正则解析 HTML 的行为,options 中的方法则用于自定义方法和处理 AST 语法树对象。

OK!HTML的解析部分就讲解完啦~配合着之前的那篇学习Vue中那些正则表达式,顺着我的思路,相信一定可以顺利GET解析过程的。

Vue.js学习系列

鉴于前端知识碎片化严重,我希望能够系统化的整理出一套关于Vue的学习系列博客。

Vue.js学习系列项目地址

关于作者

VioletJack,高效学习前端工程师,喜欢研究提高效率的方法,也专注于Vue前端相关知识的学习、整理。

欢迎关注、点赞、评论留言~我将持续产出Vue相关优质内容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值