本文所有程式碼均儲存在HouyunCheng / mini-2vdom
虛擬DOM
只是實現MVVM
的一種方案,或者說是檢視更新的一種策略,是實現最小化更新的diff
演算法的操作物件。
建立掃描器
所有編譯行為的第一步都是遍歷整個字串,於是我們建立Scanner
類,專門用於掃描整個字串。
class Scanner {
constructor(text) {
this.text = text;
// 指標
this.pos = 0;
// 尾巴 剩餘字元
this.tail = text;
}
/**
* 路過指定內容
*
* @memberof Scanner
*/
scan(tag) {
if (this.tail.indexOf(tag) === 0) {
// 直接跳過指定內容的長度
this.pos += tag.length;
// 更新tail
this.tail = this.text.substring(this.pos);
}
}
/**
* 讓指標進行掃描,直到遇見指定內容,返回路過的文字
*
* @memberof Scanner
* @return str 收集到的字串
*/
scanUntil(stopTag) {
// 記錄開始掃描時的初始值
const startPos = this.pos;
// 當尾巴的開頭不是stopTg的時候,說明還沒有掃描到stopTag
while (!this.eos() && this.tail.indexOf(stopTag) !== 0 ) {
// 改變尾巴為當前指標這個字元到最後的所有字元
this.tail = this.text.substring(++this.pos);
}
// 返回經過的文字資料
return this.text.substring(startPos, this.pos).trim();
}
/**
* 判斷指標是否到達文字末尾(end of string)
*
* @memberof Scanner
*/
eos() {
return this.pos >= this.text.length;
}
}
而scanUntil
方法用於掃描字串,並將掃描過的內容返回,用於收集為token
。整個掃描會分段進行,直到字串的結尾。
轉換為沒有巢狀結構的tokens
先看程式碼,我們先例項化Scanner
用於掃描整個傳入字串,同時初始化一個tokens
陣列用於儲存token
和一個word
用於儲存sanner
收集到的字串。
整個轉化行為會持續到字串的末尾,而scan
和scanUntil
交替進行,不斷獲取<
和>
之間的內容(即標籤和屬性)或者>
和<
之間的內容(即標籤內的內容,包括文字和子標籤)。
為了區分開始標籤和結束標籤,我們在生成的token
陣列中的第一項新增#
或/
作為開始或結束的標記,第二項為標籤名,第三項,我們放入開始標籤中收集到的屬性,而不是將屬性單獨放在一個token
中,這樣做是為了簡化後邊將tokens
轉化為巢狀結構的操作。
於是,我們得到了由形如[型別標記, 標籤名, 資料, 文字]
組成的二維陣列。
這裡對是一個標籤否有屬性這一點使用了非常簡單粗暴的實現,即看<
和>
中收集到的字串中是否有空格,有空格則判斷為有屬性,沒空格則判斷為沒有屬性。
在收集標籤屬性的時候,順便使用propsParser
對標籤屬性進行了簡單解析。
/**
* 將html字串轉為無巢狀結構的token,返回tokens陣列
*
* @param {string} html
* @return {array}
*/
function collectTokens(html) {
const scanner = new Scanner(html);
const tokens = [];
let word = '';
while (!scanner.eos()) {
// 掃描文字
const text = scanner.scanUntil('<');
scanner.scan('<');
tokens[tokens.length - 1] && tokens[tokens.length - 1].push(text);
// 掃描標籤<>中的內容
word = scanner.scanUntil('>');
scanner.scan('>');
// 如果沒有掃描到值,就跳過本次進行下一次掃描
if (!word) continue;
// 區分開始標籤 # 和結束標籤 /
if (word.startsWith('/')) {
tokens.push(['/', word.slice(1)]);
} else {
// 如果有屬性存在,則解析屬性
const firstSpaceIdx = word.indexOf(' ');
if (firstSpaceIdx === -1) {
tokens.push(['#', word, {}]);
} else {
// 解析屬性
const data = propsParser(word.slice(firstSpaceIdx))
tokens.push(['#', word.slice(0, firstSpaceIdx), data]);
}
}
}
return tokens;
}
使用propsParser簡單解析標籤屬性
在propsParser
中,我們同樣使用Scanner
進行掃描,用=
進行分割,分別得到key
和value
。
由於某些屬性是單屬性的,比如字串<button loading disabled class="btn">
中的loading
,以=
分割的話會得到loading disabled class
作為key
,這顯然是錯誤的。於是我們同樣使用簡單粗暴的方式,用是否有空格來判斷是否有單屬性,同時將單屬性的值設定為true
。
由於這裡直接使用了"
和="
進行掃描,所以當前的程式不支援單引號,同時=
和"
之間不能有空格。
同時,這裡只是對標籤屬性進行了簡單的拆分,並沒有對class
和style
內的屬性進行拆分。那是之後的步驟。當然,也可以放在這裡進行。
function propsParser(propsStr) {
propsStr = propsStr.trim();
const scanner = new Scanner(propsStr);
const props = {};
while(!scanner.eos()) {
let key = scanner.scanUntil('=');
// 對單屬性的處理
const spaceIdx = key.indexOf(' ');
if (spaceIdx !== -1) {
const keys = key.replace(/\s+/g, ' ').split(' ');
const len = keys.length;
for (let i = 0; i < len - 1; i++) {
props[keys[i]] = true;
}
key = keys[len - 1].trim();
}
scanner.scan('="');
const val = scanner.scanUntil('"');
props[key] = val || true;
scanner.scan('"');
}
return props;
}
生成有巢狀結構的tokens
在之前生成的tokens
是沒有巢狀結構的,是一個簡單的二維陣列。在這裡,我們要將其轉換有巢狀結構的tokens
。
對於巢狀結構,通常使用棧
來生成,遇到開始標籤(這裡為#
)則壓棧,遇到結束標籤(這裡為/
)則出棧。
在這裡,我們使用stack
來儲存棧狀態,用collector
來收集巢狀的內容,在壓棧和出棧的同時也修改collector
的指向,以保證巢狀層次的準確性。
同時,我們將巢狀結構放在token
的第三個元素的位置。得到形如[型別標記, 標籤名, 子節點, 資料, 文字]
的tokens
。
function nestTokens(tokens) {
const nestedTokens = [];
const stack = [];
let collector = nestedTokens;
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i];
switch (token[0]) {
case '#':
// 收集當前token
collector.push(token);
// 壓入棧中
stack.push(token);
// 由於進入了新的巢狀結構,新建一個陣列儲存巢狀結構
// 並修改collector的指向
token.splice(2, 0, []);
collector = token[2];
break;
case '/':
// 出棧
stack.pop();
// 將收集器指向上一層作用域中用於存放巢狀結構的陣列
collector = stack.length > 0
? stack[stack.length - 1][2]
: nestedTokens;
break;
default:
collector.push(token);
}
}
return nestedTokens;
}
整合tokenizer函式
有了以上兩個函式函式之後,我們可以將其整合為一個函式,方便之後呼叫。
function tokenizer(html) {
return nestTokens(collectTokens(html));
}
將tokens轉換為虛擬DOM
這一步相對來說就簡單很多,只需要安裝tokens
的結構把相應的資料取出即可。
同時,在這裡我們對class
和style
屬性進行解析,將形如{class: "item active"}
的class
屬性轉換為
{
class: {
item: true,
active: true
}
}
的形式。
將形如{style: "border: 1px solid red; height: 300px"}
轉換為
{
style: {
border: "border: 1px solid red",
height: "300px"
}
}
的形式。
同時將在data
中的屬性key
提取出來。由於當前的虛擬DOM還沒有上樹,所有elm
屬性為undefined
。對於子節點,我們使用遞迴將子節點追加到children
陣列中。
於是最終我們得到形如
{
sel: "div",
children: [{
sel: "p",
data: {},
elm: undefined,
text: "文字",
key: "1",
}
}],
data: {class: {container: true}, id: "main"},
elm: undefined,
text: undefined,
key: undefined,
}
的虛擬DOM結構。
以下是tokens2vdom
的程式碼實現。
function tokens2vdom(tokens) {
const vdom = {};
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i];
vdom['sel'] = token[1];
vdom['data'] = token[3];
// 解析類名
if (vdom['data']['class']) {
vdom['data']['class'] = classParser(vdom['data']['class']);
}
// 解析行類樣式
if (vdom['data']['style']) {
vdom['data']['style'] = styleParser(vdom['data']['style']);
}
// 新增key
if (vdom['data']['key']) {
vdom['key'] = vdom['data']['key'];
delete vdom['data']['key'];
} else {
vdom['key'] = undefined;
}
if (token[4]) {
vdom['text'] = token[token.length - 1];
} else {
vdom['text'] = undefined;
}
vdom['elm'] = undefined;
const children = token[2];
if (children.length === 0) {
vdom['children'] = undefined;
continue;
};
vdom['children'] = [];
for (let j = 0; j < children.length; j++) {
vdom['children'].push(tokens2vdom([children[j]]));
}
if (vdom['children'].length === 0) {
delete vdom['children'];
}
}
return vdom;
}
整合toVDOM函式
到這裡我們的需求就基本實現了,我們將之前的函式整合為一個函式即可。
function toVDOM (html) {
const tokens = tokenizer(html);
const vdom = tokens2vdom(tokens);
return vdom;
}
虛擬DOM的結構參照 snabbdom/snabbdom
本文完整的程式碼實現可以檢視 HouyunCheng / mini-2vdom