mustache模板引擎详解

458 阅读6分钟

首先什么是模板引擎呢?

模板引擎是将数据要变为视图最优雅的解决方案. 中文名为胡子,也就是我们在vue中使用的{{}}; mustache通过render函数解析 由模板字符串转化为的token数据data结合生成dom模板。如下图所示:

image.png

实现原理分析:

image.png 1. 将模板字符串编译成tokens,其中tokens是js的二维数组

2. 数据与 tokens 结合解析为 dom 字符串。

mustache 的底层核心原理

  • mustache 库不能用简单的正则表达式思路实现
  • 在较为简单的示例情况下,可以用正则表达式实现
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

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

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

        // 最简单的模板引擎的实现机理,利用的是正则表达式中的replace()方法。
        // replace()的第二个参数可以是一个函数,这个函数提供捕获的东西的参数,
        // 第一个参数findStr就找到的匹配内容     第三个参数就是原串
        // 正则()内的内容可以进行捕获  第二个参数$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>
</body>

</html>

手写实现代码:

第一步:将模板字符串转换为Tokens

scanner.js

用于扫描模板字符串,找到tag内的变量,与tag外的变量

/* 扫描器类*/
export default class Scanner {
    constructor(templateStr) {
        this.templateStr = templateStr;// 将模板字符串写到实例身上
        this.pos = 0; // 指针
        this.tail = templateStr;// 尾巴,起始为模板字符串原文,后边指向未扫描部分的内容
    }

    // 功能弱,就是走过指定内容,没有返回值(作用:跳过 {{ }} )
    scan(tag) {
        if (this.tail.indexOf(tag) == 0) {
            // tag有多长,比如{{长度是2,就让指针后移多少位
            this.pos += tag.length;
            // 尾巴也要变,改变尾巴为从当前指针这个字符开始,到最后的全部字符
            this.tail = this.templateStr.substring(this.pos);
        }
    }

    // 让指针进行扫描,直到遇见指定内容结束,并且能够返回结束之前路过的文字
    // 作用:返回遇到 {{ }} 之前的内容
    scanUtil(stopTag) {  
        // 记录从哪里开始扫描,既执行本方法时的pos值
        const pos_backup = this.pos;
        // 当尾巴的开头不是stopTag的时候,就说明还没有扫描到stopTag
        // 写&&很有必要,因为防止找不到,那么寻找到最后也要停止下来
        while (!this.eos() && this.tail.indexOf(stopTag) != 0) {
            this.pos++;
            // 改变尾巴为从当前指针这个字符开始,到最后的全部字符的内容
            this.tail = this.templateStr.substring(this.pos);
        }
        // 返回结束之前路过的文字
        return this.templateStr.substring(pos_backup, this.pos);
    }

    // 指针是否已经到头,返回布尔值。end of string
    eos() {
        return this.pos >= this.templateStr.length;
    }
};

parseTemplateToTokens.js

  • 循环使用扫描器,直到将模板字符串全部扫描结束,最后将模板字符串变为tokens数组
import nestTokens from './nestTokens.js';
import Scanner from "./Scanner";

