本文首发于我的知乎专栏,转发于掘金。若需要用于商业用途,请经本人同意。
尊重每一位认真写文章的前端大佬,文末给出了本人思路的参考文章。
前言
能够访问到这篇文章的同学,初衷是想知道如何编写 的模板引擎。为了照顾一些没有使用过模板引擎的同学,先来稍微介绍一下什么叫模板引擎。
如果没有使用过模板引擎,但是又尝试过在页面渲染一个列表的时候,那么一般的做法是通过拼接字符串实现的,如下:
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列表,看上去貌似不会复杂(如果使用字符串拼接,会繁琐很多),但是这里有一点糟糕的是:数据和结构强耦合。这导致的问题是如果数据或者结构发生变化时,都需要改变上面的代码,这在当下前端开发中是不能忍受的,我们需要的是数据和结构松耦合。
如果要实现松耦合,那么就应该结构归结构,数据从服务器获取并整理好之后,再通过模板渲染数据,这样我们就可以将精力放在 上了。而使用模板引擎的话是这样实现的。如下:
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结构与 语句、变量分离,通过Function构造函数 + apply(call)动态生成具有数据性的HTML代码。而如果要考虑性能的话,可以将模板进行缓存处理。
请记住上面所说的本质,甚至背诵下来。
实现一个模板引擎函数,大致有以下步骤:
- 模板获取
- 模板中HTML结构与 语句、变量分离
- Function + apply(call)动态生成 代码
- 模板缓存
OK,接下来看看如何实现吧: )
- 模板获取
一般情况下,我们会把模板写在 标签中,赋予id属性,标识模板的唯一性;赋予type='text/html'属性,标识其MIME类型为HTML,如下
< 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></ >在模板引擎中,选用<% xxx %>标识 语句,主要用于流程控制,无输出;<%= xxx %>标识 变量,用于将数据输出到模板;其余部分都为HTML代码。(与EJS类似)。当然,你也可以用<@ xxx @>, <=@ @>、<* xxx *>, <*= xxx *>等。
传入模板引擎函数中的第一个参数,可以是一个id,也可以是模板字符串。此时,需要通过正则去判断是模板字符串还是id。如下
let tpl = ''const tmpl = (str, data) => { // 如果是模板字符串,会包含非单词部分(<, >, %, 等);如果是id,则需要通过getElementById获取 if (!/[sW]/g.test(str)) { tpl = document.getElementById(str).innerHTML } else { tpl = str }}2. HTML结构与 语句、变量分离
这一步骤是引擎中最最最重要的步骤,如果实现了,那就是实现了一大步了。所以我们使用两种方法来实现。假如获取到的模板字符串如下:
" <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 (!/[sW]/g.test(str)) { tpl = document.getElementById(str).innerHTML } else { tpl = str } let result = `let p = []; p.push('` result += `${ tpl.replace(/[
]/g, '') .replace(/<%=s*([^%>]+?)s*%>/g, "'); p.push($1); p.push('") .replace(/<%/g, "');") .replace(/%>/g, "p.push('") }` result += "'); return p.join('');" }细细品味上面的每一个步骤,就能够将HTML结构和 语句、变量拼接起来了。拼接之后的代码如下(格式化代码了,否则没有换行的)
" 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 (!/[sW]/g.test(str)) { tpl = document.getElementById(str).innerHTML } else { tpl = str } let result = `let p = []; p.push('` result += `${ tpl.replace(/[
]/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函数匹配到的值// 匹配模板idconst idReg = /[sW]/g// 匹配 语句或变量const tplReg = /<%=?s*([^%>]+?)s*%>/gconst add = (str, result) => { str = str.replace(/[
]/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) // 匹配 语句、变量 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数组了。但是 语句也push进去了,变量作为字符串push进去了.. 原因跟第一种方法一样,要把语句单独拎出来,变量以自身push进数组。改造一下代码
let tpl = ''let match = '' // 记录exec函数匹配到的值// 匹配模板idconst idReg = /[sW]/g// 匹配 语句或变量const tplReg = /<%=?s*([^%>]+?)s*%>/gconst keyReg = /(for|if|else|switch|case|break|{|})/g // **** 增加正则匹配语句const add = (str, result, js) => { str = str.replace(/[
]/g, '') .replace(/\/g, '\\') .replace(/'/g, "\'") // **** 增加三元表达式的判断,三种情况: 语句、 变量、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) // **** 匹配 语句、变量 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结构、 语句和变量
当然,两种方法都可以使用字符串拼接,但是我在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----------------开始计算数组拼接数组拼接运行时间: 1359ms3. Function + apply(call)动态生成HTML代码
上面两种方法中,result是字符串,怎么将其变成可执行的 代码呢?这里使用了Function构造函数来创建一个函数(当然也可以使用eval函数,但是不推荐)
大多数情况下,创建一个函数会直接使用函数声明或函数表达式的方式
function test () {}const test = function test () {}以这种方式生成的函数会成为Function构造函数的实例对象
test instanceof Function // trueconst 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 = ''// 匹配模板的idlet idReg = /[sW]/gconst cache = {}const add = tpl => { // 匹配成功的值做替换操作 return tpl.replace(/[
]/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 = {}// 匹配模板idconst idReg = /[sW]/g// 匹配 语句或变量const tplReg = /<%=?s*([^%>]+?)s*%>/g// 匹配各种关键字const keyReg = /(for|if|else|switch|case|break|{|})/gconst add = (str, result, js) => { str = str.replace(/[
]/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) // 匹配 语句、变量 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改变函数执行的作用域}最后
呼,基本上说完了,最后还是想稍微总结一下
假如!假如面试的时候面试官问你,请大致描述一下 模板引擎的原理,那么以下的总结可能会给予你一些帮助。
噢.. 模板引擎实现的原理大致是将模板中的HTML结构和 语句、变量分离,将HTML结构以字符串的形式push到数组中,将 语句独立抽取出来,将 变量以其自身push到数组中,通过replace函数的替换或者exec函数的遍历,构建出带有数据的HTML代码,最后通过Function构造函数 + apply(call)函数生成可执行的 代码。
如果回答出来了,面试官心里顿时发现千里马:欸,好像很叼也?接着试探一下:
- 为什么要用数组?可以用字符串吗?两者有什么区别?
- 简单的一下replace和exec函数的使用?
- exec 和match函数有什么不同?
- /<%=?s*([^%>]+?)s*%>/g 这段正则是什么意思?
- 简单说明apply、call、bind函数的区别?
- Function构造函数的使用,有什么弊端?
- 函数声明和函数表达式的区别?
- ....
这一段总结还可以扯出好多知识点... 翻滚吧,千里马!
OK,至此,关于实现一个简单的 模板引擎就介绍到这里了,如果读者耐心、细心的看完了这篇文章,我相信你的收获会是满满的。如果看完了仍然觉得懵逼,如果不介意的话,可以再多品味几次。
参考文章:
- 书籍推荐:《 高级程序设计 第三版》
- 最简单的 模板引擎 - 谦行 - 博客园
- 只有20行 代码!手把手教你写一个页面模板引擎
原文作者:凯斯
本文来源: 掘金 如需转载请联系原作者
继续阅读与本文标签相同的文章
React转微信小程序:构思
-
Spark性能优化:资源调优篇
2026-06-02栏目: 教程
-
pandas 列批量归一化
2026-06-02栏目: 教程
-
django创建项目案例1详细介绍方法01
2026-06-02栏目: 教程
-
django创建项目案例1后台关联添加续02
2026-06-02栏目: 教程
-
django创建项目案例1使用模板续03
2026-06-02栏目: 教程
