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

凱斯發表於2018-07-03

本文首發於我的知乎專欄,轉發於掘金。若需要用於商業用途,請經本人同意。

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

前言

能夠訪問到這篇文章的同學,初衷是想知道如何編寫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程式碼。而如果要考慮效能的話,可以將模板進行快取處理。

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

實現一個模板引擎函式,大致有以下步驟:

  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 %>,結合第3、4步,替換為'); arr.push(xxx); arr.push('
  6. 最後拼接字串'); 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結構的,這裡如果稍微思考一下,如果模板中出現了單引號,那會影響整個函式的執行的。還有一點,如果出現了 \ 反引號,會將單引號轉義了。所以需要對單引號和反引號做一下優化處理。

  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程式碼!手把手教你寫一個頁面模板引擎


相關文章