Vue源码解读一:模板引擎
折原娇君 -什么是模板引擎?
模板引擎是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的文档,就是将模板文件和数据通过模板引擎生成一个HTML代码。
本篇内容需要的js常量及dom结构<body>
<ul id="list"></ul>
<script>
var arr = [
{name: '小明', age:23},
{name: '小红', age:25},
{name: '小强', age: 27}
]
</script>
</body>
模板引擎的发展纯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)
}
这种方式内存开销大,繁杂冗长。
数组join()法-以字符串的视角追加内容var list = document.getElementById('list')
for (let i = 0;i < arr.length; i++) {
list.innerHTML += [
'<li>',
' <div >'+arr[i].name+'的信息</div>',
' <div >',
' <p>姓名:'+arr[i].name+'</p>',
' <p>年龄:'+arr[i].age+'</p>',
' <p>性别:</p>',
' </div>',
'</li>'
].join('');
}
ES6反引号法-字符串本身可以换行,减少了短字符串的个数var list = document.getElementById('list')
for (let i = 0;i < arr.length; i++) {
list.innerHTML += `
<li>
<div >${arr[i].name}的信息</div>
<div >
<p>姓名:${arr[i].name}</p>
<p>年龄:${arr[i].age}</p>
<p>性别:</p>
</div>
</li>
`
}
mustache模板引擎var templateStr = `
{{#arr}}
<li>
<div >{{name}}的信息</div>
<div >
<p>姓名:{{name}}</p>
<p>年龄:{{age}}</p>
<p>性别:</p>
</div>
</li>
{{/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", "<h1>我买了一个"],
["name", "thing"],
["text", ",好"],
["name", "mood"],
["text", "啊</h1>"]
]
这个二维数组的每一项就是一个token。
mustache具体实现思路如下:
准备好模板字符串与数据,定义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;
}
定义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 !== '') {
// 尝试写一下去掉空格,智能判断是普通文字的空格,还是标签中的空格
// 标签中的空格不能去掉比如<div >不能去掉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组装完毕
/**
* 函数的功能是让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
}
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];
}
在已经确定有下级的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如下图:
渲染到页面上: