vue底层之Mustache

623 阅读4分钟

mustache

引言:vue中的{{}}是使用了mustache语法,还有v-for,v-if等等指令也是基于这种模板引擎,本文对mustache进行深入,mustache的源码官方有九百行,作者在最后实现了简单的百行版本(主体功能实现,错误处理小功能等未进行处理)。

什么是模板引擎

数据变为视图的最优雅的解决方案。

历史发展:

纯DOM (手动创建dom节点和对应赋值)-> join -> es6模板字符串 -> 模板引擎

假设有这样一个数据

[
    {name:'A',sex:'male'},
    {name:'B',sex:'female'},
    {name:'C',sex:'male'}
]

要变为这样的视图

<ul>
    <li>
        <div class="left">
            A的信息
        </div>
        <div class="right">
           <p>
               姓名A
            </p>
            <p>
                性别:male
            </p>
        </div>
    </li>
     <li>
        <div class="left">
            B的信息
        </div>
        <div class="right">
           <p>
               姓名B
            </p>
            <p>
                性别:female
            </p>
        </div>
    </li>
     <li>
        <div class="left">
            C的信息
        </div>
        <div class="right">
           <p>
               姓名C
            </p>
            <p>
                性别:male
            </p>
        </div>
    </li>
</ul>

DOM法:

<html>
    <ul class="container">
        
    </ul>
</html>
let data= [
    {name:'A',sex:'male'},
    {name:'B',sex:'female'},
    {name:'C',sex:'male'}
];
let ulEle = document.getElementByClass('container');
for (let i=0;i<data.length;i++){
    let li = document.createElement('li');
    let left = document.createElement('div');
    left.className = 'left';
    left.innerHtml = data[i].name+'的信息';
    let right = document.createElement('');
    right.className = 'right';
    right.innerHtml = '<p>姓名:'+data[i].name+'</p><p>性别:'+data[i].sex+'</p>';
    li.appendChild(left);
    li.appendChild(right);
    ulEle.appendChild(li);
}

join法(利用join生成带有结构的html字符串):

<html>
    <head></head>
    <body>
        <div id="container">
        </div>
    </body>
    <script>
        let data= [
    {name:'A',sex:'male'},
    {name:'B',sex:'female'},
    {name:'C',sex:'male'}
];
for (let i=0;i<data.length;i++){
   let HtmlStr = [
       '<li>',
        '<div class="left">',
            data[i].name+'的信息',
       '</div>',
       '<div class="right">',
           '<p>姓名:'+data[i].name+'</p>',
            '<p>性别:'+data[i].sex+'</p>',
        '</div>',
    '</li>'
   ].join(''); 
    let ulEle = document.getElementById('container');
    ulEle.innerHTML += HtmlStr;
}
    </script>
</html>

es6 反引号`` 由于允许换行,所以基于上面join思想更加简便

<html>
    <head></head>
    <body>
        <div id="container">
        </div>
    </body>
    <script>
        let data= [
    {name:'A',sex:'male'},
    {name:'B',sex:'female'},
    {name:'C',sex:'male'}
];
for (let i=0;i<data.length;i++){
   let HtmlStr = `
	<li>
        <div class="left">
           ${data[i].name}的信息
        </div>
        <div class="right">
           <p>
               姓名${data[i].name}
            </p>
            <p>
                性别:${data[i].s}
            </p>
        </div>
    </l>
`
    let ulEle = document.getElementById('container');
    ulEle.innerHTML += HtmlStr;
}
    </script>
</html>

mustache使用

mustache是最早的模板引擎,vue只是引用了语法

引入mustache库才能使用

如上面的例子可以写成

