Mustache

1,692 阅读7分钟

mustache: 中文意思是:髭;上唇的胡子;长髭

它是一款经典的前端模板引擎,在前后端分离的技术架构下面,一度流行。之前也用过 art-template 之类的模板插件,应该也是同样的原理。如今随着 前端三大框架 的流行,这种方式已经深入前端人心。但是我还是第一次听到这个框架,就去了解了一下。真的是,日用而不知。

Mustache

简单介绍一下我所知道的前端历史

前后端不分离

页面基本是静态页面,后端采用JSP,freemarker,jdea,babel等渲染框架对前端模板进行预编译。

前后分离

使用字符串拼接

前端获取数据以后,利用如下的集中拼接方式

  var data = {name:'孙悟空',age:19}
  var html = "<div>" + data.name +"</div>"
  document.getElementById('container').innerHTML = html

使用反引号

  var data = {name:'孙悟空',age:19}
  var html = `<div>${data.name}</div>`
  document.getElementById('container').innerHTML = html

遇到循环时候

  var html = ""
  var data = {student:[{name:'张三'},{name:'李四'},{name:'王五'}]}
  data.students.forEach(function(stu){
    html += "<li>" + item.name + "</li>"
  })
  document.getElementById('student').innerHTML = html

换一种写法: 使用join()方法, 或者 concat 方法等

  var html = ""
  var data = {student:[{name:'张三',age: 20},{name:'李四',age: 18},{name:'王五', age: 30}]}
  data.students.forEach(function(item){
    html += ["<li>" + item.name + "</li>","<li>" + item.age + "</li>"].join(" ")
  })
  document.getElementById('student').innerHTML = html

使用 art-template 渲染模板

