前端模板引擎相信大家都不会陌生了吧,尤其是注重前后端分离的今天(除非你还在用拼接字符串)。
引擎一词总让人感觉很高端的样子,其实归根结底也只是处理字符串的一种方式而已。
本文总结了3种实现模板引擎的方式,最后将逐步实现一个类似于 underscore.template 的模板插件。
一、replace模板
replace 是字符串提供的一个超级强大的方法,这里举例介绍简单的使用。
1 2 3 4 5 6 7 8 9 10 11 12
| function replacer(match, p1, p2, p3, offset, string) { return [p1, p2, p3].join(' - '); }
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer);
|
再举几个简单的案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| '123'.replace('1', 'A') 'lalala 2Away0x2'.replace(/2(.*)2/, '$1')
const trim = str => str.replace(/(^\s*)|(\s*$)/g, '')
trim(' abc ')
const format = str => (...args) => str.replace(/{(\d+)}/g, (match, p) => args[p] || '')
format('lalal{0}wowowo{1}hahah{2}')('-A-', '-B-', '-C')
|
replace模板原理:
先在模板中预留占位,再将对应的数据填入
实现:
目标1:可填充简单数据
1 2
| const tpl = (str, data) => str.replace(/{{(.*)}}/g, (match, p) => data[p]) tpl('<div>{{data}}</div>', {data: 'tpl'})
|
目标2:可填充嵌套数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
function tpl (str, data) { const reg = /{{([a-zA-Z$_][a-zA-Z$_0-9\.]*)}}/g
return str.replace(reg, (match, p) => { const paths = p.split('.') let result = data
while (paths.length > 0) result = result[ paths.shift() ] return String(result) || match }) } tpl('<div>{{data.a}}</div>', {data: {a: 'tpl'}})
|
最终代码
1 2 3 4 5 6 7 8 9 10 11 12
| function tpl (str, data) { const reg = /{{([a-zA-Z$_][a-zA-Z$_0-9\.]*)}}/g
return str.replace(reg, (match, p) => { const paths = p.split('.') let result = data
while (paths.length > 0) result = result[ paths.shift() ] return String(result) || match }) }
|
优缺点:
- 优点:简单
- 缺点:模板不支持表达式(for/if/else等等),所有数据得先计算好再填入,灵活性差,难以满足复杂的需求。
资料
replace详细语法
二、es6模板字符串
模板字符串是 es6 中我最爱的特性啦!比起传统模板引擎,我更喜欢用模板字符串来编写组件
- 模板字符串包裹在 反引号(Esc按钮下面那个) 中,其中可通过 ${} 的语法进行插值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| `123123 23213`
const str1 = `123${'tpl'}456` const str2 = `123${false || 'tpl'}456` const str3 = `123${true ? 'tpl' : ''}456` const str4 = `123${ (function () {return 'tpl'}()) }456` const fn = () => 'tpl' const str5 = `123${ fn() }456` const str6 = `123${ ['T', 'P', 'L'].map(s => s.toLowerCase()).join('') }456` console.log([str1, str2, str3, str4, str5, str6].every(s => s === '123tpl456'))
var a = 5, b = 10 function tag (strArr, ...vals) { console.log(strArr, vals) } tag`Hello ${ a + b } world ${a * b}`
|
下面用es6模板字符串写一个分页组件:
See the Pen XVKxyO by 石其龙 (@jinwang) on CodePen.
资料
es6模板字符串语法
三、Function模板
Function 是 js 提供的一个用于构造 Function 对象的构造函数
1 2 3 4 5 6 7 8
| function log (user, msg) { console.log(user, msg) } log('Away0x', 'lalala')
const log = new Function('user', 'msg', 'console.log(user, msg)') log('Away0x', 'lalala')
|
大多数前端模板引擎都是用这种方式实现的,其原理在于运用了 js Function 对象可将字符串解析为函数的能力。
一个普通模板引擎的工作步骤大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| {# if( data.con > 20 ) { #} <p>ifififififif</p> {# } else { #} <p>elseelseelseelse</p> {# } #}
const functionbody = ` var tpl = '' if (data.con > 20) { tpl += '<p>ifififififif</p>' } else { tpl += '<p>ifififififif</p>' } return tpl `
new Function('data', functionbody)(data)
|
由此可见,只要将模板识别码里的字符串内容生成 js语句,而其余内容之前加上 一个 ‘tpl += ‘ 即可。
实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const tpl = (str, data) => { const tplStr = str.replace(/\n/g, '') .replace(/{{(.+?)}}/g, (match, p) => `'+(${p})+'`) .replace(/{#(.+?)#}/g, (match, p) => `'; ${p}; tpl += '`) return new Function('data', `var tpl='${tplStr}'; return tpl;`)(data) }
const str = ` {# if( data.con > 20 ) { #} <p>ifififififif</p> {# } else { #} <p>elseelseelseelse</p> {# } #}
{# for(var i = 0; i < data.list.length; i++) { #} <p>{{i }} : {{ data.list[i] }}</p> {# } #} ` const data = {con:21, list: [1,2,3,4,5,76,87,8]} console.log( tpl(str, data) )
|
ok, 一个最最简单的模板引擎就已经完成了,支持在模板中嵌入 js 语句,虽然只有不到10行,但还是挺强大的对不?
资料
Function语法
拓展:分步实现模板引擎
第一步:为了能够更好的使用,将前面的代码抽成一个类。
- 标识符格式有可能和后端模板引擎冲突,因此应实现成可配置的
- 在模板中应能添加注释
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| class Tpl { constructor (config) { const defaultConfig = { signs: { varSign: ['{{', '}}'], evalSign: ['{#', '#}'], commentSign: ['<!--', '-->'], noCommentSign: ['{@', '@}'] } } this.config = Object.assign({}, defaultConfig, config) Object.keys(this.config.signs).forEach(key => { this.config.signs[key].splice(1, 0, '(.+?)') this.config.signs[key] = new RegExp(this.config.signs[key].join(''), 'g') }) } _compile (str, data){ const tpl = str.replace(/\n/g, '') .replace( this.config.signs.noCommentSign, () => '') .replace( this.config.signs.commentSign, (match, p) => `'+'<!-- ${p} -->'+'`) .replace( this.config.signs.varSign, (match, p) => `'+(${p})+'`) .replace( this.config.signs.evalSign, (match, p) => { let exp = p.replace('>', '>').replace('<', '<') return `'; ${exp}; tpl += '` })
return new Function('data', `var tpl='${tpl}'; return tpl;`)(data) } compile (tplStr, data) { return this._compile(tplStr, data) } }
function tpl (config) { return new Tpl(config) }
console.log( tpl().compile(str, data) )
|
第二步:解决注释的bug
- 解决办法:在解析注释时,如注释里有标识符,则将其先替换成其他符号,等语句变量的解析完成时,再替换回来
1 2 3 4 5 6
| .replace( this.config.signs.commentSign, (match, p) => { const exp = p.replace(/[\{\<\}\>]/g, match => `&*&${match.charCodeAt()}&*&`) return `'+'<!-- ${exp} -->'+'` })
.replace(/\&\*\&(.*?)\&\*\&/g, (match, p) => String.fromCharCode(p))
|
第三步:增强语法支持
书写模板时,很多时候js语法需要用到’{‘或者’}’的时候,模板本身也有这个符号,顿时’{‘乱飞的情况就出现了。有些模板就提供了更好的语法:
1 2 3 4 5 6 7 8 9 10 11
| {@ if data.con > 20 @} <p>ifififififif</p> {@ elif data.con === 20 @} // } else if (data.con === 20) { <p>elseelseelseelseifififififif</p> {@ else @} <p>elseelseelseelse</p> {/@ if @}
{@ each data.list as item @} <p>循环 {{ index + 1 }} 次: {{ item }}</p> {/@ each @}
|
其实就是在解析的语句时多了些处理,我们来把它加上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
_syntax (str){ const arr = str.trim().split(/\s+/) let exp = str
if (arr[0] === 'if') { exp = `if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'else') { exp = '} else {' } else if (arr[0] === 'elif') { exp = `} else if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'each') { exp = `for (var index = 0, len = ${arr[1]}.length; index < len; index++) { var item = ${arr[1]}[index]` }
return exp }
.replace( this.config.signs.evalSign, (match, p) => { let exp = p.replace('>', '>').replace('<', '<') exp = this.config.syntax ? this._syntax(exp) : exp return `'; ${exp}; tpl += '` })
.replace( this.config.signs.endEvalSign, () => "'} tpl += '")
|
第四步:增加过滤器支持
很多模板引擎中都有提供很多好用的过滤器:
1 2 3 4
| // 字符串转大写的过滤器 <p>{{ 'tpl' | upper }}</p> => <p>TPL</p> // 支持流式 <p>{{ 'tpl' | f1 | f2 }}</p>
|
现在我们也来支持一下过滤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
const Filters = { upper: str => str.toUpperCase() }
.replace( this.config.signs.varSign, (match, p) => { const filterIndex = p.indexOf('|') let val = p
if (filterIndex !== -1) { const arr = val.split('|').map(s => s.trim()), filters = arr.slice(1) || [], oldVal = arr[0]
val = filters.reduce((curVal, filterName) => { if ( ! Filters[filterName] ) { throw new Error(`没有 ${filterName} 过滤器`) return } return `Filters['${filterName}'](${curVal})` }, oldVal) }
return `'+(${val})+'` })
|
至此,我们的模板引擎就初步完成了,总结下功能:
- 支持在模板中使用 js 语句
- 支持自定义标识符
- 支持更简洁的语法模式
- 支持过滤器
以上代码总结一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
| const Filters = { upper: str => str.toUpperCase(), lower: str => str.toLowerCase(), reverse: str => str.split('').reverse().join(''), escape: str => str.replace(/&(?!\w+;)/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') }
class Tpl { constructor (config={}) { const defaultConfig = { signs: { varSign: ['{{', '}}'], evalSign: ['{#', '#}'], endEvalSign: ['{/#', '#}'], commentSign: ['<!--', '-->'], noCommentSign: ['{@', '@}'] }, syntax: false }
this.config = Object.assign({}, defaultConfig, config) Object.keys(this.config.signs).forEach(key => { this.config.signs[key].splice(1, 0, '(.+?)') this.config.signs[key] = new RegExp(this.config.signs[key].join(''), 'g') }) } _syntax (str) { const arr = str.trim().split(/\s+/) console.log('1111: '+arr) let exp = str
if (arr[0] === 'if') { console.log('2222: '+arr.slice(1).join(' ')) exp = `if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'else') { exp = '} else {' } else if (arr[0] === 'elif') { exp = `} else if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'each') { exp = `for (var index = 0, len = ${arr[1]}.length; index < len; index++) {var item = ${arr[1]}[index]` }
return exp } _compile (str, data){ const tpl = str.replace(/\n/g, '') .replace( this.config.signs.noCommentSign, () => '') .replace( this.config.signs.commentSign, (match, p) => { const exp = p.replace(/[\{\<\}\>]/g, match => `&*&${match.charCodeAt()}&*&`) return `'+'<!-- ${exp} -->'+'` }) .replace( this.config.signs.varSign, (match, p) => { const filterIndex = p.indexOf('|') let val = p
if (filterIndex !== -1) { const arr = val.split('|').map(s => s.trim()), filters = arr.slice(1) || [], oldVal = arr[0]
val = filters.reduce((curVal, filterName) => { if ( ! Filters[filterName] ) { throw new Error(`没有 ${filterName} 过滤器`) return } return `Filters['${filterName}'](${curVal})` }, oldVal) } console.log(val) return `'+(${val})+'` }) .replace( this.config.signs.evalSign, (match, p) => { let exp = p.replace('>', '>').replace('<', '<') exp = this.config.syntax ? this._syntax(exp) : exp return `'; ${exp}; tpl += '` }) .replace( this.config.signs.endEvalSign, () => "'} tpl += '") .replace(/\&\*\&(.*?)\&\*\&/g, (match, p) => String.fromCharCode(p))
return new Function('data', `var tpl='${tpl}'; return tpl;`)(data) } compile (tplStr, data) { try { return this._compile(tplStr, data) } catch (err) { console.warn(err) console.trace() } } }
function tpl (config) { return new Tpl(config) }
|
跑代码测试一下吧!!
See the Pen VyjNNq by 石其龙 (@jinwang) on CodePen.