如今说起前端开发,基本上都离不开前端框架。随着前端技术不断迭代,前端框架相关的文档和社区日益完善,前端入门也越来越简单了。我们可以快速上手一些工具和框架,但常常会忽略其中的设计和原理。
对框架和工具的了解不够深入,会导致我们在遇到一些偏门的问题时容易找不到方向,也不利于个人的知识领域扩展,不能很好地进行技术选型。
今天,我会带你了解前端框架为什么会这么热门,以及介绍前端框架的核心能力——模板引擎的实现原理。在讲解的过程中,一些代码会以 Vue.js 作为示例。
我们先来看一下,为什么要使用前端框架。
为什么要使用前端框架
一个工具被大多数人使用、成为热门,离不开相关技术发展的历史进程。了解这些工具和框架出现的原因,我们可以及时掌握技术的发展方向,保持对技术的敏感度、更新自身的认知,这些都会成为我们自身的竞争力。
前端框架也一样。在前端框架出现之前,jQuery 也是前端开发必备的工具库,大多数项目中都会使用。短短几年间,前端开发却变得无法离开前端框架,这中间到底发生了什么呢?
前端的飞速发展
曾几何时,一提到前端,大家都会想到 jQuery。那是 jQuery 一把梭的年代,不管前端后台都会用 jQuery 完成页面开发。那时候前端开发的工作倾向于切图和重构,重页面样式而轻逻辑,工作内容常常是拼接 JSP 模板、拼 PHP 模板以及调节浏览器兼容。
为什么 jQuery 那么热门呢?除了超方便的 Sizzle 引擎元素选择器、简单易用的异步请求库 ajax,还有链式调用的编程方式使得代码如行云流水般流畅。jQuery 提供的便捷几乎满足了当时前端的大部分工作(所以说 jQuery 一把梭不是毫无道理的)。
接下来短短的几年时间,前端经历了特别多的改变。随着 Node.js 的出现、NPM 包管理的完善,再加上热闹的开源社区,前端领域获得了千千万万开发者的支援。从页面开发到工具库开发、框架开发、脚本开发、到服务端开发,单线程的 JavaScript 正在不断进行自我革新,从而将领域不断拓宽,形成了如今你所能看到的、获得赋能的前端。
那么,是什么原因导致了 jQuery 被逐渐冷落,前端框架站上了舞台中央呢?其中的原因有很多,包括业务场景的进化、技术的更新迭代,比如前端应用逐渐复杂、单页应用的出现、前端模块化等。
前端框架的出现
前面第 8 讲中,我们知道了浏览器是如何渲染页面的。从用户的角度来看,浏览器生成了最终的渲染树,并通过光栅化来将页面显示在屏幕上,页面渲染的工作就完成了。
实际上,浏览器页面更多的不只是静态页面的渲染,还包括点击、拖拽等事件操作以及接口请求、数据渲染到页面等动态的交互逻辑,因此我们还常常需要更新页面的内容。
要理解前端框架为什么如此重要,需要先看看在框架出现前,前端开发是如何实现和用户进行交互的。
这个过程跟上一讲事件驱动的内容很相似,以一个常见的表单提交作为例子,会包括编写静态页面、给对应的元素绑定对应的事件、事件触发时更新页面内容等步骤,这是最简单的页面交互。
对于更新页面内容这个步骤,如果我们页面中有很多的内容需要更新,光拼接字符串我们可能就有一大堆代码。
以下的例子,为了不占用大量的篇幅,使用了 jQuery,否则代码量会更多。
举个例子,抢答活动中常常会出现题目和多个答案进行选择,我们现在需要开发一个管理端,对这些抢答卡片进行管理。假设一个问题会包括两个答案,我们可以通过新增卡片的方式来添加一套问答,编辑卡片的过程包括这些步骤。
1. 新增一个卡片时,通过插入 DOM 节点的方式添加卡片样式。
var index = 0;
function addCard() {
var body = $("#the-dom");
var addDom = body.find(".the-class");
addDom.before('<div class="col-lg-4" data-index="' + index + '"></div>');
var theDom = body.find('[data-index="' + index + '"]');
theDom.innerHTML(
`<input type="text" class="form-control question" placeholder="你的问题">
<input type="text" class="form-control option-a" placeholder="回答1">
<input type="text" class="form-control option-b" placeholder="回答2">
`
);
index++;
return theDom;
}
2. 卡片内编辑题目和答案时,会有字数限制(使用 jQuery 对输入框的输入事件进行监听,并限制输入内容)。
theDom
.on("keyup", ".question", function (ev) {
ev.target.value = ev.target.value.substr(0, 20);
})
.on("keyup", ".option-a", function (ev) {
ev.target.value = ev.target.value.substr(0, 10);
})
.on("keyup", ".option-b", function (ev) {
ev.target.value = ev.target.value.substr(0, 10);
});
3. 获取输入框内的内容(使用 jQuery 选择元素并获取内容),用于提交到后台。
function getCardValue(index) {
var body = $("#the-dom");
var theDom = body.find('[data-index="' + index + '"]');
var questionName = theDom.find(".question").val();
var optionA = theDom.find(".option-a").val();
var optionB = theDom.find(".option-b").val();
return { questionName, optionA, optionB };
}
可以看到,仅是实现一个问答卡片的编辑就需要编写不少的代码,大多数代码内容都是为了拼接 HTML 内容、获取 DOM 节点、操作 DOM 节点。
这些代码逻辑,如果我们使用 Vue 来实现,只需要这么写:
<template>
<div v-for="card in cards">
<input
type="text"
class="form-control question"
v-model="card.questionName"
placeholder="你的问题"
/>
<input
type="text"
class="form-control option-a"
v-model="card.optionA"
placeholder="回答1"
/>
<input
type="text"
class="form-control option-b"
v-model="card.optionB"
placeholder="回答2"
/>
</div>
</template>
<script>
export default {
name: "Cards",
data() {
return {
cards: [],
};
},
methods: {
// 添加一个卡片
addCard() {
this.cards.push({
questionName: "",
optionA: "",
optionB: "",
});
},
// 获取卡片的输入值
getCardValue(index) {
return this.cards[index];
},
},
};
</script>
可见,前端框架提供了便利的数据绑定、界面更新、事件监听等 API,我们不需要再手动更新前端页面的内容、维护一大堆的 HTML 和变量拼接的动态内容了。
使用前端框架对开发效率有很大的提升,同时也在一定程度上避免了代码可读性、可维护性等问题。这也是为什么前端框架这么热门,大家都会使用它来进行开发的原因。
那么,前端框架是怎么做到这些的呢?要实现这些能力,离不开其中的模板引擎。
前端框架的核心——模板引擎
当用户对页面进行操作、页面内容更新,我们需要实现的功能流程包括:
- 监听操作;
- 获取数据变量;
- 使用数据变量拼接成 HTML 模板;
- 将 HTML 内容塞到页面对应的地方;
- 将 HTML 片段内需要监听的点击等事件进行绑定。
可以看到,实现逻辑会比较复杂和烦琐。
如果使用前端框架,我们可以:
- 使用将数据变量绑定到 HTML 模板的方式,来控制展示的内容;
- 配合一些条件判断、条件循环等逻辑,控制交互的具体逻辑;
- 通过改变数据变量,框架会自动更新页面内容。
这样,我们可以快速高效地完成功能开发,代码的可读性和维护性都远胜于纯手工实现。
如果使用数据驱动的方式,还可以通过让逻辑与 UI 解耦的方式,提升代码的可维护性。其中的数据绑定、事件绑定等功能,前端框架是依赖模板引擎的方式来实现的。
以 Vue 为例子,对于开发者编写的 Vue 代码,Vue 会将其进行以下处理从而渲染到页面中:
- 解析语法生成 AST 对象;
- 根据生成的 AST 对象,完成
data
数据初始化; - 根据 AST 对象和
data
数据绑定情况,生成虚拟 DOM 对象; - 将虚拟 DOM 对象生成真正的 DOM 元素插入到页面中,此时页面会被渲染。
模板引擎将模板语法进行解析,分别生成 HTML DOM,使用像 HTML 拼接的方式(在对应的位置绑定变量、指令解析获取拼接逻辑等等),同时配合事件的管理、虚拟 DOM 的设计,可以最大化地提升页面的性能。
这些便是模板引擎主要的工作,我们来分别看一下。
解析语法生成 AST 对象
抽象语法树(Abstract Syntax Tree)也称为 AST 语法树,指的是源代码语法所对应的树状结构。其实我们的 DOM 结构树,也是 AST 的一种,浏览器会对 HTML DOM 进行语法解析、并生成最终的页面。
生成 AST 的过程涉及编译器的原理,一般经过以下过程。
- 语法分析。模板引擎需要在这个过程中识别出特定的语法,比如
v-if
/v-for
这样的指令,或是<MyCustomComponent>
这样的自定义 DOM 标签,还有@click
/:props
这样的简化绑定语法等。 - 语义分析。这个过程会审查源程序有无语义错误,为代码生成阶段收集类型信息,一般类型检查也会在这个过程中进行。例如我们绑定了某个不存在的变量或者事件,又或者是使用了某个未定义的自定义组件等,都会在这个阶段进行报错提示。
- 生成 AST 对象。
以 Vue 为例,生成 AST 的过程包括 HTML 模板解析、元素检查和预处理:
* 将HTML编译成AST对象
* 该代码片段基于Vue2.x版本
*/
export function parse(
template: string,
options: CompilerOptions
): ASTElement | void {
parseHTML(template, {
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
start(tag, attrs, unary) {
const element: ASTElement = {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent: currentParent,
children: [],
};
if (isForbiddenTag(element) && !isServerRendering()) {
element.forbidden = true;
process.env.NODE_ENV !== "production" &&
warn(
"Templates should only be responsible for mapping the state to the " +
"UI. Avoid placing tags with side-effects in your templates, such as " +
`<${tag}>` +
", as they will not be parsed."
);
}
for (let i = 0; i < preTransforms.length; i++) {
preTransforms[i](element, options);
}
if (inVPre) {
processRawAttrs(element);
} else {
processFor(element);
processIf(element);
processOnce(element);
processKey(element);
element.plain = !element.key && !attrs.length;
processRef(element);
processSlot(element);
processComponent(element);
for (let i = 0; i < transforms.length; i++) {
transforms[i](element, options);
}
processAttrs(element);
}
},
});
return root;
}
到这里,Vue 将开发者的模板代码解析成 AST 对象,我们来看看这样的 AST 对象是怎样生成 DOM 元素的。
AST 对象生成 DOM 元素
前面提到,在编译解析和渲染过程中,模板引擎会识别和解析模板语法语义、生成 AST 对象,最后根据 AST 对象会生成最终的 DOM 元素。
举个例子,我们写了以下这么一段 HTML 模板:
<div>
<a>123</a>
<p>456<span>789</span></p>
</div>
模板引擎可以在语法分析、语义分析等步骤后,得到这样的一个 AST 对象:
thisDiv = {
dom: {
type: "dom",
ele: "div",
nodeIndex: 0,
children: [
{
type: "dom",
ele: "a",
nodeIndex: 1,
children: [{ type: "text", value: "123" }],
},
{
type: "dom",
ele: "p",
nodeIndex: 2,
children: [
{ type: "text", value: "456" },
{
type: "dom",
ele: "span",
nodeIndex: 3,
children: [{ type: "text", value: "789" }],
},
],
},
],
},
};
这个 AST 对象维护我们需要的一些信息,包括 HTML 元素里:
- 需要绑定哪些变量(变量更新的时候需要更新该节点内容);
- 是否有其他的逻辑需要处理(比如含有逻辑指令,如
v-if
、v-for
等); - 哪些节点绑定了事件监听事件(是否匹配一些常用的事件能力支持,如
@click
)。
模板引擎会根据 AST 对象生成最终的页面片段和逻辑,在这个过程中会通过添加特殊标识(例如元素 ID、属性标记等)的方式来标记 DOM 节点,配合 DOM 元素选择方式、事件监听方式等,在需要更新的时候可快速定位到该 DOM 节点,并进行节点内容更新,从而实现页面内容的更新。
目前来说,前端模板渲染的实现一般分为以下两种方式。
- 字符串模版方式:使用拼接的方式生成 DOM 字符串,直接通过
innderHTML()
插入页面。 - 节点模版方式:使用
createElement()
/appendChild()
/textContent
等方法动态地插入 DOM 节点。
在使用字符串模版的时候,我们将nodeIndex
绑定在元素属性上,主要用于在数据更新时追寻节点进行内容更新。
在使用节点模版的时候,我们可在创建节点时将该节点保存下来,直接用于数据更新:
// 假设这是一个生成 DOM 的过程,包括 innerHTML 和事件监听
function generateDOM(astObject) {
const { dom, binding = [] } = astObject;
// 生成DOM,这里假装当前节点是baseDom
baseDom.innerHTML = getDOMString(dom);
// 对于数据绑定的,来进行监听更新吧
baseDom.addEventListener("data:change", (name, value) => {
// 寻找匹配的数据绑定
const obj = binding.find((x) => x.valueName == name);
// 若找到值绑定的对应节点,则更新其值。
if (obj) {
baseDom.find(`[data-node-index="${obj.nodeIndex}"]`).innerHTML = value;
}
});
}
// 获取DOM字符串,这里简单拼成字符串
function getDOMString(domObj) {
// 无效对象返回''
if (!domObj) return "";
const { type, children = [], nodeIndex, ele, value } = domObj;
if (type == "dom") {
// 若有子对象,递归返回生成的字符串拼接
const childString = "";
children.forEach((x) => {
childString += getDOMString(x);
});
// dom对象,拼接生成对象字符串
return `<${ele} data-node-index="${nodeIndex}">${childString}</${ele}>`;
} else if (type == "text") {
// 若为textNode,返回text的值
return value;
}
}
通过上面的方式,前端框架实现了将 AST 对象生成 DOM 元素,并将这些 DOM 元素渲染或更新到页面上。
或许你会觉得疑惑:原本就是一个<div>
HTML 模板,经过 AST 生成一个对象,最终还是生成一个<div>
DOM 节点,看起来好像挺多余的。
实际上,在这个过程中,模板引擎可以实现更多功能。
模板引擎可以做更多
将 HTML 模板解析成 AST 对象,再根据 AST 对象生成 DOM 节点,在这个过程中前端框架可以实现以下功能:
- 排除无效 DOM 元素(非自定义组件、也非默认组件的 DOM 元素),在构建阶段可及时发现并进行报错;
- 可识别出自定义组件,并渲染对应的组件;
- 可方便地实现数据绑定、事件绑定等功能;
- 为虚拟 DOM Diff 过程打下铺垫;
- HTML 转义(预防 XSS 漏洞)。
这里我们以第 5 点预防 XSS 漏洞为例子,详细地介绍一下模板引擎是如何避免 XSS 攻击的。
预防 XSS 漏洞
我们知道 XSS 的整个攻击过程大概为:
- 攻击者提交含有恶意代码的内容(比如 JavaScript 脚本);
- 页面渲染的时候,这些内容未被过滤就被加载处理,比如获取 Cookie、执行操作等;
- 其他用户在浏览页面的时候,就会在加载到恶意代码时受到攻击。
要避免网站用户受到 XSS 攻击,主要方法是将用户提交的内容进行过滤处理。大多数前端框架会自带 HTML 转义功能,从而避免的 XSS 攻击。
以 Vue 为例,使用默认的数据绑定方式(双大括号、v-bind
等)会进行 HTML 转义,将数据解释为普通文本,而非 HTML 代码。
除此预防 XSS 漏洞之外,前端框架还做了一些性能、安全性等方面的优化,也提供了一些用于项目开发配套的工具,包括路由的管理、状态和数据的管理等工具。
小结
今天我带大家了解了前端框架的出现,由于前端框架帮开发者解决了很多重复性的工作(拼接 HTML 模板、DOM 元素查找、DOM 元素事件监听等),极大地提升了开发者的效率,同时还提升了代码的可读性和可维护性,因此受到很多前端开发的追捧。
除此之外,我还介绍了前端框架中模板引擎的实现原理,包括解析语法生成 AST 对象、根据 AST 对象生成 DOM 元素,并对生成的 DOM 元素进行标记,则可以在变量改变的时候,解析找到相应的 DOM 元素进行内容的更新。
在了解这些内容之后,我们可以在页面渲染遇到性能问题的时候,根据所使用框架的具体实现,找到可能导致页面渲染卡顿或是不流畅的原因。除此之外,在使用框架的过程中,遇到一些语法报错、XSS 安全漏洞等问题的时候,也可以快速找到解决办法。
今日思考:React.js 中的 JSX 和模板引擎是什么关系?在留言区写出你的想法!