<script id=”test” type=”text/html”>
  <div>
    <div class="mine">{{name}}</div>
    <ol id="me" style="color: red">
      {{#students}}
        <li>
          学生{{name}}的爱好是
          <ol>
            {{#hobbies}}
              <li>{{.}}</li>
            {{/hobbies}}
          </ol>
        </li>
      {{/students}}
    </ol>
  </div>
</script>
  var html = template('test', data);
  document.getElementById(‘content’).innerHTML = html;

用 vue react等框架渲染

再后来运用vue react 等框架以后的渲染模式大家应该很清楚,这里就不再阐述了

mustache的用法

举个例子:

var templateStr =`
    <div>
      <div class="mine">{{name}}</div>
      <ol id="me" style="color: red">
        {{#students}}
          <li>
            学生{{name}}的爱好是
            <ol>
              {{#hobbies}}
                <li>{{.}}</li>
              {{/hobbies}}
            </ol>
          </li>
        {{/students}}
      </ol>
    </div>
  var data = {
    name: '齐天大圣',
    students: [
      {name:'小明', hobbies: ['游泳','健身']},
      {name:'小红', hobbies: ['足球','篮球', '羽毛球']},
      {name:'小强', hobbies: ['吃饭','睡觉']}
    ]
  }

对于上述的js模板,通过mustache处理以后就会变成

<div>
    <div class="mine">齐天大圣</div>
    <ol id="me" style="color: red">
      <li>学生小明的爱好是<ol>
          <li>游泳</li>
          <li>健身</li>
        </ol>
      </li>
      <li>学生小红的爱好是<ol>
          <li>足球</li>
          <li>篮球</li>
          <li>羽毛球</li>
        </ol>
      </li>
      <li>学生小强的爱好是<ol>
          <li>吃饭</li>
          <li>睡觉</li>
        </ol>
      </li>
    </ol>
  </div>

是不是很像vue react中的语法,可以想象如今框架肯定借鉴了这会写法,并把它加以改进,发扬光大。

逻辑分析

  1. 对于简单的模板,我们可以用正则表达式进行实现

    例如下面的简单的:

    模板字符串如下:

<h1>我买了一个{{thing}},我觉得好{{mood}}</h1>

数据如下:

{ thing: '华为手机', mood: '开心' }

实现方式如下:

var data = { thing: '华为手机', mood: '开心' }
var result = '<h1>我买了一个{{thing}},我觉得好{{mood}}</h1>'.replace(/\{\{(\w+)\}\}/g, function(match, $1){
  // $1 分别是 thing mood
  return data[$1]
})
console.log(result) // <h1>我买了一个华为手机,我觉得好开心</h1>
  1. 但是当情况复杂时候,例如循环时候或者判断时候,正则思路就不行了,

tips: 模板字符串如下(其中.代表展开)

<ul>
  {{#arr}}
  	<li>{{.}}</li>
  {{/arr}}
</ul>

数据如下

{ arr: ["香蕉","苹果","橘子","西瓜"] }

原理分析

mustache 的渲染步骤分为了两步

步骤如下:

var tokens =  parseTemplateToTokens(templateStr)
// 调用 renderTemplate 函数,让tokens 数组变成 dom 字符串
var domHtml = renderTemplate(tokens, data)

对于如下模板,渲染步骤:

<div>
   <div class="mine">{{name}}</div>
      <ol id="me" style="color: red">
        {{#students}}
          <li>
            学生{{name}}的爱好是
            <ol>
              {{#hobbies}}
                <li>{{.}}</li>
              {{/hobbies}}
            </ol>
          </li>
        {{/students}}
      </ol>
    </div>
 </div>
  1. 将模板渲染位 tokens 数组,结构类似于 image-20210626212151391.png

  2. 将 tokens 数组转换为相应的 html,(结合data)

    var data = {
      name: '齐天大圣',
      students: [
        { name: '小明', hobbies: ['游泳', '健身'] },
        { name: '小红', hobbies: ['足球', '篮球', '羽毛球'] },
        { name: '小强', hobbies: ['吃饭', '睡觉'] }
      ]
    }
    

    转变的 html 结果如下

      <div>
        <div class="mine">齐天大圣</div>
        <ol id="me" style="color: red">
          <li>学生小明的爱好是<ol>
              <li>游泳</li>
              <li>健身</li>
            </ol>
          </li>
          <li>学生小红的爱好是<ol>
              <li>足球</li>
              <li>篮球</li>
              <li>羽毛球</li>
            </ol>
          </li>
          <li>学生小强的爱好是<ol>
              <li>吃饭</li>
              <li>睡觉</li>
            </ol>
          </li>
        </ol>
      </div>
    

代码实现

模板变量如下

  1. 实现 parseTemplateToTokens 函数

    1. 书写一个扫描类,遍历字符串模板,里面有两个方法,一个是开始扫描,一个是扫描截止

      ①:跳过某个字符的扫描方法: 接受一个参数,当尾巴模板是以这个 参数 为处理更新当前指针和剩余字符串模板,比如 参数为 {{ , 就需要把当前指针向后移动两位({{的长度),并且 尾巴字符串 也要进行相应截取

      ②:扫描截止方法:接受一个参数,进行循环,当循环到当前参数字符串时候,就停止,并且返回开始循环到停止循环时中间的字符串。 例如当第一次扫描到 {{ 时,返回从开始位置到当前位置之间的字符串;接着扫描指针移动 {{ 的位置,再次调用,遇到 }},返回当前扫描指针到 }} 的字符,那就是{{ 和 }} 中间的变量,

      ③:当前再加一个方法:指针位置是否已经到最后了,返回值是一个布尔值

      class Scanner {
        constructor(templateStr){
          // 指针
          this.pos = 0
          // 尾巴,一开始就是模板字符串原文
          this.tail = templateStr
          this.templateStr = templateStr
        }
      
        scan(tag){
          if(this.tail.indexOf(tag) == 0){
            // tag 有多长,比如 {{ 长度是2,就让指针后移动几位
            this.pos += tag.length
            this.tail = this.templateStr.substr(this.pos)
          }
        }
      
        // 让指针进行扫描 直到遇到指定内容结束,并且能够返回结束之前路过的文字
        scanUtil(stopTag){
          // 记录一下开始的位置
          var 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)
        }
      
        // 指针是否到头,返回布尔值
        eos(){
          return this.pos >= this.templateStr.length
        }
      }
  1. 完成 parseTemplateToTokens 函数

分析: 接受一个参数:当前字符串模板,利用 Scanner 进行处理,刚开始:指针从0开始,剩余的模板字符串(也称为尾巴)为当前所有字符串。首先调用遍历到 {{ 位置的方法,获得 {{ 前面的字符串,并push到一个数组中,以及更新指针和剩余的字符串。然后调用跳过扫描 {{ 的方法更新 当前指针 和 剩余模板。接着继续执行遍历到 }} 的位置,获得{{ 和 }} 之间的变量,push 到数组中,接着调用跳过 {{ 的方法,然后重复上述步骤,直到指针走到最后一位。

tips: 当获得 {{ 和 }} 之间的字符串时,有可能是带有 # 或者 / 的这里需要进行特殊处理,往数组 push 时候增加相应类型以示区分。text 指静态文字,name 指的是 {{ 和 }} 之间不带(#、/)的变量,# 和 / 之后的变量也有进行记录

      function parseTemplateToTokens(templateStr){
        // 创建扫描器
          var scanner = new Scanner(templateStr)
          var tokens = []
          var word=""
          while(!scanner.eos()){
            word = scanner.scanUtil("{{")
            // 这里可以判断处理一下 空格问题,需要判断处理,例如 <li class="red">这里的空格就不能做处理
            // 增加判断:空格是在 标签中的空格还是 标签间的空格
            if(word){
              let _word=""
              let isInnerTag = false
              for (let index = 0; index < word.length; index++) {
                const element = word[index];
                if(element === "<"){
                  isInnerTag = true
                }else if(element === ">"){
                  isInnerTag = false
                }
                // 如果当前element 是空格,只有在 isInnerTag 为 true 时候才能加
                if(/\s/.test(element)){
                  if(isInnerTag){
                    _word += element
                  }
                }else{
                  _word += element
                }
              }
              tokens.push(['text', _word])
            }
            scanner.scan("{{")
            word = scanner.scanUtil("}}")
            if(word){
              if(word[0] === "#"){
                // 存起来,从下标为1的项开始存取,因为下标为0的项是#
                tokens.push(['#', word.substr(1)])
              }else if(word[0] === "/"){
                tokens.push(['/', word.substr(1)])
              }else{          
                tokens.push(['name', word])
              }
            } 
            scanner.scan("}}")
          }
          return tokens
      }
  以上获得了 tokens 数组,
  
  对于如下模板
  
      <div>
         <div class="mine">{{name}}</div>
            <ol id="me" style="color: red">
              {{#students}}
                <li>
                  学生{{name}}的爱好是
                  <ol>
                    {{#hobbies}}
                      <li>{{.}}</li>
                    {{/hobbies}}
                  </ol>
                </li>
              {{/students}}
            </ol>
          </div>
      </div>

获得到的tokens数组是这样的
image.png

然后还要处理里面的 # 和 / , 因为#和/ 是成对出现的,中间的内容应该是# 后面的子项。

所以还需要一个处理上述tokens 的数组

      function nestToken(tokens){
        // 结果数组
        var nestTokens = []
        var sections = []
        // 收集器,收集子元素或者孙元素等,天生指向 nestTokens 数组,引用类型值,所以指向的是同一个数组
        // 收集器的指向会发生变化。当遇见# 时候,收集器会遇到 当前token 的下标为2的新数组,
        var collector = nestTokens
        var isFlag = true
        // 栈结构,存放小tokens, 栈顶(靠近端口的,最新进入的)tokens数组中前操作的这个tokens小数组
        tokens.forEach((token,index) => {
          switch (token[0]) {
            case '#':
              // 收集器放入这个token
              collector.push(token)
              // 入栈
              sections.push(token)
              // 收集器要换人了, 给token 添加下标为2的项目,并让收集器指向它
              collector = token[2]= []
              break
            case '/':
              // 出栈 pop 会返回刚刚弹出的项
              sections.pop()
              // 改变收集器为栈结构队尾(队尾就是栈顶) 那项下标为2的数组
              collector = sections.length > 0 ? sections[sections.length-1][2] : nestTokens
              break
            default:
              collector.push(token)
              break
          }
        })
        return nestTokens
      }

上面代码 精妙的地方就是声明了一个 收集器 collector 数组,当遇到 # 的时候,收集器要指向当前项目的下标为2的一项并且设置为数组,此后遍历的 token项是 被收集到收集器中,也就是token[2]中变为子项,并且有一个数组 sections push 当前token项;当遇到到 / 时候,对sections进行弹栈处理,并且进行判断处理,如果之前已经有过了#(sections数组length还不为0),那么收集器就指向sections栈顶的那一项的下标为2的数组,否则就代表是最外层,收集器指向最外层 nestTokens.

经过上述函数处理以后的结果就是

image.png 完成 parseTemplateToTokens 和 nestToken 数组

  1. 实现 renderTemplate 函数

经过上述分析,已经拿到了 带有嵌套关系的 数组结构

   function renderTemplate(tokens, data){
     var resultStr = ""
     for (let index = 0; index < tokens.length; index++) {
       const element = tokens[index];
       if(element[0] === "text"){
         resultStr +=element[1]
       }else if(element[0] === "name"){
         // 如果是name,说明是变量,需要对齐进行其他处理,因为可能是 a.b.c 
         resultStr += lookUp(data, element[1])
       }else if(element[0] === "#"){
         // 对于数组要进行解析处理,需要循环然后调用 renderTemplate 方法
         resultStr += parseArray(element[2], data[element[1]])
       }
     }
     return resultStr
   }
   
   // 处理 数组中 name 为 a.b.c 的变量
   function lookUp(dataObj, keyName){
     if(keyName.indexOf('.') !==-1 && keyName !== "."){
       var temp = dataObj
       var keys = keyName.split('.')
       for (let index = 0; index < keys.length; index++) {
         const element = keys[index];
         temp = temp[keys[index]]
       }
       return temp
     }
     return dataObj[keyName]
   }
   
   function parseArray(token, array){
     var resultStr = ""
     array.forEach(item => {
       // 这里兼容 . 属性,否则会报错
       resultStr += renderTemplate(token, {
         ...item,
         '.': item
       })
     })
     return resultStr
   }

完成

至此完成了mustache 的初步解析,当然源码比之更为复杂精炼。这里只是介绍了其基本原理。

资源参考

Vue源码解析系列课程之mustache模板引擎