用过vue的都知道在模板中我们可以使用{{xx}}
来渲染data
中的属性,这个语法叫做Mustache插值表达式
,用法简单,但心中也有一个疑问,它是如何做到的呢?接下来就让我们一探究竟吧!
1、使用正则来实现
比如说有这样一个模板字符
let tempStr2 = '我是一名{{develpoer}},我在学习{{knowledge}}知识!';
现在需要将字符串里面{{xxx}}
替换成数据,那么可以使用正则来实现
let tempStr2 = '我是一名{{develpoer}},我在学习{{knowledge}}知识!';
let data = {
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}}外的普通字符串分为一类,并将他们存储到数组中,比如:
这就是tokens思想,拿到了这样的一个数组我们就好办事了,想怎样拼接数据还不是自己说了算。
3、拆解模板字符串并分类
思路(这里假定分割符就是一对{{
}}
):
- 在模板字符串中使用
变量
或使用遍历
、if判断
的地方一定是使用{{}}
包裹着的 - 所有的普通字符串都是在
{{
的左边,因此可以通过查找{{
的位置来找到普通字符串,然后进行截取 {{
的位置前面的字符串已经被截取掉了,现在的模板字符串就变成了{{xxx}}
,那么现在该如何获取- ...
xxx
呢?- 新思路——用字符串截取(不要再想正则了哦~)。前面已经把
{{
前面的普通字符串给截取掉了,那么{{
也可以截取掉呀,截取掉{{
后模板字符串变成了xxx}}
- ...
xxx}}
这个字符串跟原始的模板字符串好像哦,只是- ...
{{
变成了}}
,那我们跟第2步一样操作就可以,找到}}
的位置,然后截取- 截取掉
xxx
后字符串变成了}}
,那我们再把- ...
}}
截取掉,然后就又回到了步骤2,如此循环直到没有字符串可截取了即可
代码实现:
/**
* 模板字符串扫描器
* 用于扫描分隔符{{}}左右两边的普通字符串,以及取得{{}}中间的内容。(当然分隔符不一定是{{}})
*/
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); // 跳过 }} 分隔符
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);
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;
}
调用后的结果:
到这一步之后就没有什么特别难的了,有了这样的结构,再结合数据就很容易了。
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 = 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 = 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 = {
...dataItem,
// 添加一个"."属性,主要是为了在模板中使用{{.}}语法时可以使用
'.': dataItem
}
}else{
nextData = {
// 添加一个"."属性,主要是为了在模板中使用{{.}}语法时可以使用
'.': 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);
7、现存问题
- 在
{{}}
中使用运算符(如加减、三元运算)的功能暂不知如何实现? - 循环的时候暂不支持给
当前循环项
起名字
8、结语
Mustache的tokens思想真的赞!!!以后我们遇到相似需求时也可以使用它的这个思想来实现,而非揪着正则、字符串替换不放。
感谢:感谢尚硅谷
,及尚硅谷的尚硅谷Vue源码解析系列课程
、谢老师
!