从零开始实现前端模板引擎
前端模板引擎相信大家都不会陌生了吧,尤其是注重前后端分离的今天(除非你还在用拼接字符串)。
引擎
一词总让人感觉很高端的样子,其实归根结底也只是处理字符串的一种方式而已。
本文总结了3种实现模板引擎的方式,最后将编写一个 类似于 underscore.template 的模板插件。
replace
介绍
replace 是字符串提供的一个超级强大的方法,这里只介绍简单的使用,更多详细的语法参见下面提供的链接
一参可为字符串
或 正则
:
为正则时有两种情况: 普通匹配模式
和 全局匹配模式
:
全局匹配模式下,若二参为函数,则该函数在每次匹配时都会被调用
二参可为 字符串
或 一个用于生成字符串的函数
:
当为字符串时:
可在字符串中使用 特殊替换字符 ($n ...)
当为函数时:
函数中不能用 特殊替换字符
一参为正则匹配的文本
倒数第二参为匹配到的子字符串在原字符串中的偏移量
最后一参为被匹配的原始字符串
其余参数为正则中每个分组匹配到的文本
function replacer(match, p1, p2, p3, offset, string) { // match 为 'abc12345#$*%' : 正则匹配的文本 // p1,p2,p3 分别为 abc 12345 #$*% : 即每个小组匹配到的文本, pn 表示有 n 个小组 // offset 为 0 : 匹配到的子字符串在原字符串中的偏移量。 // (比如,如果原字符串是'abcd',匹配到的子字符串时'bc',那么这个参数将是1) // string 为 'abc12345#$*%' : 被匹配的原始字符串 return [p1, p2, p3].join(' - '); } // 注意正则中的 括号 ,这里分有 3个组 var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer); // newString => 'abc - 12345 - #$*%'
以下是各种情况下的具体案例:
// 最基础的使用 '123'.replace('1', 'A') // 'A23' 'lalala 2Away0x2'.replace(/2(.*)2/, '$1') // 'lalala Away0x' // trim const trim = str => str.replace(/(^\s*)|(\s*$)/g, '') trim(' abc ') // 'abc' // format const format = str => (...args) => str.replace(/{(\d+)}/g, (match, p) => args[p] || '') format('lalal{0}wowowo{1}hahah{2}')('-A-', '-B-', '-C') // lala-A-wowo-B-haha-C 原理
先在模板中预留占位( {{填充数据}} ),再将对应的数据填入
实现要求1: 可填充简单数据
const tpl = (str, data) => str.replace(/{{(.*)}}/g, (match, p) => data[p]) tpl('<div>{{data}}</div>', {data: 'tpl'}) // '<div>tpl</div>'
要求2: 可填充嵌套数据
// 可根据占位符 {{data.a}} 中的 "." 来获得数据的依赖路径,从而得到对应的数据 // 由于 使用 "." 连接,所以其前后应为合法的变量名,因此需重新构造正则 /* 合法变量名 * - 开头可为字符和少量特殊字符: [a-zA-Z$_] * - 余部还可是数字: [a-zA-Z$_0-9] */ // 除开头外还需匹配 连接符 "." ,因此最终正则为: /{{([a-zA-Z$_][a-zA-Z$_0-9\.]*)}}/g function tpl (str, data) { const reg = /{{([a-zA-Z$_][a-zA-Z$_0-9\.]*)}}/g // 全局匹配模式下,replace 的回调在每次匹配时都会执行, // p 为占位符中的变量,该例为 data.a return str.replace(reg, (match, p) => { const paths = p.split('.') // ['data', 'a'] let result = data while (paths.length > 0) result = result[ paths.shift() ] // 得到路径最末端的数据 return String(result) || match // 需转成字符串,因为可能遇到 0, null 等数据 }) } tpl('<div>{{data.a}}</div>', {data: {a: 'tpl'}}) // '<div>tpl</div>'
最终代码:
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 }) } 优缺点 优点: 简单 缺点: 无法在模板中使用表达式,所有数据都得事先计算好再填入,且填充的数据应为基础类型,灵活性差,难以满足复杂的需求 资料
详细语法
es6 模板字符串 介绍 模板字符串是 es6 中我最爱的特性啦!比起传统模板引擎,我更喜欢用模板字符串来编写组件 模板字符串包裹在 反引号(Esc按钮下面那个) 中,其中可通过 ${} 的语法进行插值// 特性一:多行 `123123 23213` // 特性二: 字符串中可插值(强大的不要不要的) /* 作为一门伪函数式编程语言,js 的很多语法都可以返回数据: * - 表达式: 各种运算符表达式,三目(可用来替代简单的判断语句) * - 函数: 封装各种复杂的逻辑,最后返回一个值即可 * - 方法: 如一些有返回值的数据方法 * - 最强大的如数组的 map, filter ... */ // 以下字符串都等于 '123tpl456' 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}` // strArr => ['Hello ', ' world ', ''] // vals => [15, 30] (${}里的值) 案例 由于直接用模板字符串当模板引擎了,所以就直接写个组件吧 演示 代码 用这种方法写模板需注意的是一定要细分组件(很函数式,有种写 jsx 的既视感) 资料 详细语法 相关文章 new Function 介绍 Function 是 js 提供的一个用于构造 Function 对象的构造函数 使用:
// 普通函数 function log (user, msg) { console.log(user, msg) } log('Away0x', 'lalala') // Function 构造函数 const log = new Function('user', 'msg', 'console.log(user, msg)') log('Away0x', 'lalala') 实现 大多数前端模板引擎都是用这种方式实现的,其原理在于运用了 js Function 对象可将字符串解析为函数的能力。 一个普通模板引擎的工作步骤大致如下:
// 1. 编写模板 {@ if( data.con > 20 ) { @} <p>ifififififif</p> {@ } else { @} <p>elseelseelseelse</p> {@ } @} // 2. 由模板生成函数体字符串 const functionbody = ` var tpl = '' if (data.con > 20) { tpl += '<p>ifififififif</p>' } else { tpl += '<p>ifififififif</p>' } return tpl ` // 3. 通过 Function 解析字符串并生成函数 new Function('data', functionbody)(data) 由此可见,只要将 {@ @} 里的字符串内容生成 js 语句,而其余内容之前加上 一个 'tpl += ' 即可。 实现代码如下:
const tpl = (str, data) => { const tplStr = str.replace(/\n/g, '') .replace(/{{(.+?)}}/g, (match, p) => `'+(${p})+'`) .replace(/{@(.+?)@}/g, (match, p) => `'; ${p}; tpl += '`) // console.log(tplStr) 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) ) // <p>ifififififif</p> // <p>0 : 1</p><p>1 : 2</p><p>2 : 3</p><p>3 : 4</p><p>4 : 5</p><p>5 : 76</p><p>6 : 87</p><p>7 : 8</p>
ok, 一个最最简单的模板引擎就已经完成了,支持在模板中嵌入 js 语句,虽然只有不到10行,但还是挺强大的对不。
拓展 实现模板类为了能够更好的使用,将前面的代码抽成一个类。
需求: 标识符格式有可能和后端模板引擎冲突,因此应实现成可配置的 {@ @}: 用于嵌套逻辑语句 {{ }}: 用于嵌套变量或表达式 在模板中应能添加注释,注释有两种: <!-- -->: 会输出 {# #}: 这种注释会在编译时被忽略,即只在模板中可见class Tpl { constructor (config) { const defaultConfig = { signs: { varSign: ['{{', '}}'], // 变量/表达式 evalSign: ['{@', '@}'], // 语句 commentSign: ['<!--', '-->'], // 普通注释 noCommentSign: ['{#', '#}'] // 忽略注释 } } // 可通过配置来修改标识符 this.config = Object.assign({}, defaultConfig, config) // ['{{', '}}'] => /{{([\\s\\S]+?)}}/g 构造正则 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
上面的代码在解析一些特殊模板注释(如下)时会出错
<!-- {{a}} --> // 由于注释中有标识符,因此会将 a 作为变量解析,会报未定义错误 解决: 在解析注释时,如注释里有标识符,则将其先替换成其他符号,等语句变量的解析完成时,再替换回来
.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 好烦呀,各种 '{' 乱飞,有些模板提供了更好看的语法:
{@ if data.con > 20 @} // if (data.con > 20) { <p>ifififififif</p> {@ elif data.con === 20 @} // } else if (data.con === 20) { <p>elseelseelseelseifififififif</p> {@ else @} // } else { <p>elseelseelseelse</p> {/@ if @} // } // for (var index = 0; index < data.list.length; index++) { var item = data.list[index] {@ each data.list as item @} <p>循环 {{ index + 1 }} 次: {{ item }}</p> {/@ each @}
其实就是在解析语句时多做一些处理而已:
// 配置中增加 syntax 属性,默认 false, 其为 true 是开启 语法模式 // 配置中增加语法模式结束语句的标识符: endEvalSign: ['{/@', '@}'] // 给 Tpl 类添加方法,用于 语法模式 的语句解析 _syntax (str) { const arr = str.trim().split(/\s+/) let exp = str if (arr[0] === 'if') { // if (xx) { exp = `if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'else') { // } else { exp = '} else {' } else if (arr[0] === 'elif') { // } else if (xx) { exp = `} else if ( ${arr.slice(1).join(' ')} ) {` } else if (arr[0] === 'each') { // for (var index = 0, len = xx.length; index < len; index++) { var item = xx[index] exp = `for (var index = 0, len = ${arr[1]}.length; index < len; index++) {var item = ${arr[1]}[index]` } return exp } // 修改 _compile 解析语句的 replace .replace( this.config.signs.evalSign, (match, p) => { let exp = p.replace('>', '>').replace('<', '<') // 语法模式 exp = this.config.syntax ? this._syntax(exp) : exp return `'; ${exp}; tpl += '` }) // 增加结束标识的解析 {/@ if @} {/@ each @} .replace( this.config.signs.endEvalSign, () => "'} tpl += '") 过滤器 很多模板引擎中都有提供很多好用的过滤器:
// 字符串转大写的过滤器 <p>{{ 'tpl' | upper }}</p> => <p>TPL</p> // 支持流式 <p>{{ 'tpl' | f1 | f2 }}</p> 现在我们来编写这个拓展
// 由于过滤器是对于变量的操作,所以只需在解析变量标识符{{}}的过程中做一下处理即可 // {{}} => 无过滤器直接返回, {{ xx | xx }} 有过滤器则调用 Filters 类中对应的过滤器函数 // 过滤器函数 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})+'` }) // 使用 // <h1>{{ 'tpl' | upper | reverse }}</h1> // => 'LPT' 至此该模板引擎就完成了,总结下功能: 支持在模板中使用 js 语句 支持自定义标识符 支持更简洁的语法模式 支持过滤器 其他 虽然这个模板工具还是有点简陋,比如没有个很友善的报错机制,不支持 include 等功能,但日常跑跑小项目什么的已经足够强大了 测试了下,我们这个 模板工具 的性能略优于 underscore.template,但比 Mustache 要差一些 演示 代码 资料 详细语法 只有20行Javascript代码!手把手教你写一个页面模板引擎 最后 如还有其他的实现方式,欢迎提意见 github 完整代码 ps: 觉得不错的话,别忘了 star 哦
版权声明:
1、该文章(资料)来源于互联网公开信息,我方只是对该内容做点评,所分享的下载地址为原作者公开地址。2、网站不提供资料下载,如需下载请到原作者页面进行下载。