持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第17天,点击查看活动详情
一文读懂 Vue 实现原理和性能优化
Vue 实现原理
1.Vue 简介
现在的大前端时代,是一个动荡纷争的时代,江湖中已经分成了很多门派,主要以 Vue,React 还有 Angular 为首,形成前端框架三足鼎立的局势。Vue 在前端框架中的地位就像曾经的 jQuery,由于其简单易懂、开发效率高,已经成为了前端工程师必不可少的技能之一。
Vue 是一种渐进式 JavaScript 框架,完美融合了第三方插件和 UI 组件库,它和 jQuery 最大的区别在于,Vue 无需开发人员直接操作 DOM 节点,就可以改变页面渲染内容,在应用开发者具有一定的 HTML、CSS、JavaScript 的基础上,能够快速上手,开发出优雅、简洁的应用程序模块。
但是我们提及 Vue 的时候,更多的是关注它的用法,而不是学习它是如何解决前端问题的,这多少有点亚健康。有前端开发经验的人,一定在开发过程中遇到过奇奇怪怪的问题,然后稀里糊涂地解决,倘若再次遇到相似的问题,便再次手足无措,作为一名前端工程师,在遇到问题的时候我们是否能准确定位产生问题的原因并及时解决,主要取决于我们对前端框架的理解是否足够深入。
2.Vue 实现原理
2.1 虚拟 DOM(Virtual DOM)
随着时代的发展,Web 应用的页面交互效果越来越复杂,页面功能越来越丰富,需要维护的状态越来越多,DOM 操作也越来越频繁。DOM 操作虽然简单易用,但是会产生不好维护的问题。
在程序执行的过程中,Watcher 初始化时会将每一个节点和状态进行一一关联和映射,setter 监听到 Data 的状态发生改变后,就会通知 Watcher,Watcher 会将这些变化通知曾经记录过的 DOM 以及跟这些状态相关的节点,从而触发页面的渲染过程。组件接收到状态变化后,会通过编译将模板转换成渲染函数 Render,执行渲染函数就会得到一个虚拟 DOM 树,通过对比旧的虚拟 DOM 和新生成的虚拟 DOM 树,来更新对应的实际 DOM 节点,执行页面渲染。
主流前端框架几乎都在使用虚拟 DOM,但是在使用虚拟 DOM 的时候,Angular 和 React 都无法确定具体是哪个状态发生了变化,因此需要在旧的虚拟 DOM 和新的虚拟 DOM 之间进行暴力对比,但 Vue 从 1.0 版本开始,就通过细粒度的绑定来更新视图,也就是说,当状态发生变化的时候 Vue 可以知道具体是哪个状态哪些节点需要发生改变,从而对这个节点执行更新,然而这种细粒度的变化侦测会有一些内存开销影响性能,一个项目越复杂,开销就越大。
Vue 从 2.0 版本后,为了优化性能,引入了虚拟 DOM,选择了一个折中的方案,既不需要暴力对比整个新旧虚拟 DOM,也不需要通过细粒度的绑定来实现视图的更新,即以组件为单位进行 Watcher 监听,也就是说即便一个组件内有多个节点使用了某个状态,也只需一个 Watcher 来监听这个状态的变化,当这个状态发生变化时,Watcher 通知组件,组件内部通过虚拟 DOM 的方式去进行节点的对比和重新渲染。
2.2 常用指令实现原理
指令是指 Vue 提供的以“v-”前缀的特性,当指令中表达式的内容发生变化时,会连带影响 DOM 内容发生变化。Vue.directive 全局 API 可以创建自定义指令,并获取全局指令,除了自定义指令,Vue 还内置了一些开发过程中常用的指令,如 v-if、v-for 等。在 Vue 模板解析时,会将指令解析到 AST,使用 AST 生成字符串的过程中实现指令的功能。
在解析模板时,会将节点上的指令解析出来并添加到 AST 的 directives 属性中,directives 将数据发送到 VNode 中,在虚拟 DOM 进行页面渲染时,会触发某些钩子函数,当钩子函数被触发后,就说明指令已生效。
2.2.1 v-if 指令原理
在应用程序中使用 v-if 指令:
<div v-if="create">create if</div>
<div v-else>create else</div>
在编译阶段生成:
(create)
? _c('div',[_v("create if")])
: _c('div',[_v("create else")])
在代码执行时,会根据 create 的值来选择创建哪个节点。
2.2.2 v-for 指令原理
在应用程序中使用 v-for 指令:
<li v-for="(item,index) in list">{{item}}</li>
在编译阶段生成:
_l((list), function(item, index){
return _c('li',[
_v(_s(item))
])
})
_l 是 renderList 的别名,执行代码时,_l 函数会循环 list 变量,调用第二个参数中传递的函数,传递两个参数:item 和 index,当_c 函数被调用时,会执行_v 函数,创建一个节点。
2.2.3 自定义指令原理
在应用程序中,指令的处理逻辑分别监听了 create 函数、update 函数以及 destory 函数,具体实现如下:
export default {
create: updateDirectives,
update: updateDirectives,
destory: function unbindDirectives (vnode){
updateDirectives(vnode, emptyNode)
}
}
钩子函数被触发后,会执行 updateDirectives 函数,代码如下:
function updateDirectives(oldVnode, vnode){
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
在该函数中,不论是否存在旧虚拟节点,只要其中存在 directives,就会执行_update 函数,_update 函数代码如下:
function _update(oldVnode, vnode) {
const isCreate = oldVnode === emptyNode
const isDestory = vnode === emptyNode
const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
const dirsWithInsert = []
const dirsWithPostpatch = []
let key, oldDir, dir
for (key in newDirs) {
oldDir = oldDirs[key]
dir = newDirs[key]
if (!oldDir) { //新指令触发 bind
callHook(dir, 'bind', vnode, oldVnode)
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
} else { //指令已存在触发 update
dir.oldValue = oldDir.value
callHook(dir, 'update', vnode, oldVnode)
if (dir.def && dir.def.componentUpdated) {
dirsWithPostpatch.push(dir)
}
}
}
if (dirsWithInsert.length) {
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
callInsert()
}
}
if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for(let i = 0; i < dirsWithPostpatch.length; i++) {
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}
if (!isCreate) {
for(key in oldDirs) {
if (!newDirs[key]) {
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestory)
}
}
}
}
isCreate:判断该虚拟节点是否是一个新建的节点。
isDistory:判断是否删除一个旧虚拟节点。
oldDirs:旧的指令集合,oldVnode 中保存的指令。
newDirs:新的指令集合,vnode 中保存的指令。
dirsWithInsert:触发 inserted 指令钩子函数的指令列表。
dirsWithPostpatch:触发 componentUpdated 钩子函数的指令列表。
通过 normalizeDirectives 函数将模板中使用的指令从用户注册的自定义指令集合中取出来的结果如下:
{
v-customize: {
def: {inserted: f},
modifiers: {},
name: "customize",
rawName: "v-customize"
}
}
自定义指令的代码为:
Vue.directives('customize', {
inserted: function (el) {
el.customize()
}
})
虚拟 DOM 在对比和渲染时,会根据不同情景触发不同的钩子函数,当使用虚拟节点创建一个新的实际节点时,会触发 create 钩子函数,当一个 DOM 节点插入到父节点时,会触发 insert 钩子函数。
callHook 函数执行钩子函数的方式如下:
function callHook(dir, hook, vnode, oldVnode, isDestory) {
const fn = dir.def && dir.def[hook]
if (fn) {
try {
fn(vnode.elm, dir, vnode, oldVnode, isDestory)
} catch (e) {
handleError(e, vnode.context, `directive ${dir.name} ${hook} hook`)
}
}
}
callHook 函数的参数意义分别为:
dir:指令对象。
hook:将要触发的钩子函数名。
vnode:新的虚拟节点。
oldVnode:旧的虚拟节点。
isDestory:判断是否删除一个旧虚拟节点。
虚拟 DOM 在渲染时会触发的所有钩子函数及其触发机制如下:
需要注意的是,remove 函数是只有一个元素从其父元素中移除时才会触发,如果该元素是被移除元素的子元素,则不会触发 remove 函数。
前端性能优化
前端在一个应用中,主要承担在用户打开一个页面时,发送请求到服务器,接收服务器返回的页面进行渲染并将渲染结果呈现给用户的功能。
要提高前端性能需要从与用户操作无关的客户端和服务端交互和浏览器解析页面着手,也就是从传输和渲染两方面着手。
1.请求传输
1.1 请求维度
基于目前前后端传输广泛使用的 HTTP 1.1 协议,可以从压缩请求的大小和减少请求的数量两方面着手进行优化,主要的优化手段如下:
1.2 协议维度
从 1.0 的短连接到 1.1 的长连接到 HTTP2.0、3.0,做出了很多改变,每次协议的升级对前端性能优化来讲都是一次飞跃。HTTP2.0 的新特性如下:
二进制分帧:HTTP2.0 在应用层与传输层之间增加一个二进制分帧层,将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码,使通信都在一个可以承载任意数量的双向数据流的 TCP 连接上完成。
压缩头部:使用 HPACK 算法,规定了在客户端和服务器端会使用并且维护“首部表”来跟踪和存储之前发送的键值对,对于相同的头部,不必再通过请求发送,减少了头部开销。
多路复用:客户端和服务器可以把 HTTP 消息分解为互不依赖的帧,然后乱序发送,最后再在另一端把它们重新组合起来。
请求优先级:每个流都可以带有一个 31bit 的优先值:0 表示最高优先级;2 的 31 次方-1 表示最低优先级。
服务器推送:通过提供 push-promise 帧来实现真正意义上的浏览器推送,摆脱利用 ajax 轮询进行伪实时的场景。
2.浏览器渲染
2.1 浏览器单线程解析渲染阻塞
浏览器的主要构成如下:
它的几个常驻线程如下:
由于 GUI 线程和 JS 引擎线程互斥,故衍生了一系列避免渲染过程中发生阻塞的优化方法,如样式文件放头部,脚本文件放在 DOM 节点最末尾;针对不需要操作 DOM 的脚本,可以采用动态创建 script 标签的方式载入;脚本文件加上 async 或者 defer 等。
2.2 巨大的 DOM 开销
在浏览器渲染的过程中,巨大的 DOM 开销无疑成为了渲染效率是最大瓶颈。通过如下代码可以输出一个空 DOM 节点,查看它所包含的 300 余个属性和事件。
let ele = document.createElement("div")
let obj = {}
for (const prop in ele) {
obj[prop]=ele[prop]
}
console.log(obj)
2.2.1 重绘与回流
重绘是指当页面展示元素中的一些元素需要更新属性,这些属性只是影响元素的外观、风格,而不会影响布局的,比如 background-color。回流是指当页面展示元素中的一部分(或全部)因为元素的规模尺寸、布局、隐藏等改变而需要重新构建。显然,重绘不一定导致回流,回流必然导致重绘。
它们都会带来一定的 DOM 开销,需要尽力去避免,常见的避免手段有避免触发同步布局事件;对于复杂动画效果,使用绝对定位让其脱离文档流;css3 硬件加速(transform、opacity、filters、Will-change)等。
2.2.2 虚拟 DOM
针对巨大的 DOM 开销,除了尽力避免重绘和回流,近几年还有一种比较流行的,各大框架比如 VUE、react 都使用的虚拟 DOM 的方式。
虚拟 DOM 是一颗以 js 对象为基础的树,用对象属性来描述节点,是对 DOM 的抽象,通过一系列操作将其映射到真实环境。
用一段代码来模拟展示一下这个过程,首先用户编写模板如下:
<ul id="myId">
<li v-for="item in list">{{item}}</li>
</ul>
编译后的内容如下所示,采用了 creatElement 语法糖的形式创建节点。
createElement {
"ul",
{
attr:{
id: "myId"
}
},
[
createElement("li", 1),
createElement("li", 2),
createElement("li", 3)
]
}
经过渲染函数的执行生成虚拟 DOM 树,其大致结构如下:
最终将虚拟 DOM 树转化为真实 DOM。
虚拟 DOM 对性能的 DOM 开销的优化主要体现在当节点有变化时,它可以通过 differ 算法比较变化前后的虚拟 DOM 结构的变化,通过对节点属性的修改做必要的调整,而不是无脑的销毁旧节点创建新节点。
这个过程的主要步骤是:用 js 对象结构表示 DOM 树的结构,然后用这个树构造一个真正的 DOM 树,插入文档中;当状态变更时,重新构造一棵新树,与旧树进行对比,记录差异;将记录的差异应用到所构建的真正的 DOM 树上。需要特别注意的是,differ 算法遵循同级比较的原则,在使用的过程中要尽量减少跨层级的 DOM 调整。