编写一个简单的JavaScript模板引擎

编写一个简单的JavaScript模板引擎

前言

能够访问到这篇文章的同学,初衷是想知道如何编写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代码!手把手教你写一个页面模板引擎
发布于 2018-06-29

文章被以下专栏收录