將HTML字元串編譯為虛擬DOM對象的基礎實現
- 2021 年 5 月 8 日
- 筆記
- javascript
本文所有程式碼均保存在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

