用過vue的都知道在模板中我們可以使用{{xx}}來渲染data中的屬性,這個語法叫做Mustache插值表達式,用法簡單,但心中也有一個疑問,它是如何做到的呢?接下來就讓我們一探究竟吧!
1、使用正則來實現
比如説有這樣一個模板字符
let tempStr2 = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';
現在需要將字符串裏面{{xxx}}替換成數據,那麼可以使用正則來實現
let tempStr2 = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';
let data = {
develpoer: 'web前端程序猿',
knowledge: 'Mustache插值語法'
};
let resultStr = tempStr2.replace(/{{(\w+)}}/g, function (matched, $1){
// {{develpoer}} develpoer
// {{knowledge}} knowledge
console.log(matched, $1);
return data[$1];
});
// 結果: 我是一名web前端程序猿,我在學習Mustache插值語法知識!
console.log('結果:', resultStr);
使用正則的弊端就是隻能實現簡單的插值語法,稍微複雜點的如循環、if判斷等功能就實現不了了。
2、Mustache的底層思想:tokens思想
let tempStr = `
<ul>
{{#students}}
<li>
<dl>
<dt>{{name}}</dt>
{{#hobbys}}
<dd>{{.}}</dd>
{{/hobbys}}
</dl>
</li>
{{/students}}
</ul>
`;
遇到這樣的一個模板字符串,按照我們以往的編程思維,大多數人想的肯定是怎麼拿到{{#students}}與{{/students}}中間的內容,用正則是不可能實現的了,對着這串字符串發呆苦想半天還是沒有結果。
那假如我們將這個字符串裏的內容進行分類呢?比如{{xxx}}分為一類,除去{{xxx}}外的普通字符串分為一類,並將他們存儲到數組中,比如:
這就是tokens思想,拿到了這樣的一個數組我們就好辦事了,想怎樣拼接數據還不是自己説了算。
3、拆解模板字符串並分類
思路(這裏假定分割符就是一對{{ }}):
- 在模板字符串中使用
變量或使用遍歷、if判斷的地方一定是使用{{}}包裹着的 - 所有的普通字符串都是在
{{的左邊,因此可以通過查找{{的位置來找到普通字符串,然後進行截取 {{的位置前面的字符串已經被截取掉了,現在的模板字符串就變成了{{xxx}}<li>...,那麼現在該如何獲取xxx呢?- 新思路——用字符串截取(不要再想正則了哦~)。前面已經把
{{前面的普通字符串給截取掉了,那麼{{也可以截取掉呀,截取掉{{後模板字符串變成了xxx}}<li>... xxx}}<li>...這個字符串跟原始的模板字符串好像哦,只是{{變成了}},那我們跟第2步一樣操作就可以,找到}}的位置,然後截取- 截取掉
xxx後字符串變成了}}<li>...,那我們再把}}截取掉,然後就又回到了步驟2,如此循環直到沒有字符串可截取了即可
代碼實現:
/**
* 模板字符串掃描器
* 用於掃描分隔符{{}}左右兩邊的普通字符串,以及取得{{}}中間的內容。(當然分隔符不一定是{{}})
*/
class Scanner{
constructor (templateStr) {
this.templateStr = templateStr;
this.pos = 0; // 查找字符串的指針位置
this.tail = templateStr; // 模板字符串的尾巴
}
/**
* 掃瞄模板字符串,跳過遇到的第一個匹配的分割符
* @param delimiterReg
* @returns {undefined}
*/
scan(delimiterReg){
if(this.tail){
let matched = this.tail.match(delimiterReg);
if(!matched){
return;
}
if(matched.index != 0){ // 分隔符的位置必須在字符串開頭才能進行後移操作,否則會錯亂
return;
}
let delimiterLength = matched[0].length;
this.pos += delimiterLength; // 指針位置需加上分隔符的長度
this.tail = this.tail.substr(delimiterLength);
// console.log(this);
}
}
/**
* 掃瞄模板字符串,直到遇到第一個匹配的分隔符,並返回第一個分隔符(delimiterReg)之前的字符串
* 如:
* var str = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';
* 第一次運行:scanUtil(/{{/) => '我是一名'
* 第二次運行:scanUtil(/{{/) => '我在學習'
* @param delimiterReg 分割符正則
* @returns {string}
*/
scanUtil(delimiterReg){
// 查找第一個分隔符所在的位置
let index = this.tail.search(delimiterReg);
let matched = '';
switch (index){
case -1: // 沒有找到,如果沒有找到則説明後面沒有使用mustache語法,那麼把所有的tail都返回
matched = this.tail;
this.tail = '';
break;
case 0: // 分隔符在開始位置,則不做任何處理
break;
default:
/*
如果找到了第一個分隔符的位置,則截取第一個分割符位置前的字符串,設置尾巴為找到的分隔符及其後面的字符串,並更新指針位置
*/
matched = this.tail.substring(0, index);
this.tail = this.tail.substring(index);
}
this.pos += matched.length;
// console.log(this);
return matched;
}
/**
* 判斷是否已經查找到字符串結尾了
* @returns {boolean}
*/
eos(){
return this.pos >= this.templateStr.length;
}
}
export { Scanner };
使用:
import {Scanner} from './Scanner';
let tempStr = `
<ul>
{{#students}}
<li>
<dl>
<dt>{{name}}</dt>
{{#hobbys}}
<dd>{{.}}</dd>
{{/hobbys}}
</dl>
</li>
{{/students}}
</ul>
`;
let startDeli = /{{/; // 開始分割符
let endDeli = /}}/; // 結束分割符
let scanner = new Scanner(tempStr);
console.log(scanner.scanUtil(startDeli)); // 獲取 {{ 前面的普通字符串
scanner.scan(startDeli); // 跳過 {{ 分隔符
console.log(scanner.scanUtil(endDeli)); // 獲取 }} 前面的字符串
scanner.scan(endDeli); // 跳過 }} 分隔符
console.log('---------------------------------------------');
console.log(scanner.scanUtil(startDeli)); // 獲取 {{ 前面的普通字符串
scanner.scan(startDeli); // 跳過 {{ 分隔符
console.log(scanner.scanUtil(endDeli)); // 獲取 }} 前面的字符串
scanner.scan(endDeli); // 跳過 }} 分隔符
結果:
4、將字符串模板轉換成tokens數組
前面的Scanner已經可以解析字符串了,現在我們只需要將模板字符串組裝起來即可。
代碼實現
import {Scanner} from '../Scanner';
/**
* 將模板字符串轉換成token
* @param templateStr 模板字符串
* @param delimiters 分割符,它的值為一個長度為2的正則表達式數組
* @returns {*[]}
*/
export function parseTemplateToTokens(templateStr, delimiters = [/{{/, /}}/]){
let [startDelimiter, endDelimiter] = delimiters;
let tokens = [];
if(!templateStr){
return tokens;
}
let scanner = new Scanner(templateStr);
while (!scanner.eos()){
// 獲取開始分隔符前面的字符串
let beforeStartDelimiterStr = scanner.scanUtil(startDelimiter);
if(beforeStartDelimiterStr.length > 0){
tokens.push(['text', beforeStartDelimiterStr]);
// console.log(beforeStartDelimiterStr);
}
// 跳過開始分隔符
scanner.scan(startDelimiter);
// 獲取開始分隔符與結束分隔符之間的字符串
let afterEndDelimiterStr = scanner.scanUtil(endDelimiter);
if(afterEndDelimiterStr.length == 0){
continue;
}
if(afterEndDelimiterStr.charAt(0) == '#'){
tokens.push(['#', afterEndDelimiterStr.substr(1)]);
}else if(afterEndDelimiterStr.charAt(0) == '/'){
tokens.push(['/', afterEndDelimiterStr.substr(1)]);
}else {
tokens.push(['name', afterEndDelimiterStr]);
}
// 跳過結束分隔符
scanner.scan(endDelimiter);
}
return tokens;
}
使用:
import {parseTemplateToTokens} from './parseTemplateToTokens';
let tempStr = `
<ul>
{{#students}}
<li>
<dl>
<dt>{{name}}</dt>
{{#hobbys}}
<dd>{{.}}</dd>
{{/hobbys}}
</dl>
</li>
{{/students}}
</ul>
`;
let delimiters = [/{{/, /}}/];
var tokens = parseTemplateToTokens(templateStr, delimiters);
console.log(tokens);
結果:
5、再次組裝tokens
前面我們使用的模板字符串中存在嵌套結構,而前面組裝的tokens是一維的數組,使用一維數組來渲染循環結構的模板字符串顯然不大可能,就算可以,代碼也會很難理解。
此時我們就需要對一維的數組進行再次組裝,這一次我們要將它組裝成嵌套結構,並且前面封裝的一維數組也是符合條件的。
代碼:
/**
* 將平鋪的tokens數組轉換成嵌套結構的tokens數組
* @param tokens 一維tokens數組
* @returns {*[]}
*/
export function nestsToken(tokens){
var resultTokens = []; // 結果集
var stack = []; // 棧數組
var collector = resultTokens; // 結果收集器
tokens.forEach(token => {
let tokenFirst = token[0];
switch (tokenFirst){
case '#':
// 遇到#號就將當前token推入進棧數組中
stack.push(token);
collector.push(token);
token[2] = [];
// 並將結果收集器設置為剛入棧的token的子集
collector = token[2];
break;
case '/':
// 遇到 / 就將棧數組中最新入棧的那個移除掉
stack.pop();
// 並將結果收集器設置為棧數組中棧頂那個token的子集,或者是最終的結構集
collector = stack.length > 0 ? stack[stack.length - 1][2] : resultTokens;
break;
default:
// 如果不是#、/則直接將當前這個token添加進結果集中
collector.push(token);
}
});
return resultTokens;
}
調用後的結果:
到這一步之後就沒有什麼特別難的了,有了這樣的結構,再結合數據就很容易了。
6、渲染模板
下面代碼是我的簡單實現方式:
代碼:
import {lookup} from './lookup';
/**
* 根據tokens將模板字符串渲染成html
* @param tokens
* @param datas 數據
* @returns {string}
*/
function renderTemplate(tokens, datas){
var resultStr = '';
tokens.forEach(tokenItem => {
var type = tokenItem[0];
var tokenValue = tokenItem[1];
switch (type){
case 'text': // 普通字符串,直接拼接即可
resultStr += tokenValue;
break;
case 'name': // 訪問對象屬性
// lookup是一個用來以字符串的形式動態的訪問對象上深層的屬性的方法,如:lookup({a: {b: {c: 100}}}, 'a.b.c')、lookup({a: {b: {c: 100}}}, 'a.b');
resultStr += lookup(datas, tokenValue);
break;
case '#':
let valueReverse = false;
if(tokenValue.charAt(0) == '!'){ // 如果第一個字符是!,則説明是在使用if判斷做取反操作
tokenValue = tokenValue.substr(1);
valueReverse = true;
}
let val = datas[tokenValue];
resultStr += parseArray(tokenItem, valueReverse ? !val : val, datas);
break;
}
});
return resultStr;
}
/**
* 解析字符串模板中的循環
* @param token token
* @param datas 當前模板中循環所需的數據數據
* @param parentData 上一級的數據
* @returns {string}
*/
function parseArray(token, datas, parentData){
// console.log('parseArray datas', datas);
if(!Array.isArray(datas)){ // 如果數據的值不是數組,則當做if判斷來處理
let flag = !!datas;
// 如果值為真,則渲染模板,否則直接返回空
return flag ? renderTemplate(token[2], parentData) : '';
}
var resStr = '';
datas.forEach(dataItem => {
// console.log('dataItem', dataItem);
let nextData;
if(({}).toString.call(dataItem) != '[object, Object]'){
nextData = {
...dataItem,
// 添加一個"."屬性,主要是為了在模板中使用{{.}}語法時可以使用
'.': dataItem
}
}else{
nextData = {
// 添加一個"."屬性,主要是為了在模板中使用{{.}}語法時可以使用
'.': dataItem
};
}
resStr += renderTemplate(token[2], nextData);
});
return resStr;
}
export {renderTemplate, parseArray};
使用:
import {parseTemplateToTokens} from './parseTemplateToTokens';
import {nestsToken} from './nestsTokens';
import {renderTemplate} from './renderTemplate';
let tempStr = `
<ul>
{{#students}}
<li>
<dl>
<dt>{{name}}</dt>
{{#hobbys}}
<dd>{{.}}</dd>
{{/hobbys}}
</dl>
</li>
{{/students}}
</ul>
`;
let datas = {
students: [
{name: 'Html', hobbys: ['超文本標記語言', '網頁結構'], age: 1990, ageThen25: true, show2: true},
{name: 'Javascript', hobbys: ['弱類型語言', '動態腳本語言', '讓頁面動起來'], age: 1995, ageThen25: 0, show2: true},
{name: 'Css', hobbys: ['層疊樣式表', '裝飾網頁', '排版'], age: 1994, ageThen25: 1, show2: true},
]
};
let delimiters = [/{{/, /}}/];
var tokens = parseTemplateToTokens(templateStr, delimiters);
console.log(tokens);
var nestedTokens = nestsToken(tokens);
console.log(nestedTokens);
var html = renderTemplate(nestedTokens, datas);
console.log(html);
效果:
7、現存問題
- 在
{{}}中使用運算符(如加減、三元運算)的功能暫不知如何實現? - 循環的時候暫不支持給
當前循環項起名字
8、結語
Mustache的tokens思想真的贊!!!以後我們遇到相似需求時也可以使用它的這個思想來實現,而非揪着正則、字符串替換不放。
感謝:感謝尚硅谷,及尚硅谷的尚硅谷Vue源碼解析系列課程、謝老師!