export default function parseTemplateToTokens(templateStr){
    var tokens=[];  //存储内容
    // 创建扫描器
    var scanner = new Scanner(templateStr);
    var words;
    // 扫描器工作,当scanner没有到头时运行
    while (!scanner.eos()) {
        words = scanner.scanUtil("{{"); // 收集开始标记之前文字
       
        if(words !=""){
            // 去掉空格,智能判断是普通文字的空格,还是标签中的空格
            // 标签中的空格不能去掉,比如<div class="box">不能去掉class前面的空格
            let isJJH = false; //是否在尖角号<> 里面 ,默认不在
            var _words = ""; // 空白字符串
            for (let i = 0; i < words.length; i++) {
                // 判断是否在标签中
                if (words[i] == "<") {
                    isJJH = true;
                } else if (words[i] == ">") {
                    isJJH = false;
                }
                // 如果这项不是空格,拼接上  \s 空格  \S非空格
                if(!/\s/.test(words[i])){
                    _words += words[i];
                }else{
                    //是空格,只有在当它在标签内时,才拼接上
                    if(isJJH){
                        _words += words[i];
                    }
                }
            }
            // console.log(words);
            // console.log(_words);
            tokens.push(["text",_words]);//把去掉空格的文字存起来
        }
        scanner.scan("{{"); //过双大括号

        words = scanner.scanUtil("}}"); // 收集属性名
        if (words != "") {
            // 这个words就是{{}}中间的东西。判断一下首字符
            if(words[0] == "#"){ //循环属性  嵌套标记
                // 存起来,从下标为1的项开始存,因为下标为0的项是#
                tokens.push(["#",words.substring(1)]);
            }else if(words[0]=="/"){ //循环属性  循环结束标记
                tokens.push(["/", words.substring(1)]);
            }else{//正常属性
                tokens.push(["name",words]);//存起来
            }
        }
        scanner.scan("}}"); //过双大括号
    }
    // 返回折叠的tokens
    // console.log(nestTokens(tokens));
    return nestTokens(tokens);
}

nestToken.js

  • 整合折叠tokens,将#和/之间的tokens能够整合起来,作为它的下标为3的项
/* 
    函数的功能是折叠tokens,将#和/之间的tokens整合起来,作为它的下标为3的项.即从一维变为嵌套数组
*/
export default function nestTokens(tokens) {
    var resultTokens = [];// 存放结果数组
    // 栈结构,存放栈顶操作的小tokens;
    // 栈顶,即tokens数组中当前操作的token小数组
    var stack = []; //栈顶小tokens

    // 收集器,天生指向resultTokens结果数组,引用类型值,所以指向的是同一个数组
    // 收集器的指向会变化,当遇见#的时候,收集器会指向这个token的下标为2的新数组
    var collector = resultTokens;
    // console.log(tokens);
    // 遍历每个tokens
    for (let i = 0; i < tokens.length; i++) {
        let token = tokens[i]; //[0]:类型  [1]:值  [2]:子token
        debugger;
        // 对token的首项进行判断
        switch (token[0]) {
            // 如果token[0]是"#"说明是循环数组,需要使用栈思想进行嵌套
            case "#":
                //此时resultTokens和collector指向同一个引用地址(则resultTokens也会push值token)
                collector.push(token);
                stack.push(token); //token入账
                //将收集器collector的指向变成当前token的第3项,将token用collector折叠
                collector = token[2] = [];
                break;
            // 如果token[0]是"/",说明是循环结束(不需要推入数据)
            case "/":
                stack.pop();  // stack出栈
                // 改变收集器为结构队尾(队尾才是栈顶,即数组下标为2处)
                // 若stack长度大于2,说明还有循环,收集器collector指向上一个token的第三项
                collector = stack.length > 0 ?
                    stack[stack.length - 1][2] : resultTokens;
                break;
            default:
                // 甭管当前的collector是谁,可能是结果nestedTokens,也可能是某个token的下标为2的数组,推入token即可。
                collector.push(token);
        }
    }
    // console.log(collector == resultTokens); //有时相等,有时不相等
    return resultTokens;
}

