likes
comments
collection
share

前端模板引擎之mustache手写实现

作者站长头像
站长
· 阅读数 9

什么是模板引擎?

概括来说:将数据按照特定的方式转化为视图(html)的一种技术。 举个例子,将图中的data数据转化为 视图(html)结构数据。 前端模板引擎之mustache手写实现 搬一下百度上的概念:模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的文档。 模板引擎的核心原理就是两个字:替换。将预先定义的标签字符替换为指定的业务数据,或者根据某种定义好的流程进行输出。

Mustache模板引擎

Mustache介绍

Mustache是一款非常经典的前端模板引擎,是一套轻逻辑的模板语法。它可以来处理HTML,配置文件,源代码等文件。

它把模板中的标签展开成给定的映射或属性值。这里的轻逻辑是指模板里面没有if语句,else语句,for循环语句,只有模板标签。

mustache 是 “胡子”的意思,因为它的嵌入标记{{}}非常像胡子。mustache是最早的模板引擎库,比Vue诞生的早多了,它的底层实现机理在当时是非常有创造性的、轰动性的,为后续模板引擎的发展提供了崭新的思路。{{}}也被Vue沿用。

在前后端分离的技术架构中,前端模板引擎是一种可以被考虑的技术选型,随着前端三大框架(Angular、React、Vue)的流行,前端的模板技术已经成了标配。Mustache的价值在于稳定性和经典。

Mustache官网:mustache.github.io/mustache.5.…

Mustache的安装与使用

任何能够使用JavaScript的地方都可以使用mustache.js渲染模板。包括浏览器或者像node、CouchDB 这样的服务器环境。

Mustache安装

npm install mustache --save

Mustache使用

简单使用

我们来看一个简单的例子

import mustache from "mustache/mustache.mjs";
let view = {
    title: "Joe",
    calc: function () {
        return 2 + 4;
    }
};
let output = mustache.render("{{title}} spends {{calc}}", view);
console.log(output);
// Joe spends 6

通过上面的实例我们可以看到mustache使用{{}}作为标记的界定符。Vue里面的双大括号语法参考了mustache的实现。 mustache通过占位符来表示动态数据的位置。例如,一个{{title}}表示一个占位符,将在渲染的过程中被对应的数据替换。数据可以是简单的变量,也可以是对象的属性。

迭代列表

