mustache模板引擎的原理与实现,手写实现vue的胡子语法

264 阅读4分钟

mustache的基本使用:

  • 在vue中使用是由两个大括号将变量包裹起来
<p>{{name}}</p>

实现原理分析:

1. 将模板字符串转化为tokens,tokens是js的二维数组

  • 比如以下的模板字符串
<p>{{group.name}}成员的基本信息</p>
<ul>
    {{#arr}}
        <br>
        <li>姓名:{{name}}</li>
        <ul>爱好: 
            {{#hobbies}}
                <ol>{{.}}</ol>
            {{/hobbies}}
        </ul>
    {{/arr}}
</ul>

{{#arr}}与{{/arr}}之间,是需要遍历arr,{{#hobbies}}与{{/hobbies}}也是一个道理

  • 转换为tokens是这样的
[
    ['text', '\n    <p>'],  // text表示是正常字符串
    ['name', 'group.name'],   // name 表示'group.name'是变量
    ['text', '成员的基本信息</p>\n    <ul>\n        '],
    ['#', 'arr', [     // '#'表示要遍历后面那个变量'arr'
        ['text', '\n            <br>\n            <li>姓名:'],
        ['name', 'name'],     
        ['text', '</li>\n            <ul>爱好: \n                '],
        ['#', 'hobbies',[    // 这里也要遍历'hobbies'
            ['text', '\n                    <ol>'],
            ['name', '.'],      // 遍历的是数组,所以变量名是点
            ['text', '</ol>\n                ']
        ]],
        ['text', '\n            </ul>\n        ']
    ]],
    ['text', '\n    </ul>\n']
]

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

遍历tokens,结合数据,将tokens转换为DOM字符串

  • 比如以下数据,结合上面的tokens
let data = {
    group: {
        name: "三组"
    },
    arr: [
        { name: "张三", hobbies: ["篮球", "足球"] },
        { name: "李四", hobbies: ["拼图", "看电视剧", "购物"] },
        { name: "王五", hobbies: ["打游戏"] },
        { name: "赵六", hobbies: ["跑步", "股票"] }
    ]
}
  • 解析为以下的dom字符串
    <p>三组成员的基本信息</p>
    <ul>
            <br>
            <li>姓名:张三</li>
            <ul>爱好: 
                    <ol>篮球</ol>
                    <ol>足球</ol>
            </ul>
            <br>
            <li>姓名:李四</li>
            <ul>爱好: 
                    <ol>拼图</ol>
                    <ol>看电视剧</ol>
                    <ol>购物</ol>
            </ul>
            <br>
            <li>姓名:王五</li>
            <ul>爱好: 
                    <ol>打游戏</ol>
            </ul>
            <br>
            <li>姓名:赵六</li>
            <ul>爱好: 
                    <ol>跑步</ol>
                    <ol>股票</ol>
            </ul>
    </ul>

实现代码:

  • 本案例主要以mustache库为原型,手写胡子语法,非Vue的mustache。

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

scanner.js

  • 用于扫描模板字符串,找到tag内的变量,与tag外的变量
// 扫描器
export default class Scanner {
    constructor(template) {
        this.template = template
        this.pos = 0         // 记录当前扫描的字符串的索引
        this.tail = template   // 记录剩下未扫描的字符串,刚开始是模板字符串
    }
    // 跳过标记
    scan(tag) {
        if (this.tail.indexOf(tag) == 0) {
            this.pos += tag.length     // 跳过标记
            this.tail = this.template.slice(this.pos)   // 重新获取剩下的字符串
        }
    }
    // 扫描直到标记
    scanUtil(tag) {
        let pos_start = this.pos   // 记录从哪开始扫描
        // 如果未扫描到标记就继续扫描,直到标记
        while (!this.eos() && this.tail.indexOf(tag) !== 0) {  
            this.pos++
            this.tail = this.template.slice(this.pos)
        }
        return this.template.slice(pos_start, this.pos)  // 返回标记前的所有字符
    }
    // end of string 判断是否扫描结束
    eos() {
        return this.pos >= this.template.length
    }
}

getToken.js

  • 循环使用扫描器,直到将模板字符串全部扫描结束
import Scanner from "./scanner"   // 引入扫描器
import nestToken from "./nestToken"   // 整合扫描后的tokens
// 将字符串转换为tokens
export default function getTokens(template) {
    let tokens = []
    let word // 记录每次扫描后的结果
    // 实例化扫描器
    let scanner = new Scanner(template)
    // 循环扫描,直到扫描完整个模板字符串
    while (!scanner.eos()) {
        word = scanner.scanUtil("{{") // 获取“{{”前的字符串
        tokens.push(["text", word])    // “{{”前的字符串是text
        scanner.scan("{{")
        if (!scanner.eos()) {
            word = scanner.scanUtil("}}") // 获取括号内的字符串
            if (word[0] == "#") {     // 如果括号内第一个字符是#号,表示要循环,单独处理
                tokens.push(["#", word.slice(1)])
            } else if (word[0] == "/") { // 如果括号内第一个字符是/,表示循环结束,单独处理
                tokens.push(["/", word.slice(1)])
            } else {
                tokens.push(["name", word])    // 其他情况,就是name
            }
            scanner.scan("}}")
        }
    }
    return nestToken(tokens)   // 整合扫描后得到的tokens
}

nestToken.js

  • 整合折叠tokens,将#和/之间的tokens能够整合起来,作为它的下标为3的项
// 折叠tokens
export default function nestToken(tokens) {
  let nestToken = []
  let sections = []   // 栈
  let collector = nestToken   // 收集器,默认指向nestToken
  // 循环tokens
  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    switch (token[0]) {
      case "#":  // 如果第一个参数是#,则需要折叠
        // 入栈
        sections.push(token)
        // 收集
        collector.push(token)
        // 改变收集器指向
        collector = token[2] = []
        break;
      case "/":
        // 出栈
        sections.pop()
        // 改变收集器执行,如果栈中还有数据,指向最后一个,栈中没有数据的话指向nestToken
        collector = sections.length > 0 ? sections[sections.length - 1][2] : nestToken
        break;
      default:
        collector.push(token)
        break;
    }
  }
  return nestToken
}

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

margeTokenAndData.js

  • 让tokens数组变为dom字符串
import lookdata from "./lookdata";  // 引入对象查找方法
// 将tokens转换为DOM字符串
export default function margeTokenAndData(tokens, data) {
    let resultStr = ""
    // 循环tokens
    for (let i = 0; i < tokens.length; i++) {
        const token = tokens[i];
        if (token[0] == "text") {  // 第一个是text不做处理
            resultStr += token[1]
        } else if (token[0] == "name") {  // 第一个是name,需要赋值
            resultStr += lookdata(data, token[1])
        } else if (token[0] == "#") {    // 第一个是#号,需要循环数组
            let arr = lookdata(data, token[1])
            // 循环数组,并且递归转换模板字符串
            arr.forEach(obj => {
                resultStr += margeTokenAndData(token[2], obj)
            });
        }
    }
    return resultStr
}

lookdata.js

  • 查找对象的某个属性
  • 解决以下两个问题:
    1. obj[a.b.c],无法获取的问题
    2. 如果是遍历数组,mustache中是{{.}},就是直接返回这个obj
export default function lookdata(obj, parms) {
    if (parms == ".") return obj   // 如果参数只有一个点,直接返回该对象
    if (parms.includes(".")) {    // 参数如果是a.b.c,则分割参数后,依次获取
        let arr = parms.split(".")
        return arr.reduce((pre, item) => pre[item], obj)
    }
    return obj[parms]  // 如果参数不满足以上逻辑,则可以返回obj[parms]
}

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

index.js

  • 整合以上方法,向外暴露模板引擎与其render方法
import getTokens from "./getToken"
import margeTokenAndData from "./margeTokenAndData"
// 挂载到window对象上向外暴露
window.ylzTE = {
    render(template, data) {
        let tokens = getTokens(template) // 获取tokens
        return margeTokenAndData(tokens, data) // 将tokens与数据结合为dom字符串
    }
}
/*-----------------------------------------------------------------------------*/
// 测试代码
let template = `
    <h1>
    <p>{{group.name}}成员的基本信息</p>
    <ul>
        {{#arr}}
            <br>
            <li>姓名:{{name}}</li>
            <li>年龄:{{age}}</li>
            <li>性别:{{sex}}</li>
            <ul>爱好:
                {{#hobbies}}
                    <ol>{{.}}</ol>
                {{/hobbies}}
            </ul>
        {{/arr}}
    </ul>
    </h1>
`
let data = {
    group: {
        name: "三组"
    },
    arr: [
        { name: "张三", age: 19, sex: "男", hobbies: ["篮球", "足球"] },
        { name: "李四", age: 23, sex: "女", hobbies: ["拼图", "看电视剧", "购物"] },
        { name: "王五", age: 43, sex: "男", hobbies: ["打游戏"] },
        { name: "赵六", age: 22, sex: "男", hobbies: ["跑步", "股票"] }
    ]
}
const box = document.getElementById("box")
box.innerHTML = ylzTE.render(template, data)