<ul>
    {{#data}}
    <div class="left">
        {{name}}的信息
    </div>
    <div class="right">
        <p>
            姓名:{{name}}
        </p>
        <p>
            性别:{{sex}}
        </p>
    </div>
    {{/data}}
</ul>

render方法将数据data可以渲染进模板字符串中,实现数据转为视图

Mustache.render(templateStr, data);生成渲染数据后的HTML字符串

data中的数组(假设arr)可以用开始的{{#arr}}和结束的{{/arr}}来声明循环渲染的内容

<ul>
            {{#arr}}
                <li>
                    <div class="hd">{{name}}的基本信息</div>    
                    <div class="bd">
                        <p>姓名:{{name}}</p>    
                        <p>性别:{{sex}}</p>    
                        <p>年龄:{{age}}</p>    
                    </div>
                </li>
            {{/arr}}
</ul>
        

<script>
        var templateStr = document.getElementById('mytemplate').innerHTML;

        var data = {
            arr: [
                { "name": "小明", "age": 12, "sex": "男" },
                { "name": "小红", "age": 11, "sex": "女" },
                { "name": "小强", "age": 13, "sex": "男" }
            ]
        };

        var domStr = Mustache.render(templateStr, data);
        var container = document.getElementById('container');
        container.innerHTML = domStr;
    </script>

在每次{{#array}},{{/}} 循环遍历数组,如果数组每项为基础数据值也可以用 {{. }}来渲染值。

还可以用{{#bool}} ,用data中的布尔值来确定是否渲染DOM,(类似v-show)

<body>
    <div id="container"></div>

    <script src="jslib/mustache.js"></script>
    <script>
        var templateStr = `
            {{#m}}
                <h1>你好</h1>
            {{/m}}
        `;

        var data = {
            m: false
        };

        var domStr = Mustache.render(templateStr, data);
        
        var container = document.getElementById('container');
        container.innerHTML = domStr;
    </script>
</body>

如果不用``,还可以用scrpit,type不为javascript来储存模板字符串。

 <!-- 模板 -->
    <script type="text/template" id="mytemplate">
        <ul>
            {{#arr}}
                <li>
                    <div class="hd">{{name}}的基本信息</div>    
                    <div class="bd">
                        <p>姓名:{{name}}</p>    
                        <p>性别:{{sex}}</p>    
                        <p>年龄:{{age}}</p>    
                    </div>
                </li>
            {{/arr}}
        </ul>
    </script>

    <script src="jslib/mustache.js"></script>
    <script>
        var templateStr = document.getElementById('mytemplate').innerHTML;

        var data = {
            arr: [
                { "name": "小明", "age": 12, "sex": "男" },
                { "name": "小红", "age": 11, "sex": "女" },
                { "name": "小强", "age": 13, "sex": "男" }
            ]
        };

        var domStr = Mustache.render(templateStr, data);
        var container = document.getElementById('container');
        container.innerHTML = domStr;
    </script>

mustache的底层核心机理

结论:mustache不能用简单的正则表达式实现思路

<script>
        var templateStr = '<h1>我买了一个{{thing}},花了{{money}}元,好{{mood}}</h1>';

        var data = {
            thing: '白菜',
            money: 5,
            mood: '激动'
        };

        // 最简单的模板引擎的实现机理,利用的是正则表达式中的replace()方法。
        // replace()的第二个参数可以是一个函数,这个函数提供捕获的东西的参数,就是$1
        // 结合data对象,即可进行智能的替换
        function render(templateStr, data) {
            return templateStr.replace(/\{\{(\w+)\}\}/g, function (findStr, $1) {
                return data[$1];
            });
        }

        var result = render(templateStr, data);
        console.log(result);
    </script>

其中的正则匹配 {{ 开头,}}结尾,中间使用()来捕获内容给回调函数的第二个参数,函数中返回要替换匹配的值,(使用第二个参数捕获的属性和 对象[str]取值) 。

实现了简单用data替换正则表达{{}}里的值,实现最简单的模板引擎

tokens

模板字符串的js来表示(js的嵌套数组),是抽象语法树,虚拟节点的起源。

根据源码分割token的打印,大致可以看出相应的分割规则。

最开始是一个二维数组,每项的第一个项为类型:文本是'text'(包括标签和换行空格等); 循环数组是'#',从#到/之间的部分都为这一个行;数据值为'name',代表要替换的基础数据

tokens数组的每一行都是由{{}}来分割的,每一行第一项为类型,第二项为值(其中text就为所有的字符串,#则为要遍历数组的属性名),第三项为在整个模板字符串的开始索引,第四项为整个模板字符串的结束索引。如果是#还有第五项和第六项:第五项为递归的二维数组,这意味着从一开始的规则重新进行分割 ,因此模板字符串嵌套层数高,tokens相应也会深层嵌套; 第六项为/的循环结束索引

        var templateStr = `
            <ul>
                {{#arr}}
                    <li>
                        {{name}}的爱好是:
                        <ol>
                            {{#hobbies}} 
                                <li>{{.}}</li>
                            {{/hobbies}} 
                        </ol>
                    </li>    
                {{/arr}}
            </ul>
        `;

        var data = {
            arr: [
                {'name': '小明', 'age': 12, 'hobbies': ['游泳', '羽毛球']},
                {'name': '小红', 'age': 11, 'hobbies': ['编程', '写作文', '看报纸']},
                {'name': '小强', 'age': 13, 'hobbies': ['打台球']},
            ]
        };

        var domStr = Mustache.render(templateStr, data);
        
        var container = document.getElementById('container');
        container.innerHTML = domStr;

看下相应的tokens分解

mustache的实现

使用webpack创建工程化项目,使得js文件分功能模块更加清晰。

npm init -y

npm install webpack webpack-cli webpack-dev-server

//webpack.config.js配置文件
const path = require('path');

module.exports = {
    // 模式,开发
    mode: 'development',
    // 入口
    entry: './src/index.js',
    // 打包到什么文件
    output: {
        filename: 'main.js'
    },
    // 配置一下webpack-dev-server
    devServer: {
        // 静态文件根目录
        contentBase: path.join(__dirname, "www"),
        // 不压缩
        compress: false,
        // 端口号
        port: 8080,
        // 虚拟打包的路径,main.js文件没有真正的生成
        publicPath: "/xuni/"
    }
};
整体实现思路

大方向为两个工作: 将模板字符串转化为tokens数组 ; 利用tokens数组和传进去的data生成填充数据后的HTML代码

细分步骤:

  1. 实现scanner类
  2. 生成tokens数组
  3. tokens数组的折叠
  4. 将数据和tokens数组进行结合生成HTML字符串
实现scanner类

scanner类实现的功能为匹配所有的{{和}},并将{{前的字符串和{{后 }}前的字符串提取出来。

方法有两个: scanUtil实现跳到指定字符串的位置并收集跳过的字符串,scan实现直接跳过指定的字符串。

具体代码如下

//scanner.js
export default class Scanner {
    constructor(templateStr) {
        console.log(templateStr);
        this.templateStr = templateStr;
        this.pos = 0;
        this.tail = templateStr.substring(this.pos);
    }
    scan(skipStr) { //scan用于直接跳过指定的字符串
    if (this.tail.indexOf(skipStr)== 0)
    {
        this.pos += skipStr.length;
        this.tail = this.templateStr.substring(this.pos);
    }
    }
    scanUtil(UtilStr) {  //跳转到指定字符串的开头,并返回经过的字符串
        let startIndex = this.pos;
        while (this.tail.indexOf(UtilStr) != 0 && this.pos < this.templateStr.length) {
            this.pos++;
            this.tail = this.templateStr.substring(this.pos);
        }
        return this.templateStr.substring(startIndex,this.pos);
    }

}

使用index.js测试如下

//index.js
import scanner from './scanner.js'
window.MustacheEngine = { //声明全局下的MustacheEngine对象
    render : function(templateStr,Data){
      let scan = new scanner(templateStr);
      while(scan.pos <scan.templateStr.length){
        console.log(scan.scanUtil('{{'));
          scan.scan('{{');
          console.log(scan.scanUtil('}}'));
          scan.scan('}}');
      }
    }
}
生成tokens数组

创建genTokens.js文件导出genTokens函数,在index.js中引入并接收生成的tokens数组。

import Scanner  from "./scanner";
import nestTokens from "./nestTokens";
export default function genTokens(templateStr){
    let tokens = [];
    let scanner = new Scanner(templateStr);
    let words;
    while (scanner.pos < scanner.templateStr.length){
        words = scanner.scanUtil('{{');
        if (words){
            tokens.push(['text',words]);
        }
        scanner.scan('{{');
        words = scanner.scanUtil('}}');
        
        if (words){
            switch (words[0]){
                case '#':
                    tokens.push(['#',words.substring(1)]);
                    break;
                case '/':
                    tokens.push(['/',words.substring(1)]);
                    break;
                default:
                    tokens.push(['name',words])
            }
        }
        scanner.scan('}}');
    }
    let resultTokens = nestTokens(tokens);
    return resultTokens;
}
tokens数组的折叠

#和/要形成子数组的开始和结束,适用于栈的结构来进行操作。

遇到#就压栈,/就出栈。

新建nextTokens.js来进行tokens的折叠。

先来看官方源码

  function nestTokens(tokens) {
      //nestTokens是结果数组,返回折叠后的tokens数组
      var nestedTokens = [];
      //collector是数组的指针,会随着嵌套关系变化
      var collector = nestedTokens;
      //sections是栈,遇到#压栈,遇到/出栈,用来管理嵌套关系,里面存储的都是要进行嵌套的初始数组
      var sections = [];

      var token, section;
      for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
          token = tokens[i];
		//此时的token第一项为类型,第二项为值,三四为索引,如果是#第五项要折叠成子数组
          switch (token[0]) {
              case '#':
              case '^':
                  //collector压栈,代表着当前未处理数组中最深的数组先把这个嵌套数组先存储
                  collector.push(token);
                  //维护嵌套:压栈
                  sections.push(token);
                  //这里collector指向目前最深的数组
                  collector = token[4] = [];
                  break;
              case '/':
                  //栈顶出栈
                  section = sections.pop();
                  
                  section[5] = token[2];
                  //根据维护的栈情况来确定指向:如果还有项指向目前栈顶数组,没有说明嵌套完毕指向返回的结果数组。
                  collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
                  break;
              default:
                  //所有文本都直接由collector进行压栈。
                  collector.push(token);
          }
      }

      return nestedTokens;
  }

实现:

//nestTokens.js
export default function nestTokens(tokens){
    //结果数组
    let resultTokens = [];
    //数组指针,指向当前未处理的最深层次数组
    let current = resultTokens;
    //维护的栈
    let sections = [];
    for (let i=0 ;i<tokens.length;i++){
        //token为未折叠的初始二维数组
        let token = tokens[i];
        //类型匹配
        switch (token[0]){
            case '#':
                //压栈储存数组
                current.push(token);
                sections.push(token);
                //重点: 开辟索引2的子数组并把指针指向子数组
                current = token[2] = [];
                break;
            case '/':
                sections.pop();
                //重点: 维护的栈如果还有内容则指向栈顶进行子数组内容填充,数目0指向结果数组
                current = sections.length>0 ? sections[sections.length-1][2]:resultTokens
                break;
            default:
                //由于current是动态的,所有内容都由current来储存
                current.push(token);
                break;
        }   
    }
    return resultTokens;
}
将数据和tokens数组进行结合生成HTML字符串

其中提前写好lookup函数,用于{{}}中使用.运算符

//lookup.js
//对data对象实现深层次的属性查找 如a.b.c
export default function lookup(keyStr,Data){
    if (keyStr.indexOf('.')!= 0 ){
        let keyArr = keyStr.split('.');
        let temp = Data;
        for (let i=0;i< keyArr.length;i++){
            temp = temp[keyArr[i]];
        }
        return temp;
    }
    return Data[keyStr];
}

创建renderTemplate,返回最后生成的HTML

import lookup  from "./lookup.js";
export default function renderTemplate(tokens,Data){
    let HtmlStr='';
    for (let i=0;i<tokens.length;i++){
        let token = tokens[i];
        let type = token[0];
        switch (type){
            case 'text':
                HtmlStr+= token[1];
                break;
            case 'name':
                let value = lookup(token[1],Data);
                HtmlStr += value;
                break;
            case '#':
                let array = lookup(token[1],Data);
                for (let i=0;i<array.length;i++){
                    HtmlStr += renderTemplate(token[2],{...array[i],'.':array[i]});
                }
        }
    }
    return HtmlStr;
}

最终的index.js

import genTokens from './genTokens.js'
import renderTemplate from './renderTemplate.js'

window.MustacheEngine = { //声明全局下的MustacheEngine对象
    render : function(templateStr,Data){
       let tokens = genTokens(templateStr);
      //  console.log(tokens);
       let DomStr  = renderTemplate(tokens,Data);
      //  console.log(`DomStr:${DomStr}`);
       return DomStr;
    }
}

总结

未涉及到vue的模板指令v-for,v-if等等,但它们都是基于mustache,都是将数据转成视图。

将数据转成视图的方法有很多(有循环则遍历数组): DOM法(手动根据data来创建相对应的HTML代码);数组join法(将模板HTML字符串每一行都写在数组中,数据进行相对应的填充,最后利用join''生成HTML);es6反引号`(join的升级版,由于可以换行,直接写在反引号中,用$填充数据,最后遍历生成HTML); 引用Mustache库函数render生成(传进模板字符串和数据生成)

模板字符串使用规则如下:

插入数据三种形式

{arg}, {a.b}, {.} 分别代表着传入数据的 arg属性值;数据的a对象属性中的b的属性值;用在循环中并且每一项都为基础属性值

遍历 {{#array}} 开始 ,{{/array}} 结束,中间的内容会循环渲染array的长度次数,里面自动转为array对象中的数据

{{#boolean}开始,{{/m}}结束,类似v-show。true添加dispaly:none的样式

实现的原理: 将传入的模板字符串转成tokens,结合传入的data生成填充数据渲染后的HTML字符串。

tokens是反映模板字符串结构和内容的数组:它的分割是基于{{}},里面的内容为name类型,值为key;外面的字符串内容都为text,值就是字符串;遇到{{#array}},会被解析为 #类型,值为array的引用,这个时候会开辟第2的索引,存放子tokens数组。遇到{{/array}},会被解析为/类型,值为array索引。

具体实现的步骤:

  1. Scanner类:储存templateStr,pos,tail,实现过指定字符串并返回内容的函数
  2. 生成tokens:生成scan实例,遍历模板字符串,使用scan{{拿到text的内容并存进tokens中;使用}}拿到要渲染的内容进行分类:如果是#开头说明要循环,存入#类型的数组,值为数组的key;如果是/开头存入/类型,值为数组;其他情况则是data的key,存入name类型
  3. tokens数组的折叠:维护一个栈sections和数组指针current,添加内容都在栈顶添加(栈中如果没有元素则代表是根数组tokens数组),遇到token项类型为'#',则压栈(包括sections和当前数组),开辟第三个位置数组,并将current指向栈顶元素,遇到token项类型为'/',则直接出栈,并考虑current是否指向根数组;其他情况则为name或者text的token直接存至current数组中去。
  4. tokens和data结合生成HTML:对tokens数组进行每一项类型检查:如果为text,直接加进htmlStr中去;如果为name,将数据值加入htmlStr;如果为#,则需要进行递归调用,将这一项的子tokens和封装了.属性的子对象传入参数。最后返回整个htmlStr。

手写版本

大致框架:

export default function render(templateStr,Data){
    //生成tokens
    let tokens = genTokens(templateStr);
    //生成HTML
    let htmlStr = renderTemplate(tokens,Data);
    return htmlStr;
}
function genTokens(templateStr){
    //生成初始tokens
    let initTokens = initTokens(templateStr);
    //生成折叠后的tokens
    let resultTokens = nestTokens(initTokens);
    return resultTokens;
}
function initTokens(templateStr){
    let initTokens = [];
    let pos = 0;
    return initTokens;
}
function nestTokens(initTokens){
    let resultTokens = [];
    return resultTokens;
}
function renderTemplate(tokens,Data){
    let htmlStr = '';
    return htmlStr;
}

具体实现:

window.MustacheEngine = {};
let mustacheEngine = window.MustacheEngine;
//主函数,生成渲染后的HTML字符串
window.MustacheEngine.render = function (templateStr, Data) {
    //生成tokens
    let tokens = mustacheEngine.genTokens(templateStr);
    //生成HTML
    let htmlStr = mustacheEngine.renderTemplate(tokens, Data);
    return htmlStr;
}

window.MustacheEngine.genTokens = function (templateStr) {
    //生成初始tokens
    let initTokens = mustacheEngine.initTokens(templateStr);
    //生成折叠后的tokens
    let resultTokens = mustacheEngine.nestTokens(initTokens);
    return resultTokens;
}

//生成初始tokens
window.MustacheEngine.initTokens = function (templateStr) {
    //初始化的tokens生成
    let initTokens = [];
    //记录位置
    let pos = 0;
    while (pos < templateStr.length) {
        //收集{{之前的内容
        let contentObj = mustacheEngine.scan('{{', pos, templateStr);
        let content = contentObj.content;
        //text类型的token
        initTokens.push(['text',content]);
        pos = contentObj.pos;
        pos +=  templateStr.substring(pos).indexOf('{{')==0 ?'{{'.length:0;
        //收集{{之后,}}之前的内容
        contentObj = mustacheEngine.scan('}}', pos, templateStr);
        content = contentObj.content;
        if (content!== ''){
            //判断类型
            switch(content[0]){
                case '#':
                    initTokens.push(['#',content.substring(1)]);
                    break;
                case '/':
                    initTokens.push(['/',content.substring(1)]);
                    break;
                default:
                    initTokens.push(['name',content])
            }
        }
        pos = contentObj.pos;
        pos +=  templateStr.substring(pos).indexOf('}}')==0 ?'}}'.length:0;
    }
    return initTokens;
}

//生成嵌套tokens
window.MustacheEngine.nestTokens = function (initTokens) {
    //整体算法是利用栈先入后出,一直处理栈顶元素符合HTML结构。
    //console.log(initTokens);
    //折叠后的数组
    let resultTokens = [];
    //维护的栈
    let sections = [];
    //数组指针current,指向目前栈顶token,没有栈顶则处理根数组resultTokens
    let current = resultTokens;
    for (let i=0;i<initTokens.length;i++){
        let token = initTokens[i];
        let type = token[0];
        switch (type){
            case '#':
                //#代表要循环
                sections.push(token);
                current.push(token);
                //这里开辟子数组在索引2的位置
                current = token[2] = [];
                break;
            case '/':
                //结束循环
                sections.pop();
                //current指向栈顶(开辟的子数组),栈中没有则指向根数组
                current = sections.length >0 ? sections[sections.length-1][2] : resultTokens;
                break;
            default:
                current.push(token);
        }
    }
    
    return resultTokens;
}

//结合tokens和数据,返回HTML字符串
window.MustacheEngine.renderTemplate = function (tokens, Data) {
    // console.log(tokens);
    // console.log(Data);
    let htmlStr = '';
    for (let i=0;i<tokens.length;i++){
        let token = tokens[i];
        let type = token[0];
        switch (type){
            case 'text':
                htmlStr += token[1];
                break;
            case 'name':
                //实现对象的.调用
                let value = mustacheEngine.lookup(token[1],Data);
                htmlStr += value;
                break;
            case '#':
                //找到传入data的array引用
                let array = mustacheEngine.lookup(token[1],Data);
                for (let i=0;i<array.length;i++){
                    htmlStr += mustacheEngine.renderTemplate(token[2],{
                        ...array[i],
                        '.':array[i] // 实现.代表数组每一项为基础数据值,如 ['value1','value2']
                    })
                }
        }
    }
    return htmlStr;
}
//扫描到指定字符串并返回之前的内容
window.MustacheEngine.scan = function (scanStr, pos, templateStr) {
    let pre = pos;
    let tail = templateStr.substring(pos);
    while (tail.indexOf(scanStr) != 0) {
        if (pos >= templateStr.length)
            break;
        pos++;
        tail = templateStr.substring(pos);
    }
    return { 'pos': pos, 'content': templateStr.slice(pre, pos) };
}

//给出keyStr和对象obj,返回属性(实现.)/ a.b.c
window.MustacheEngine.lookup = function(keyStr,obj){
    //注意!:这里实现对象的., keyStr就是.
    if (keyStr.indexOf('.')!= -1 && keyStr!== '.'){
        let keyArray = keyStr.split('.');
        let temp = obj;
        for (let i=0;i<keyArray.length;i++){
            temp  = temp[keyArray[i]];
        }
        return temp;
    }   
    //没有.则直接返回obj的key属性值
    return obj[keyStr];
}