Mustache底层原理及简单实现

用过vue的都知道在模板中我们可以使用 {{xx}}来渲染 data中的属性,这个语法叫做 Mustache插值表达式,用法简单,但心中也有一个疑问,它是如何做到的呢?接下来就让我们一探究竟吧!
1、使用正则来实现 比如说有这样一个模板字符
let tempStr2 = '我是一名{{develpoer}},我在学习{{knowledge}}知识!';

现在需要将字符串里面{{xxx}}替换成数据,那么可以使用正则来实现
let tempStr2 = '我是一名{{develpoer}},我在学习{{knowledge}}知识!'; let data = https://www.it610.com/article/{ develpoer:'web前端程序猿', knowledge: 'Mustache插值语法' }; let resultStr = tempStr2.replace(/{{(\w+)}}/g, function (matched, $1){ // {{develpoer}} develpoer // {{knowledge}} knowledge console.log(matched, $1); return data[$1]; }); // 结果: 我是一名web前端程序猿,我在学习Mustache插值语法知识! console.log('结果:', resultStr);

使用正则的弊端就是只能实现简单的插值语法,稍微复杂点的如循环if判断等功能就实现不了了。
2、Mustache的底层思想:tokens思想
let tempStr = `
    {{#students}}
  • {{name}}
    {{#hobbys}}
    {{.}}
    {{/hobbys}}
  • {{/students}}
`;

遇到这样的一个模板字符串,按照我们以往的编程思维,大多数人想的肯定是怎么拿到{{#students}}与{{/students}}中间的内容,用正则是不可能实现的了,对着这串字符串发呆苦想半天还是没有结果。
那假如我们将这个字符串里的内容进行分类呢?比如{{xxx}}分为一类,除去{{xxx}}外的普通字符串分为一类,并将他们存储到数组中,比如:
Mustache底层原理及简单实现
文章图片

这就是tokens思想,拿到了这样的一个数组我们就好办事了,想怎样拼接数据还不是自己说了算。
3、拆解模板字符串并分类 思路(这里假定分割符就是一对{{ }}):
  1. 在模板字符串中使用变量或使用遍历if判断的地方一定是使用{{}}包裹着的
  2. 所有的普通字符串都是在{{的左边,因此可以通过查找{{的位置来找到普通字符串,然后进行截取
  3. {{的位置前面的字符串已经被截取掉了,现在的模板字符串就变成了{{xxx}}
  4. ...,那么现在该如何获取xxx呢?
  5. 新思路——用字符串截取(不要再想正则了哦~)。前面已经把{{前面的普通字符串给截取掉了,那么{{也可以截取掉呀,截取掉{{后模板字符串变成了xxx}}
  6. ...
  7. xxx}}
  8. ...这个字符串跟原始的模板字符串好像哦,只是{{变成了}},那我们跟第2步一样操作就可以,找到}}的位置,然后截取
  9. 截取掉xxx后字符串变成了}}
  10. ...,那我们再把}}截取掉,然后就又回到了步骤2,如此循环直到没有字符串可截取了即可
【Mustache底层原理及简单实现】代码实现:
/** * 模板字符串扫描器 * 用于扫描分隔符{{}}左右两边的普通字符串,以及取得{{}}中间的内容。(当然分隔符不一定是{{}}) */ class Scanner{ constructor (templateStr) { this.templateStr = templateStr; this.pos = 0; // 查找字符串的指针位置 this.tail = templateStr; // 模板字符串的尾巴 }/** * 扫瞄模板字符串,跳过遇到的第一个匹配的分割符 * @param delimiterReg * @returns {undefined} */ scan(delimiterReg){ if(this.tail){ let matched = this.tail.match(delimiterReg); if(!matched){ return; } if(matched.index != 0){ // 分隔符的位置必须在字符串开头才能进行后移操作,否则会错乱 return; } let delimiterLength = matched[0].length; this.pos += delimiterLength; // 指针位置需加上分隔符的长度 this.tail = this.tail.substr(delimiterLength); // console.log(this); } }/** * 扫瞄模板字符串,直到遇到第一个匹配的分隔符,并返回第一个分隔符(delimiterReg)之前的字符串 * 如: *var str = '我是一名{{develpoer}},我在学习{{knowledge}}知识!'; *第一次运行:scanUtil(/{{/) => '我是一名' *第二次运行:scanUtil(/{{/) => '我在学习' * @param delimiterReg 分割符正则 * @returns {string} */ scanUtil(delimiterReg){ // 查找第一个分隔符所在的位置 let index = this.tail.search(delimiterReg); let matched = ''; switch (index){ case -1: // 没有找到,如果没有找到则说明后面没有使用mustache语法,那么把所有的tail都返回 matched = this.tail; this.tail = ''; break; case 0: // 分隔符在开始位置,则不做任何处理 break; default: /* 如果找到了第一个分隔符的位置,则截取第一个分割符位置前的字符串,设置尾巴为找到的分隔符及其后面的字符串,并更新指针位置 */ matched = this.tail.substring(0, index); this.tail = this.tail.substring(index); } this.pos += matched.length; // console.log(this); return matched; }/** * 判断是否已经查找到字符串结尾了 * @returns {boolean} */ eos(){ return this.pos >= this.templateStr.length; } }export { Scanner };

使用:
import {Scanner} from './Scanner'; let tempStr = `
    {{#students}}
  • {{name}}
    {{#hobbys}}
    {{.}}
    {{/hobbys}}
  • {{/students}}
`; let startDeli = /{{/; // 开始分割符 let endDeli = /}}/; // 结束分割符let scanner = new Scanner(tempStr); console.log(scanner.scanUtil(startDeli)); // 获取 {{ 前面的普通字符串 scanner.scan(startDeli); // 跳过 {{ 分隔符console.log(scanner.scanUtil(endDeli)); // 获取 }} 前面的字符串 scanner.scan(endDeli); // 跳过 }} 分隔符console.log('---------------------------------------------'); console.log(scanner.scanUtil(startDeli)); // 获取 {{ 前面的普通字符串 scanner.scan(startDeli); // 跳过 {{ 分隔符console.log(scanner.scanUtil(endDeli)); // 获取 }} 前面的字符串 scanner.scan(endDeli); // 跳过 }} 分隔符

结果:
Mustache底层原理及简单实现
文章图片

4、将字符串模板转换成tokens数组 前面的Scanner已经可以解析字符串了,现在我们只需要将模板字符串组装起来即可。
代码实现
import {Scanner} from '../Scanner'; /** * 将模板字符串转换成token * @param templateStr 模板字符串 * @param delimiters 分割符,它的值为一个长度为2的正则表达式数组 * @returns {*[]} */ export function parseTemplateToTokens(templateStr, delimiters= [/{{/, /}}/]){ let [startDelimiter, endDelimiter] = delimiters; let tokens = []; if(!templateStr){ return tokens; } let scanner = new Scanner(templateStr); while (!scanner.eos()){ // 获取开始分隔符前面的字符串 let beforeStartDelimiterStr = scanner.scanUtil(startDelimiter); if(beforeStartDelimiterStr.length > 0){ tokens.push(['text', beforeStartDelimiterStr]); // console.log(beforeStartDelimiterStr); } // 跳过开始分隔符 scanner.scan(startDelimiter); // 获取开始分隔符与结束分隔符之间的字符串 let afterEndDelimiterStr = scanner.scanUtil(endDelimiter); if(afterEndDelimiterStr.length == 0){ continue; } if(afterEndDelimiterStr.charAt(0) == '#'){ tokens.push(['#', afterEndDelimiterStr.substr(1)]); }else if(afterEndDelimiterStr.charAt(0) == '/'){ tokens.push(['/', afterEndDelimiterStr.substr(1)]); }else { tokens.push(['name', afterEndDelimiterStr]); } // 跳过结束分隔符 scanner.scan(endDelimiter); }return tokens; }

使用:
import {parseTemplateToTokens} from './parseTemplateToTokens'; let tempStr = `
    {{#students}}
  • {{name}}
    {{#hobbys}}
    {{.}}
    {{/hobbys}}
  • {{/students}}
`; let delimiters = [/{{/, /}}/]; var tokens = parseTemplateToTokens(templateStr, delimiters); console.log(tokens);

结果:
Mustache底层原理及简单实现
文章图片

5、再次组装tokens 前面我们使用的模板字符串中存在嵌套结构,而前面组装的tokens是一维的数组,使用一维数组来渲染循环结构的模板字符串显然不大可能,就算可以,代码也会很难理解。
此时我们就需要对一维的数组进行再次组装,这一次我们要将它组装成嵌套结构,并且前面封装的一维数组也是符合条件的。
代码:
/** * 将平铺的tokens数组转换成嵌套结构的tokens数组 * @param tokens 一维tokens数组 * @returns {*[]} */ export function nestsToken(tokens){ var resultTokens = []; // 结果集 var stack = []; // 栈数组 var collector = resultTokens; // 结果收集器tokens.forEach(token => { let tokenFirst = token[0]; switch (tokenFirst){ case '#': // 遇到#号就将当前token推入进栈数组中 stack.push(token); collector.push(token); token[2] = []; // 并将结果收集器设置为刚入栈的token的子集 collector = token[2]; break; case '/': // 遇到 / 就将栈数组中最新入栈的那个移除掉 stack.pop(); // 并将结果收集器设置为栈数组中栈顶那个token的子集,或者是最终的结构集 collector = stack.length > 0 ? stack[stack.length - 1][2] : resultTokens; break; default: // 如果不是#、/则直接将当前这个token添加进结果集中 collector.push(token); } }); return resultTokens; }

调用后的结果:
Mustache底层原理及简单实现
文章图片

到这一步之后就没有什么特别难的了,有了这样的结构,再结合数据就很容易了。
6、渲染模板 下面代码是我的简单实现方式:
代码:
import {lookup} from './lookup'; /** * 根据tokens将模板字符串渲染成html * @param tokens * @param datas 数据 * @returns {string} */ function renderTemplate(tokens, datas){ var resultStr = ''; tokens.forEach(tokenItem => { var type = tokenItem[0]; var tokenValue = https://www.it610.com/article/tokenItem[1]; switch (type){ case'text': // 普通字符串,直接拼接即可 resultStr += tokenValue; break; case 'name': // 访问对象属性 // lookup是一个用来以字符串的形式动态的访问对象上深层的属性的方法,如:lookup({a: {b: {c: 100}}}, 'a.b.c')、lookup({a: {b: {c: 100}}}, 'a.b'); resultStr += lookup(datas, tokenValue); break; case '#': let valueReverse = false; if(tokenValue.charAt(0) == '!'){ // 如果第一个字符是!,则说明是在使用if判断做取反操作 tokenValue = https://www.it610.com/article/tokenValue.substr(1); valueReverse = true; } let val = datas[tokenValue]; resultStr += parseArray(tokenItem, valueReverse ? !val : val, datas); break; } }); return resultStr; }/** * 解析字符串模板中的循环 * @param token token * @param datas 当前模板中循环所需的数据数据 * @param parentData 上一级的数据 * @returns {string} */ function parseArray(token, datas, parentData){ // console.log('parseArray datas', datas); if(!Array.isArray(datas)){ // 如果数据的值不是数组,则当做if判断来处理 let flag = !!datas; // 如果值为真,则渲染模板,否则直接返回空 return flag ? renderTemplate(token[2], parentData) : ''; } var resStr = ''; datas.forEach(dataItem => { // console.log('dataItem', dataItem); let nextData; if(({}).toString.call(dataItem) != '[object, Object]'){ nextData = https://www.it610.com/article/{ ...dataItem, // 添加一个"."属性,主要是为了在模板中使用{{.}}语法时可以使用 '.': dataItem } }else{ nextData = https://www.it610.com/article/{ // 添加一个"."属性,主要是为了在模板中使用{{.}}语法时可以使用 '.': dataItem }; }resStr += renderTemplate(token[2], nextData); }); return resStr; }export {renderTemplate, parseArray};

使用:
import {parseTemplateToTokens} from './parseTemplateToTokens'; import {nestsToken} from './nestsTokens'; import {renderTemplate} from './renderTemplate'; let tempStr = `
    {{#students}}
  • {{name}}
    {{#hobbys}}
    {{.}}
    {{/hobbys}}
  • {{/students}}
`; let datas = { students: [ {name: 'Html', hobbys: ['超文本标记语言', '网页结构'], age: 1990, ageThen25: true, show2: true}, {name: 'Javascript', hobbys: ['弱类型语言', '动态脚本语言', '让页面动起来'], age: 1995, ageThen25: 0, show2: true}, {name: 'Css', hobbys: ['层叠样式表', '装饰网页', '排版'], age: 1994, ageThen25: 1, show2: true}, ] }; let delimiters = [/{{/, /}}/]; var tokens = parseTemplateToTokens(templateStr, delimiters); console.log(tokens); var nestedTokens = nestsToken(tokens); console.log(nestedTokens); var html = renderTemplate(nestedTokens, datas); console.log(html);

效果:
Mustache底层原理及简单实现
文章图片

7、现存问题
  • {{}}中使用运算符(如加减、三元运算)的功能暂不知如何实现?
  • 循环的时候暂不支持给当前循环项起名字
8、结语 Mustache的tokens思想真的赞!!!以后我们遇到相似需求时也可以使用它的这个思想来实现,而非揪着正则、字符串替换不放。
感谢:感谢尚硅谷,及尚硅谷的尚硅谷Vue源码解析系列课程谢老师

    推荐阅读