“抽象语法树”、“虚拟节点” 开山鼻祖-mustache 模板引擎

126 阅读7分钟

mustache 模板引擎

什么是模板引擎

  • 模板引擎是将数据变为视图的最优雅的解决方案,如下图所示

    image-20220113155516878

  • 历史上曾经出现的数据变为视图的方法 实现下图页面不同方法对比 image-20220113163227710

    • 纯 DOM 法:非常笨拙,无实战价值

      <!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>
          <ul id="list">
          </ul>
      
          <script>
              var arr = [
                  { "name": "小明", "age": 12, "sex": "男" },
                  { "name": "小红", "age": 11, "sex": "女" },
                  { "name": "小强", "age": 13, "sex": "男" }
              ];
      
              var list = document.getElementById('list');
      
              for (var i = 0; i < arr.length; i++) {
                  // 每遍历一项,都要用DOM方法去创建li标签
                  let oLi = document.createElement('li');
                  // 创建hd这个div
                  let hdDiv = document.createElement('div');
                  hdDiv.className = 'hd';
                  hdDiv.innerText = arr[i].name + '的基本信息';
                  // 创建bd这个div
                  let bdDiv = document.createElement('div');
                  bdDiv.className = 'bd';
                  // 创建三个p
                  let p1 = document.createElement('p');
                  p1.innerText = '姓名:' + arr[i].name;
                  bdDiv.appendChild(p1);
                  let p2 = document.createElement('p');
                  p2.innerText = '年龄:' + arr[i].age;
                  bdDiv.appendChild(p2);
                  let p3 = document.createElement('p');
                  p3.innerText = '性别:' + arr[i].sex;
                  bdDiv.appendChild(p3);
      
                  // 创建的节点是孤儿节点,所以必须要上树才能被用户看见
                  oLi.appendChild(hdDiv);
                  // 创建的节点是孤儿节点,所以必须要上树才能被用户看见
                  oLi.appendChild(bdDiv);
                  // 创建的节点是孤儿节点,所以必须要上树才能被用户看见
                  list.appendChild(oLi);
              }
          </script>
      </body>
      
      </html>
      
      • 数组 join 法 因为先前没有es6的反引号法,本质上是使用了字符串拼接的方法

        <!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>
            <ul id="list"></ul>
        
            <script>
                var arr = [
                    { "name": "小明", "age": 12, "sex": "男" },
                    { "name": "小红", "age": 11, "sex": "女" },
                    { "name": "小强", "age": 13, "sex": "男" }
                ];
        
                var list = document.getElementById('list');
        
                // 遍历arr数组,每遍历一项,就以字符串的视角将HTML字符串添加到list中
                for (let i = 0; i < arr.length; i++) {
                    list.innerHTML += [
                        '<li>',
                        '    <div class="hd">' + arr[i].name + '的信息</div>',
                        '    <div class="bd">',
                        '        <p>姓名:' + arr[i].name + '</p>',
                        '        <p>年龄:' + arr[i].age  + '</p>',
                        '        <p>性别:' + arr[i].sex + '</p>',
                        '    </div>',
                        '</li>'
                    ].join('')
                }
         
            </script>
        </body>
        
        </html>
        
    • ES6 的反引号法

      <!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>
          <ul id="list"></ul>
      
          <script>
              var arr = [
                  { "name": "小明", "age": 12, "sex": "男" },
                  { "name": "小红", "age": 11, "sex": "女" },
                  { "name": "小强", "age": 13, "sex": "男" }
              ];
      
              var list = document.getElementById('list');
      
              // 遍历arr数组,每遍历一项,就以字符串的视角将HTML字符串添加到list中
              for (let i = 0; i < arr.length; i++) {
                  list.innerHTML += `
                      <li>
                          <div class="hd">${arr[i].name}的基本信息</div>    
                          <div class="bd">
                              <p>姓名:${arr[i].name}</p>    
                              <p>性别:${arr[i].sex}</p>    
                              <p>年龄:${arr[i].age}</p>    
                          </div>    
                      </li>
                  `;
              }
          </script>
      </body>
      
      </html>
      

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>
    <div id="container"></div>

    <!-- 模板 -->
    <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>
</body>

</html>

不循环

