Vue源码解读一(模板引擎)

什么是模板引擎? 模板引擎是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的文档,就是将模板文件和数据通过模板引擎生成一个HTML代码。
本篇内容需要的js常量及dom结构


    模板引擎的发展
    1. 纯dom法-创建节点法
    var list = document.getElementById('list') for (var i = 0; i < arr.length; i++) { // 每遍历一项,都要用DOM方法创建li标签 var oli = document.createElement('li'); var hdDiv = document.createElement('div'); hdDiv.className = 'hd'; hdDiv.innerText = arr[i].name + '基本信息'; var dbDiv = document.createElement('div'); dbDiv.className = 'db'; dbDiv.innerText = arr[i].name + '的基本信息'; var p1 = document.createElement('p') p1.innerText = '姓名' + arr[i].name // 创建的节点是孤儿节点,必须上树才能被用户看见 dbDiv.appendChild(p1) oli.appendChild(hdDiv) oli.appendChild(dbDiv) list.appendChild(oli) }

    这种方式内存开销大,繁杂冗长。
    1. 数组join()法-以字符串的视角追加内容
    var list = document.getElementById('list') for (let i = 0; i < arr.length; i++) { list.innerHTML += [ '
  • ', ''+arr[i].name+'的信息', '', '姓名:'+arr[i].name+'
    ', '年龄:'+arr[i].age+'
    ', '性别:
    ', '', '
  • ' ].join(''); }

    1. ES6反引号法-字符串本身可以换行,减少了短字符串的个数
    var list = document.getElementById('list') for (let i = 0; i < arr.length; i++) { list.innerHTML += `
  • ${arr[i].name}的信息姓名:${arr[i].name}
    年龄:${arr[i].age}
    性别:
  • ` }

    1. mustache模板引擎
    var templateStr = ` {{#arr}}
  • {{name}}的信息姓名:{{name}}
    年龄:{{age}}
    性别:
  • {{/arr}} `; // render接收两个参数:1.模板字符串templateStr;2.数据data var domStr = Mustache.render(templateStr, data); // 最后生成dom字符串domStr // console.log(domStr) var container = document.getElementById('list'); container.innerHTML = domStr;

    mustache模板字符串实现思路 Mustache的底层核心机理tokens:js的嵌套数组(模板字符串的js表示形式),且Tokens 是“抽象语法树”、“虚拟节点”等等的思路来源。
    Mustache核心机理
    stateDiagram-v2 模板字符串 --> tokens tokens --> Dom字符串 数据 --> Dom字符串

    一个tokens如下:
    [ ["text", "我买了一个"], ["name", "thing"], ["text", ",好"], ["name", "mood"], ["text", "啊"] ]

    【Vue源码解读一(模板引擎)】这个二维数组的每一项就是一个token。
    mustache具体实现思路如下:
    1. 准备好模板字符串与数据,定义render渲染函数,传入模板字符串与数据,返回编译好的Dom字符串:
    var htmlStr = render(templateStr, data) document.getElementById('main').innerHTML = htmlStr

    在render渲染函数内部,把模板字符串编译成tokens数组,再把tokens编译成Dom字符串:
    render(templateStr, data) { // 调用parseTempToToken函数,让模板字符串变成tokens数组 var tokens = parseTempToToken(templateStr); // 调用renderTemplate函数,让tokens数组变为dom字符串 var domStr = renderTemplate(tokens, data) console.log('domStr:\n', domStr) return domStr; }

    1. 定义parseTempToToken,将模板字符串变为tokens数组
      /** * 将模板字符串变为tokens数组 */ export default function parseTempToToken (templateStr) { let tokens = []; // 创建扫描器 let scanner = new Scanner(templateStr); let words; // 让扫描器工作 while (!scanner.eos()) { // 收集开始标记出现之前的文字 words = scanner.scanUtil('{{'); if (words !== '') { // 尝试写一下去掉空格,智能判断是普通文字的空格,还是标签中的空格 // 标签中的空格不能去掉比如不能去掉class前面的空格 let isInJJH = false; // 空白字符串 var _words = ''; for (let i = 0; i < words.length; i++) { // 判断是否在标签内 if (words[i] === '<') { isInJJH = true } else if (words[i] === '>') { isInJJH = false } if (!/\s/.test(words[i])) { // 如果这项不是空格,拼接上 _words += words[i] } else { // 如果这项是空格,只有在标签里才保留空格 if(isInJJH) { _words += words[i] } } } // 存起来,去掉空格 tokens.push(['text', _words]); } // 过双括号{{ scanner.scan('{{') // 收集开始标记出现之前的文字 words = scanner.scanUtil('}}'); if (words !== '') { // 这个words就是{{}}中间的内容,判断一下首字符 if (words[0] === '#') { // 存起来,从下标为1的项开始存,因为下标为0的项是# tokens.push(['#', words.substring(1)]) } else if (words[0] === '/') { // 存起来,从下标为1的项开始存,因为下标为0的项是/ tokens.push(['/', words.substring(1)]) } else { // 存起来 tokens.push(['name', words]); } // 存起来 // tokens.push(['name', words]); } // 过双括号{{ scanner.scan('}}') } // 返回折叠的tokens return nestTokens(tokens); }

      1)定义扫描器,主要体现在对模板字符串遍历时,指针的移动
      /** * 扫描器类 */ 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) } // 指针是否已经到头,返回布尔值 eos() { return this.tail === ''; } }

      parseTempToToken函数中调用扫描器scanner类的scanUtil方法(收集开始标记之前的文字)、scan方法(过滤双括号{{,}}),eos方法(判断是否分割到字符尾部),最后返回分割好的平级tokens。
      2)定义nestTokens函数,将上一步扫描完生成的平级tokens处理为嵌套tokens
    /** * 函数的功能是折叠tokens,将#和/之间的tokens能够整合起来,作为它的下标为3的项 */ export default function nestTokens(tokens) { // 结果数组 let nestTokens = []; // 栈结构,存放小tokens,栈顶(靠近端口的,最新进入的)的tokens数组中当前操作的这个tokens小数组 var sections = []; // 收集器,初始指向nestTokens,引用类型值,所以指向的是同一个数组 // 收集器的指向会变化,当遇见#的时候,收集器会指向这个token的下标为2的新数组 let collector = nestTokens; for(let i = 0; i < tokens.length; i++) { let token = tokens[i]; switch(token[0]) { case '#': // 收集器中放入这个token collector.push(token); // 入栈 sections.push(token); // 收集器要换内容, 给token添加下标为2的项,并让收集器指向它 collector = token[2] = []; break; case '/': // 出栈,pop()会返回刚刚弹出的项 let section_pop = sections.pop(); // 改变收集器为栈结构队尾(队尾是栈顶)那项的下标为2的数组 collector = sections.length > 0 ? sections[sections.length - 1][2] : nestTokens; break; default: collector.push(token) break; } } return nestTokens; }

    在nestTokens 通过设置收集器collector,并在循环中判断token字符中是否有“#”来判断当前级有没有下一级,有的话将收集器指向本级的下一级,通过‘/’判断本机循环结束并出栈;如果都没有那就是本级的平级,压入栈顶。
    至此,模板字符串的tokens组装完毕
    1. 定义renderTemplate函数,将tokens注入数据(把tokens数组变为dom字符串)
    /** * 函数的功能是让tokens数组变为dom字符串 */ export default function renderTemplate(tokens, data) { // 结果字符串 var resultStr = ''; // 遍历tokens for (let i = 0; i < tokens.length; i++) { let token = tokens[i]; // 看类型 if (token[0] === 'text') { resultStr += token[1] } else if (token[0] === 'name') { // 如果是name类型,那么就直接使用它的值,当然要用lookup // 防止这里是'a.b.b'有逗号的形式 resultStr += lookup(data, token[1]) } else if (token[0] === '#') { resultStr += parseArray(token, data) } } return resultStr }

    1. renderTemplate中调用lookup函数,在dataObj对象中,寻找用连续点符号的keyName属性,也就是读取嵌套的复杂数据类型中的值。
    /** * 功能是可以在dataObj对象中,寻找用连续点符号的keyName属性,比如,dataObj 是 * { *a: { *b: { *c: 100 *} *} * } * 那么lookup(dataObj, 'a.b.b') 结果就是100 */ export default function lookup(dataObj, keyName) { // 看看keyName 中有没有点符号 if (keyName.indexOf('.') > -1 && keyName !== '.') { var keys = keyName.split('.'); // 设置一个临时变量,这个临时变量用于周转,一层一层找下去 var temp = dataObj; for (let i = 0; i < keys.length; i++) { // 每找一层,都把它设为新的临时变量 temp = temp[keys[i]] } return temp; } // 如果没有点符号 return dataObj[keyName]; }

    1. 在已经确定有下级的token中递归调用parseArray()方法,遍历数据,再在renderTemplate中填充。
    /** * 处理数组,结合renderTemplate实现递归 * 这个函数收的参数是token!而不是tokens! * token 就是一个简单的['#', 'student', []] * 这个函数要递归调用renderTemplate函数,调用多少次由data决定 * 比如data的形式是这样的: * { students: [ {name: '小明', age: 12, hobbies: ['游泳', '羽毛球']}, {name: '小红', hobbies: ['足球', '篮球', '羽毛球']} ] } 那么parseArray()函数就要递归调用renderTemplate()三次,数组长度是3 */ export default function parseArray(token, data) { // 得到整体数据data中这个数组要使用的部分 var v = lookup(data, token[1]); // 结果字符串 var resultStr = ''; // 注意,下面这个循环可能是整个包中最难思考的一个循环 // 它是遍历数据,而不是遍历tokens for (let i = 0; i < v.length; i++) { // 这里需要补一个“.”属性 resultStr += renderTemplate(token[2], { ...v[i], '.': v[i] }) } return resultStr; }

    到此mustache实现模板字符串的整个过程就全部结束了,回到最初,渲染函数render里边的得到的tokens,domStr如下图:
    Vue源码解读一(模板引擎)
    文章图片

    渲染到页面上:
    Vue源码解读一(模板引擎)
    文章图片

      推荐阅读