本文首發於我的知乎專欄,轉發於掘金。若需要用於商業用途,請經本人同意。
尊重每一位認真寫文章的前端大佬,文末給出了本人思路的參考文章。
前言
能夠訪問到這篇文章的同學,初衷是想知道如何編寫JavaScript的模板引擎。為了照顧一些沒有使用過模板引擎的同學,先來稍微介紹一下什麼叫模板引擎。
如果沒有使用過模板引擎,但是又嘗試過在頁面渲染一個列表的時候,那麼一般的做法是通過拼接字串實現的,如下:
const arr = [{
"name": "google",
"url": "https://www.google.com"
}, {
"name": "baidu",
"url": "https://www.baidu.com/"
}, {
"name": "凱斯",
"url": "https://www.zhihu.com/people/Uncle-Keith/activities"
}]
let html = ''
html += '<ul>'
for (var i = 0; i < arr.length; i++) {
html += `<li><a href="${arr[i].url}">${arr[i].name}</a></li>`
}
html += '</ul>'
複製程式碼
上面程式碼中,我使用了ES6的反引號(``)語法動態生成了一個ul列表,看上去貌似不會複雜(如果使用字串拼接,會繁瑣很多),但是這裡有一點糟糕的是:資料和結構強耦合。這導致的問題是如果資料或者結構發生變化時,都需要改變上面的程式碼,這在當下前端開發中是不能忍受的,我們需要的是資料和結構鬆耦合。
如果要實現鬆耦合,那麼就應該結構歸結構,資料從伺服器獲取並整理好之後,再通過模板渲染資料,這樣我們就可以將精力放在JavaScript上了。而使用模板引擎的話是這樣實現的。如下:
HTML列表
<ul>
<% for (var i = 0; i < obj.users.length; i++) { %>
<li>
<a href="<%= obj.users[i].url %>">
<%= obj.users[i].name %>
</a>
</li>
<% } %>
</ul>複製程式碼
JS資料
const arr = [{
"name": "google",
"url": "https://www.google.com"
}, {
"name": "baidu",
"url": "https://www.baidu.com/"
}, {
"name": "凱斯",
"url": "https://www.zhihu.com/people/Uncle-Keith/activities"
}]
const html = tmpl('list', arr)
console.log(html)
複製程式碼
列印出的結果為
" <ul>
<li><a href="https://www.google.com">google</a>
</li>
<li><a href="https://www.baidu.com/">baidu</a>
</li>
<li><a href="https://www.zhihu.com/people/Uncle-Keith/activities">凱斯</a>
</li>
</ul> "
複製程式碼
從以上的程式碼可以看出,將結構和資料傳入tmpl函式中,就能實現拼接。而tmpl正是我們所說的模板引擎(函式)。接下來我們就來實現一下這個函式。
模板引擎的實現
通過函式將資料塞到模板裡面,函式內部的具體實現還是通過拼接字串來實現。而通過模板的方式,可以降低拼接字串出錯而造成時間成本的增加。
而模板引擎函式實現的本質,就是將模板中HTML結構與JavaScript語句、變數分離,通過Function建構函式 + apply(call)動態生成具有資料性的HTML程式碼。而如果要考慮效能的話,可以將模板進行快取處理。
請記住上面所說的本質,甚至背誦下來。
實現一個模板引擎函式,大致有以下步驟:
- 模板獲取
- 模板中HTML結構與JavaScript語句、變數分離
- Function + apply(call)動態生成JavaScript程式碼
- 模板快取
OK,接下來看看如何實現吧: )
- 模板獲取
一般情況下,我們會把模板寫在script標籤中,賦予id屬性,標識模板的唯一性;賦予type='text/html'屬性,標識其MIME型別為HTML,如下
<script type="text/html" id="template">
<ul>
<% if (obj.show) { %>
<% for (var i = 0; i < obj.users.length; i++) { %>
<li>
<a href="<%= obj.users[i].url %>">
<%= obj.users[i].name %>
</a>
</li>
<% } %>
<% } else { %>
<p>不展示列表</p>
<% } %>
</ul>
</script>
複製程式碼
在模板引擎中,選用<% xxx %>標識JavaScript語句,主要用於流程控制,無輸出;<%= xxx %>標識JavaScript變數,用於將資料輸出到模板;其餘部分都為HTML程式碼。(與EJS類似)。當然,你也可以用<@ xxx @>, <=@ @>、<* xxx *>, <*= xxx *>等。
傳入模板引擎函式中的第一個引數,可以是一個id,也可以是模板字串。此時,需要通過正則去判斷是模板字串還是id。如下
let tpl = ''
const tmpl = (str, data) => {
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!/[\s\W]/g.test(str)) {
tpl = document.getElementById(str).innerHTML
} else {
tpl = str
}
}
複製程式碼
2. HTML結構與JavaScript語句、變數分離
這一步驟是引擎中最最最重要的步驟,如果實現了,那就是實現了一大步了。所以我們使用兩種方法來實現。假如獲取到的模板字串如下:
" <ul>
<% if (obj.show) { %>
<% for (var i = 0; i < obj.users.length; i++) { %>
<li>
<a href="<%= obj.users[i].url %>">
<%= obj.users[i].name %>
</a>
</li>
<% } %>
<% } else { %>
<p>不展示列表</p>
<% } %>
</ul> "
複製程式碼
先來看看第一種方法吧,主要是通過replace函式替換實現的。說明一下主要流程:
- 建立陣列arr,再拼接字串arr.push('
- 遇到換行回車,替換為空字串
- 遇到<%時,替換為');
- 遇到>%時,替換為arr.push('
- 遇到<%= xxx %>,結合第3、4步,替換為'); arr.push(xxx); arr.push('
- 最後拼接字串'); return p.join('');
在程式碼中,需要將第5步寫在2、3步驟前面,因為有更高的優先順序,否則會匹配出錯。如下
let tpl = ''
const tmpl = (str, data) => {
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!/[\s\W]/g.test(str)) {
tpl = document.getElementById(str).innerHTML
} else {
tpl = str
}
let result = `let p = []; p.push('`
result += `${
tpl.replace(/[\r\n\t]/g, '')
.replace(/<%=\s*([^%>]+?)\s*%>/g, "'); p.push($1); p.push('")
.replace(/<%/g, "');")
.replace(/%>/g, "p.push('")
}`
result += "'); return p.join('');"
}
複製程式碼
細細品味上面的每一個步驟,就能夠將HTML結構和JavaScript語句、變數拼接起來了。拼接之後的程式碼如下(格式化程式碼了,否則沒有換行的)
" let p = [];
p.push('<ul>');
if (obj.show) {
p.push('');
for (var i = 0; i < obj.users.length; i++) {
p.push('<li><a href="');
p.push(obj.users[i].url);
p.push('">');
p.push(obj.users[i].name);
p.push('</a></li>');
}
p.push('');
} else {
p.push('<p>不展示列表</p>');
}
p.push('</ul>');
return p.join(''); "
複製程式碼
這裡要注意的是,我們不能將JavaScript語句push到陣列裡面,而是單獨存在。因為如果以JS語句的形式push進去,會報錯;如果以字串的形式push進去,那麼就不會有作用了,比如for迴圈、if判斷都會無效。當然JavaScript變數push到陣列內的時候,要注意也不能以字串的形式,否則會無效。如
p.push('for(var i =0; i < obj.users.length; i++){') // 無效
p.push('obj.users[i].name') // 無效
p.push(for(var i =0; i < obj.users.length; i++){) // 報錯
複製程式碼
從模板引擎函式可以看出,我們是通過單引號來拼接HTML結構的,這裡如果稍微思考一下,如果模板中出現了單引號,那會影響整個函式的執行的。還有一點,如果出現了 \ 反引號,會將單引號轉義了。所以需要對單引號和反引號做一下優化處理。
- 模板中遇到 \ 反引號,需要轉義
- 遇到 ' 單引號,需要將其轉義
轉換為程式碼,即為
str.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
複製程式碼
結合上面的部分,即
let tpl = ''
const tmpl = (str, data) => {
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!/[\s\W]/g.test(str)) {
tpl = document.getElementById(str).innerHTML
} else {
tpl = str
}
let result = `let p = []; p.push('`
result += `${
tpl.replace(/[\r\n\t]/g, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/<%=\s*([^%>]+?)\s*%>/g, "'); p.push($1); p.push('")
.replace(/<%/g, "');")
.replace(/%>/g, "p.push('")
}`
result += "'); return p.join('');"
}
複製程式碼
這裡的模板引擎函式用了ES6的語法和正規表示式,如果對正規表示式懵逼的同學,可以先去學習正則先,懂了之後再回頭看這篇文章,會恍然大悟的。
OK,來看看第二種方法實現模板引擎函式。跟第一種方法不同的是,不只是使用replace函式進行簡單的替換。簡單說一下思路:
- 需要一個正規表示式/<%=?\s*([^%>]+?)\s*%>/g, 可以匹配<% xxx %>, <%= xxx %>
- 需要一個輔助變數cursor,記錄HTML結構匹配的開始位置
- 需要使用exec函式,匹配過程中內部的index值會根據每一次匹配成功後動態的改變
- 其餘一些邏輯與第一種方法類似
OK,我們來看看具體的程式碼
let tpl = ''
let match = '' // 記錄exec函式匹配到的值
// 匹配模板id
const idReg = /[\s\W]/g
// 匹配JavaScript語句或變數
const tplReg = /<%=?\s*([^%>]+?)\s*%>/g
const add = (str, result) => {
str = str.replace(/[\r\n\t]/g, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
result += `result.push('${string}');`
return result
}
const tmpl = (str, data) => {
// 記錄HTML結構匹配的開始位置
let cursor = 0
let result = 'let result = [];'
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!idReg.test(str)) {
tpl = document.getElementById(str).innerHTML
} else {
tpl = str
}
// 使用exec函式,每次匹配成功會動態改變index的值
while (match = tplReg.exec(tpl)) {
result = add(tpl.slice(cursor, match.index), result) // 匹配HTML結構
result = add(match[1], result) // 匹配JavaScript語句、變數
cursor = match.index + match[0].length // 改變HTML結果匹配的開始位置
}
result = add(tpl.slice(cursor), result) // 匹配剩餘的HTML結構
result += 'return result.join("")'
}
console.log(tmpl('template'))
複製程式碼
上面使用了輔助函式add,每次傳入str的時候,都需要對傳入的模板字串做優化處理,防止模板字串中出現非法字元(換行,回車,單引號',反引號\ 等)。執行後程式碼格式化後如下(實際上沒有換行,因為替換成空字串了,為了好看..)。
" let result =[];
result.push('<ul>');
result.push('if (obj.show) {');
result.push('');
result.push('for (var i = 0; i < obj.users.length; i++) {');
result.push('<li><a href="');
result.push('obj.users[i].url');
result.push('">');
result.push('obj.users[i].name');
result.push('</a></li>');
result.push('}');
result.push('');
result.push('} else {');
result.push('<p>什麼鬼什麼鬼</p>');
result.push('}');
result.push('</ul>');
return result.join("") "
複製程式碼
從以上程式碼中,可以看出HTML結構作為字串push到result陣列了。但是JavaScript語句也push進去了,變數作為字串push進去了.. 原因跟第一種方法一樣,要把語句單獨拎出來,變數以自身push進陣列。改造一下程式碼
let tpl = ''
let match = '' // 記錄exec函式匹配到的值
// 匹配模板id
const idReg = /[\s\W]/g
// 匹配JavaScript語句或變數
const tplReg = /<%=?\s*([^%>]+?)\s*%>/g
const keyReg = /(for|if|else|switch|case|break|{|})/g // **** 增加正則匹配語句
const add = (str, result, js) => {
str = str.replace(/[\r\n\t]/g, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
// **** 增加三元表示式的判斷,三種情況:JavaScript語句、JavaScript變數、HTML結構。
result += js ? str.match(keyReg) ? `${str}` : `result.push(${str});` : `result.push('${str}');`
return result
}
const tmpl = (str, data) => {
// 記錄HTML結構匹配的開始位置
let cursor = 0
let result = 'let result = [];'
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!idReg.test(str)) {
tpl = document.getElementById(str).innerHTML
} else {
tpl = str
}
// 使用exec函式,每次匹配成功會動態改變index的值
while (match = tplReg.exec(tpl)) {
result = add(tpl.slice(cursor, match.index), result) // 匹配HTML結構
result = add(match[1], result, true) // **** 匹配JavaScript語句、變數
cursor = match.index + match[0].length // 改變HTML結果匹配的開始位置
}
result = add(tpl.slice(cursor), result) // 匹配剩餘的HTML結構
result += 'return result.join("")'
}
console.log(tmpl('template'))
複製程式碼
執行後的程式碼格式化後如下
" let result = [];
result.push('<ul>');
if (obj.show) {
result.push('');
for (var i = 0; i < obj.users.length; i++) {
result.push('<li><a href="');
result.push(obj.users[i].url);
result.push('">');
result.push(obj.users[i].name);
result.push('</a></li>');
}
result.push('');
} else {
result.push('<p>什麼鬼什麼鬼</p>');
}
result.push('</ul>');
return result.join("") "
複製程式碼
至此,已經達到了我們的要求。
兩種模板引擎函式的實現已經介紹完了,這裡稍微總結一下
- 兩種方法都使用了陣列,拼接完成後再join一下
- 第一種方法純屬使用replace函式,匹配成功後進行替換
- 第二種方法使用exec函式,利用其動態改變的index值捕獲到HTML結構、JavaScript語句和變數
當然,兩種方法都可以使用字串拼接,但是我在Chrome瀏覽器中對比了一下,陣列還是快很多的呀,所以這也算是一個優化方案吧:用陣列拼接比字串拼接要快50%左右!以下是字串和陣列拼接的驗證
console.log('開始計算字串拼接')
const start2 = Date.now()
let str = ''
for (var i = 0; i < 9999999; i++) {
str += '1'
}
const end2 = Date.now()
console.log(`字串拼接執行時間: ${end2 - start2}`ms)
console.log('----------------')
console.log('開始計算陣列拼接')
const start1 = Date.now()
const arr = []
for (var i = 0; i < 9999999; i++) {
arr.push('1')
}
arr.join('')
const end1 = Date.now()
console.log(`陣列拼接執行時間: ${end1 - start1}`ms)
複製程式碼
結果如下:
開始計算字串拼接
字串拼接執行時間: 2548ms
----------------
開始計算陣列拼接
陣列拼接執行時間: 1359ms
複製程式碼
3. Function + apply(call)動態生成HTML程式碼
上面兩種方法中,result是字串,怎麼將其變成可執行的JavaScript程式碼呢?這裡使用了Function建構函式來建立一個函式(當然也可以使用eval函式,但是不推薦)
大多數情況下,建立一個函式會直接使用函式宣告或函式表示式的方式
function test () {}
const test = function test () {}
複製程式碼
以這種方式生成的函式會成為Function建構函式的例項物件
test instanceof Function // true
複製程式碼
當然也可以直接使用Function建構函式直接建立一個函式,這樣做的效能會稍微差了一些(雙重解析,JavaScript解析JavaScript程式碼,程式碼包含在字串中,也就是說在 JavaScript 程式碼執行的同時必須新啟動一個解析器來解析新的程式碼。例項化一個新的解析器有不容忽視的開銷,所以這種程式碼要比直接解析慢得多。)
const test = new Function('arg1', 'arg2', ... , 'console.log(arg1 + arg2)')
test(1 + 2) // 3
複製程式碼
魚和熊掌不可得兼,渲染便利的同時帶來了部分的效能損失
Function建構函式可以傳入多個引數,最後一個引數代表執行的語句。因此我們可以這樣
const fn = new Funcion(result)
複製程式碼
如果需要傳入引數,可以使用call或者apply改變函式執行時所在的作用域即可。
fn.apply(data)
複製程式碼
4. 模板快取
使用模板的原因不僅在於避免手動拼接字串而帶來不必要的錯誤,而且在某些場景下可以複用模板程式碼。為了避免同一個模板多次重複拼接字串,可以將模板快取起來。我們這裡快取當傳入的是id時可以快取下來。實現的邏輯不復雜,在接下來的程式碼可以看到。
好了, 結合上面講到的所有內容,給出兩種方式實現的模板引擎的最終程式碼
第一種方法:
let tpl = ''
// 匹配模板的id
let idReg = /[\s\W]/g
const cache = {}
const add = tpl => {
// 匹配成功的值做替換操作
return tpl.replace(/[\r\n\t]/g, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/<%=\s*([^%>]+?)\s*%>/g, "'); p.push($1); p.push('")
.replace(/<%/g, "');")
.replace(/%>/g, "p.push('")
}
const tmpl = (str, data) => {
let result = `let p = []; p.push('`
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!idReg.test(str)) {
tpl = document.getElementById('template').innerHTML
if (cache[str]) {
return cache[str].apply(data)
}
} else {
tpl = str
}
result += add(tpl)
result += "'); return p.join('');"
let fn = new Function(result) // 轉成可執行的JS程式碼
if (!cache[str] && !idReg.test(str)) { // 只用傳入的是id的情況下才快取模板
cache[str] = fn
}
return fn.apply(data) // apply改變函式執行的作用域
}
複製程式碼
第二種方法:
let tpl = ''
let match = ''
const cache = {}
// 匹配模板id
const idReg = /[\s\W]/g
// 匹配JavaScript語句或變數
const tplReg = /<%=?\s*([^%>]+?)\s*%>/g
// 匹配各種關鍵字
const keyReg = /(for|if|else|switch|case|break|{|})/g
const add = (str, result, js) => {
str = str.replace(/[\r\n\t]/g, '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
result += js ? str.match(keyReg) ? `${str}` : `result.push(${str});` : `result.push('${str}');`
return result
}
const tmpl = (str, data) => {
let cursor = 0
let result = 'let result = [];'
// 如果是模板字串,會包含非單詞部分(<, >, %, 等);如果是id,則需要通過getElementById獲取
if (!idReg.test(str)) {
tpl = document.getElementById(str).innerHTML
// 快取處理
if (cache[str]) {
return cache[str].apply(data)
}
} else {
tpl = str
}
// 使用exec函式,動態改變index的值
while (match = tplReg.exec(tpl)) {
result = add(tpl.slice(cursor, match.index), result) // 匹配HTML結構
result = add(match[1], result, true) // 匹配JavaScript語句、變數
cursor = match.index + match[0].length // 改變HTML結果匹配的開始位置
}
result = add(tpl.slice(cursor), result) // 匹配剩餘的HTML結構
result += 'return result.join("")'
let fn = new Function(result) // 轉成可執行的JS程式碼
if (!cache[str] && !idReg.test(str)) { // 只有傳入的是id的情況下才快取模板
cache[str] = fn
}
return fn.apply(data) // apply改變函式執行的作用域
}
複製程式碼
最後
呼,基本上說完了,最後還是想稍微總結一下
假如!假如面試的時候面試官問你,請大致描述一下JavaScript模板引擎的原理,那麼以下的總結可能會給予你一些幫助。
噢.. 模板引擎實現的原理大致是將模板中的HTML結構和JavaScript語句、變數分離,將HTML結構以字串的形式push到陣列中,將JavaScript語句獨立抽取出來,將JavaScript變數以其自身push到陣列中,通過replace函式的替換或者exec函式的遍歷,構建出帶有資料的HTML程式碼,最後通過Function建構函式 + apply(call)函式生成可執行的JavaScript程式碼。
如果回答出來了,面試官心裡頓時發現千里馬:欸,好像很叼也?接著試探一下:
- 為什麼要用陣列?可以用字串嗎?兩者有什麼區別?
- 簡單的一下replace和exec函式的使用?
- exec 和match函式有什麼不同?
- /<%=?\s*([^%>]+?)\s*%>/g 這段正則是什麼意思?
- 簡單說明apply、call、bind函式的區別?
- Function建構函式的使用,有什麼弊端?
- 函式宣告和函式表示式的區別?
- ....
這一段總結還可以扯出好多知識點... 翻滾吧,千里馬!
OK,至此,關於實現一個簡單的JavaScript模板引擎就介紹到這裡了,如果讀者耐心、細心的看完了這篇文章,我相信你的收穫會是滿滿的。如果看完了仍然覺得懵逼,如果不介意的話,可以再多品味幾次。
參考文章:
- 書籍推薦:《JavaScript高階程式設計 第三版》
- 最簡單的JavaScript模板引擎 - 謙行 - 部落格園
- 只有20行Javascript程式碼!手把手教你寫一個頁面模板引擎