<!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>
    <div id="container"></div>

    <script src="jslib/mustache.js"></script>
    <script>
        var templateStr = `
            <h1>我买了一个{{thing}},好{{mood}}啊</h1>
        `;

        var data = {
            thing: '华为手机',
            mood: '开心'
        };

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

循环简单数组

image-20220209101153143

<!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>
    <div id="container"></div>

    <script src="jslib/mustache.js"></script>
    <script>
        var templateStr = `
            <ul>
                {{#arr}}
                    <li>{{.}}</li>    
                {{/arr}}
            </ul>
        `;

        var data = {
            arr: ['A', 'B', 'C']
        };

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

数组的嵌套情况

<!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>
    <div id="container"></div>

    <script src="jslib/mustache.js"></script>
    <script>
        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;
    </script>
</body>
</html>

布尔值

相当于 vue 中的 v-if

<!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>
    <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>
</html>

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}},花了{{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>
    </body>
    
    </html>
    
  • 但是当情况复杂时,正则表达式的思路肯定就不行了。比如这下面的模板字符串,是不能用正则表达式的思路实现的 image-20220210095900048

  • mustache库的机理

    image-20220210095422439

mustache库底层重点要做两个事情:

  1. 将模板字符串编译为tokens形式

    • tokens 是一个 JS 的嵌套数组,说白了,就是模板字符串的 JS 表示

    • 它是 “抽象语法树”、“虚拟节点” 等等的开山鼻祖

      <h1>我买了一个{{thing}},好{{mood}}啊</h1> // 对应的tokens如下
      

      image-20220210134706208

  2. 将tokens结合数据,解析为dom字符串

带你手写实现 mustache 库

开发时的注意事项


  • 学习源码时,源码思想要借鉴,而不要抄袭。要能够发现源码中书写的精彩的地方;

  • 将独立的功能拆写为独立的js文件中完成,通常是一个独立的类,每个单独的功能必须能独立的“单元测试”;

  • 应该围绕中心功能,先把主干完成,然后修剪枝叶;

  • 功能并不需要一步到位,功能的拓展要一步步完成,有的非核心功能甚至不需实现;

环境搭建


  1. 初始化项目

    npm init // 连续按回车
    
  2. 安装相关依赖,利用 webpack-dev-server 提供了一个基本的 web server,并且具有 live reloading(实时重新加载) 功能

    npm i -D webpack@4 webpack-cli@3 webpack-dev-server@3
    
  3. 新建 webpack.config.js 文件

    const path = require('path');
    
    module.exports = {
        // 模式,开发
        mode: 'development',
        // 入口
        entry: './src/index.js',
        // 打包到什么文件
        output: {
            filename: 'bundle.js'
        },
        // 配置一下webpack-dev-server
        devServer: {
            // 静态文件根目录
            contentBase: path.join(__dirname, "www"),
            // 不压缩
            compress: false,
            // 端口号
            port: 8080,
            // 虚拟打包的路径,bundle.js文件没有真正的生成
            publicPath: "/xuni/",
            // 自动打开页面
            open: true,
        }
    };
    
    
  4. 新建 src 文件夹,在其中新建 index.js 文件

    aleart("hello");
    
  5. 新建 www 文件夹,在其中新建 index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <script src="/xuni/bundle.js"></script>
    </head>
    <body>
        <h3>我是页面hi!</h3>
    </body>
    </html>
    
  6. 修改 package.json 文件,新增启动命令

    {
      "name": "self_template",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "dev": "webpack-dev-server" // 新增启动命令
      },
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "webpack": "^4.46.0",
        "webpack-cli": "^3.3.12",
        "webpack-dev-server": "^3.11.3"
      }
    }
    
  7. 到此环境搭建完成,运行 yarn dev命令启动项目

