编写一个简单的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代码!手把手教你写一个页面模板引擎
相关文章
相关标签/搜索