上面例子只是比较简单的情况,在实际的开发中,经常会出现嵌套,循环的情况,之前说到过Mustache是一套轻逻辑的模板语法,里面没有for循环。 为了解决这个问题,mustache提供了{{#condition}}...{{/condition}}{{#list}}...{{/list}}的语法,我们可以根据条件和迭代列表来渲染内容。其中双括号中的#代表循环的开始,/代表结束,中间的内容就代表着循环体。

再看个例子

//引入Mustacha
import mustache from "mustache/mustache.mjs";
let template =
`<ul>
   {{#arr}}
    <li>
       <div>{{name}}的基本信息</div>
       <div>
          <p>姓名:{{name}}</p>
          <p>性别:{{sex}}</p>
          <p>年龄:{{age}}</p>
       </div>
     </li>
   {{/arr}}
</ul>`;
let view = {
    arr:[
        { name: "小王",sex: "男",age: 18},
        { name: "小明",sex: "男",age: 25},
        { name: "小刘",sex: "男",age: 30}
    ]
}
let output = mustache.render(template,view);
console.log(output);

控制台输出:反引号法可以保留空格 前端模板引擎之mustache手写实现 从上面可以看到 Mustache以一种非常简洁的方式生成了动态的内容。

Mustache解析数据规律

mustache引擎执行过程

通过上面的实例可以看到我们将只要渲染的数据和模板文件传递给Mustache模板引擎,引擎会自动的解析模板文件并根据数据进行渲染,生成最终的结果。那么模板引擎是如何实现这一过程的呢?看下这张图。 前端模板引擎之mustache手写实现 在这个过程中引擎会先将模板文件编译成tokens,然后再把tokens结合数据渲染成dom结构的字符串。也就是说它主要实现了两件事

  1. 将模板文件编译成tokens形式
  2. 将tokens结合数据,生成dom的字符串

tokens是什么

在Mustache模板引擎中tokens是最重要的部分,它是连接模板文件和数据的桥梁。它本质是一个JS的嵌套数组,主要功能是将模板文件转化为JavaScript方便操作的数据格式。

普通情况

举个例子,如果模板文件是<h1>Today {{title}} spends {{calc}} dollar</h1>,那它经过编译后生成的tokens就是这种嵌套数组形式。 前端模板引擎之mustache手写实现

如上图所示,模板文件实际上就是一个纯文本文件,我们可以把它当做一个长字符串。在这个长字符串中以双括号{{}}为标记进行切割,切割后的一个个片段就是一个个token,用一个数组来存储token的信息。 前端模板引擎之mustache手写实现 如图所示,每一个token元素对应一个数组,数组第一个元素存储token的类型,主要有textname两种类型,text指代纯文本内容,不含标签,占位符和相关的控制结构。name指代{{}}双括号中的内容。,第二个元素存储对应的内容,第三和第四个元素是token字段的开始位置和结束位置,[0,10)左闭右开。

循环嵌套情况

上面的模板文件中是不包含迭代的,对于有嵌套循环的情况,Mustache模板引擎将循环嵌套部分当做一整个token,然后再将这整个token分割为一个个的小token。如下图所示:前端模板引擎之mustache手写实现 上图中的模板文件是用 模板字符串(反引号) 包裹的,可以保留空格和换行,所以解析后的tokens中有空格和换行符\n

据图可以看到整个循环部分被当做一个token,token数组的第一个值是#表示这个token的类型是迭代的token,第二个值就是迭代的列表或条件对应的变量,第三、第四个值分别表示迭代条件的开始位置与结束位置,第五个值表示迭代的内容,作为一个tokens,套入其中。第六个值则是整个迭代结束的位置。

调试官方的Mustache模板引擎

我现在安装的mustache插件是最新版本,版本号为:4.2.0,引入的js文件的路径为:node_modules/mustache/mustache.js。 找到js文件中的 parseTemplate 方法,其主要作用是生成tokens,修改代码,在控制台输出其生成的tokens。 前端模板引擎之mustache手写实现

引入修改后的js文件,测试下

//引入Mustacha
import mustache from "mustache/mustache.js";
//创建一个模板
let template =
`<ul>
   {{#arr}}
    <li>
       <div>{{name}}的基本信息</div>
       <div>
          <p>姓名:{{name}}</p>
          <p>性别:{{sex}}</p>
          <p>年龄:{{age}}</p>
       </div>
     </li>
   {{/arr}}
</ul>`;
let view = {
    arr:[
        { name: "小王",sex: "男",age: 18},
        { name: "小明",sex: "男",age: 25},
        { name: "小刘",sex: "男",age: 30}
    ]
}
let output = mustache.render(template,view);

控制台输出:

前端模板引擎之mustache手写实现

Mustache模板引擎将模板字符串解析成tokens的规则我们已经了解清楚了,接下来尝试手动实现这个过程。

Mustache实现模板文件到tokens的转变

根据上面的介绍,我们已经了解到Mustache模板引擎解析数据的规律,接下来我们就需要用代码来实现模板文件到tokens的转变。

简单情况

最终目标

先看一下模板文件,我们需要将{{...}}中的内容提取出来,并将其转化为二维数组。在本次手写实现过程中token对应数组['text','<h1>Today',0,10]第三,第四元素未起到作用,所以我们只实现前两位['text','<h1>Today']

前端模板引擎之mustache手写实现

实现思路分析

1,想要提取模板字符串中的{{}}包裹的数据,必须要遍历模板字符串,来逐个查找。

2,设置一个指针pos,用来标记遍历模板字符串时的进度位置。 前端模板引擎之mustache手写实现

3,指针pos将模板字符串分割成两部分:已扫描字符串待扫描字符串(包含pos指向的字符)。

前端模板引擎之mustache手写实现

4,指针pos向右移动,直到首次找到待扫描字符串前两位字符是{{时暂停扫描或未找到直接扫描完成。

前端模板引擎之mustache手写实现

5,如若匹配到待扫描字符串前两位字符是{{,并将此次扫描的字符串加入数组。注意,在{{符号前面的字符串的类型为text

前端模板引擎之mustache手写实现

6,存储数据后继续扫描,并直接将指针pos前移两位。

前端模板引擎之mustache手写实现

7,继续扫描,直到首次找到待扫描字符串前两位字符是}}时暂停本次扫描或未匹配到直接扫描完毕。 前端模板引擎之mustache手写实现

8,找到匹配元素后,将本次扫描数据存入数组中。并将指针pos后移两位。 注意在{{}}之前的字符串类型为name

前端模板引擎之mustache手写实现

9,继续扫描,依次循环执行第4,5,6,7,8步的操作,直到字符串扫描结束。

代码实现

创建Scanner类,主要用作扫描模板字符串。

/**
 * Scanner 类主要作用是扫描模板字符串
 * 以'{{' 和 '}}'分隔,将字符串分离开来。
 */
export default class Scanner{
  constructor(template){
    //存储模板信息
    this.template = template;
    //初始标记位
    this.pos = 0;
    //待扫描字符串
    this.tail = this.template;
  }

  /**
   * 匹配 {{ }} 等串,并使指针pos前进2位,
   * @param re
   * @returns {string|*}
   */
  scan(re){
    this.startIndex = this.pos;
    //匹配匹配待字符串中是否有 re
    let match = this.tail.match(re);

    //如果没有匹配到或匹配位置不是在头部,返回空字符串
    if(!match || match.index !== 0){
      return "";
    }

    //获取被匹配的字符 re
    let string = match[0];

    //待匹配字符串去掉被匹配的字符 re
    this.tail = this.tail.substring(string.length);

    //pos指针前进被匹配字符的长度
    this.pos += string.length;

    //返回被匹配字符re
    return string;
  }

  /**
   * 扫描模板字符串
   * @param re
   * @returns {string}
   */
  scanUntil(re){
    //search方法,用于检索字符串中指定的子串,或检索与正则表达式相匹配的子串,
    //返回子串第一次出现的位置,没有匹配到则返回-1
    //index 是匹配子串的位置,  match是被被扫描的字符串
    let index = this.tail.search(re),match;

    switch(index){
        //未匹配到,待匹配字符串被扫描完毕
      case -1:
        match = this.tail;
        this.tail = "";
        break;
        //在字符串头部匹配到
      case 0:
        match = "";
        break;
      default:
        match = this.tail.substring(0,index);
        this.tail = this.tail.substring(index);
    }

    //指针pos前进扫描字符的长度
    this.pos += match.length;

    //返回扫描的字符串
    return match;
  }

  /**
   * 判断模板字符串是否扫描完成
   * @returns {boolean}
   */
  eos(){
    return this.tail == '';
  }
}

创建parseTemplateToTokens方法,将模板字符串转化为tokens。

//引入扫描器
import Scanner from "./Scanner";
export default function parseTemplateToTokens(template){
    let tokens = [];
    //创造一个扫描器实例
    let scanner = new Scanner(template);
    let word;
    while(!scanner.eos()){
        //匹配首次出现的{{,并获取匹配到的字符串
        word = scanner.scanUntil('{{');
        //将匹配字符串存入数组
        if(word != ""){
            tokens.push(['text',word]);
        }
        //指针pos后移
        scanner.scan('{{');
        word = scanner.scanUntil('}}');
        if(word !=""){
            tokens.push(['name',word]);
        }
        scanner.scan('}}');
    }
    return tokens;
}

测试:

import parseTemplateToTokens from "./parseTemplateToTokens";
let template = "<h1>Today {{title}} spends {{calc}} dollar</h1>";
let tokens = parseTemplateToTokens(template);
console.log(tokens);

控制台打印:

前端模板引擎之mustache手写实现

嵌套循环情况

最终目标

我们需要将模板文件中有嵌套循环的部分,即将{{#...}}{{/...}}之间的模板代码作为一个token存储起来,如图所示:

前端模板引擎之mustache手写实现 循环部分对应的token类型以#区分,内容存储在一个数组里面,作为token里面的第三个元素。循环嵌套部分的代码按照无嵌套循环的情况处理。

实现思路分析

以之前简单情况为基础,在其上面继续深入。以之前的处理逻辑,转换的结果如下 前端模板引擎之mustache手写实现

根据循环部分的规则,token的类型应该为#,我们需要做一下处理,将循环部分与非循环部分区分开。对parseTemplateToTokens方法做一下更改。

import Scanner from "./Scanner";
export default function parseTemplateToTokens(template){
  ...
  while(!scanner.eos()){
   ...
    //对{{}}内的数据做判断,是循环部分还是普通情况
    word = scanner.scanUntil('}}');
    if(word != ""){
      //判断是否有循环
      if(word[0] == "#"){
        tokens.push(['#',word.substring(1)]);
      }else if(word[0] == '/'){
        tokens.push(['/',word.substring(1)]);
      }else{
        tokens.push(['name',word]);
      }
    }
    scanner.scan('}}');
  }
  return tokens;
}

更改后测试结果为: 前端模板引擎之mustache手写实现

接下来,我们需要将循环的部分放到一个token里面,如下图所示:前端模板引擎之mustache手写实现

将方框内的数据转化为一个数组,可以利用栈的思想,遇#入栈,遇/出栈。 实现思路过程: 创建三个数组: nestTokens 主要用来存储修改后的tokens, sections 数组作为栈,存储循环,条件数据。 collector 数组作为搜集器,搜集循环里面的数据。

初始时,nestTokens和collector指向同一个数组 前端模板引擎之mustache手写实现

1 开始循环,普通情况,遇到text,name类型的token,使用collector.push(),将token添加进数组。 前端模板引擎之mustache手写实现

2 遇到#号类型的token, 第一步,先将数据进栈。 第二步,通过collector将数据添加到数组中,效果如图所示。 前端模板引擎之mustache手写实现 第三步,创建一个新数组存储循环部分数据,并将新数组作为类型为#数据的第三个元素,同时,将collector指向新数组。 前端模板引擎之mustache手写实现 之后继续遍历,执行第一步的操作 前端模板引擎之mustache手写实现

遇到#类型的token继续执行第二步的操作 前端模板引擎之mustache手写实现

3 遇到/类型的token数据,表示一个循环结束,其之前的状态如下 前端模板引擎之mustache手写实现

在这步需要执行的操作有:将section最近加入的元素弹出,并将collector指向上一层。这里指回上一层,是根据section里面的数据来判断的。

前端模板引擎之mustache手写实现

之后,继续执行,最终成功将tokens转化为层次结构

前端模板引擎之mustache手写实现

代码实现

创建nestTokens方法,实现对tokens的转化。

export default function nestTokens(tokens){
  //创建一个数组,存储修改后的tokens
  let nestTokens = [];
  //创建一个栈,存储循环,条件数据
  let sections = [];
  //创建一个搜集器,搜集循环里面的数据
  let collector = nestTokens;

  for(let i = 0; i<tokens.length; i++){
    let token = tokens[i];

    switch(token[0]){
      case "#":
        //入栈
        sections.push(token);
        //收集器collector收集数据
        collector.push(token);
        //改变收集器collector指向,之后可以将循环内数据存放到token中
        collector = token[2] = [];
        break;
      case "/":
        //遇到/就出栈
        sections.pop();
        //每次出栈说明该层循环遍历完成,改变collector的指向,将其指回上一层
        collector = sections.length>0? sections[sections.length-1][2]:nestTokens;
        break;
      default:
        collector.push(token);
        break;
    }
  }
  return nestTokens;
}

在parseTemplateToTokens中引入 nesTokens方法

import Scanner from "./Scanner";
import nestTokens from "./nestTokens";
export default function parseTemplateToTokens(template){
    ...
    return nestTokens(tokens);
}

测试结果

let template =
`<ul>
   {{#arr}}
    <li>
       <div>{{name}}的基本信息</div>
       <div>
          <p>姓名:{{name}}</p>
          <p>性别:{{sex}}</p>
          <p>年龄:{{age}}</p>
          <p>
          爱好:{{#hobbies}}
          <span> {{.}} </span>
          {{/hobbies}}
          </p>
       </div>
     </li>
   {{/arr}}
</ul>`;
let data = {
    arr:[
        { name: "小王",sex: "男",age: 18,hobbies:["篮球","羽毛球"]},
        { name: "小明",sex: "男",age: 25,hobbies: ["王者荣耀","决斗连接"]},
        { name: "小刘",sex: "男",age: 30,hobbies: ["冲浪","滑雪"]}
    ]
};

import parseTemplateToTokens from "./parseTemplateToTokens";
let tokens = parseTemplateToTokens(template);
console.log(tokens);

前端模板引擎之mustache手写实现

至此,我们完成了从模板文件到tokens的转变,只剩下将tokens与数据结合生成对应的html。

Mustache实现tokens与数据的结合

关于将tokens与data结合生成html片段的代码我就不详细讲解了,不是很复杂,直接看代码吧。

创建一个renderTemplate函数实现这个功能。它接收两个参数:转化好的tokens、数据

export default function renderTemplate(tokens,data){
    let templateStr = "";
    for(let i = 0;i<tokens.length;i++){
        let token = tokens[i];
        switch(token[0]){
            case "text":
                templateStr += token[1];
                break;
            case "name":
                templateStr += lookup(data,token[1]);
                break;
            case "#":
            case "^":
                templateStr += parseArray(token,data);
                break;
            default:break;
        }
    }
    return templateStr;
}

/**
 * 将token转换成字符串
 * @param token
 * @param data
 */
function parseArray(token,data){
    let tmpStr = "";
    let childrenTokens = token[2];
    let childData = [];
    if(!data.hasOwnProperty(token[1])){
        return tmpStr;
    }else{
        childData = data[token[1]];
        for(let i=0; i<childData.length; i++){
            tmpStr += renderTemplate(childrenTokens,childData[i]);
        }
    }
    return tmpStr;
}

/**
 * 取出data中对应的数据值
 * @param data
 * @param valueName
 */
function lookup(data,valueName){
    let temp = data;
    if(typeof temp == 'string' && valueName == '.'){
        return temp;
    }
    let nameArr = valueName.split(".");
    if(nameArr.length >0){
        for(let i = 0;i<nameArr.length;i++){
            temp = temp[nameArr[i]];
        }
        return temp;
    }
}

创建一个Mustache类,传入模板文件和数据,返回匹配好的字符串。

import parseTemplateTOTokens from "./parseTemplateToTokens";
import renderTemplate from "./renderTemplate";
export default class Mustache{
    constructor(template,data){
        this.template = template;
        this.data = data;
    }
    render(){
        const {template,data} = this;
        //获取token
        let tokens = parseTemplateTOTokens(template);
        //将token与数据结合
        let templateStr = renderTemplate(tokens,data);
        return templateStr;
    }
}

将字符串挂载到dom结构上。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <div id="app"></div>
    <script>
      let template = `<ul>
   {{#arr}}
    <li>
       <div>{{name}}的基本信息</div>
       <div>
          <p>姓名:{{name}}</p>
          <p>性别:{{sex}}</p>
          <p>年龄:{{age}}</p>
          <p>爱好:{{#hobbies}}<span> {{.}} </span>{{/hobbies}}</p>
       </div>
     </li>
   {{/arr}}
</ul>`;
      let data = {
        title: "Joe",
        calc:"6",
        mentality:{
          positive:"good"
        },
        arr:[
          { name: "小王",sex: "男",age: 18,hobbies:["篮球","羽毛球"]},
          { name: "小明",sex: "男",age: 25,hobbies: ["王者荣耀","决斗连接"]},
          { name: "小刘",sex: "男",age: 30,hobbies: ["冲浪","滑雪"]}
        ]
      };
      setTimeout(()=>{
        let mustache = new Mustache(template,data);
        let node = document.getElementById('app');
        node.innerHTML = mustache.render();
      },500)
    </script>
  </body>
</html>

结果展示:

前端模板引擎之mustache手写实现

gitHub地址:https://github.com/seapack-hub/webpack-loader-plugin