Vue源码解析之mustache模板引擎
个人简介
👀个人主页: 前端杂货铺
🙋♂️学习方向: 主攻前端方向,正逐渐往全干发展
📃个人状态: 研发工程师,现效力于中国工业软件事业
🚀人生格言: 积跬步至千里,积小流成江海
🥇推荐学习:🍍前端面试宝典 🎨100个小功能 🍉Vue2 🍋Vue3 🍓Vue2/3项目实战 🥝Node.js实战 🍒Three.js🌕个人推广:每篇文章最下方都有加入方式,旨在交流学习&资源分享,快加入进来吧
文章目录
- 前言
- 将数据变为视图
- 纯DOM法
- 数组的 join 方法
- ES6 的反引号法
- mustache 基本使用
- mustache 核心理念
- 手写 mustache
- 搭建环境
- 手撕 mustache
- 总结
前言
知其然知其所以然,本篇文档我们来学习 Vue 源码之 mustache 模板引擎。
将数据变为视图
纯DOM法
纯 DOM 方法的关键点在于使用 createElement
创建节点和使用 appendChild
向节点的子节点列表的末尾添加新的子节点。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<style>
.hd {
color: orange;
}
.bd {
color: skyblue;
}
</style>
<body>
<ul id="list">
</ul>
<script>
// 定义人的基本信息
const arr = [
{"name": "小明", "age": 30, "gender": "male"},
{"name": "小小", "age": 20, "gender": "female"},
{"name": "小山", "age": 10, "gender": "male"},
]
// 获取 ul
const list = document.getElementById('list');
// 循环人的基本信息数组,把信息构建至页面中
for (let i = 0; i < arr.length; i++) {
// 创建li标签
let oLi = document.createElement("li");
// 创建hd这个div,存放 “xx的基本信息”
let hdDiv = document.createElement("div");
hdDiv.className = 'hd';
hdDiv.innerText = arr[i].name + '的基本信息';
// 创建bd这个div,存放人的详细基本信息
let bdDiv = document.createElement("div");
bdDiv.className = 'bd';
// 人的基本信息(姓名、年龄、性别)放入 p 标签中
let p1 = document.createElement("p");
p1.innerText = '姓名:' + arr[i].name;
bdDiv.appendChild(p1);
let p2 = document.createElement("p");
p2.innerText = '年龄:' + arr[i].age;
bdDiv.appendChild(p2);
let p3 = document.createElement("p");
p3.innerText = '性别:' + arr[i].gender;
bdDiv.appendChild(p3);
oLi.appendChild(hdDiv);
hdDiv.appendChild(bdDiv);
list.appendChild(oLi);
}
</script>
</body>
</html>
数组的 join 方法
数组 join 方法关键在于字符串的拼接。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<ul id="list">
</ul>
<script>
// 定义人的基本信息
const arr = [
{"name": "小明", "age": 30, "gender": "male"},
{"name": "小小", "age": 20, "gender": "female"},
{"name": "小山", "age": 10, "gender": "male"},
]
const list = document.getElementById('list');
for (let i = 0; i < arr.length; i++) {
list.innerHTML += [
'<li>',
' <div class="hd"></div>' + arr[i].name + '的信息</div>',
' <div class="bd">',
' <p>姓名:' + arr[i].name + '</p>',
' <p>年龄:' + arr[i].age + '</p>',
' <p>性别:' + arr[i].gender + '</p>',
' </div>',
'</li>'
].join('')
}
</script>
</body>
</html>
ES6 的反引号法
ES6 反引号法可以更优雅的把 html 标签写入字符串中。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<ul id="list"></ul>
</body>
<script>
// 定义人的基本信息
const arr = [
{"name": "小明", "age": 30, "gender": "male"},
{"name": "小小", "age": 20, "gender": "female"},
{"name": "小山", "age": 10, "gender": "male"},
]
const list = document.getElementById('list');
for (let i = 0; i < arr.length; i++) {
list.innerText += `
<li>
<div class="hd">${arr[i].name}的基本信息</div>
<div class="bd">
<p>姓名:${arr[i].name}</p>
<p>性别:${arr[i].gender}</p>
<p>年龄:${arr[i].age}</p>
</div>
</li>
`
}
</script>
</html>
mustache 基本使用
mustache.js 是一个简单强大的 JavaScript 模板引擎,使用它可以简化在 js 代码中的 html 编写。
基本使用
// 初始化
npm init -y
// 安装 mastache
npm install mastache
mustache 的使用非常简单, 使用花括号 {{#xxx}}
开始,使用 花括号 {{/xxx}}
结束,中间包裹着 key 值,最后再使用 mustache.render(templateStr, data)
,完成数据的渲染。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Document</title>
</head>
<body>
<div id="container"></div>
<!-- 模版 -->
<script type="text/template" id="myTemplate">
<ul>
{{#arr}}
<li>
<div class="hd">{{name}}的基本信息</div>
<div class="bd">
<p>姓名:{{name}}</p>
<p>性别:{{gender}}</p>
<p>年龄:{{age}}</p>
</div>
</li>
{{/arr}}
</ul>
</script>
<script type="module">
import mustache from "./node_modules/mustache/mustache.mjs";
// 获取模版
const templateStr = document.getElementById("myTemplate").innerHTML;
// 定义数据
const data = {
arr: [
{ name: "小明", age: 30, gender: "male" },
{ name: "小小", age: 20, gender: "female" },
{ name: "小山", age: 10, gender: "male" },
],
};
const domStr = mustache.render(templateStr, data);
const container = document.getElementById("container");
container.innerHTML = domStr;
</script>
</body>
</html>
mustache 核心理念
tokens 是一个 JS 的嵌套数组,是模板字符串的 JS 表示。
对于一维模板字符串及对应 tokens,如下:
对于二维模板字符串及 tokens 如下:
mustache 库底层重点要做的两件事情:
- 将模板字符串编译为 tokens 形式
- 将 tokens 结合数据,解析为 dom 字符串
手写 mustache
搭建环境
总体目录如下:
新建一个文件夹,打开终端,输入以下命令后回车以初始化项目。
npm init -y
安装依赖。
npm install
安装 webpack、webpack-dev-server、webpck-cli。
npm i webpack@4 webpack-dev-server@3 webpack-cli@3
修改 package.json
文件的 dev 内容如下,更换项目启动方式。
{
"name": "templateengine",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "webpack-dev-server"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"webpack": "^4.47.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.3"
}
}
手动创建 webpack.config.js
文件,并进行如下配置。
const path = require("path");
module.exports = {
// 模式,开发
mode: "development",
// 入口
entry: "./src/index.js",
// 打包到什么文件
output: {
filename: "bundle.js",
},
// 配置一下 webpack-dev-server
devServer: {
// 静态文件根目录
contentBase: path.join(__dirname, "www"),
// 压缩
compress: false,
// 端口号
port: 8080,
// 虚拟打包到路径,bundle.js文件没有真正的生成
publicPath: "/xuni/",
},
};
手动创建 www
文件及 www/index.html
文件,添加如下内容。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>你好,这里是前端杂货铺!</h1>
<script src="/xuni/bundle.js"></script>
</body>
</html>
手动创建 src
文件及 src/index.js
文件,添加如下内容。
console.log('hello');
OK,此时基本配置已完成,接下来我们打开终端键入 npm run dev
命令回车,然后访问 8080 端口。
手撕 mustache
手撕 mustache 源码地址
接下来,我们来手写简易版的 mustache,体验一下模版引擎的设计巧妙之处。
代码文件结构如下:
在 index.html
文件中,我们编写 模版字符串 和 数据,并使用 TemplateEngine.render(模板字符串, 数据)
来获取 dom 数据,之后添加到 div 容器中。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="/xuni/bundle.js"></script>
<script>
// 模板字符串
const templateStr = `
<div>
<ul>
{{#students}}
<li>
学生{{name}}的爱好是
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/students}}
</ul>
</div>
`;
// 数据
const data = {
students: [
{
name: "张三",
hobbies: ["敲代码", "乒乓球", "健身"],
},
{
name: "李四",
hobbies: ["游泳", "跑步"],
},
{
name: "王五",
hobbies: ["唱歌", "跳舞"],
},
],
};
// 获取 dom 数据
const domStr = TemplateEngine.render(templateStr, data);
const container = document.getElementById("container");
container.innerHTML = domStr;
</script>
</body>
</html>
在 src/index.js
文件中,全局提供模板引擎对象,渲染方法的入参为定义的 模板字符串 和 数据,通过 parseTemplateToTokens
解析字符串为 tokens,通过 renderTemplate
渲染 tokens 数组为 dom 字符串。
import parseTemplateToTokens from "./parseTemplateToTokens";
import renderTemplate from "./renderTemplate";
// 全局提供模版引擎对象
window.TemplateEngine = {
// 渲染方法
render(templateStr, data) {
// 模版字符串变为tokens数组
const tokens = parseTemplateToTokens(templateStr);
// 调用renderTemplate方法,渲染tokens数组为dom字符串
const domStr = renderTemplate(tokens, data);
// 返回组织好的 dom 字符串
return domStr;
},
};
通过上面两个核心方法,我们将获得如下的数据。
扫描器类 Scanner,用于扫描传入的模板字符串。当遇到 {{
和 }}
内容时停止扫描,并返回停止扫描之前路过的内容。
/**
* 扫描器类
*/
export default class Scanner {
constructor(templateStr) {
this.templateStr = templateStr;
// 指针
this.pos = 0;
// 尾巴,初始值为传入的字符串
this.tail = templateStr;
}
// 走过指定的内容,没有返回值,用于过滤掉 {{ 和 }} 这样的内容
scan(tag) {
// 如果捕获到了tag,就让指针后移tag的长度,不会做任何处理,只是过滤掉
if (this.tail.indexOf(tag) === 0) {
// {{ 和 }} 的长度为2,就让指针后移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);
}
// 返回扫描过的内容(不包括stopTag)
return this.templateStr.substring(pos_backup, this.pos);
}
// 指针是否已经到头
eos() {
return this.pos >= this.templateStr.length;
}
}
parseTemplateToTokens.js
文件用于将模板字符串变为初步的 tokens 数组。在 Scanner 的加持下,按照一定的规则 “制作” tokens。
import Scanner from "./Scanner";
import nestTokens from "./nestTokens";
/**
* 将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {
// 存储 tokens
const tokens = [];
// 创建扫描器,扫描模板字符串
const scanner = new Scanner(templateStr);
let words;
// 让扫描器工作(当扫描器的指针没有到头时)
while (!scanner.eos()) {
// 扫描字符,直到遇到 {{ 时结束,因为 {{ 之前的内容是text类型
words = scanner.scanUtil("{{");
// 如果扫描的内容不为空,就作为text类型存入tokens
if (words !== "") {
tokens.push(["text", words]);
}
// 过滤 {{,指针后移2位,因为 {{ 并没有实质作用,过滤掉就好
scanner.scan("{{");
// 再次扫描字符,直到遇到 }} 时结束,因为 {{ 和 }} 之间的内容是需要处理的name类型
words = scanner.scanUtil("}}");
// 根据再次扫描的内容的第一个字符,判断应该 push 到 tokens 中的类型
if (words !== "") {
// 按照第一个字符组织tokens,# 为遍历的开始,/ 为遍历的结束
if (words[0] === "#") {
tokens.push(["#", words.substring(1)]);
} else if (words[0] === "/") {
tokens.push(["/", words.substring(1)]);
} else {
tokens.push(["name", words]);
}
}
scanner.scan("}}");
}
// 返回折叠收集的tokens
return nestTokens(tokens);
}
要进行进一步的 tokens 处理(多层级),就需要 nestTokens.js
来大显身手了。
/**
* 折叠tokens,将#和/之间的tokens能够整合起来,作为它下标为2的项
*/
export default function nestTokens(tokens) {
// 结果数组,需要最后返回的
const nestedTokens = [];
// 栈结构,用于保存 # 的token
const sections = [];
// 收集器引用,默认指向nestedTokens
let collector = nestedTokens;
// 遍历在 parseTemplateToTokens 中组织好的 tokens
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
switch (token[0]) {
// 第一个字符如果为 #,就往收集器中放入这个token,并且入栈
case "#":
// 收集器中放入这个token
collector.push(token);
// 入栈
sections.push(token);
// 收集器此时要换位这个token的下标为2的项,因为之后需要push的是它的子项
collector = token[2] = [];
break;
case "/":
// 出栈
sections.pop();
// 改变收集器为栈结构末尾的数组
collector =
sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens;
break;
default:
// 收集器中放入这个token
collector.push(token);
}
}
return nestedTokens;
}
下面我们要做的就是把最终的 tokens 转为 dom 字符串了。
编写 lookUp.js
文件,用于在对象中查找 key 值。如 dataObj = {a: {b: {c: 100}}},keyName = 'a.b.c'
,那么调用该方法将返回100。
/**
* 在 dataObj 对象中查找 keyName 的值
* 如dataObj = {a: {b: {c: 100}}},keyName = 'a.b.c',返回100
* @param {*} dataObj 对象
* @param {*} keyName 要查找的key
* @returns
*/
export default function lookUp(dataObj, keyName) {
// 查看 keyName 中是否有 ., 但不能是 .
if (keyName !== "." && keyName.indexOf(".") !== -1) {
// 以 . 分隔,例如 a.b.c,先拆分为 [a, b, c]
const keys = keyName.split(".");
// 临时变量,用于逐层查找
let temp = dataObj;
// 每找一层,就把它设置为新的临时变量
for (let i = 0; i < keys.length; i++) {
temp = temp[keys[i]];
}
return temp;
}
// 没有.直接返回
return dataObj[keyName];
}
下面我们编写 parseArray.js
和 renderTemplate.js
文件,分别用于处理数组和渲染模板字符串。
import lookUp from "./lookUp";
import renderTemplate from "./renderTemplate";
/**
* 处理数组,结合 renderTemplate 方法实现递归
* token:["#", "students", [xxx]]
*
* 这个函数要递归调用 renderTemplate 方法
* 调用次数取决于 data 中的数组长度
*/
export default function parseArray(token, data) {
// 得到数据整体data中这个数组要使用的数据
const v = lookUp(data, token[1]);
// 结果字符串
let resultStr = "";
// 遍历数据,而不是tokens。数组中的数据有几条就遍历几次
for (let i = 0; i < v.length; i++) {
// 拼接,在此处要补一个 “.” 属性
resultStr += renderTemplate(token[2], {
...v[i], // 简单数组,直接展开
".": v[i], // 为了处理 {{.}} 的情况
});
}
return resultStr;
}
import lookUp from "./lookUp";
import parseArray from "./parseArray";
/**
* 渲染模版
* @param {*} tokens
* @param {*} data
* @returns
*/
export default function renderTemplate(tokens, data) {
let resultStr = "";
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token[0] === "text") {
resultStr += token[1];
} else if (token[0] === "name") {
// name类型,获取值
resultStr += lookUp(data, token[1]);
} else if (token[0] === "#") {
// 递归处理下标为2·的数组
resultStr += parseArray(token, data);
}
}
return resultStr;
}
此时编码完毕,打开浏览器查看渲染内容如下,至此,手撕完毕!!
总结
本篇文章,我们首先认识了几种将模型变为视图的方法。
之后学习了Mustache 的基本使用和核心理念。
通过手写 Mustache 模板引擎,我们 深入理解 了其 工作原理和实现细节。虽然手写版本可能不如官方实现那么完善,但 这个过程让我对模板引擎的设计有了更深刻的认识。Mustache 的简洁性和无逻辑特性使其成为一种非常灵活的模板引擎,适用于各种场景。
如果你也对模板引擎的实现感兴趣,我建议你尝试手写一个简化版的 Mustache 或类似的模板引擎。这不仅能够加深你对前端技术的理解,还能提升你的编程能力。
好啦,本篇文章到这里就要和大家说再见啦,祝你这篇文章阅读愉快,你下篇文章的阅读愉快留着我下篇文章再祝!
参考资料:
- mustache 百度百科
- Vue源码解析之mustache模板引擎(尚硅谷)