扫描器


  1. 首先,我们需要修改 index.html 文件,来调用模板引擎的渲染函数

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <script src="/xuni/bundle.js"></script>
    </head>
    <body>
        <h3>我是页面hi!</h3>
        <script>
             // 模板字符串
            var templateStr = `
                <div>
                    <ul>
                        {{#students}}
                        <li class="myli">
                            学生{{name}}的爱好是
                            <ol>
                                {{#hobbies}}
                                <li>{{.}}</li>
                                {{/hobbies}}
                            </ol>
                        </li>
                        {{/students}}
                    </ul>
                </div>
            `;
            zx_template.render(templateStr);
        </script>
    </body>
    </html>
    
  2. 接着,需要在入口文件中定义渲染函数

    window.zx_template = {
        render(templateStr,data) {
           console.log("渲染函数调用成功!")
        }
    };
    
  3. 接着我们来实现扫描器的具体代码。

    1. 首先我们需要明确扫描器的作用:将模板字符串分割,比如将我都红红火火{{index}},哈哈哈{{yyds}}分割成 我都红红火火、index、,哈哈哈、yyds

    2. 既然是分割字符串,那肯定需要对字符串进行截取,那自然少不了使用 substring/substr 了,因为还需要判断有没有扫描到最后,所以要使用 indexOf 来获取当先扫描的位置,那接下来的问题就是如何截取了,具体代码如下

      /* 
          扫描器类
      */
      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;
          }
      };
      
    3. 对扫描器进行单元测试

      import Scanner from "./Scanner.js"
      
      window.zx_template = {
          render(templateStr,data) {
              var scanner = new Scanner(templateStr);
              var word;
            	// 当扫描器没有走到头时
              while(scanner.pos !== templateStr.length) {
                  word = scanner.scanUtil("{{");
                  console.log(word);
                  scanner.scan("{{");
                  
                  word = scanner.scanUtil("}}");
                  console.log(word);
                  scanner.scan("}}");
              }
          }
      };
      

      根据控制台输出的结果来看,扫描器的基本功能实现

      image-20220212192036501

      1. 接下来我们进一步优化扫描器,在 src 下新建 parseTemplateToTokens.js 文件,将其封装成一个函数,并让其返回数组形式的 tokens
      import Scanner from "./Scanner.js"
      
      export default function parseTemplateToTokens() {
          var scanner = new Scanner(templateStr);
          var words;
          var tokens = [];
          while(scanner.pos !== templateStr.length) {
              words = scanner.scanUtil("{{");
              if(words !== '') {
                  tokens.push(["text",words])
              }
              scanner.scan("{{");
              
              words = scanner.scanUtil("}}");
              if(words !== '') {
                  if(words[0] === '#') {
                      tokens.push(["#",words.substr(1)])
                  } else if(words[0] === '/') {
                      tokens.push(["/",words.substr(1)])
                  } else {
                      tokens.push(["name",words])
                  }
              }
              scanner.scan("}}");
          }
          return tokens;
      }
      
    4. 在 index.js 中进行测试

      import parseTemplateToTokens from "./parseTemplateToTokens.js"
      window.zx_template = {
          render(templateStr,data) {
             var tokens = parseTemplateToTokens(templateStr);
             console.log(tokens);
          }
      };
      

      成功打印

      image-20220212213159232

折叠器


之前,我们已经将扫描器的结果处理成数组的形式,但是数组是处理不了嵌套情况的。那么接下来我们可以对其进行遍历处理成我们最终想要的结果,如下图image-20220213065118284

  1. 在 src 文件下新建文件 nestTokens.js 文件

    export default function nestTokens(tokens) {
        // 结果数组
        var nestedTokens = [];
        // 栈
        var sections = [];
        // 收集器,根据当前栈动态变化
        var collector = nestedTokens;
        // 遍历数组
        for(let token of tokens) {
            switch(token[0]) {
                case '#' :
                    // 收集器放入当前遍历的 token
                    collector.push(token);
                    // 入栈
                    sections.push(token); 
                    // 改变当前存储器的位置 
                    collector = token[2] = [];
                    break;
                case '/':
                    // 出栈
                    sections.pop();
                    // 改变收集器为栈结构队尾(队尾是栈顶)那项的下标为2的数组
                    collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens;
                    break;
                default :
                    // 甭管当前的collector是谁,可能是结果nestedTokens,也可能是某个token的下标为2的数组,甭管是谁,推入collctor即可。
                    collector.push(token);
            }
        }
        return nestedTokens;
    }
    
  2. 在 parseTemplateToTokens.js 文件中使用

    import Scanner from "./Scanner.js"
    import nestTokens from "./nestTokens"
    
    export default function parseTemplateToTokens() {
        var scanner = new Scanner(templateStr);
        var words;
        var tokens = [];
        while(scanner.pos !== templateStr.length) {
            words = scanner.scanUtil("{{");
            if(words !== '') {
                tokens.push(["text",words])
            }
            scanner.scan("{{");
            
            words = scanner.scanUtil("}}");
            if(words !== '') {
                if(words[0] === '#') {
                    tokens.push(["#",words.substr(1)])
                } else if(words[0] === '/') {
                    tokens.push(["/",words.substr(1)])
                } else {
                    tokens.push(["name",words])
                }
            }
            scanner.scan("}}");
        }
        return nestTokens(tokens);
    }
    

