根据 Vue 官方的介绍,Vue 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。但 Vue 并不只是一个简单的视图库,通过与一系列周边工具的配合,它也可以轻易地构建大型应用。
所以 Vue 框架到底做了些什么呢?为什么几乎大多数前端都基本需要掌握呢?前言里也讲到了 jQuery 一把梭的时代,而大多数使用过 jQuery 的非前端人员,都有种“前端开发怎么这么多花样”的感觉。那要怎么理解 Vue 框架提供的能力,这些能力又是怎样提高开发人员的开发效率和代码维护性的呢?
模板引擎
模板引擎大概是 Vue 里最主要、又最核心的一个能力。前面也讲到,在模板引擎还没有出现的时候,前端需要手动更新前端页面的内容,需要维护一大堆的 HTML 和变量拼接的动态内容,虽然 jQuery 的出现提升了 DOM 元素的操作性,但依然难以避免代码的可读性、可维护性上存在的一些问题。
以前我们更新页面的内容,大概的流程是:监听操作 -> 获取数据变量 -> 使用数据拼接成 HTML 模板 -> 将 HTML 内容塞到页面对应的地方 -> 将 HTML 片段内需要监听的点击等事件进行绑定。
这么复杂的逻辑,如今使用 Vue,就可以方便地在模板里用插值表达式{{}}
、v-bind
绑定变量来展示,同时配合v-if
、v-for
这些内置指令,就可以很方便地写出可读性和维护性都很不错的代码了。什么是插值表达式?什么是指令?这些我们会放在后面的章节里介绍。这里我们主要来介绍下 Vue 框架做了什么事情,这里先讲一下数据绑定。
我们在 Vue 里渲染一块内容,一般会有以下流程:
(1) 解析语法生成 AST。
(2) 根据 AST 结果,完成 data 数据初始化。
(3) 根据 AST 结果和 data 数据绑定情况,生成虚拟 DOM。
(4) 将虚拟 DOM 生成真正的 DOM 插入到页面中,此时页面会被渲染。
这样的过程要怎么去理解呢,我们一点点来看。
1. 解析语法生成 AST
抽象语法树(Abstract Syntax Tree)也称为 AST 语法树,指的是源代码语法所对应的树状结构。也就是说,对于一种具体编程语言下的源代码,通过构建语法树的形式将源代码中的语句映射到树中的每一个节点上。
其实我们的 DOM 结构树,也是 AST 的一种,把 HTML DOM 语法解析并生成最终的页面。而模板引擎中常用的,则是将模板语法解析,分别生成 HTML DOM,使用像 HTML 拼接这样的方式(在对应的位置绑定变量、指令解析获取拼接逻辑等等),同时配合事件的管理、虚拟 DOM 的设计,可以最大化地提升页面的性能。
我们仔细看看这个过程是怎样的。
1.1 捕获特定语法
生成 AST 的过程涉及到编译器的原理,一般经过以下过程(参考维基百科):
(1) 语法分析。
语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等。语法分析程序判断源程序在结构上是否正确,源程序的结构由上下文无关文法描述。例如 Vue 框架中,有v-if
/v-for
这样的指令,也有<my-custom-component>
这样的自定义 DOM 标签,还有@click
/:props
这样的简化绑定语法,模板引擎需要将它们一一解析出来,并相应地进行后续的处理。
(2) 语义分析。
语义分析是编译过程的一个逻辑阶段,语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息,一般类型检查也会在这个过程中进行。在 Vue 框架中,例如我们绑定了某个不存在的变量或者事件,又或者是使用了某个未定义的自定义组件等,都会在这个阶段进行报错提示。
(3) 生成 AST。
AST 的结构则根据使用者需要定义,1.1.2 节中生成的一些 AST 对象都是本人根据需要假设定义的,并不完全是 Vue 中最终的实现效果。
关于编译器的更详细的原理,大家感兴趣可以翻看其他大佬们的著作,例如很经典的编译原理。Vue 里到底是怎样处理的呢,语法分析、语义分析基本上是通过正则的方式来处理,生成 AST 的部分我们可以看一下简化后的源码,其实就是将解析出来的元素、指令、属性、父子节点关系等内容进行处理,得到一个 AST 对象:
/** * 将HTML编译成AST对象 */ 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; }
1.2 DOM 元素捕获
最简单的,我们来捕获一个<div>
元素,然后生成一个<div>
元素。例如开发者写了以下这么一段模板,我们可以对它们进行捕获:
<div> <a>123</a> <p>456<span>789</span></p> </div>
捕获后我们或许可以得到这样的一个对象:
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" }] } ] } ] } };
这个对象维护我们需要的一些信息,某个 HTML 元素里需要绑定哪些变量(变量更新的时候需要更新该节点内容),以怎样的方式来拼接(是否有逻辑指令,如v-if
、v-for
等),哪些节点绑定了怎样的事件监听事件(是否匹配一些常用的事件能力支持),所以这里 AST 能做的事情是很多了。
我们最终还是会根据 AST 对象生成真实的页面片段和逻辑,实现过程其实也是将很多的特殊标识(例如元素 ID、属性标记等)打到该元素上,同时配合一些 Javascript 的元素选择方式、事件监听方式等,来将这个元素动态化(支持内容更新、节点更新)而实现最终的页面效果。Vue 会根据 AST 对象生成一段可执行的代码,我们看看这部分的实现:
// 生成一个元素 function genElement(el: ASTElement): string { // 根据该元素是否有相关的指令、属性语法对象,来进行对应的代码生成 if (el.staticRoot && !el.staticProcessed) { return genStatic(el); } else if (el.once && !el.onceProcessed) { return genOnce(el); } else if (el.for && !el.forProcessed) { return genFor(el); } else if (el.if && !el.ifProcessed) { return genIf(el); } else if (el.tag === "template" && !el.slotTarget) { return genChildren(el) || "void 0"; } else if (el.tag === "slot") { return genSlot(el); } else { // component或者element的代码生成 let code; if (el.component) { code = genComponent(el.component, el); } else { const data = el.plain ? undefined : genData(el); const children = el.inlineTemplate ? null : genChildren(el, true); code = `_c('${el.tag}'${ data ? `,${data}` : "" // data }${ children ? `,${children}` : "" // children })`; } // 模块转换 for (let i = 0; i < transforms.length; i++) { code = transforms[i](el, code); } // 返回最后拼装好的可执行的代码 return code; } }
1.3 模板引擎赋能
原本就是一个<div>
,经过 AST 生成一个对象,最终还是生成一个<div>
,这是多余的步骤吗?不是的,在这个过程中我们可以实现一些功能:
(1) 排除无效 DOM 元素,并在构建过程可进行报错。
(2) 使用自定义组件的时候,可匹配出来。
(3) 可方便地实现数据绑定、事件绑定等功能。
(4) 为虚拟 DOM Diff 过程打下铺垫。
(5) HTML 转义(预防 XSS 漏洞)。
通过通用的模板引擎,我们能处理掉很多低效又重复的工作,例如浏览器兼容、全局事件的统一管理和维护、模板更新的虚拟 DOM 机制、树状组织管理组件,而留给开发者的事情就可以少之又少。现在我们知道了模板引擎都做了什么事情,就可以区分开 Vue 框架提供的能力和我们需要自行处理的逻辑,可以更专注于业务开发。
我们来看看虚拟 DOM 的机制。
2. 虚拟 DOM
虚拟 DOM 如今都被作为前端面试基础题库之一了,多多少少面试者都要去了解下,当初 React 就是因为虚拟 DOM 的提出,暂时赢下了浏览器性能之争(尤其在 AngularJS 的脏检查机制对比下)。当然,这都是几年前的事情了,如今几大框架的性能问题,早也不是什么大问题了。
虚拟 DOM 大概可分成三个过程:
(1) 用 JS 对象模拟 DOM 树,得到一棵虚拟 DOM 树。
(2) 当页面数据变更时,生成新的虚拟 DOM 树,比较新旧两棵虚拟 DOM 树的差异。
(3) 把差异应用到真正的 DOM 树上。
2.1 用 JS 对象模拟 DOM 树
为什么要用到虚拟 DOM 呢?不知道大家仔细研究过 DOM 节点对象没,一个真正的 DOM 元素非常庞大,拥有很多的属性值。因为一个 DOM 节点它包括了太多太多的属性、元素和事件对象,实际上我们并不是全部都会用到,通常包括节点内容、元素位置、样式、节点的添加删除等方法。所以,我们通过用 JS 对象表示 DOM 元素的方式,可以大大降低了比较差异的计算量。
看一下 Vue 源码,我们会发现 VNode 只有这些属性:
tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node // strictly internal raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? asyncFactory: Function | void; // async component factory function asyncMeta: Object | void; isAsyncPlaceholder: boolean; ssrContext: Object | void; fnContext: Component | void; // real context vm for functional nodes fnOptions: ?ComponentOptions; // for SSR caching devtoolsMeta: ?Object; // used to store functional render context fordevtools fnScopeId: ?string; // functional scope id support
2.2 比较新旧两棵虚拟 DOM 树的差异
虚拟 DOM 中,差异对比也是很关键的一步,这里简单说一下。当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异。通常来说这样的差异需要记录:
- 需要替换掉原来的节点
- 移动、删除、新增子节点
- 修改了节点的属性
- 对于文本节点的文本内容改变
图 1-1 DOM 树对比
如图 1-1,这里我们对比两棵 DOM 树,得到的差异有:p 元素插入了一个 span 元素子节点,然后原先的文本节点挪到了 span 元素子节点下面。最后通过 JS 操作就可以实现完,具体实现的步骤则是第三步:
2.3 把差异应用到真正的 DOM 树上
经过差异对比之后,我们能获得一组差异记录,接下里我们需要使用它。差异记录要应用到真正的 DOM 树上,需要进行一些操作,例如节点的替换、移动、删除,文本内容的改变等。
我们来看一下,在 Vue 中是怎么进行 DOM Diff 的,虽然代码里很多函数没贴出来,但其实看函数名也可以大概理解都是什么作用,例如updateChildren
、addVnodes
、removeVnodes
、setTextContent
等,大致是个对比差异然后更新的操作:
// 简单看这段代码感受下 // 对比差异然后更新 const oldCh = oldVnode.children; const ch = vnode.children; if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode); } if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); } else if (isDef(ch)) { if (process.env.NODE_ENV !== "production") { checkDuplicateKeys(ch); } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ""); addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ""); } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text); } if (isDef(data)) { if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode); }
前言中我们提到,页面的更新有两种方式:字符串模板和节点模板。而字符串模板innerHTML
的方式会消耗比较多的计算,节点模板的方式又需要管理特别多的节点信息和引用保存,使用虚拟 DOM 则可以有效地降低浏览器计算和性能。
3. 数据绑定
在 Vue 中,最基础的模板语法是数据绑定,例如:
<div>{{ message }}</div>
这里使用插值表达式{{}}
绑定了一个message
的变量,开发者在 Vue 实例data
中绑定该变量:
new Vue({ data: { message: "测试文本" } });
最终页面展示内容为<div>测试文本</div>
。这是怎么做到的呢?
3.1 数据绑定的实现
这种使用双大括号来绑定变量的方式,我们称之为数据绑定。它是怎么实现的呢,前面也有大致提到,数据绑定的过程其实不复杂:
(1) 解析语法生成 AST。
(2) 根据 AST 结果生成 DOM。
(3) 将数据绑定更新至模板。
上述这个过程,是 Vue 中模板引擎在做的事情。我们来看看上面在 Vue 里的代码片段<div>{{ message }}</div>
,我们可以通过 DOM 元素捕获,解析后获得这样一个 AST 对象:
thisDiv = { dom: { type: "dom", ele: "div", nodeIndex: 0, children: [{ type: "text", value: "" }] }, binding: [{ type: "dom", nodeIndex: 0, valueName: "message" }] };
这样,我们在生成一个 DOM 的时候,同时添加对message
的监听,数据更新时我们会找到对应的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; } }
这样,我们就能在message
变量更新的时候,通过该变量关联的引用,来自动更新对应展示的内容。而要知道message
变量什么时候进行了改变,我们需要对数据进行监听。
3.2 数据更新监听
我们能看到,上面的简单代码描述过程中,使用的数据监听方法是用了addEventListener("data:change", Function)
的方式。其实每个框架都会有自己的方式,例如 Angular 使用的则是大家都熟知的脏检查。
(1) Angular: watch 机制。 脏检查是什么呢?在 AngularJS 中,并不直接监听数据的变动,而是监听常见的事件如用户交互(点击、输入等)、定时器、生命周期等。在每次事件触发完毕后,计算数据的新值和旧值是否有差异,若有差异则更新页面,并触发下一次的脏检查,直到没有差异或是次数达到设定阈值。很显然,这样每一次脏检查可能会形成环状,导致死循环。虽然 AngularJS 有阈值控制,但也无法避免这种机制所导致的低效甚至性能问题。
由于设计上存在的这些问题一直被大家诟病,在 Angular2 中应用的组织类似 DOM,也是树结构的,脏检查会从根组件开始,自上而下对树上的所有子组件进行检查。相比 AngularJS 中的带有环的结构,这样的单向数据流效率更高,而且容易预测,性能上也有不少的提升。
(2) React: 手动 set 写入。
在 React 里,是需要手动调用set()
的方式写入数据来更新模版。
(3) Vue: Getter/Setter。
而在 Vue 中,主要是依赖了Getter/Setter
:
Object.defineProperty(obj, key, { enumerable: true, configurable: true, // getter get: function reactiveGetter() { const value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value; }, // setter,最终更新后会通知噢 set: function reactiveSetter(newVal) { const value = getter ? getter.call(obj) : val; if (newVal === value || (newVal !== newVal && value !== value)) { return; } if (process.env.NODE_ENV !== "production" && customSetter) { customSetter(); } if (getter && !setter) return; if (setter) { setter.call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep.notify(); } });
在数据更新的时候就执行了模板更新、watch、computed 等一些工作,而 Vue3.0 将使用Proxy
的方式来进行,可参考《第16章 关于 Vue 3.0》。
4. XSS 漏洞填补
模板引擎还可以协助预防下 XSS 相关漏洞。我们知道,XSS 的整个攻击过程大概为:
(1) 通常页面中包含的用户输入内容都在固定的容器或者属性内,以文本的形式展示。
(2) 攻击者利用这些页面的用户输入片段,拼接特殊格式的字符串,突破原有位置的限制,形成了代码片段。
(3) 攻击者通过在目标网站上注入脚本,使之在用户的浏览器上运行,从而引发潜在风险。
避免 XSS 的方法之一主要是将用户所提供的内容进行过滤,而大多数模板引擎会自带 HTML 转义功能。在 Vue 中,默认的数据绑定方式(双大括号、v-bind
等)会进行 HTML 转义,将数据解释为普通文本,而非 HTML 代码。来感受下这堆正则和转义,篇幅关系,只贴一部分来观摩下:
// 元素标签和属性相关的正则表达式 const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`; const qnameCapture = `((?:${ncname}\\:)?${ncname})`; const startTagOpen = new RegExp(`^<${qnameCapture}`); const startTagClose = /^\s*(\/?)>/; const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); const doctype = /^<!DOCTYPE [^>]+>/i; // 避免注释中被处理掉 const comment = /^<!\--/; const conditionalComment = /^<!\[/; // 特殊元素处理 export const isPlainTextElement = makeMap("script,style,textarea", true); const reCache = {}; // 转义 const decodingMap = { "<": "<", ">": ">", """: '"', "&": "&", " ": "\n", "	": "\t", "'": "'" };
当然,如果你一定要输出 HTML 代码,也可以使用v-html
指令输出。官方文档也有特殊说明:你的站点上动态渲染的任意 HTML 可能会非常危险,因为它很容易导致 XSS 攻击。请只对可信内容使用 HTML 插值,绝不要对用户提供的内容使用插值。
Vue 中大多数能力都依赖于模板引擎,包括组件化管理、事件管理、Vue 实例、生命周期等等,更多的能力阅读,大家感兴趣的可以去翻一下 Vue 源码,相信你理解了 AST、虚拟 DOM、数据绑定相关的机制之后,再去翻阅源码看具体的实现也不再是难事了。