这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战”
-
如今说起前端开发,基本上都离不开前端框架。随着前端技术不断迭代,前端框架相关的文档和社区日益完善,前端入门也越来越简单了。我们可以快速上手一些工具和框架,但常常会忽略其中的设计和原理。
-
对框架和工具的了解不够深入,会导致我们在遇到一些偏门的问题时容易找不到方向,也不利于个人的知识领域扩展,不能很好地进行技术选型。
-
今天,我会带你了解前端框架为什么会这么热门,以及介绍前端框架的核心能力——模板引擎的实现原理。在讲解的过程中,一些代码会以 Vue.js 作为示例。
-
我们先来看一下,为什么要使用前端框架。
为什么要使用前端框架
-
一个工具被大多数人使用、成为热门,离不开相关技术发展的历史进程。了解这些工具和框架出现的原因,我们可以及时掌握技术的发展方向,保持对技术的敏感度、更新自身的认知,这些都会成为我们自身的竞争力。
-
前端框架也一样。在前端框架出现之前,jQuery 也是前端开发必备的工具库,大多数项目中都会使用。短短几年间,前端开发却变得无法离开前端框架,这中间到底发生了什么呢?
前端的飞速发展
-
曾几何时,一提到前端,大家都会想到 jQuery。那是 jQuery 一把梭的年代,不管前端后台都会用 jQuery 完成页面开发。那时候前端开发的工作倾向于切图和重构,重页面样式而轻逻辑,工作内容常常是拼接 JSP 模板、拼 PHP 模板以及调节浏览器兼容。
-
为什么 jQuery 那么热门呢?除了超方便的 Sizzle 引擎元素选择器、简单易用的异步请求库 ajax,还有链式调用的编程方式使得代码如行云流水般流畅。jQuery 提供的便捷几乎满足了当时前端的大部分工作(所以说 jQuery 一把梭不是毫无道理的)。
-
接下来短短的几年时间,前端经历了特别多的改变。随着 Node.js 的出现、NPM 包管理的完善,再加上热闹的开源社区,前端领域获得了千千万万开发者的支援。从页面开发到工具库开发、框架开发、脚本开发、到服务端开发,单线程的 JavaScript 正在不断进行自我革新,从而将领域不断拓宽,形成了如今你所能看到的、获得赋能的前端。
-
那么,是什么原因导致了 jQuery 被逐渐冷落,前端框架站上了舞台中央呢?其中的原因有很多,包括业务场景的进化、技术的更新迭代,比如前端应用逐渐复杂、单页应用的出现、前端模块化等。
前端框架的出现
-
从用户的角度来看,浏览器生成了最终的渲染树,并通过光栅化来将页面显示在屏幕上,页面渲染的工作就完成了。
-
实际上,浏览器页面更多的不只是静态页面的渲染,还包括点击、拖拽等事件操作以及接口请求、数据渲染到页面等动态的交互逻辑,因此我们还常常需要更新页面的内容。
-
要理解前端框架为什么如此重要,需要先看看在框架出现前,前端开发是如何实现和用户进行交互的。
-
举个例子,抢答活动中常常会出现题目和多个答案进行选择,我们现在需要开发一个管理端,对这些抢答卡片进行管理。假设一个问题会包括两个答案,我们可以通过新增卡片的方式来添加一套问答,编辑卡片的过程包括这些步骤。
-
- 新增一个卡片时,通过插入 DOM 节点的方式添加卡片样式。
复制代码
var index = 0;
// 用来新增一个卡片,卡片内需要填写一些内容
function addCard() {
// 获取一个id为the-dom的元素
var body = $("#the-dom");
// 从该元素内获取class为the-class的元素
var addDom = body.find(".the-class");
// 在the-class元素前方插入一个div
addDom.before('<div class="col-lg-4" data-index="' + index + '"></div>');
// 同时保存下来该DOM节点,方便更新内容
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自增
index++;
return theDom;
}
复制代码
-
- 卡片内编辑题目和答案时,会有字数限制(使用 jQuery 对输入框的输入事件进行监听,并限制输入内容)。
// theDom使用上面代码保存下来的引用
// 问题绑定值
theDom
.on("keyup", ".question", function (ev) {
ev.target.value = ev.target.value.substr(0, 20);
})
// 答案a绑定值
.on("keyup", ".option-a", function (ev) {
ev.target.value = ev.target.value.substr(0, 10);
})
// 答案b绑定值
.on("keyup", ".option-b", function (ev) {
ev.target.value = ev.target.value.substr(0, 10);
});
复制代码
- 获取输入框内的内容(使用 jQuery 选择元素并获取内容),用于提交到后台。
// 获取卡片的输入值
// theDom 使用上面代码保存下来的引用
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这样的指令,或是这样的自定义 DOM 标签,还有@click/:props这样的简化绑定语法等。
-
语义分析。这个过程会审查源程序有无语义错误,为代码生成阶段收集类型信息,一般类型检查也会在这个过程中进行。例如我们绑定了某个不存在的变量或者事件,又或者是使用了某个未定义的自定义组件等,都会在这个阶段进行报错提示。
生成 AST 对象。
以 Vue 为例,生成 AST 的过程包括 HTML 模板解析、元素检查和预处理:
复制代码
/**
* 将HTML编译成AST对象
* 该代码片段基于Vue2.x版本
*/
export function parse(
template: string,
options: CompilerOptions
): ASTElement | void {
// 返回AST对象
// 篇幅原因,一些前置定义省略
// 此处开始解析HTML模板
parseHTML(template, {
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
start(tag, attrs, unary) {
// 一些前置检查和设置、兼容处理此处省略
// 此处定义了初始化的元素AST对象
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 {
// 处理指令,此处包括v-for/v-if/v-once/key等等
processFor(element);
processIf(element);
processOnce(element);
processKey(element); // 删除结构属性
// 确定这是否是一个简单的元素
element.plain = !element.key && !attrs.length;
// 处理ref/slot/component等属性
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 元素渲染或更新到页面上。
或许你会觉得疑惑:原本就是一个
实际上,在这个过程中,模板引擎可以实现更多功能。
模板引擎可以做更多
-
将 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 漏洞之外,前端框架还做了一些性能、安全性等方面的优化,也提供了一些用于项目开发配套的工具,包括路由的管理、状态和数据的管理等工具。