本文來自《深入淺出Vue.js》模板編譯原理篇的第九章,主要講述瞭如何將模板解析成AST,這一章的內容是全書最複雜且燒腦的章節。本文排版較為緊湊和圖片是未經加工的原稿,真實紙質書的排版和圖片會更加精緻。
通過第8章的學習,我們知道解析器在整個模板編譯中的位置。我們只有將模板解析成AST後,才能基於AST做優化或者生成程式碼字串,那麼解析器是如何將模板解析成AST的呢?
本章中,我們將詳細介紹解析器內部的執行原理。
9.1 解析器的作用
解析器要實現的功能是將模板解析成AST。
例如:
<div>
<p>{{name}}</p>
</div>
複製程式碼
上面的程式碼是一個比較簡單的模板,它轉換成AST後的樣子如下:
{
tag: "div"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: undefined,
attrsList: [],
attrsMap: {},
children: [
{
tag: "p"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: {tag: "div", ...},
attrsList: [],
attrsMap: {},
children: [{
type: 2,
text: "{{name}}",
static: false,
expression: "_s(name)"
}]
}
]
}
複製程式碼
其實AST並不是什麼很神奇的東西,不要被它的名字嚇倒。它只是用JS中的物件來描述一個節點,一個物件代表一個節點,物件中的屬性用來儲存節點所需的各種資料。比如,parent
屬性儲存了父節點的描述物件,children
屬性是一個陣列,裡面儲存了一些子節點的描述物件。再比如,type
屬性代表一個節點的型別等。當很多個獨立的節點通過parent
屬性和children
屬性連在一起時,就變成了一個樹,而這樣一個用物件描述的節點樹其實就是AST。
9.2 解析器內部執行原理
事實上,解析器內部也分了好幾個子解析器,比如HTML解析器、文字解析器以及過濾器解析器,其中最主要的是HTML解析器。顧名思義,HTML解析器的作用是解析HTML,它在解析HTML的過程中會不斷觸發各種鉤子函式。這些鉤子函式包括開始標籤鉤子函式、結束標籤鉤子函式、文字鉤子函式以及註釋鉤子函式。
虛擬碼如下:
parseHTML(template, {
start (tag, attrs, unary) {
// 每當解析到標籤的開始位置時,觸發該函式
},
end () {
// 每當解析到標籤的結束位置時,觸發該函式
},
chars (text) {
// 每當解析到文字時,觸發該函式
},
comment (text) {
// 每當解析到註釋時,觸發該函式
}
})
複製程式碼
你可能不能很清晰地理解,下面我們舉個簡單的例子:
<div><p>我是Berwin</p></div>
複製程式碼
當上面這個模板被HTML解析器解析時,所觸發的鉤子函式依次是:start
、start
、chars
、end
、end
。
也就是說,解析器其實是從前向後解析的。解析到<div>
時,會觸發一個標籤開始的鉤子函式start
;然後解析到<p>
時,又觸發一次鉤子函式start
;接著解析到我是Berwin
這行文字,此時觸發了文字鉤子函式chars
;然後解析到</p>
,觸發了標籤結束的鉤子函式end
;接著繼續解析到</div>
,此時又觸發一次標籤結束的鉤子函式end
,解析結束。
因此,我們可以在鉤子函式中構建AST節點。在start
鉤子函式中構建元素型別的節點,在chars
鉤子函式中構建文字型別的節點,在comment
鉤子函式中構建註釋型別的節點。
當HTML解析器不再觸發鉤子函式時,就代表所有模板都解析完畢,所有型別的節點都在鉤子函式中構建完成,即AST構建完成。
我們發現,鉤子函式start
有三個引數,分別是tag
、attrs
和unary
,它們分別代表標籤名、標籤的屬性以及是否是自閉合標籤。
而文字節點的鉤子函式chars
和註釋節點的鉤子函式comment
都只有一個引數,只有text
。這是因為構建元素節點時需要知道標籤名、屬性和自閉合標識,而構建註釋節點和文字節點時只需要知道文字即可。
什麼是自閉合標籤?舉個簡單的例子,input
標籤就屬於自閉合標籤:<input type="text" />
,而div
標籤就不屬於自閉合標籤:<div></div>
。
在start
鉤子函式中,我們可以使用這三個引數來構建一個元素型別的AST節點,例如:
function createASTElement (tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
parent,
children: []
}
}
parseHTML(template, {
start (tag, attrs, unary) {
let element = createASTElement(tag, attrs, currentParent)
}
})
複製程式碼
在上面的程式碼中,我們在鉤子函式start
中構建了一個元素型別的AST節點。
如果是觸發了文字的鉤子函式,就使用引數中的文字構建一個文字型別的AST節點,例如:
parseHTML(template, {
chars (text) {
let element = {type: 3, text}
}
})
複製程式碼
如果是註釋,就構建一個註釋型別的AST節點,例如:
parseHTML(template, {
comment (text) {
let element = {type: 3, text, isComment: true}
}
})
複製程式碼
你會發現,9.1節中看到的AST是有層級關係的,一個AST節點具有父節點和子節點,但是9.2節中介紹的建立節點的方式,節點是被拉平的,沒有層級關係。因此,我們需要一套邏輯來實現層級關係,讓每一個AST節點都能找到它的父級。下面我們介紹一下如何構建AST層級關係。
構建AST層級關係其實非常簡單,我們只需要維護一個棧(stack)即可,用棧來記錄層級關係,這個層級關係也可以理解為DOM的深度。
HTML解析器在解析HTML時,是從前向後解析。每當遇到開始標籤
,就觸發鉤子函式start
。每當遇到結束標籤
,就會觸發鉤子函式end
。
基於HTML解析器的邏輯,我們可以在每次觸發鉤子函式start
時,把當前構建的節點推入棧中;每當觸發鉤子函式end
時,就從棧中彈出一個節點。
這樣就可以保證每當觸發鉤子函式start
時,棧的最後一個節點就是當前正在構建的節點的父節點,如圖9-1所示。
下面我們用一個具體的例子來描述如何從0到1構建一個帶層級關係的AST。
假設有這樣一個模板:
<div>
<h1>我是Berwin</h1>
<p>我今年23歲</p>
</div>
複製程式碼
上面這個模板被解析成AST的過程如圖9-2所示。
圖9-2構建AST的過程(下面的(1)~(12)需要改成圖中那樣黑底白字的)
圖9-2給出了構建AST的過程,圖中的黑底白數字代表解析的步驟,具體如下。
(1) 模板的開始位置是div
的開始標籤,於是會觸發鉤子函式start
。start
觸發後,會先構建一個div
節點。此時發現棧是空的,這說明div
節點是根節點,因為它沒有父節點。最後,將div
節點推入棧中,並將模板字串中的div
開始標籤從模板中擷取掉。
(2) 這時模板的開始位置是一些空格,這些空格會觸發文字節點的鉤子函式,在鉤子函式裡會忽略這些空格。同時會在模板中將這些空格擷取掉。
(3) 這時模板的開始位置是h1
的開始標籤,於是會觸發鉤子函式start
。與前面流程一樣,start
觸發後,會先構建一個h1
節點。此時發現棧的最後一個節點是div
節點,這說明h1
節點的父節點是div
,於是將h1
新增到div
的子節點中,並且將h1
節點推入棧中,同時從模板中將h1
的開始標籤擷取掉。
(4) 這時模板的開始位置是一段文字,於是會觸發鉤子函式chars
。chars
觸發後,會先構建一個文字節點,此時發現棧中的最後一個節點是h1
,這說明文字節點的父節點是h1
,於是將文字節點新增到h1
節點的子節點中。由於文字節點沒有子節點,所以文字節點不會被推入棧中。最後,將文字從模板中擷取掉。
(5) 這時模板的開始位置是h1
結束標籤,於是會觸發鉤子函式end
。end
觸發後,會把棧中最後一個節點彈出來。
(6) 與第(2)步一樣,這時模板的開始位置是一些空格,這些空格會觸發文字節點的鉤子函式,在鉤子函式裡會忽略這些空格。同時會在模板中將這些空格擷取掉。
(7) 這時模板的開始位置是p
開始標籤,於是會觸發鉤子函式start
。start
觸發後,會先構建一個p
節點。由於第(5)步已經從棧中彈出了一個節點,所以此時棧中的最後一個節點是div
,這說明p
節點的父節點是div
。於是將p
推入div
的子節點中,最後將p
推入到棧中,並將p
的開始標籤從模板中擷取掉。
(8) 這時模板的開始位置又是一段文字,於是會觸發鉤子函式chars
。當chars
觸發後,會先構建一個文字節點,此時發現棧中的最後一個節點是p
節點,這說明文字節點的父節點是p
節點。於是將文字節點推入p
節點的子節點中,並將文字從模板中擷取掉。
(9) 這時模板的開始位置是p
的結束標籤,於是會觸發鉤子函式end
。當end
觸發後,會從棧中彈出一個節點出來,也就是把p
標籤從棧中彈出來,並將p
的結束標籤從模板中擷取掉。
(10) 與第(2)步和第(6)步一樣,這時模板的開始位置是一些空格,這些空格會觸發文字節點的鉤子函式並且在鉤子函式裡會忽略這些空格。同時會在模板中將這些空格擷取掉。
(11) 這時模板的開始位置是div
的結束標籤,於是會觸發鉤子函式end
。其邏輯與之前一樣,把棧中的最後一個節點彈出來,也就是把div
彈了出來,並將div
的結束標籤從模板中擷取掉。
(12)這時模板已經被擷取空了,也就代表著HTML解析器已經執行完畢。這時我們會發現棧已經空了,但是我們得到了一個完整的帶層級關係的AST語法樹。這個AST中清晰寫明瞭每個節點的父節點、子節點及其節點型別。
9.3 HTML解析器
通過前面的介紹,我們發現構建AST非常依賴HTML解析器所執行的鉤子函式以及鉤子函式中所提供的引數,你一定會非常好奇HTML解析器是如何解析模板的,接下來我們會詳細介紹HTML解析器的執行原理。
9.3.1 執行原理
事實上,解析HTML模板的過程就是迴圈的過程,簡單來說就是用HTML模板字串來迴圈,每輪迴圈都從HTML模板中擷取一小段字串,然後重複以上過程,直到HTML模板被截成一個空字串時結束迴圈,解析完畢,如圖9-2所示。
在擷取一小段字串時,有可能擷取到開始標籤,也有可能擷取到結束標籤,又或者是文字或者註釋,我們可以根據擷取的字串的型別來觸發不同的鉤子函式。
迴圈HTML模板的虛擬碼如下:
function parseHTML(html, options) {
while (html) {
// 擷取模板字串並觸發鉤子函式
}
}
複製程式碼
為了方便理解,我們手動模擬HTML解析器的解析過程。例如,下面這樣一個簡單的HTML模板:
<div>
<p>{{name}}</p>
</div>
複製程式碼
它在被HTML解析器解析的過程如下。
最初的HTML模板:
`<div>
<p>{{name}}</p>
</div>`
複製程式碼
第一輪迴圈時,擷取出一段字串<div>
,並且觸發鉤子函式start
,擷取後的結果為:
`
<p>{{name}}</p>
</div>`
複製程式碼
第二輪迴圈時,擷取出一段字串:
`
`
複製程式碼
並且觸發鉤子函式chars
,擷取後的結果為:
`<p>{{name}}</p>
</div>`
複製程式碼
第三輪迴圈時,擷取出一段字串<p>
,並且觸發鉤子函式start
,擷取後的結果為:
`{{name}}</p>
</div>`
複製程式碼
第四輪迴圈時,擷取出一段字串{{name}}
,並且觸發鉤子函式chars
,擷取後的結果為:
`</p>
</div>`
複製程式碼
第五輪迴圈時,擷取出一段字串</p>
,並且觸發鉤子函式end
,擷取後的結果為:
`
</div>`
複製程式碼
第六輪迴圈時,擷取出一段字串:
`
`
複製程式碼
並且觸發鉤子函式chars
,擷取後的結果為:
`</div>`
複製程式碼
第七輪迴圈時,擷取出一段字串</div>
,並且觸發鉤子函式end
,擷取後的結果為:
``
複製程式碼
解析完畢。
HTML解析器的全部邏輯都是在迴圈中執行,迴圈結束就代表解析結束。接下來,我們要討論的重點是HTML解析器在迴圈中都幹了些什麼事。
你會發現HTML解析器可以很聰明地知道它在每一輪迴圈中應該擷取哪些字串,那麼它是如何做到這一點的呢?
通過前面的例子,我們發現一個很有趣的事,那就是每一輪擷取字串時,都是在整個模板的開始位置擷取。我們根據模板開始位置的片段型別,進行不同的擷取操作。
例如,上面例子中的第一輪迴圈:如果是以開始標籤開頭的模板,就把開始標籤擷取掉。
再例如,上面例子中的第四輪迴圈:如果是以文字開始的模板,就把文字擷取掉。
這些被擷取的片段分很多種型別,示例如下。
- 開始標籤,例如
<div>
。 - 結束標籤,例如
</div>
。 - HTML註釋,例如
<!-- 我是註釋 -->
。 - DOCTYPE,例如
<!DOCTYPE html>
。 - 條件註釋,例如
<!--[if !IE]>-->我是註釋<!--<![endif]-->
。 - 文字,例如
我是Berwin
。
通常,最常見的是開始標籤、結束標籤、文字以及註釋。
9.3.2 擷取開始標籤
上一節中我們說過,每一輪迴圈都是從模板的最前面擷取,所以只有模板以開始標籤開頭,才需要進行開始標籤的擷取操作。
那麼,如何確定模板是不是以開始標籤開頭?
在HTML解析器中,想分辨出模板是否以開始標籤開頭並不難,我們需要先判斷HTML模板是不是以<
開頭。
如果HTML模板的第一個字元不是<
,那麼它一定不是以開始標籤開頭的模板,所以不需要進行開始標籤的擷取操作。
如果HTML模板以<
開頭,那麼說明它至少是一個以標籤開頭的模板,但這個標籤到底是什麼型別的標籤,還需要進一步確認。
如果模板以<
開頭,那麼它有可能是以開始標籤開頭的模板,同時它也有可能是以結束標籤開頭的模板,還有可能是註釋等其他標籤,因為這些型別的片段都以<
開頭。那麼,要進一步確定模板是不是以開始標籤開頭,還需要藉助正規表示式來分辨模板的開始位置是否符合開始標籤的特徵。
那麼,如何使用正規表示式來匹配模板以開始標籤開頭?我們看下面的程式碼:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 以開始標籤開始的模板
'<div></div>'.match(startTagOpen) // ["<div", "div", index: 0, input: "<div></div>"]
// 以結束標籤開始的模板
'</div><div>我是Berwin</div>'.match(startTagOpen) // null
// 以文字開始的模板
'我是Berwin</p>'.match(startTagOpen) // null
複製程式碼
通過上面的例子可以看到,只有'<div></div>'
可以成功匹配,而以</div>
開頭的或者以文字開頭的模板都無法成功匹配。
在9.2節中,我們介紹了當HTML解析器解析到標籤開始時,會觸發鉤子函式start
,同時會給出三個引數,分別是標籤名(tagName
)、屬性(attrs
)以及自閉合標識(unary
)。
因此,在分辨出模板以開始標籤開始之後,需要將標籤名、屬性以及自閉合標識解析出來。
在分辨模板是否以開始標籤開始時,就可以得到標籤名,而屬性和自閉合標識則需要進一步解析。
當完成上面的解析後,我們可以得到這樣一個資料結構:
const start = '<div></div>'.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: []
}
}
複製程式碼
這裡有一個細節很重要:在前面的例子中,我們匹配到的開始標籤並不全。例如:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
'<div></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div></div>"]
'<p></p>'.match(startTagOpen)
// ["<p", "p", index: 0, input: "<p></p>"]
'<div class="box"></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div class="box"></div>"]
複製程式碼
可以看出,上面這個正規表示式雖然可以分辨出模板是否以開始標籤開頭,但是它的匹配規則並不是匹配整個開始標籤,而是開始標籤的一小部分。
事實上,開始標籤被拆分成三個小部分,分別是標籤名、屬性和結尾,如圖9-3所示。
圖9-3 開始標籤被拆分成三個小部分(程式碼用程式碼體)通過“標籤名”這一段字元,就可以分辨出模板是否以開始標籤開頭,此後要想得到屬性和自閉合標識,則需要進一步解析。
1. 解析標籤屬性
在分辨模板是否以開始標籤開頭時,會將開始標籤中的標籤名這一小部分擷取掉,因此在解析標籤屬性時,我們得到的模板是下面虛擬碼中的樣子:
' class="box"></div>'
複製程式碼
通常,標籤屬性是可選的,一個標籤的屬性有可能存在,也有可能不存在,所以需要判斷標籤是否存在屬性,如果存在,對它進行擷取。
下面的虛擬碼展示瞭如何解析開始標籤中的屬性,但是它只能解析一個屬性:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box"></div>'
let attr = html.match(attribute)
html = html.substring(attr[0].length)
console.log(attr)
// [' class="box"', 'class', '=', 'box', undefined, undefined, index: 0, input: ' class="box"></div>']
複製程式碼
如果標籤上有很多屬性,那麼上面的處理方式就不足以支撐解析任務的正常執行。例如下面的程式碼:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
let attr = html.match(attribute)
html = html.substring(attr[0].length)
console.log(attr)
// [' class="box"', 'class', '=', 'box', undefined, undefined, index: 0, input: ' class="box" id="el"></div>']
複製程式碼
可以看到,這裡只解析出了class
屬性,而id
屬性沒有解析出來。
此時剩餘的HTML模板是這樣的:
' id="el"></div>'
複製程式碼
所以屬性也可以分成多個小部分,一小部分一小部分去解析與擷取。
解決這個問題時,我們只需要每解析一個屬性就擷取一個屬性。如果擷取完後,剩下的HTML模板依然符合標籤屬性的正規表示式,那麼說明還有剩餘的屬性需要處理,此時就重複執行前面的流程,直到剩餘的模板不存在屬性,也就是剩餘的模板不存在符合正規表示式所預設的規則。
例如:
const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
let end, attr
const match = {tagName: 'div', attrs: []}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
html = html.substring(attr[0].length)
match.attrs.push(attr)
}
複製程式碼
上面這段程式碼的意思是,如果剩餘HTML模板不符合開始標籤結尾部分的特徵,並且符合標籤屬性的特徵,那麼進入到迴圈中進行解析與擷取操作。
通過match
方法解析出的結果為:
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id','=', 'el', null, null]
]
}
複製程式碼
可以看到,標籤中的兩個屬性都已經解析好並且儲存在了attrs
中。
此時剩餘模板是下面的樣子:
"></div>"
複製程式碼
我們將屬性解析後的模板與解析之前的模板進行對比:
// 解析前的模板
' class="box" id="el"></div>'
// 解析後的模板
'></div>'
// 解析前的資料
{
tagName: 'div',
attrs: []
}
// 解析後的資料
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id','=', 'el', null, null]
]
}
複製程式碼
可以看到,標籤上的所有屬性都已經被成功解析出來,並儲存在attrs
屬性中。
2. 解析自閉合標識
如果我們接著上面的例子繼續解析的話,目前剩餘的模板是下面這樣的:
'></div>'
複製程式碼
開始標籤中結尾部分解析的主要目的是解析出當前這個標籤是否是自閉合標籤。
舉個例子:
<div></div>
複製程式碼
這樣的div
標籤就不是自閉合標籤,而下面這樣的input
標籤就屬於自閉合標籤:
<input type="text" />
複製程式碼
自閉合標籤是沒有子節點的,所以前文中我們提到構建AST層級時,需要維護一個棧,而一個節點是否需要推入到棧中,可以使用這個自閉合標識來判斷。
那麼,如何解析開始標籤中的結尾部分呢?看下面這段程式碼:
function parseStartTagEnd (html) {
const startTagClose = /^\s*(\/?)>/
const end = html.match(startTagClose)
const match = {}
if (end) {
match.unarySlash = end[1]
html = html.substring(end[0].length)
return match
}
}
console.log(parseStartTagEnd('></div>')) // {unarySlash: ""}
console.log(parseStartTagEnd('/><div></div>')) // {unarySlash: "/"}
複製程式碼
這段程式碼可以正確解析出開始標籤是否是自閉合標籤。
從程式碼中列印出來的結果可以看到,自閉合標籤解析後的unarySlash
屬性為/
,而非自閉合標籤為空字串。
3. 實現原始碼
前面解析開始標籤時,我們將其拆解成了三個部分,分別是標籤名、屬性和結尾。我相信你已經對開始標籤的解析有了一個清晰的認識,接下來看一下Vue.js中真實的程式碼是什麼樣的:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
function advance (n) {
html = html.substring(n)
}
function parseStartTag () {
// 解析標籤名,判斷模板是否符合開始標籤的特徵
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: []
}
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)
return match
}
}
}
複製程式碼
上面的程式碼是Vue.js中解析開始標籤的原始碼,這段程式碼中的html
變數是HTML模板。
呼叫parseStartTag
就可以將剩餘模板開始部分的開始標籤解析出來。如果剩餘HTML模板的開始部分不符合開始標籤的正規表示式規則,那麼呼叫parseStartTag
就會返回undefined
。因此,判斷剩餘模板是否符合開始標籤的規則,只需要呼叫parseStartTag
即可。如果呼叫它後得到了解析結果,那麼說明剩餘模板的開始部分符合開始標籤的規則,此時將解析出來的結果取出來並呼叫鉤子函式start
即可:
// 開始標籤
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
複製程式碼
前面我們說過,所有解析操作都執行在迴圈中,所以continue
的意思是這一輪的解析工作已經完成,可以進行下一輪解析工作。
從程式碼中可以看出,如果呼叫parseStartTag
之後有返回值,那麼會進行開始標籤的處理,其處理邏輯主要在handleStartTag
中。這個函式的主要目的就是將tagName
、attrs
和unary
等資料取出來,然後呼叫鉤子函式將這些資料放到引數中。
9.3.3 擷取結束標籤
結束標籤的擷取要比開始標籤簡單得多,因為它不需要解析什麼,只需要分辨出當前是否已經擷取到結束標籤,如果是,那麼觸發鉤子函式就可以了。
那麼,如何分辨模板已經擷取到結束標籤了呢?其道理其實和開始標籤的擷取相同。
如果HTML模板的第一個字元不是<
,那麼一定不是結束標籤。只有HTML模板的第一個字元是<
時,我們才需要進一步確認它到底是不是結束標籤。
進一步確認時,我們只需要判斷剩餘HTML模板的開始位置是否符合正規表示式中定義的規則即可:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = '</div>'.match(endTag)
const endTagMatch2 = '<div>'.match(endTag)
console.log(endTagMatch) // ["</div>", "div", index: 0, input: "</div>"]
console.log(endTagMatch2) // null
複製程式碼
上面程式碼可以分辨出剩餘模板是否是結束標籤。當分辨出結束標籤後,需要做兩件事,一件事是擷取模板,另一件事是觸發鉤子函式。而Vue.js中相關原始碼被精簡後如下:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
html = html.substring(endTagMatch[0].length)
options.end(endTagMatch[1])
continue
}
複製程式碼
可以看出,先對模板進行擷取,然後觸發鉤子函式。
9.3.4 擷取註釋
分辨模板是否已經擷取到註釋的原理與開始標籤和結束標籤相同,先判斷剩餘HTML模板的第一個字元是不是<
,如果是,再用正規表示式來進一步匹配:
const comment = /^<!--/
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd))
}
html = html.substring(commentEnd + 3)
continue
}
}
複製程式碼
在上面的程式碼中,我們使用正規表示式來判斷剩餘的模板是否符合註釋的規則,如果符合,就將這段註釋文字擷取出來。
這裡有一個有意思的地方,那就是註釋的鉤子函式可以通過選項來配置,只有options.shouldKeepComment
為真時,才會觸發鉤子函式,否則只擷取模板,不觸發鉤子函式。
9.3.5 擷取條件註釋
條件註釋不需要觸發鉤子函式,我們只需要把它擷取掉就行了。
擷取條件註釋的原理與擷取註釋非常相似,如果模板的第一個字元是<
,並且符合我們事先用正規表示式定義好的規則,就說明需要進行條件註釋的擷取操作。
在下面的程式碼中,我們通過indexOf
找到條件註釋結束位置的下標,然後將結束位置前的字元都擷取掉:
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
continue
}
}
複製程式碼
我們來舉個例子:
const conditionalComment = /^<!\[/
let html = '<![if !IE]><link href="non-ie.css" rel="stylesheet"><![endif]>'
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
html = html.substring(conditionalEnd + 2)
}
}
console.log(html) // '<link href="non-ie.css" rel="stylesheet"><![endif]>'
複製程式碼
從列印結果中可以看到,HTML中的條件註釋部分擷取掉了。
通過這個邏輯可以發現,在Vue.js中條件註釋其實沒有用,寫了也會被擷取掉,通俗一點說就是寫了也白寫。
9.3.6 擷取DOCTYPE
DOCTYPE
與條件註釋相同,都是不需要觸發鉤子函式的,只需要將匹配到的這一段字元擷取掉即可。下面的程式碼將DOCTYPE
這段字元匹配出來後,根據它的length
屬性來決定要擷取多長的字串:
const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
continue
}
複製程式碼
示例如下:
const doctype = /^<!DOCTYPE [^>]+>/i
let html = '<!DOCTYPE html><html lang="en"><head></head><body></body></html>'
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
html = html.substring(doctypeMatch[0].length)
}
console.log(html) // '<html lang="en"><head></head><body></body></html>'
複製程式碼
從列印結果可以看到,HTML中的DOCTYPE
被成功擷取掉了。
9.3.7 擷取文字
若想分辨在本輪迴圈中HTML模板是否已經擷取到文字,其實很簡單,我們甚至不需要使用正規表示式。
在前面的其他標籤型別中,我們都會判斷剩餘HTML模板的第一個字元是否是<
,如果是,再進一步確認到底是哪種型別。這是因為以<
開頭的標籤型別太多了,如開始標籤、結束標籤和註釋等。然而文字只有一種,如果HTML模板的第一個字元不是<
,那麼它一定是文字了。
例如:
我是文字</div>
複製程式碼
上面這段HTML模板並不是以<
開頭的,所以可以斷定它是以文字開頭的。
那麼,如何從模板中將文字解析出來呢?我們只需要找到下一個<
在什麼位置,這之前的所有字元都屬於文字,如圖9-4所示。
在程式碼中可以這樣實現:
while (html) {
let text
let textEnd = html.indexOf('<')
// 擷取文字
if (textEnd >= 0) {
text = html.substring(0, textEnd)
html = html.substring(textEnd)
}
// 如果模板中找不到<,就說明整個模板都是文字
if (textEnd < 0) {
text = html
html = ''
}
// 觸發鉤子函式
if (options.chars && text) {
options.chars(text)
}
}
複製程式碼
上面的程式碼共有三部分邏輯。
第一部分是擷取文字,這在前面介紹過了。<
之前的所有字元都是文字,直接使用html.substring
從模板的最開始位置擷取到<
之前的位置,就可以將文字擷取出來。
第二部分是一個條件:如果在整個模板中都找不到<
,那麼說明整個模板全是文字。
第三部分是觸發鉤子函式並將擷取出來的文字放到引數中。
關於文字,還有一個特殊情況需要處理:如果<
是文字的一部分,該如何處理?
舉個例子:
1<2</div>
複製程式碼
在上面這樣的模板中,如果只擷取第一個<
前面的字元,最後被擷取出來的將只有1,而不能把所有文字都擷取出來。
那麼,該如何解決這個問題呢?
有一個思路是,如果將<
前面的字元擷取完之後,剩餘的模板不符合任何需要被解析的片段的型別,就說明這個<
是文字的一部分。
什麼是需要被解析的片段的型別?在9.3.1節中,我們說過HTML解析器是一段一段擷取模板的,而被擷取的每一段都符合某種型別,這些型別包括開始標籤、結束標籤和註釋等。
說的再具體一點,那就是上面這段程式碼中的1被擷取完之後,剩餘模板是下面的樣子:
<2</div>
複製程式碼
<2
符合開始標籤的特徵麼?不符合。
<2
符合結束標籤的特徵麼?不符合。
<2
符合註釋的特徵麼?不符合。
當剩餘的模板什麼都不符合時,就說明<
屬於文字的一部分。
當判斷出<
是屬於文字的一部分後,我們需要做的事情是找到下一個<
並將其前面的文字擷取出來加到前面擷取了一半的文字後面。
這裡還用上面的例子,第二個<
之前的字元是<2
,那麼把<2
擷取出來後,追加到上一次擷取出來的1
的後面,此時的結果是:
1<2
複製程式碼
擷取後剩餘的模板是:
</div>
複製程式碼
如果剩餘的模板依然不符合任何被解析的型別,那麼重複此過程。直到所有文字都解析完。
說完了思路,我們看一下具體的實現,虛擬碼如下:
while (html) {
let text, rest, next
let textEnd = html.indexOf('<')
// 擷取文字
if (textEnd >= 0) {
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// 如果'<'在純文字中,將它視為純文字對待
next = rest.indexOf('<', 1)
if (next < 0) break
textEnd += next
rest = html.slice(textEnd)
}
text = html.substring(0, textEnd)
html = html.substring(textEnd)
}
// 如果模板中找不到<,那麼說明整個模板都是文字
if (textEnd < 0) {
text = html
html = ''
}
// 觸發鉤子函式
if (options.chars && text) {
options.chars(text)
}
}
複製程式碼
在程式碼中,我們通過while
來解決這個問題(注意是裡面的while
)。如果剩餘的模板不符合任何被解析的型別,那麼重複解析文字,直到剩餘模板符合被解析的型別為止。
在上面的程式碼中,endTag
、startTagOpen
、comment
和conditionalComment
都是正規表示式,分別匹配結束標籤、開始標籤、註釋和條件註釋。
在Vue.js原始碼中,擷取文字的邏輯和其他的實現思路一致。
9.3.8 純文字內容元素的處理
什麼是純文字內容元素呢?script
、style
和textarea
這三種元素叫作純文字內容元素。解析它們的時候,會把這三種標籤內包含的所有內容都當作文字處理。那麼,具體該如何處理呢?
前面介紹開始標籤、結束標籤、文字、註釋的擷取時,其實都是預設當前需要擷取的元素的父級元素不是純文字內容元素。事實上,如果要擷取元素的父級元素是純文字內容元素的話,處理邏輯將完全不一樣。
事實上,在while
迴圈中,最外層的判斷條件就是父級元素是不是純文字內容元素。例如下面的虛擬碼:
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素為正常元素的處理邏輯
} else {
// 父元素為script、style、textarea的處理邏輯
}
}
複製程式碼
在上面的程式碼中,lastTag
代表父元素。可以看到,在while
中,首先進行判斷,如果父元素不存在或者不是純文字內容元素,那麼進行正常的處理邏輯,也就是前面介紹的邏輯。
而當父元素是script
這種純文字內容元素時,會進入到else
這個語句裡面。由於純文字內容元素都被視作文字處理,所以我們的處理邏輯就變得很簡單,只需要把這些文字擷取出來並觸發鉤子函式chars
,然後再將結束標籤擷取出來並觸發鉤子函式end
。
也就是說,如果父標籤是純文字內容元素,那麼本輪迴圈會一次性將這個父標籤給處理完畢。
虛擬碼如下:
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素為正常元素的處理邏輯
} else {
// 父元素為script、style、textarea的處理邏輯
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
const rest = html.replace(reStackedTag, function (all, text) {
if (options.chars) {
options.chars(text)
}
return ''
})
html = rest
options.end(stackedTag)
}
}
複製程式碼
上面程式碼中的正規表示式可以匹配結束標籤前包括結束標籤自身在內的所有文字。
我們可以給replace
方法的第二個引數傳遞一個函式。在這個函式中,我們得到了引數text
(代表結束標籤前的所有內容),觸發了鉤子函式chars
並把text
放到鉤子函式的引數中傳出去。最後,返回了一個空字串,代表將匹配到的內容都截掉了。注意,這裡的截掉會將內容和結束標籤一起擷取掉。
最後,呼叫鉤子函式end
並將標籤名放到引數中傳出去,代表本輪迴圈中的所有邏輯都已處理完畢。
假如我們現在有這樣一個模板:
<div id="el">
<script>console.log(1)</script>
</div>
複製程式碼
當解析到script
中的內容時,模板是下面的樣子:
console.log(1)</script>
</div>
複製程式碼
此時父元素為script
,所以會進入到else
中的邏輯進行處理。在其處理過程中,會觸發鉤子函式chars
和end
。
鉤子函式chars
的引數為script
中的所有內容,本例中大概是下面的樣子:
chars('console.log(1)')
複製程式碼
鉤子函式end
的引數為標籤名,本例中是script
。
處理後的剩餘模板如下:
</div>
複製程式碼
9.3.9 使用棧維護DOM層級
通過前面幾節的介紹,特別是9.3.8節中的介紹,你一定會感到很奇怪,如何知道父元素是誰?
在前面幾節中,我們並沒有介紹HTML解析器內部其實也有一個棧來維護DOM層級關係,其邏輯與9.2.1節相同:就是每解析到開始標籤,就向棧中推進去一個;每解析到標籤結束,就彈出來一個。因此,想取到父元素並不難,只需要拿到棧中的最後一項即可。
同時,HTML解析器中的棧還有另一個作用,它可以檢測出HTML標籤是否正確閉合。例如:
<div><p></div>
複製程式碼
在上面的程式碼中,p
標籤忘記寫結束標籤,那麼當HTML解析器解析到div
的結束標籤時,棧頂的元素卻是p
標籤。這個時候從棧頂向棧底迴圈找到div
標籤,在找到div
標籤之前遇到的所有其他標籤都是忘記了閉合的標籤,而Vue.js會在非生產環境下在控制檯列印警告提示。
關於使用棧來維護DOM層級關係的具體實現思路,9.2.1節已經詳細介紹過,這裡不再重複介紹。
9.3.10 整體邏輯
前面我們把開始標籤、結束標籤、註釋、文字、純文字內容元素等的擷取方式拆分開,單獨進行了詳細介紹。本節中,我們就來介紹如何將這些解析方式組裝起來完成HTML解析器的功能。
首先,HTML解析器是一個函式。就像9.2節介紹的那樣,HTML解析器最終的目的是實現這樣的功能:
parseHTML(template, {
start (tag, attrs, unary) {
// 每當解析到標籤的開始位置時,觸發該函式
},
end () {
// 每當解析到標籤的結束位置時,觸發該函式
},
chars (text) {
// 每當解析到文字時,觸發該函式
},
comment (text) {
// 每當解析到註釋時,觸發該函式
}
})
複製程式碼
所以HTML解析器在實現上肯定是一個函式,它有兩個引數——模板和選項:
export function parseHTML (html, options) {
// 做點什麼
}
複製程式碼
我們的模板是一小段一小段去擷取與解析的,所以需要一個迴圈來不斷擷取,直到全部擷取完畢:
export function parseHTML (html, options) {
while (html) {
// 做點什麼
}
}
複製程式碼
在迴圈中,首先要判斷父元素是不是純文字內容元素,因為不同型別父節點的解析方式將完全不同:
export function parseHTML (html, options) {
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素為正常元素的處理邏輯
} else {
// 父元素為script、style、textarea的處理邏輯
}
}
}
複製程式碼
在上面的程式碼中,我們發現這裡已經把整體邏輯分成了兩部分,一部分是父標籤是正常標籤的邏輯,另一部分是父標籤是script
、style
、textarea
這種純文字內容元素的邏輯。
如果父標籤為正常的元素,那麼有幾種情況需要分別處理,比如需要分辨出當前要解析的一小段模板到底是什麼型別。是開始標籤?還是結束標籤?又或者是文字?
我們把所有需要處理的情況都列出來,有下面幾種情況:
- 文字
- 註釋
- 條件註釋
DOCTYPE
- 結束標籤
- 開始標籤
我們會發現,在這些需要處理的型別中,除了文字之外,其他都是以標籤形式存在的,而標籤是以<
開頭的。
所以邏輯就很清晰了,我們先根據<
來判斷需要解析的字元是文字還是其他的:
export function parseHTML (html, options) {
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 做點什麼
}
let text, rest, next
if (textEnd >= 0) {
// 解析文字
}
if (textEnd < 0) {
text = html
html = ''
}
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素為script、style、textarea的處理邏輯
}
}
}
複製程式碼
在上面的程式碼中,我們可以通過<
來分辨是否需要進行文字解析。關於文字解析的內容,詳見9.3.7節。
如果通過<
分辨出即將解析的這一小部分字元不是文字而是標籤類,那麼標籤類有那麼多型別,我們需要進一步分辨具體是哪種型別:
export function parseHTML (html, options) {
while (html) {
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 註釋
if (comment.test(html)) {
// 註釋的處理邏輯
continue
}
// 條件註釋
if (conditionalComment.test(html)) {
// 條件註釋的處理邏輯
continue
}
// DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
// DOCTYPE的處理邏輯
continue
}
// 結束標籤
const endTagMatch = html.match(endTag)
if (endTagMatch) {
// 結束標籤的處理邏輯
continue
}
// 開始標籤
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 開始標籤的處理邏輯
continue
}
}
let text, rest, next
if (textEnd >= 0) {
// 解析文字
}
if (textEnd < 0) {
text = html
html = ''
}
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素為script、style、textarea的處理邏輯
}
}
}
複製程式碼
關於不同型別的具體處理方式,前面已經詳細介紹過,這裡不再重複。
9.4 文字解析器
文字解析器的作用是解析文字。你可能會覺得很奇怪,文字不是在HTML解析器中被解析出來了麼?準確地說,文字解析器是對HTML解析器解析出來的文字進行二次加工。為什麼要進行二次加工?
文字其實分兩種型別,一種是純文字,另一種是帶變數的文字。例如下面這樣的文字是純文字:
Hello Berwin
複製程式碼
而下面這樣的是帶變數的文字:
Hello {{name}}
複製程式碼
在Vue.js模板中,我們可以使用變數來填充模板。而HTML解析器在解析文字時,並不會區分文字是否是帶變數的文字。如果是純文字,不需要進行任何處理;但如果是帶變數的文字,那麼需要使用文字解析器進一步解析。因為帶變數的文字在使用虛擬DOM進行渲染時,需要將變數替換成變數中的值。
我們在9.2節中介紹過,每當HTML解析器解析到文字時,都會觸發chars
函式,並且從引數中得到解析出的文字。在chars
函式中,我們需要構建文字型別的AST,並將它新增到父節點的children
屬性中。
而在構建文字型別的AST時,純文字和帶變數的文字是不同的處理方式。如果是帶變數的文字,我們需要藉助文字解析器對它進行二次加工,其程式碼如下:
parseHTML(template, {
start (tag, attrs, unary) {
// 每當解析到標籤的開始位置時,觸發該函式
},
end () {
// 每當解析到標籤的結束位置時,觸發該函式
},
chars (text) {
text = text.trim()
if (text) {
const children = currentParent.children
let expression
if (expression = parseText(text)) {
children.push({
type: 2,
expression,
text
})
} else {
children.push({
type: 3,
text
})
}
}
},
comment (text) {
// 每當解析到註釋時,觸發該函式
}
})
複製程式碼
在chars
函式中,如果執行parseText
後有返回結果,則說明文字是帶變數的文字,並且已經通過文字解析器(parseText
)二次加工,此時構建一個帶變數的文字型別的AST並將其新增到父節點的children
屬性中。否則,就直接構建一個普通的文字節點並將其新增到父節點的children
屬性中。而程式碼中的currentParent
是當前節點的父節點,也就是前面介紹的棧中的最後一個節點。
假設chars
函式被觸發後,我們得到的text
是一個帶變數的文字:
"Hello {{name}}"
複製程式碼
這個帶變數的文字被文字解析器解析之後,得到的expression
變數是這樣的:
"Hello "+_s(name)
複製程式碼
上面程式碼中的_s
其實是下面這個toString
函式的別名:
function toString (val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}
複製程式碼
假設當前上下文中有一個變數name
,其值為Berwin
,那麼expression
中的內容被執行時,它的內容是不是就是Hello Berwin
了?
我們舉個例子:
var obj = {name: 'Berwin'}
with(obj) {
function toString (val) {
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val, null, 2)
: String(val)
}
console.log("Hello "+toString(name)) // "Hello Berwin"
}
複製程式碼
在上面的程式碼中,我們列印出來的結果是"Hello Berwin"
。
事實上,最終AST會轉換成程式碼字串放在with
中執行,這部分內容會在第11章中詳細介紹。
接著,我們詳細介紹如何加工文字,也就是文字解析器的內部實現原理。
在文字解析器中,第一步要做的事情就是使用正規表示式來判斷文字是否是帶變數的文字,也就是檢查文字中是否包含{{xxx}}
這樣的語法。如果是純文字,則直接返回undefined
;如果是帶變數的文字,再進行二次加工。所以我們的程式碼是這樣的:
function parseText (text) {
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
if (!tagRE(text)) {
return
}
}
複製程式碼
在上面的程式碼中,如果是純文字,則直接返回。如果是帶變數的文字,該如何處理呢?
一個解決思路是使用正規表示式匹配出文字中的變數,先把變數左邊的文字新增到陣列中,然後把變數改成_s(x)
這樣的形式也新增到陣列中。如果變數後面還有變數,則重複以上動作,直到所有變數都新增到陣列中。如果最後一個變數的後面有文字,就將它新增到陣列中。
這時我們其實已經有一個陣列,陣列元素的順序和文字的順序是一致的,此時將這些陣列元素用+
連起來變成字串,就可以得到最終想要的效果,如圖9-5所示。
在圖9-5中,最上面的字串代表即將解析的文字,中間兩個方塊代表陣列中的兩個元素。最後,使用陣列方法join
將這兩個元素合併成一個字串。
具體實現程式碼如下:
function parseText (text) {
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
if (!tagRE.test(text)) {
return
}
const tokens = []
let lastIndex = tagRE.lastIndex = 0
let match, index
while ((match = tagRE.exec(text))) {
index = match.index
// 先把 {{ 前邊的文字新增到tokens中
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 把變數改成`_s(x)`這樣的形式也新增到陣列中
tokens.push(`_s(${match[1].trim()})`)
// 設定lastIndex來保證下一輪迴圈時,正規表示式不再重複匹配已經解析過的文字
lastIndex = index + match[0].length
}
// 當所有變數都處理完畢後,如果最後一個變數右邊還有文字,就將文字新增到陣列中
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return tokens.join('+')
}
複製程式碼
這是文字解析器的全部程式碼,程式碼並不多,邏輯也不是很複雜。
這段程式碼有一個很關鍵的地方在lastIndex
:每處理完一個變數後,會重新設定lastIndex
的位置,這樣可以保證如果後面還有其他變數,那麼在下一輪迴圈時可以從lastIndex
的位置開始向後匹配,而lastIndex
之前的文字將不再被匹配。
下面用文字解析器解析不同的文字看看:
parseText('你好{{name}}')
// '"你好 "+_s(name)'
parseText('你好Berwin')
// undefined
parseText('你好{{name}}, 你今年已經{{age}}歲啦')
// '"你好"+_s(name)+", 你今年已經"+_s(age)+"歲啦"'
複製程式碼
從上面程式碼的列印結果可以看到,文字已經被正確解析了。
9.5 總結
解析器的作用是通過模板得到AST(抽象語法樹)。
生成AST的過程需要藉助HTML解析器,當HTML解析器觸發不同的鉤子函式時,我們可以構建出不同的節點。
隨後,我們可以通過棧來得到當前正在構建的節點的父節點,然後將構建出的節點新增到父節點的下面。
最終,當HTML解析器執行完畢後,我們就可以得到一個完整的帶DOM層級關係的AST。
HTML解析器的內部原理是一小段一小段地擷取模板字串,每擷取一小段字串,就會根據擷取出來的字串型別觸發不同的鉤子函式,直到模板字串截空停止執行。
文字分兩種型別,不帶變數的純文字和帶變數的文字,後者需要使用文字解析器進行二次加工。
更多精彩內容可以觀看《深入淺出Vue.js》
關於《深入淺出Vue.js》
本書使用最最容易理解的文筆來描述Vue.js的內部原理,對於想學習Vue.js原理的小夥伴是非常值得入手的一本書。
掃碼京東購買