/* 
nestTokens()方法对传入的tokens数组进行遍历,普通项正常进入nestTokens数组,
遍历过程中若是遇到嵌套开始标志#,就将其压入Stack,并且后续对nestTokens的push操作都是在Stack栈顶数组中,
如果遇到嵌套结束标志/,可以判断当前Stack是否还有值,如果有则将出栈值push到栈顶数组中,
如果没有值了则将最后一个出栈的数组push到nestTokens中并将nestTokens返回。

该部分过于精妙:
 难点:关于对象的引用
数组是对象,对象是引用赋值的同时对象的值是存在堆区的,如果两个变量保存的是同一个对象的引用,
一个变量改变时,另外一个也会跟着改变
所以数组中,     当collector变化时,会使得token[2]发生改变,进而让栈stack中的值token也发生改变
入栈后:stack存放入栈后的token,其中token中的token[2]是会随着for循环的不断继续而随collector不断变化的,
因而stack也会不断变化
出栈后:stack栈顶弹出,
resultTokens会在入栈后与collector值不相同,但是collector数组变化的推入的数据它也会跟着推入

遇到问题:无法准确表达出stack  resultTokens  collector三者之间的关系
影响:可能下次又无法理解了,下次看时 明白对象引用数据之间变化会相互影响就好

*/

第二步:数据结合tokens解析为Dom字符串

renderTemplate.js

  • 让tokens数组变为dom字符串
import parseArray from './parseArray.js';
/* 
    函数的功能是让tokens数组变为dom字符串
*/
export default function renderTemplate(tokens, data) {
    // console.log(tokens);
    var resultStr = "";  //结果字符串
    // 遍历tokens
    for (let i = 0; i < tokens.length; i++) {
        let token = tokens[i];
        // console.log(token);
        // 看类型
        if (token[0] == 'text') {
            resultStr += token[1]; // 拼起来
        } else if (token[0] == "name") {
            // 如果是name类型,用lookup直接使用它的值
            // lookup:识别“a.b.c”中.的形式
            resultStr += lookup(data, token[1]);
        } else if (token[0] == "#") {
            resultStr += parseArray(token, data); //处理数组
        }
    }
    return resultStr;
}

lookup.js

  • 查找对象的某个属性

  • 解决以下两个问题:

    1. obj[a.b.c],无法获取的问题
    2. 如果是遍历数组,mustache中是{{.}},就是直接返回这个obj
// dataObj数据对象,keyName属性名
export default function lookup(dataObj, keyName) {
    // console.log(dataObj, keyName);
    // 若keyName存在 . ,且不是.本身
    if (keyName.indexOf(".") != -1 && keyName != ".") {
        var keys = keyName.split("."); //使用.分离keyName
        var temp = dataObj;//设置临时变量,寻找最终值
        // console.log(keys);

        for (let i = 0; i < keys.length; i++) {
            // 每遍历一层,迭代更新一次变量(一层层剥)
            temp = temp[keys[i]];
        }
        return temp;
    }
    // 若不存在 . 
    return dataObj[keyName];
}

parseArray.js

  • 处理数组,结合renderTemplate实现递归
import lookup from './lookup.js';
import renderTemplate from './renderTemplate.js';

export default function parseArray(token,data){
    // console.log(token, data);
    // 得到整体数据data中这个数组要使用的部分数据
    var v = lookup(data,token[1]);
    // console.log(v);
    var resulutStr = "";
    // 遍历v数组,v一定是数组(不做布尔值情况)
    // 注意,下面这个循环可能是整个包中最难思考的一个循环!!!
    // 它是遍历数据,而不是遍历tokens。数组中的数据有几条,就要遍历几条。
    for(let i =0;i<v.length;i++){
        resulutStr += renderTemplate(token[2],{ 
            ...v[i], //处理 . 的情况
            ".":v[i]
        });
    }
    return resulutStr;
}

第三步:向外暴露模板引擎与其render方法

index.js

  • 整合以上方法,向外暴露模板引擎与其render方法
import parseTemplateToTokens from "./parseTemplateToTokens"
import renderTemplate from "./renderTemplate"


// 全局提供SGG_TempalteEngine对象
window.SGG_TempalteEngine = {
    render(templateStr, data) {
        // 调用parseTemplateToTokens函数,让模板字符串变成token数组
        var tokens = parseTemplateToTokens(templateStr);
        // 调用renderTemplate函数,让tokens数组变为dom字符串
        var domStr = renderTemplate(tokens, data);
        return domStr;
    }
}