1 はじめに
前回の記事で説明したように、テンプレート解析フェーズのメインライン関数ではparse
、解析対象のコンテンツに応じて異なるパーサーが呼び出されます。
3 つの異なるパーサーの中で最も重要なのはHTML
パーサーですが、なぜそう言えるのでしょうか? HTML
パーサーは主に、テンプレート文字列に含まれる内容を解析する役割を担っており、その後、他のパーサーを呼び出して、さまざまな内容に応じて対応する処理を実行するためです。そこでこの記事では、HTML
パーサーがテンプレート文字列に含まれるさまざまなコンテンツをどのように解析するかを紹介します。
2. HTMLパーサーの内部動作処理
ソース コードでは、HTML
パーサーはテンプレート解析メインライン関数で呼び出されるparseHTML
関数であり、2 つのパラメーターが渡されます。コードは次のとおりです。parse
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
* 将HTML模板字符串转化为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) {
},
// 当解析到结束标签时,调用该函数
end () {
},
// 当解析到文本时,调用该函数
chars (text) {
},
// 当解析到注释时,调用该函数
comment (text) {
}
})
return root
}
コードから、parseHTML
関数の呼び出し時に渡される 2 つのパラメーターが次のとおりであることがわかります。
- template: 変換されるテンプレート文字列。
- オプション: 変換に必要なオプション。
最初のパラメータは、言うまでもなく変換されるテンプレート文字列です。2 番目のパラメータに注目してください。これは、HTML
テンプレートを解析するときにいくつかのパラメータを提供し、4 つのフック関数も定義します。これら 4 つのフック関数にはどのような機能があるのでしょうか? テンプレート コンパイル フェーズのメインライン関数はテンプレート文字列をテンプレート文字列に変換するとparse
言いましたが、これはテンプレート文字列を解析するために使用されます。テンプレート文字列内のさまざまなコンテンツを抽出した後、対応する抽出されたコンテンツを生成するのは誰ですか? 答えはこれら 4 つのフック関数です。HTML
AST
parseHTML
AST
これら 4 つのフック関数をパラメーターとしてパーサーに渡しますparseHTML
。パーサーがさまざまなコンテンツを解析すると、さまざまなフック関数が呼び出され、さまざまなコンテンツが生成されますAST
。
-
開始タグが解析されると、
start
関数が呼び出されて要素タイプのノードが生成されますAST
。コードは次のとおりです。// 当解析到标签的开始位置时,触发start start (tag, attrs, unary) { let element = createASTElement(tag, attrs, currentParent) } export function createASTElement (tag,attrs,parent) { return { type: 1, tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), parent, children: [] } }
start
上記のコードから、関数が 3 つのパラメーター (タグ名tag
、タグ属性attrs
、タグが自己終了かどうか)を受け取ることがわかりますunary
。フック関数が呼び出されると、createASTElement
関数が内部的に呼び出され、要素タイプのAST
ノードが作成されます。 -
この関数は、終了タグが解析されるときに呼び出されます
end
。 -
テキストに解析する場合は、
chars
関数を呼び出してテキスト タイプのノードを生成しますAST
。// 当解析到标签的文本时,触发chars chars (text) { if(text是带变量的动态文本){ let element = { type: 2, expression: res.expression, tokens: res.tokens, text } } else { let element = { type: 3, text } } }
タグのテキストが解析されると
chars
フック関数が起動され、フック関数内でまずテキストが「hello」などの変数を含む動的テキストであるかどうかが判断されます。ダイナミック テキストの場合は、AST
ダイナミック テキスト タイプのノードを作成し、ダイナミック テキストでない場合は、AST
純粋な静的テキスト タイプのノードを作成します。 -
注釈が解析されると、
comment
関数が呼び出され、注釈タイプのノードが生成されますAST
。// 当解析到标签的注释时,触发comment comment (text: string) { let element = { type: 3, text, isComment: true } }
タグのコメントが解析されると
comment
フック関数がトリガーされ、フック関数によってAST
コメント型のノードが作成されます。
さまざまなコンテンツを解析しながら、対応するフック関数を呼び出して対応するノードを生成しAST
、最後にテンプレート文字列全体の変換を完了しますAST
。これはHTML
パーサーが行う必要があることです。
3. さまざまなコンテンツを解析する方法
テンプレート文字列からさまざまなコンテンツを解析するには、まずテンプレート文字列にどのようなコンテンツが含まれるかを知る必要があります。では、通常、作成するテンプレート文字列には何を含めるのでしょうか? 並べ替えると、通常、テンプレートには次の内容が含まれます。
- 「血液を冷やすのは難しいです」などのテキスト
- 次のような HTML コメント
- 条件付きアノテーション、例: I am アノテーション
- DOCTYPE など
- 開始タグ、例:
- 終了タグ、例:
これらの種類のコンテンツには独自の特性があります。つまり、コンテンツごとに異なる特性に応じて異なる正規表現を記述して、テンプレート文字列からこれらのコンテンツを 1 つずつ解析し、コンテンツごとに異なる方法で処理する必要があります。
HTML
次に、パーサーがテンプレート文字列から上記のさまざまな種類のコンテンツをどのように解析するかを見てみましょう。
3.1 HTML コメントの解析
コメントの解析は比較的単純で、コメントは . で始まり.でHTML
終わる<!--
ことがわかっており、見つかった場合、OK、コメントは解析されます。コードは以下のように表示されます:-->
html
<!--
-->
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
}
}
上記のコードでは、テンプレート文字列がhtml
コメントから始まる規則性と一致する場合は、遡ってそれが存在するかどうかを確認し、-->
存在する場合はhtml
4 番目 (" 处,截取得到的内容就是注释的真实内容,然后调用4个钩子函数中的
comment 函数,将真实的注释内容传进去,创建注释类型的
AST` ノード) から開始します。
上記のコードで注目すべき点の 1 つは、通常、テンプレートのラベルにオプションを<template></template>
設定して、テンプレートをレンダリングするときにコメントを保持するかどうかを決定できることです。上記のコードに対応して、ユーザーがオプションを として設定すると、次のようになります。, 次に、コメント タイプのノードを作成します。コメントが予約されていない場合は、カーソルを「–>」の後に移動し、逆方向に解析を続けます。comments
options.shouldKeepComment
comments
true
shouldKeepComment
true
AST
advance
この関数は、解析カーソルを移動するために使用されます。一部を解析した後、解析が繰り返されないように、カーソルをその一部に戻します。コードは次のとおりです:
function advance (n) {
index += n // index为解析游标
html = html.substring(n)
}
の役割をより直観的に説明するためにadvance
、以下の図を参照してください: [外部リンク画像の転送に失敗しました。ソース サイトにはリーチ防止メカニズムがある可能性があります。画像を保存して直接アップロードすることをお勧めします。
advance
関数を呼び出します。
advance(3)
答えが得られました:
図からわかるように、解析カーソルはindex
最初はテンプレート文字列の位置 0 にあります。 を呼び出した後advance(3)
、解析カーソルは位置 3 に到達します。コンテンツのセクションが解析されるたびに、カーソルは 1 セクション後ろに移動し、その後、解析から カーソルは後で解析されるため、解析されたコンテンツが繰り返し解析されることはありません。
3.2 条件付きコメントの分析
条件付きコメントの解析も比較的単純です。原理はコメントの解析と同じです。最初に正規化を使用して条件付きコメントの一意の開始マークで始まるかどうかを判断し、次にその一意の終了マークを探します。見つかった場合は、それが見つかります。条件付きコメントは条件付きコメントであることを意味します。それをインターセプトするだけです。条件付きコメントは実際のツリーには存在しないため、フック関数を呼び出してノードDOM
を作成する必要はありません。AST
コードは以下のように表示されます。
// 解析是否是条件注释
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
// 若为条件注释,则继续查找是否存在']>'
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 若存在 ']>',则从原本的html字符串中把条件注释截掉,
// 把剩下的内容重新赋给html,继续向后匹配
advance(conditionalEnd + 2)
continue
}
}
3.3 DOCTYPEの解析
解析の原理DOCTYPE
は条件付きコメントの解析とまったく同じなので、ここでは詳しく説明しません。コードは次のとおりです。
const doctype = /^<!DOCTYPE [^>]+>/i
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
3.4 開始タグの解析
最初の 3 種類のコンテンツの解析と比較すると、開始タグの解析は少し複雑ですが、原理は同じで、すべて正規表現を使用して一致と抽出を行います。
まず、次のように、開始タグの正規化を使用してテンプレート文字列と一致させ、テンプレート文字列が開始タグの特性を持っているかどうかを確認します。
/**
* 匹配开始标签的正则
*/
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
関数は 3 つのパラメータ (タグ名、タグ属性、タグが self かどうか)start
を渡す必要があります。-終了。タグ名は通常のマッチング結果、つまり上記のコードで取得できますが、タグの属性とタグが自己終了かどうかについてはさらなる分析が必要です。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) とタグ属性の特性を満たしていない場合は、まだ抽出されていないタグ属性が存在することを意味し、ループに入って続行します。すべてのタグ属性が抽出されるまで、 extract を繰り返します。開始タグに準拠しない、いわゆる終了特性は、現在残っている文字列が開始タグの終わりで始まらないことを意味します。開始タグの終わりは (非自己終了タグである可能性があることがわかっています
>
) ) または/>
(自己終了タグ) の場合、残りの文字列 ( など></div>
) が開始タグの終了文字で始まる場合、タグ属性が抽出されたことを意味します。 -
解析タグが自己終了タグかどうか
には
HTML
、自己終了タグ ( など<img src=""/>
) と非自己終了タグ ( など<div></div>
) があります。これら 2 種類のタグは、AST
ノードの作成時に異なる方法で処理されるため、現在のタグが自己終了タグであるかどうかを解析する必要があります。終了タグ。解析方法は非常に単純で、タグの属性が抽出された後、残りの文字列は次の 2 種類にすぎないことがわかります。
<!--非自闭合标签--> ></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]
であることがわかります"/"
。したがって、一致結果が true であるか否かによって、現在のタグが自己終了タグであるかどう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 }
上記の 2 つの手順の後、開始タグが解析され、完全なソース コードは次のようになります。
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数组长度相等的数组
match.attrs
次のステップでは、次のように、抽出されたタグ属性配列をループします。
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)
}
}
上記のコードでは、まず定数が定義されますargs
。これは、解析されたタグ属性配列内の各属性オブジェクト、つまりmatch.attrs
配列内の各要素オブジェクトです。次のようになります。
const args = ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
次に、ラベル属性を格納するために使用される属性値を定義します。value
コード内で, ,args
を取得しようとすると、取得できなかった場合は空にコピーされることがわかります。args[3]
args[4]
args[5]
value
const value = args[3] || args[4] || args[5] || ''
次に、 を定義しますshouldDecodeNewlines
。この定数は主に互換処理用です。shouldDecodeNewlines
Yesの場合、テンプレートをコンパイルするときに、属性値内の改行文字またはタブ文字が互換性がある必要があることをtrue
意味します。Vue
そして、テンプレートをコンパイルするときに、タグの属性値内の改行文字またはタブ文字に互換性がある必要があることを意味しますshouldDecodeNewlinesForHref
。true
Vue
a
href
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlinesconst value = args[3] || args[4] || args[5] || ''
最後に、次のように、処理結果をmatch.attrs
配列の長さに等しい、以前に定義した配列に格納します。attrs
attrs[i] = {
name: args[1], // 标签属性的属性名,如class
value: decodeAttr(value, shouldDecodeNewlines) // 标签属性的属性值,如class对应的a
}
最後に、ラベルが自己終了ラベルでない場合は、次のようにラベルをスタックにプッシュします (スタックの概念については後述します)。
if (!unary) {
stack.push({
tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
タグが自己終了タグの場合は、次のようにstart
フック関数を呼び出し、処理されたパラメータを渡してAST
ノードを作成できます。
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
start
以上が開始タグを解析してフック関数を呼び出して要素型ノードを作成するまでの処理ですAST
。
3.5 終了タグの解析
終了タグの解析は、属性を解析する必要がなく、残りのテンプレート文字列が終了タグの特性に適合するかどうかを判断し、適合する場合は末尾を抽出するだけでよいため、開始タグの解析よりもはるかに簡単です。タグ名を指定して呼び出します。 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
}
上記のコードでは、end
関数を直接呼び出しているのではなく、関数を呼び出していますが、parseEndTag
関数の内部関数については後ほど紹介しますが、ここで関数のparseEndTag
内部でend
フック関数が呼び出されていることが一時的に理解できます。
3.6 テキストの解析
终于到了解析最后一种文本类型的内容了,为什么要把解析文本类型放在最后一个介绍呢?我们仔细想一下,前面五种类型都是以`<`开头的,只有文本类型的内容不是以`<`开头的,所以我们在解析模板字符串的时候可以先判断一下字符串是不是以`<`开头的,如果是则继续判断是以上五种类型的具体哪一种,而如果不是的话,那它肯定就是文本了。
テキストの解析も簡単です。テンプレート文字列を解析する前に、最初の文字列が<
どこに現れるかを調べましょう。最初の<
文字列が最初の位置にある場合、テンプレート文字列が他の 5 つのタイプで始まることを意味します。 1 つ<
は最初の位置ではなく、テンプレート文字列の中間のどこかにあります。これは、テンプレート文字列がテキストで始まり、先頭から最初の出現までの内容がすべてテキスト コンテンツであることを意味します。それがテンプレート全体にある<
場合これは string 内に見つかりません<
。これは、テンプレート文字列全体がテキストであることを意味します。これが分析の考え方です。次に、実際の分析プロセスを理解するためにソース コードを比較してみましょう。ソース コードは次のとおりです。
let 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</div>
。この状況に対処するために、次のように<
テンプレート文字列の最初の位置から最後までのインターセプトを記録します。rest
let rest = html.slice(textEnd)
次に、rest
それを使用して上記 5 種類の正規表現を照合します。どれも一致しない場合は、<
次のようにテキストそのものの内容であることを意味します。
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(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)
}
最後に、テキスト コンテンツをインターセプトしtext
、4 つのフック関数内の関数を呼び出してchars
テキスト タイプのAST
ノードを作成します。
4. 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
構築されたノードspan
の親ノードになりますAST
。
このようにして、現在構築されているノードの親ノードが見つかりました。これはスタックの 1 つの用途にすぎません。別の用途もあります。次のテンプレート文字列を見てみましょう。
<div><p><span></p></div>
上記のプロセスに従ってテンプレート文字列を解析すると、終了タグが解析されるとき</p>
、スタックの一番上のタグはp
正しいはずですが、現在は正しいです。span
これは、span
タグが正しく閉じられていないことを意味し、コンソールはthrow 警告: 「タグに一致する終了タグがありません。」 この警告はよくご存知だと思います。ここで、スタックの 2 番目の目的は、テンプレート文字列内の不適切に閉じられたタグを検出することです。
HTML
さて、このスタックの概念を踏まえて、パーサーがさまざまなコンテンツを解析する前の章のコードを振り返ってみましょう。
5. ソースコードに戻る
5.1 HTMLパーサーのソースコード
上記の内容を理解した上で、ソースコードに戻ってHTML
パーサーparseHTML
関数を一文ずつ解析してみると、関数の定義は以下のようになります。
function parseHTML(html, options) {
var stack = [];
var expectHTML = options.expectHTML;
var isUnaryTag$$1 = options.isUnaryTag || no;
var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
var index = 0;
var last, lastTag;
// 开启一个 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) {
}
}
上記のコードは大きく 3 つの部分に分けることができます。
- いくつかの定数と変数が定義されています
- while ループ
- 解析中に使用されるヘルパー関数
それらを 1 つずつ分析してみましょう。
まず、次のようにいくつかの定数を定義します。
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
空になること、つまりテンプレート文字列が完全にコンパイルされたことです。各while
ループでは、次のように、まず のhtml
値を変数 に代入します。last
last = html
この目的は、上記のすべての処理ロジックの後でもhtml
文字列が変更されていない場合、つまりhtml
文字列がどのルールにも一致しない場合、html
その文字列をプレーン テキストとして扱い、テキスト タイプのノードを作成しAST
、例外がスローされた場合: Malformed であることです。テンプレート文字列内のタグ。次のように:
//将整个字符串作为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
}
break
}
while
次に、ループ本体のコードを見てみましょう。
while (html) {
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
} else {
// parse 的内容是在纯文本标签里 (script,style,textarea)
}
}
ループ本体では、まず解析対象の文字列が 、 、 などのプレーン テキスト タグ内にあるかどうかを判断します。これらhtml
3つのタグ内のコンテンツにはタグが含まれていないため、直接テキストとして扱うことができます。判定条件は以下の通りです。script
style
textarea
HTML
!lastTag || !isPlainTextElement(lastTag)
前に述べたように、lastTag
これはスタックの最上位要素です。!lastTag
つまり、現在のhtml
文字列には親ノードがありませんが、それが 3 つのプレーン テキスト タグのいずれかであるかどうかがisPlainTextElement(lastTag)
チェックされ、そうでない場合は戻ります。lastTag
true
fasle
つまり、現在のhtml
文字列に親ノードがない、または親ノードがプレーン テキスト ラベルではない場合、6 種類のコンテンツを順番に解析できます。6 種類のコンテンツの処理方法は、次のとおりです。前のセクションの 1 つを繰り返します。
5.2 parseEndTag関数のソースコード
次に、終了タグを解析するときに残った関数を見てみましょう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)
}
}
}
}
この関数は、終了タグの名前tagName
、html
文字列内の終了タグの開始位置と終了位置、start
および合計の3 つのパラメーターを受け取りますend
。
これら 3 つのパラメータは実際にはオプションであり、渡されるパラメータに応じて機能が異なります。
- 1 つ目は、共通の終了タグを処理するために 3 つのパラメータすべてを渡すことです。
- 2つ目はパスのみです
tagName
- 3 つ目は、スタック内の残りの未処理のタグを処理するために使用される 3 つのパラメータがいずれも渡されないことです。
存在する場合はtagName
、スタックを後ろから前に走査し、tagName
スタック内で同じラベルを見つけてその位置を記録します。存在しないpos
場合は、 0 に設定されます。次のように:tagName
pos
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>=0
その時点でfor
ループを開始し、スタックの先頭から後ろから前の位置までトラバースします。スタック内にそれより大きいインデックスを持つ要素があるpos
場合、その要素には終了タグが欠けている必要があります。 。これは、通常の状況では、スタックの最上位の要素が現在の終了タグと一致する必要がある、つまり、通常の位置がスタックの最上位である必要があり、その後ろに要素が存在しないはずであるためです。の場合、背後の要素はすべて終了タグが欠落しています。この時点で非運用環境にある場合は、終了タグが欠落していることを示す警告がスローされます。さらに、分析結果の正確性を確保するために、すぐに閉じるように呼び出されます。stack
pos
stack
tagName
pos
options.end(stack[i].tag, start, end)
if (pos >= 0) {
// Close all the open elements, up the stack
for (var i = stack.length - 1; i >= pos; i--) {
if (i > pos || !tagName ) {
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
その位置以降の要素がstack
スタックからポップされ、lastTag
スタックの最上位要素に更新されます。
stack.length = pos;
lastTag = pos && stack[pos - 1].tag;
そして、pos
0以上がない場合、つまりスタック内に対応する開始ラベルが見つからtagName
ない場合は-1となります。では、このときにラベルかラベルかを判断するのですが、なぜこの 2 つのラベルを別々に判断する必要があるのでしょうか。これは、ブラウザで次のように記述した場合に発生するためです。stack
pos
tagName
br
p
HTML
<div>
</br>
</p>
</div>
ブラウザは</br>
タグを通常の
タグに自動的に解析し、</p>
ブラウザはそれを として自動的に完成します<p></p>
。そのVue
ため、これら 2 つのタグに対するブラウザの動作と一貫性を保つために、2 つの付箋は次のように個別に判断され、処理されます。 :
if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end) // 创建<br>AST节点
}
}
// 补全p标签并创建AST节点
if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
以上が終了タグの解析と処理です。
また、while
ループの後には次のコード行があります。
parseEndTag()
このコード行の実行タイミングは、文字列内のラベルの形式が間違っているhtml === last
場合にループhtml
から抜け出しますwhile
。このとき、このコード行が実行されます。このコード行は、parseEndTag
関数がパラメータを渡さない場合、parseEndTag
渡された引数はスタック上に残っている未処理のタグを処理するために使用されると前述しました。これは、関数を渡さない場合、この時点ではparseEndTag
関数内の値は0 になり、その後は常に true となり、終了タグがないことについて 1 つずつ警告され、それらを閉じるために呼び出しが行われるためです。 。pos
pos>=0
options.end
6. まとめ
この記事では主にHTML
パーサーのワークフローと動作原理を紹介します。記事は比較的長いですが、ロジックは複雑ではありません。
まず、パーサーのワークフローを紹介しますHTML
が、一言で言えば、さまざまなコンテンツを解析しながら、対応するフック関数を呼び出して対応するAST
ノードを生成し、最後にテンプレート文字列全体の変換を完了しますAST
。
次に、HTML
ユーザーが記述したテンプレート文字列内のさまざまな種類のコンテンツをパーサーがどのように解析するか、およびさまざまな種類の解析方法を紹介します。
AST
次に、構築されたノード階層が実際の階層と一致していることを保証するために、パーサー内でスタックが維持されることが導入されていますDOM
。
アイデアを理解した後、最後にソース コードに戻り、ソース コードの詳細をいくつか学びました。