編寫一個簡單的JavaScript模板引擎

本文首發於個人知乎專欄,轉發於掘金。若須要用於商業用途,請經本人贊成。html

尊重每一位認真寫文章的前端大佬,文末給出了本人思路的參考文章。前端

前言

可以訪問到這篇文章的同窗,初衷是想知道如何編寫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數據bash

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正是咱們所說的模板引擎(函數)。接下來咱們就來實現一下這個函數。app

模板引擎的實現

經過函數將數據塞到模板裏面,函數內部的具體實現仍是經過拼接字符串來實現。而經過模板的方式,能夠下降拼接字符串出錯而形成時間成本的增長。

而模板引擎函數實現的本質,就是將模板中HTML結構與JavaScript語句、變量分離,經過Function構造函數 + apply(call)動態生成具備數據性的HTML代碼。而若是要考慮性能的話,能夠將模板進行緩存處理。

請記住上面所說的本質,甚至背誦下來。

實現一個模板引擎函數,大體有如下步驟:

  1. 模板獲取
  2. 模板中HTML結構與JavaScript語句、變量分離
  3. Function + apply(call)動態生成JavaScript代碼
  4. 模板緩存

OK,接下來看看如何實現吧: )

  1. 模板獲取

通常狀況下,咱們會把模板寫在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函數替換實現的。說明一下主要流程:

  1. 建立數組arr,再拼接字符串arr.push('
  2. 遇到換行回車,替換爲空字符串
  3. 遇到<%時,替換爲');
  4. 遇到>%時,替換爲arr.push('
  5. 遇到<%= xxx %>,結合第三、4步,替換爲'); arr.push(xxx); arr.push('
  6. 最後拼接字符串'); return p.join('');

在代碼中,須要將第5步寫在二、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結構的,這裏若是稍微思考一下,若是模板中出現了單引號,那會影響整個函數的執行的。還有一點,若是出現了 \ 反引號,會將單引號轉義了。因此須要對單引號和反引號作一下優化處理。

  1. 模板中遇到 \ 反引號,須要轉義
  2. 遇到 ' 單引號,須要將其轉義

轉換爲代碼,即爲

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函數進行簡單的替換。簡單說一下思路:

  1. 須要一個正則表達式/<%=?\s*([^%>]+?)\s*%>/g, 能夠匹配<% xxx %>, <%= xxx %>
  2. 須要一個輔助變量cursor,記錄HTML結構匹配的開始位置
  3. 須要使用exec函數,匹配過程當中內部的index值會根據每一次匹配成功後動態的改變
  4. 其他一些邏輯與第一種方法相似

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("") "
複製代碼

至此,已經達到了咱們的要求。

兩種模板引擎函數的實現已經介紹完了,這裏稍微總結一下

  1. 兩種方法都使用了數組,拼接完成後再join一下
  2. 第一種方法純屬使用replace函數,匹配成功後進行替換
  3. 第二種方法使用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代碼。

若是回答出來了,面試官內心頓時發現千里馬:欸,好像很叼也?接着試探一下:

  1. 爲何要用數組?能夠用字符串嗎?二者有什麼區別?
  2. 簡單的一下replace和exec函數的使用?
  3. exec 和match函數有什麼不一樣?
  4. /<%=?\s*([^%>]+?)\s*%>/g 這段正則是什麼意思?
  5. 簡單說明apply、call、bind函數的區別?
  6. Function構造函數的使用,有什麼弊端?
  7. 函數聲明和函數表達式的區別?
  8. ....


這一段總結還能夠扯出好多知識點... 翻滾吧,千里馬!


OK,至此,關於實現一個簡單的JavaScript模板引擎就介紹到這裏了,若是讀者耐心、細心的看完了這篇文章,我相信你的收穫會是滿滿的。若是看完了仍然以爲懵逼,若是不介意的話,能夠再多品味幾回。


參考文章:

  1. 書籍推薦:《JavaScript高級程序設計 第三版》
  2. 最簡單的JavaScript模板引擎 - 謙行 - 博客園
  3. 只有20行Javascript代碼!手把手教你寫一個頁面模板引擎
相關文章
相關標籤/搜索