Mustache

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模板引擎

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容