早期數據渲染的幾種方式
在模板引擎沒有誕生之前,為了用JS
把數據渲染到頁面上,誕生了一系列數據渲染的方式。
最最基礎的,莫過於直接使用DOM接口創建所有節點。
<div id="root"></div>
<script>
var root = document.getElementById('root');
var title = document.createElement('h1');
var titleText = document.createTextNode('Hello World!');
title.appendChild(titleText);
root.appendChild(title);
</script>
這種方式需要手動創建所有節點,再依次添加到父元素中,手續繁瑣,基本不具有實際意義。
當然,也可以采用innerHTML
的方式添加,上樹:
var root = document.getElementById('root');
root.innerHTML = '<h1>Hello World!</h1>';
對於數據簡單,嵌套層級較少的html
代碼塊來說,這種方式無疑方便了許多,但是,若代碼嵌套層級太多,會對代碼可讀性造成極大影響,因為''
或者""
都是不能換行的(ES6
才有反引號可以換行),在一行代碼里進行標簽多層嵌套(想想現在看被轉譯壓縮后的代碼),這對編寫和維護都會造成極大的困難。
直到有一個天才般的想法橫空出世。
var root = document.getElementById('root');
var person = {
name: 'Wango',
age: 24,
gender: '男'
}
root.innerHTML = [
'<ul>',
' <li>姓名: ' + person.name + '</li>',
' <li>年齡: ' + person.age + '</li>',
' <li>性別: ' + person.gender + '</li>',
'</ul>',
].join('');
這個方法將不可換行的多行字符串轉換為數組的多個元素,再利用數組的join
方法拼接字符串。使得代碼的可讀性大大提升。
當然,在ES6
的模板字符串出來之后,這種hack
技巧也失去了用武之地。
root.innerHTML = `
<ul>
<li>姓名: ${person.name}</li>
<li>年齡: ${person.age}</li>
<li>性別: ${person.gender}</li>
</ul>
`;
但是同樣的,數據通常不是簡單的對象,當數據更加復雜,數組的嵌套層次更深的時候,即便是模板字符串也是力不從心。
於是,mustache庫誕生了!
實現mustache
接觸過Java
的JSP
或者Python
的DTL(The Django template language)
等模板引擎的同學對{{}}
語法一定不會陌生,模板引擎從后端引入前端后得到了更廣泛的支持,而如今,已經快成為前端框架的標配了。
更多關於mustache的信息可以查看GitHub
倉庫:
janl/mustache.js
這個mustache.js
庫暴露的對象只有一個render方法,接收模板和數據。
<script type="text/template" id="tplt">
<ul>
<li>{{name}}</li>
<li>{{age}}</li>
<li>{{gender}}</li>
</ul>
</script>
<script>
var person = {
name: 'Wango',
age: 24,
gender: '男'
}
var root = document.getElementById('root');
root.innerHTML = Mustache.render(
document.getElementById('tplt').innerHTML,
person
)
</script>
朴素的實現
沒有編譯思想的同學可能想到的第一種實現方式就是使用正則表達式配合replace
方法來進行替換,而對於上面一個例子使用正則確實也是可以實現的。
var root = document.getElementById('root');
function render(tplt, data) {
// 捕獲變量並使用數據進行替換
return tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
return data[$1];
});
}
root.innerHTML = render(
document.getElementById('tplt').innerHTML,
person
)
對於簡單的,單層無嵌套的結構來說,確實可以使用正則進行替換,但mustache
還可以支持數組的遍歷,多重嵌套遍歷,對象屬性的打點調用等,對於這些正則就捉襟見肘了。
編譯思想的應用
在數據注入之前,我們需要在模板字符串編譯為tokens
數組,再將數據注入,將tokens
拼接為最終的字符串,然后返回數據,這樣做的好處是可以更方便地處理遍歷和嵌套的問題。
於是,我們的模板引擎的render
方法如下:
render(tplt, data) {
// 轉換為tokens
const tokens = tokenizer(tplt);
// 注入數據,讓tokens轉換為DOM字符串
const html = tokens2dom(tokens, data);
// 返回數據
return html;
}
那么,tokenizer
和tokens2dom
又該如何實現呢?
首先來看tokenizer
:
這個函數的作用是將模板字符串轉換為tokens
數組,那么什么是token
?簡單來說,token
指的是由類型、數據、嵌套結構等組成的數組,所有tokens
就是一個二維數組,在本例中表現為
var tplt = `
<div>
<ol>
{{#students}}
<li>
學生{{name}}的愛好是
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/students}}
</ol>
</div>
`;
轉換為:
[
["text", "<div><ol>"],
["#", "students", [
["text", "<li>學生"],
["name", "name"],
["text", "的愛好是<ol>"],
["#", "hobbies", [
["text", "<li>"],
["name", "."],
["text", "</li>"]
]],
["text", "</ol></li>"]
]],
["text", "</ol></div>"]
]
由上例可以看出,token
有代表文本的text
類型,代表循環的#
類型,代表變量的name
類型,此為token
的第一個元素,token
的第二個元素為這個類型的值,如果有第三個元素,那么第三個元素為嵌套的結構,當然,在janl/mustache.js中還有更多的類型,這里只是簡單的列舉幾項。
Scanner對象
要從模板字符串轉換為tokens
,第一步我們應該想得到的應該是遍歷真個模板字符串,找到其中的本文,變量和嵌套結構等類型,於是可以創建一個Scanner
對象,專門負責遍歷模板字符串和返回找到的文本。
同時,token
中是不包含{{}}
的,所有還需要定義一個方法跳過這兩個字符串。
class Scanner {
constructor(tplt) {
this.tplt = tplt;
// 指針
this.pos = 0;
// 尾巴 剩余字符
this.tail = tplt;
}
/**
* 路過指定內容
*
* @memberof Scanner
*/
scan(tag) {
if (this.tail.indexOf(tag) === 0) {
// 直接跳過指定內容的長度
this.pos += tag.length;
// 更新tail
this.tail = this.tplt.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.tplt.substring(++this.pos);
}
// 返回經過的文本數據
return this.tplt.substring(startPos, this.pos).trim();
}
/**
* 判斷指針是否到達文本末尾(end of string)
*
* @memberof Scanner
*/
eos() {
return this.pos >= this.tplt.length;
}
}
掃描到了相關內容,我們就可以將數據收集起來,並轉換為不含嵌套結構的token
,於是定義一個collectTokens
函數:
function collectTokens(scanner) {
const tokens = [];
let word = '';
// 當scanner沒有到頭的就持續將獲取的token加入數組中
while (!scanner.eos()) {
// 收集文本
word = scanner.scanUntil('{{');
word && tokens.push(['text', word]);
scanner.scan('{{');
// 收集變量
word = scanner.scanUntil('}}');
// 對不同類型結構進行分類標識
switch (word[0]) {
case '#':
tokens.push(['#', word.substring(1)]);
break;
case '/':
tokens.push(['/', word.substring(1)]);
break;
default:
word && tokens.push(['name', word]);
}
scanner.scan('}}');
}
return tokens;
}
這時,我們得到了一個這樣的數組:
[
["text", "<div>↵ <ol>"],
["#", "students"],
["text", "<li>↵ 學生"],
["name", "name"],
["text", "的愛好是↵ <ol>"],
["#", "hobbies"],
["text", "<li>"],
["name", "."],
["text", "</li>"],
["/", "hobbies"],
["text", "</ol>↵ </li>"],
["/", "students"],
["text", "</ol>↵ </div>"]
]
可以看到,除了嵌套結構外,tokens
的基本特征已經具備了。那么嵌套結構該如何加入呢?我們可以分析出:
["#", "students"],
["#", "hobbies"],
["/", "hobbies"],
["/", "students"],
可知#
是嵌套結構的開始,/
是嵌套結構的結束,同時,先出現的students
反而后結束,而后出現的hobbies
反而先結束。對數據結構有一些研究的同學應該立即就能想到一種數據結構: 棧
---- 一種先進后出的結構。而在JS
中,可以用數組push
和pop
方法模擬棧結構。只要遇見#
我們就壓棧,記錄當前是哪個層級,遇見/
就出棧,退出當前層級,直到退到最外層。
於是,我們有了一個新的函數nestTokens
:
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的指向
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(tplt) {
const scanner = new Scanner(tplt.trim());
// 收集tokens,並將循環內容嵌套到tokens中,並返回
return nestTokens(collectTokens(scanner));
}
到這里,模板引擎已經完成了一大半,剩下的就是將數據注入和返回最終的字符串了。也就是tokens2dom
函數。
不過在此之前,我們還要再解決一個問題,還記得我們在使用正則替換時是怎么注入數據的嗎?
tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
return data[$1];
});
回顧一下,我們是通過data[$1]
來獲取對象數據的,可是,如果我的模板里寫的是類似{{a.b.c}}
這樣的打點調用該怎么辦?JS
可不支持obj[a.b.c]
這樣的寫法,而janl/mustache.js中是支持變量打點調用的。所以,在數據注入前,我們還需要一個函數來解決這個問題。
於是:
/**
* 在對象obj中用連續的打點字符串尋找到對象值
*
* @example lookup({a: {b: {c: 100}}}, 'a.b.c')
*
* @param {object} obj
* @param {string} key
* @return any
*/
function lookup(obj, key) {
const keys = key.split('.');
// 設置臨時變量,一層一層查找
let val = obj;
for (const k of keys) {
if(val === undefined) {
console.warn(`Can't read ${k} of undefined`);
return '';
};
val = val[k];
}
return val;
}
解決了打點調用的問題,就可以開始數據注入了,我們要對不同類型的數據進行不同的操作,文本就直接拼接,變量就查找數據,循環的就遍歷,嵌套的就遞歸。
於是:
function tokens2dom(tokens, data) {
let html = '';
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i];
// 按類型拼接字符串
switch (token[0]) {
case 'name':
if (token[1] === '.') {
html += data;
} else {
html += lookup(data, token[1]);
}
break;
case '#':
// 遞歸解決數組嵌套的情況
for (const item of data[token[1]]) {
html += tokens2dom(token[2], item);
}
break;
default:
html += token[1];
}
}
return html;
}
到這里我們的模板引擎就全部結束啦,再向全局暴露一個對象方便調用:
// 暴露全局變量
window.TemplateEngine = {
render(tplt, data) {
// 轉換為tokens
const tokens = tokenizer(tplt);
// 注入數據,讓tokens轉換為DOM字符串
const html = tokens2dom(tokens, data);
return html;
}
}
這里只是對mustache的一個簡單實現,還有類似於條件渲染等功能沒有實現,同學們有興趣的可以看看源碼
janl/mustache.js
當然可以看本文實現的代碼mini-tplt