Mustache底层原理及简单实现

heath_learning
English
用过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 = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;

遇到这样的一个模板字符串,按照我们以往的编程思维,大多数人想的肯定是怎么拿到{{#students}}与{{/students}}中间的内容,用正则是不可能实现的了,对着这串字符串发呆苦想半天还是没有结果。

那假如我们将这个字符串里的内容进行分类呢?比如{{xxx}}分为一类,除去{{xxx}}外的普通字符串分为一类,并将他们存储到数组中,比如:

这就是tokens思想,拿到了这样的一个数组我们就好办事了,想怎样拼接数据还不是自己说了算。

3、拆解模板字符串并分类

思路(这里假定分割符就是一对{{ }}):

  1. 在模板字符串中使用变量或使用遍历if判断的地方一定是使用{{}}包裹着的
  2. 所有的普通字符串都是在{{的左边,因此可以通过查找{{的位置来找到普通字符串,然后进行截取
  3. {{的位置前面的字符串已经被截取掉了,现在的模板字符串就变成了{{xxx}}<li>...,那么现在该如何获取xxx呢?
  4. 新思路——用字符串截取(不要再想正则了哦~)。前面已经把{{前面的普通字符串给截取掉了,那么{{也可以截取掉呀,截取掉{{后模板字符串变成了xxx}}<li>...
  5. xxx}}<li>...这个字符串跟原始的模板字符串好像哦,只是{{变成了}},那我们跟第2步一样操作就可以,找到}}的位置,然后截取
  6. 截取掉xxx后字符串变成了}}<li>...,那我们再把}}截取掉,然后就又回到了步骤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 = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
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); // 跳过 }} 分隔符

结果:
image.png

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 = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
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;
}

调用后的结果:
image.png
到这一步之后就没有什么特别难的了,有了这样的结构,再结合数据就很容易了。

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 = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
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);

效果:
image.png

7、现存问题

  • {{}}中使用运算符(如加减、三元运算)的功能暂不知如何实现?
  • 循环的时候暂不支持给当前循环项起名字

8、结语

Mustache的tokens思想真的赞!!!以后我们遇到相似需求时也可以使用它的这个思想来实现,而非揪着正则、字符串替换不放。

感谢:感谢尚硅谷,及尚硅谷的尚硅谷Vue源码解析系列课程谢老师

阅读 472
1.2k 声望
23 粉丝
0 条评论
1.2k 声望
23 粉丝
文章目录
宣传栏