结合 data 生成最终的 DOM 字符串


现在,我们已经实现了下图中红框中功能了,接下来就是结合数据生成最终的 dom 字符串

image-20220213075223141

首先我们先实现一个简易的版本,带大家来体验一下这个功能

  1. 在 src 下新建 renderTemplate.js 文件

    export default function renderTemplate(tokens,data) {
        var resultStr = '';
        for(let token of tokens) {
            if(token[0] === 'text') {
                resultStr += token[1]
            } else {
                resultStr += data[token[1]]
            }
        }
        return resultStr;
    }
    
  2. 接着修改 index.html 文件中模板字符串和 data

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <script src="/xuni/bundle.js"></script>
    </head>
    <body>
        <h3>我是页面hi!</h3>
        <script>
            var templateStr = "我爱{{somebody}},{{somebody}}也爱我,我今年{{a.b.c}}岁";
            var data = {
                somebody : "love",
                a: {
                    b: {
                        c: 18
                    }
                }
            }
            zx_template.render(templateStr,data);
        </script>
    </body>
    </html>
    
  3. 然后在 index.js 文件中引入使用

    import parseTemplateToTokens from "./parseTemplateToTokens.js"
    import renderTemplate from "./renderTemplate.js"
    window.zx_template = {
        render(templateStr,data) {
           var tokens = parseTemplateToTokens(templateStr);
           var resultStr = renderTemplate(tokens, data);
           console.log(resultStr);
        }
    };
    
  4. 最终打印结果如下

    我爱love,love也爱我,我今年undefined岁
    
  5. 现在问题出现了,虽然成功结合了前面的 data,但是后面较复杂的 data 确实 undefined,原因是在 js 中, data[‘a.b.c’] 肯定是识别不出来的,所以这时候我们需要封装一个函数,专门将 data[‘a.b.c’] 这种语法识别

lookup

export default function lookup(keyNameObj,keyName) {
    if(keyName.indexOf('.') !== -1 && keyName !== '.') {
        const keys = keyName.split('.');
        var result = keyNameObj;
        for(let key of keys) {
            result = result[key] // 洋葱卷
        }
        return result;
    } else {
        return keyNameObj[keyName]
    }
}

在 renderTemplate.js 中使用

import lookup from "./lookup";
export default function renderTemplate(tokens,data) {
    var resultStr = '';
    for(let token of tokens) {
        if(token[0] === 'text') {
            resultStr += token[1]
        } else {
            resultStr += lookup(data,token[1]) // 使用
        }
    }
    return resultStr;
}

parseArray

完善 renderTemplate 函数,使其支持嵌套情况

  1. 在 src 下新建 parseArray.js 文件

    import lookup from "./lookup"
    import renderTemplate from "./renderTemplate"
    export default function parseArray(token,data) {
        console.log("token",token);
        const v = lookup(data,token[1]);
        console.log("v是这个",v);
        var resultStr = '';
        for(let i of v) {
            console.log("循环里的i",i);
          	// 递归调用
            resultStr += renderTemplate(token[2], {
                ...i,
                '.': i
            });
        }
        return resultStr;
    }
    
  2. 在 renderTemplate.js 中使用

    import lookup from "./lookup";
    import parseArray from "./parseArray";
    export default function renderTemplate(tokens,data) {
        var resultStr = '';
        for(let token of tokens) {
            if(token[0] === 'text') {
                resultStr += token[1]
            } else if(token[0] === 'name') {
                resultStr += lookup(data,token[1])
            } else if(token[0] === '#') {
                resultStr += parseArray(token,data)
            }
        }
        return resultStr;
    }