本文首发于我的知乎专栏,转发于掘金。若需要用于商业用途,请经本人同意。
尊重每一位认真写文章的前端大佬,文末给出了本人思路的参考文章。
前言
能够访问到这篇文章的同学,初衷是想知道如何编写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(''); "
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
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代码!手把手教你写一个页面模板引擎
原文作者:凯斯
本文来源: 掘金 如需转载请联系原作者