博客 / 詳情

返回

Mustache底層原理及簡單實現

用過vue的都知道在模板中我們可以使用{{xx}}來渲染data中的屬性,這個語法叫做Mustache插值表達式,用法簡單,但心中也有一個疑問,它是如何做到的呢?接下來就讓我們一探究竟吧!

1、使用正則來實現

比如説有這樣一個模板字符

let tempStr2 = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';

現在需要將字符串裏面{{xxx}}替換成數據,那麼可以使用正則來實現

let tempStr2 = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';
let data = {
  develpoer: 'web前端程序猿',
  knowledge: 'Mustache插值語法'
};
let resultStr = tempStr2.replace(/{{(\w+)}}/g, function (matched, $1){
  // {{develpoer}} develpoer
  // {{knowledge}} knowledge
  console.log(matched, $1);
  return data[$1];
});
// 結果: 我是一名web前端程序猿,我在學習Mustache插值語法知識!
console.log('結果:', resultStr);

使用正則的弊端就是隻能實現簡單的插值語法,稍微複雜點的如循環if判斷等功能就實現不了了。

2、Mustache的底層思想:tokens思想

let tempStr = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;

遇到這樣的一個模板字符串,按照我們以往的編程思維,大多數人想的肯定是怎麼拿到{{#students}}與{{/students}}中間的內容,用正則是不可能實現的了,對着這串字符串發呆苦想半天還是沒有結果。

那假如我們將這個字符串裏的內容進行分類呢?比如{{xxx}}分為一類,除去{{xxx}}外的普通字符串分為一類,並將他們存儲到數組中,比如:

這就是tokens思想,拿到了這樣的一個數組我們就好辦事了,想怎樣拼接數據還不是自己説了算。

3、拆解模板字符串並分類

思路(這裏假定分割符就是一對{{ }}):

  1. 在模板字符串中使用變量或使用遍歷if判斷的地方一定是使用{{}}包裹着的
  2. 所有的普通字符串都是在{{的左邊,因此可以通過查找{{的位置來找到普通字符串,然後進行截取
  3. {{的位置前面的字符串已經被截取掉了,現在的模板字符串就變成了{{xxx}}<li>...,那麼現在該如何獲取xxx呢?
  4. 新思路——用字符串截取(不要再想正則了哦~)。前面已經把{{前面的普通字符串給截取掉了,那麼{{也可以截取掉呀,截取掉{{後模板字符串變成了xxx}}<li>...
  5. xxx}}<li>...這個字符串跟原始的模板字符串好像哦,只是{{變成了}},那我們跟第2步一樣操作就可以,找到}}的位置,然後截取
  6. 截取掉xxx後字符串變成了}}<li>...,那我們再把}}截取掉,然後就又回到了步驟2,如此循環直到沒有字符串可截取了即可

代碼實現:

/**
 * 模板字符串掃描器
 * 用於掃描分隔符{{}}左右兩邊的普通字符串,以及取得{{}}中間的內容。(當然分隔符不一定是{{}})
 */
class Scanner{
  constructor (templateStr) {
    this.templateStr = templateStr;
    this.pos = 0; // 查找字符串的指針位置
    this.tail = templateStr; // 模板字符串的尾巴
  }

  /**
   * 掃瞄模板字符串,跳過遇到的第一個匹配的分割符
   * @param delimiterReg
   * @returns {undefined}
   */
  scan(delimiterReg){
    if(this.tail){
      let matched = this.tail.match(delimiterReg);
      if(!matched){
        return;
      }
      if(matched.index != 0){ // 分隔符的位置必須在字符串開頭才能進行後移操作,否則會錯亂
        return;
      }
      let delimiterLength = matched[0].length;
      this.pos += delimiterLength; // 指針位置需加上分隔符的長度
      this.tail = this.tail.substr(delimiterLength);
      // console.log(this);
    }
  }

  /**
   * 掃瞄模板字符串,直到遇到第一個匹配的分隔符,並返回第一個分隔符(delimiterReg)之前的字符串
   * 如:
   *    var str = '我是一名{{develpoer}},我在學習{{knowledge}}知識!';
   *    第一次運行:scanUtil(/{{/) => '我是一名'
   *    第二次運行:scanUtil(/{{/) => '我在學習'
   * @param delimiterReg 分割符正則
   * @returns {string}
   */
  scanUtil(delimiterReg){
    // 查找第一個分隔符所在的位置
    let index = this.tail.search(delimiterReg);
    let matched = '';
    switch (index){
      case -1: // 沒有找到,如果沒有找到則説明後面沒有使用mustache語法,那麼把所有的tail都返回
        matched = this.tail;
        this.tail = '';
        break;
      case 0: // 分隔符在開始位置,則不做任何處理
        break;
      default:
        /*
          如果找到了第一個分隔符的位置,則截取第一個分割符位置前的字符串,設置尾巴為找到的分隔符及其後面的字符串,並更新指針位置
         */
        matched = this.tail.substring(0, index);
        this.tail = this.tail.substring(index);
    }
    this.pos += matched.length;
    // console.log(this);
    return matched;
  }

  /**
   * 判斷是否已經查找到字符串結尾了
   * @returns {boolean}
   */
  eos(){
    return this.pos >= this.templateStr.length;
  }
}

export { Scanner };

使用:

import {Scanner} from './Scanner';

let tempStr = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
let startDeli = /{{/; // 開始分割符
let endDeli = /}}/; // 結束分割符

let scanner = new Scanner(tempStr);
console.log(scanner.scanUtil(startDeli)); // 獲取 {{ 前面的普通字符串
scanner.scan(startDeli); // 跳過 {{ 分隔符

console.log(scanner.scanUtil(endDeli)); // 獲取 }} 前面的字符串
scanner.scan(endDeli); // 跳過 }} 分隔符

console.log('---------------------------------------------');

console.log(scanner.scanUtil(startDeli)); // 獲取 {{ 前面的普通字符串
scanner.scan(startDeli); // 跳過 {{ 分隔符

console.log(scanner.scanUtil(endDeli)); // 獲取 }} 前面的字符串
scanner.scan(endDeli); // 跳過 }} 分隔符

結果:
image.png

4、將字符串模板轉換成tokens數組

前面的Scanner已經可以解析字符串了,現在我們只需要將模板字符串組裝起來即可。
代碼實現

import {Scanner} from '../Scanner';

/**
 * 將模板字符串轉換成token
 * @param templateStr 模板字符串
 * @param delimiters 分割符,它的值為一個長度為2的正則表達式數組
 * @returns {*[]}
 */
export function parseTemplateToTokens(templateStr, delimiters  = [/{{/, /}}/]){
  let [startDelimiter, endDelimiter] = delimiters;
  let tokens = [];
  if(!templateStr){
    return tokens;
  }
  let scanner = new Scanner(templateStr);

  while (!scanner.eos()){
    // 獲取開始分隔符前面的字符串
    let beforeStartDelimiterStr = scanner.scanUtil(startDelimiter);
    if(beforeStartDelimiterStr.length > 0){
      tokens.push(['text', beforeStartDelimiterStr]);
      // console.log(beforeStartDelimiterStr);
    }
    // 跳過開始分隔符
    scanner.scan(startDelimiter);
    // 獲取開始分隔符與結束分隔符之間的字符串
    let afterEndDelimiterStr = scanner.scanUtil(endDelimiter);
    if(afterEndDelimiterStr.length == 0){
      continue;
    }
    if(afterEndDelimiterStr.charAt(0) == '#'){
      tokens.push(['#', afterEndDelimiterStr.substr(1)]);
    }else if(afterEndDelimiterStr.charAt(0) == '/'){
      tokens.push(['/', afterEndDelimiterStr.substr(1)]);
    }else {
      tokens.push(['name', afterEndDelimiterStr]);
    }
    // 跳過結束分隔符
    scanner.scan(endDelimiter);
  }

  return tokens;
}

使用:

import {parseTemplateToTokens} from './parseTemplateToTokens';
let tempStr = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
let delimiters = [/{{/, /}}/];
var tokens = parseTemplateToTokens(templateStr, delimiters);
console.log(tokens);

結果:

5、再次組裝tokens

前面我們使用的模板字符串中存在嵌套結構,而前面組裝的tokens是一維的數組,使用一維數組來渲染循環結構的模板字符串顯然不大可能,就算可以,代碼也會很難理解。
此時我們就需要對一維的數組進行再次組裝,這一次我們要將它組裝成嵌套結構,並且前面封裝的一維數組也是符合條件的。
代碼:

/**
 * 將平鋪的tokens數組轉換成嵌套結構的tokens數組
 * @param tokens 一維tokens數組
 * @returns {*[]}
 */
export function nestsToken(tokens){
  var resultTokens = []; // 結果集
  var stack = []; // 棧數組
  var collector = resultTokens; // 結果收集器

  tokens.forEach(token => {
    let tokenFirst = token[0];
    switch (tokenFirst){
      case '#':
        // 遇到#號就將當前token推入進棧數組中
        stack.push(token);
        collector.push(token);
        token[2] = [];
        // 並將結果收集器設置為剛入棧的token的子集
        collector = token[2];
        break;
      case '/':
        // 遇到 / 就將棧數組中最新入棧的那個移除掉
        stack.pop();
        // 並將結果收集器設置為棧數組中棧頂那個token的子集,或者是最終的結構集
        collector = stack.length > 0 ? stack[stack.length - 1][2] : resultTokens;
        break;
      default:
        // 如果不是#、/則直接將當前這個token添加進結果集中
        collector.push(token);
    }
  });

  return resultTokens;
}

調用後的結果:
image.png
到這一步之後就沒有什麼特別難的了,有了這樣的結構,再結合數據就很容易了。

6、渲染模板

下面代碼是我的簡單實現方式:
代碼:

import {lookup} from './lookup';

/**
 * 根據tokens將模板字符串渲染成html
 * @param tokens
 * @param datas 數據
 * @returns {string}
 */
function renderTemplate(tokens, datas){
  var resultStr = '';
  tokens.forEach(tokenItem => {
    var type = tokenItem[0];
    var tokenValue = tokenItem[1];
    switch (type){
      case 'text': // 普通字符串,直接拼接即可
        resultStr += tokenValue;
        break;
      case 'name': // 訪問對象屬性
        // lookup是一個用來以字符串的形式動態的訪問對象上深層的屬性的方法,如:lookup({a: {b: {c: 100}}}, 'a.b.c')、lookup({a: {b: {c: 100}}}, 'a.b');
        resultStr += lookup(datas, tokenValue);
        break;
      case '#':
        let valueReverse = false;
        if(tokenValue.charAt(0) == '!'){ // 如果第一個字符是!,則説明是在使用if判斷做取反操作
          tokenValue = tokenValue.substr(1);
          valueReverse = true;
        }
        let val = datas[tokenValue];
        resultStr += parseArray(tokenItem, valueReverse ? !val : val, datas);
        break;
    }
  });
  return resultStr;
}

/**
 * 解析字符串模板中的循環
 * @param token token
 * @param datas 當前模板中循環所需的數據數據
 * @param parentData 上一級的數據
 * @returns {string}
 */
function parseArray(token, datas, parentData){
  // console.log('parseArray datas', datas);
  if(!Array.isArray(datas)){ // 如果數據的值不是數組,則當做if判斷來處理
    let flag = !!datas;
    // 如果值為真,則渲染模板,否則直接返回空
    return flag ? renderTemplate(token[2], parentData) : '';
  }
  var resStr = '';
  datas.forEach(dataItem => {
    // console.log('dataItem', dataItem);
    let nextData;
    if(({}).toString.call(dataItem) != '[object, Object]'){
      nextData = {
        ...dataItem,
        // 添加一個"."屬性,主要是為了在模板中使用{{.}}語法時可以使用
        '.': dataItem
      }
    }else{
      nextData = {
        // 添加一個"."屬性,主要是為了在模板中使用{{.}}語法時可以使用
        '.': dataItem
      };
    }

    resStr += renderTemplate(token[2], nextData);
  });
  return resStr;
}

export {renderTemplate, parseArray};

使用:

import {parseTemplateToTokens} from './parseTemplateToTokens';
import {nestsToken} from './nestsTokens';
import {renderTemplate} from './renderTemplate';
let tempStr = `
  <ul>
    {{#students}}
        <li>
            <dl>
                <dt>{{name}}</dt>
                {{#hobbys}}
                    <dd>{{.}}</dd>
                {{/hobbys}}
            </dl>
        </li>
    {{/students}}
  </ul>
`;
let datas = {
  students: [
    {name: 'Html', hobbys: ['超文本標記語言', '網頁結構'], age: 1990, ageThen25: true, show2: true},
    {name: 'Javascript', hobbys: ['弱類型語言', '動態腳本語言', '讓頁面動起來'], age: 1995, ageThen25: 0, show2: true},
    {name: 'Css', hobbys: ['層疊樣式表', '裝飾網頁', '排版'], age: 1994, ageThen25: 1, show2: true},
  ]
};

let delimiters = [/{{/, /}}/];
var tokens = parseTemplateToTokens(templateStr, delimiters);
console.log(tokens);

var nestedTokens = nestsToken(tokens);
console.log(nestedTokens);

var html = renderTemplate(nestedTokens, datas);
console.log(html);

效果:
image.png

7、現存問題

  • {{}}中使用運算符(如加減、三元運算)的功能暫不知如何實現?
  • 循環的時候暫不支持給當前循環項起名字

8、結語

Mustache的tokens思想真的贊!!!以後我們遇到相似需求時也可以使用它的這個思想來實現,而非揪着正則、字符串替換不放。

感謝:感謝尚硅谷,及尚硅谷的尚硅谷Vue源碼解析系列課程謝老師

user avatar 1023 頭像 uncletong_doge 頭像 mapvthree 頭像 tufeiyuan_5d9f0a380c30e 頭像 mouyi_63f6f68ba66d9 頭像 air_clou_d 頭像 lfaith 頭像 yzsunlei 頭像 xiaoqianduan_58b28cfebff36 頭像 tanking 頭像 zhiyandexia 頭像 huitailangdeqingyu 頭像
16 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.