Vue2.X原理篇
Vue 是一款用于构建用户界面的 JavaScript 框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、组件化的编程模型,帮助高效地开发用户界面。
Vue 的两个核心功能:
声明式渲染:Vue 基于标准 HTML 拓展了一套模板语法,使得可以声明式地描述最终输出的 HTML 和 JavaScript 状态之间的关系。
响应性:Vue 会自动跟踪 JavaScript 状态并在其发生变化时响应式地更新 DOM。
核心概念
响应式系统
Vue 最标志性的功能就是其低侵入性的响应式系统。组件状态都是由响应式的 JavaScript 对象组成的。当更改它们时,视图会随即自动更新。
本质上,响应性是一种可以使声明式地处理变化的编程范式。
let a = 1
let b = update() // 2
function update() {
b = a + 1
}
a = 2响应式系统要做的就是当变量 a 的值变化时,让变量 b 也自动更新。
原生 JavaScript 没有提供任何机制能做到这一点,但是,可以通过追踪对象属性的读写来实现。
在 JavaScript 中有两种劫持 property 访问的方式:getter / setters 和 Proxies。Vue 2 使用 getter / setters 完全是出于支持旧版本浏览器的限制;而 Vue 3 则使用了 Proxy 来创建响应式对象,仅将 getter / setter 用于 ref。
defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。还允许修改对象的属性的属性描述符。
descriptor 是属性描述符对象,有以下属性:
configurable:布尔值,默认值:false,表示该属性的描述符是否能够被改变。
enumerable:布尔值,默认值:false,表示该属性是否出现在对象的枚举属性中。
value(数据描述符): 属性的值。
writable(数据描述符): 布尔值,默认值:false,表示属性的值是否能被修改。
get(存取描述符): 属性的 getter 函数,当访问该属性时,会调用此函数,返回值会被用作属性的值,默认值:undefined。
set(存取描述符): 属性的 setter 函数,当属性值被修改时,会调用此函数,默认值:undefined。
Object.defineProperty() 是 Vue 2.x 的核心。如果浏览器不支持 Object.defineProperty,比如,IE8,那就等于不支持 Vue 2.x 框架。
Virtual DOM
虚拟节点(Virtual DOM)本质上就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,使用 JavaScript 对象来描述 DOM 的结构,实际上它只是一层对真实 DOM 的抽象。应用的各种状态变化首先作用于 Virtual DOM,最终映射到 DOM。
Virtual DOM 在 Vue 的底层实现上,借鉴了 Snabbdom(Virtual DOM 的一种简单实现,包括模块机制、钩子函数、diff 算法),将模板编译成 Virtual DOM 渲染函数,结合 Vue 自身的一些特性,如响应式系统、指令等。
Vue 是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作 DOM,而操作真实 DOM 又是非常耗费性能的,这是因为浏览器的标准就把 DOM 设计的非常复杂。
而 JavaScript 的运算通常是比较快的,可以用 JavaScript 的计算性能来换取操作 DOM 所消耗的性能。当数据发生变化时,对比变化前后的虚拟节点,通过 DOM-Diff 算法计算出需要更新的地方,然后只更新需要更新的视图。这样就可以尽可能少的操作DOM。
这就是虚拟节点产生的原因以及最大的用途。
Vue 1.x 采用的是 DocumentFragment;而 Vue 2.x 借鉴 React 的 Virtual DOM,引入后,渲染速度提升了2~4倍,并大大降低了内存消耗。
DOM-Diff
前面提到,「当数据发生变化时,对比变化前后的虚拟节点,通过 DOM-Diff 算法计算出需要更新的地方」,以达到尽可能少的操作DOM的目的。
在 Vue 中,把 DOM-Diff 过程叫做 patch 过程。patch,意为“补丁”,即指对旧的 VNode 修补,打补丁从而得到新的 VNode。其本质就是把对比新旧两份 VNode 的过程:以新 VNode 为基准,改造旧 VNode 使之成为跟新 VNode 一样。所谓旧 VNode 就是数据变化之前视图所对应的虚拟节点,而新 VNode 是数据变化之后将要渲染的新的视图所对应的虚拟节点。
patch 过程做的其实就是事情:
创建节点:新 VNode 有而旧 VNode 没有的节点,就在旧 VNode 中创建。
删除节点:新 VNode 没有而旧 VNode 有的节点,就在旧 VNode 中删除。
更新节点:新旧 VNode 都有的节点,就以新 VNode 为准,更新旧 VNode。
DOM-Diff 算法采用的是深度优先遍历,把树形结构按照层级分解,只对同级的树节点进行比较,而不是对整个树进行逐层搜索遍历的方式。它先对比父节点是否相同,然后对比子节点是否相同,相同的话对比孙子节点是否相同。所以时间复杂度只有 O(n),是一种相当高效的算法。
DOM-Diff 算法的原理:逐个遍历新虚拟树的子节点,找到它在旧虚拟树中的位置,如果找到了就移动对应的节点,如果没找到说明是新增节点,则新建一个节点插入。遍历完成之后,如果旧虚拟树中还有没处理过的节点,则说明这些节点不需要了,删除它们即可。
观察者模式
观察者模式一般至少有一个可被观察的对象 Subject ,可以有多个观察者去观察这个对象。当被观察对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
二者的关系是通过被观察者主动建立的,被观察者至少要有三个方法——添加观察者、移除观察者、通知观察者。当被观察者将某个观察者添加到自己的观察者列表后,观察者与被观察者的关联就建立起来了。此后只要被观察者在某种时机触发通知观察者方法时,观察者即可接收到来自被观察者的消息。
优点: 响应式。目标变化就会通知观察者,这是观察者最大的有点,也是因为这个优点,观察者模式在前端才会这么出名。
缺点: 不灵活。相比订阅发布模式,由于目标和观察者是耦合在一起的,所以观察者模式需要同时引入目标和观察者才能达到响应式的效果;而订阅发布模式只需要引入事件中心,订阅者和发布者可以不再一处。
渲染流程
初始化及挂载
在 new Vue() 之后。 Vue 会调用 _init 函数进行初始化。它会初始化生命周期、事件、 props、 methods、 data、 computed 与 watch 等。其中最重要的是通过 Object.defineProperty 设置 setter 与 getter 函数,用来实现「响应式」以及「依赖收集」。
初始化之后,调用 $mount 会挂载组件。如果是运行时编译,即不存在 render 函数,但是存在 template 的情况,需要进行「编译」步骤。
编译
编译是 template 到 render 函数的过程,可以分成 parse、optimize 与 generate 三个阶段:
parse:用正则等方式解析 template 模板中的指令、class、style 等数据,形成 AST。
optimize:标记 static 静态节点,这是 Vue 在编译过程中的一处优化,后面当 update 更新界面时,会有一个 patch 的过程, Diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。
generate:将 AST 转化成渲染函数字符串的过程,得到结果是 render 函数和 staticRenderFns 函数数组的字符串表达。
generate 生成的字符串,最终会用 new Function 转换为相应的函数。
渲染
渲染是 render 函数到 DOM 的过程。
render 函数会在挂载阶段被调用。因为会读取所需对象的值,所以会触发 getter 函数进行「依赖收集」,「依赖收集」的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中。
在修改对象的值的时候,会触发对应的 setter, setter 通知之前「依赖收集」得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update 来更新视图,当然这中间还有一个 patch 的过程以及使用队列来异步更新的策略。
源码解读
new Vue()
每个 Vue 应用都是通过用 Vue 函数创建一个新的 Vue 实例开始的。new Vue() 根据传入的选项对象生成一个 Vue 实例。
初始化(_init)
Vue 实例化后,调用 this._init 方法来初始化实例对象。
Vue 本质上是一个函数。该函数声明后,会在其原型上扩展一些方法,比如,_init、$mount、__patch__、$set、$delete、$watch、$on、$once、$off、$emit、_update、$forceUpdate、$destroy、$nextTick 等方法,以及 $data、$props 等属性,同时,也会注册 compile、use、mixin、extend、util 等静态方法或属性。
初始化流程:
接收并处理 options 选项;
在 vue 实例上创建一些空对象,比如:$children、$refs、
_events、$slots、$attrs、$listeners、_provided等;再调用 beforeCreate 钩子、初始化实例的状态(比如:
_props、setup 选项、methods 中的方法、data 中的数据、computed 观察器、Watcher 观察器)、注入_provided数据、调用 beforeCreate 钩子;最后调用 vm.$mount 挂载实例。
挂载($mount)
$mount 方法中最重要的两个方法:compileToFunctions,将 template 解析为一个渲染函数 render;mountComponent 将 render 函数的返回结果挂载到 DOM 节点。
compileToFunctions 调用栈: compileToFunctions -> createCompiler -> createCompilerCreator -> createCompileToFunctionFn -> compileToFunctions -> compile -> baseCompile -> parse -> generate -> createFunction。
生成 render 函数的主要流程:
调用 parse 函数,将 template 或 el 选项的 DOM 节点,通过其 innerHTML 属性转换为字符串;
解析字符串转为 AST,并解析其属性、指令(v-for、v-if、v-once)等;
调用 generate 函数 将 AST 转换为生成函数的字符串,再转换成 render 函数。
mountComponent 调用栈: mountComponent -> new Water -> updateComponent -> vm._render() -> Vue.prototype._render -> vm._update -> Vue.prototype._update -> __patch__ -> Vue.prototype.__patch__ -> patch -> createPatchFunction -> createElm -> insert -> nodeOps.appendChild -> node.appendChild。
挂载流程:
new Watcher() 创建实例对象,并将 updateComponent 函数传入,Watcher 构造函数会执行该函数;
updateComponent 函数调用
vm._render生成虚拟节点,再以虚拟节点为入参,调用vm._update方法;vm._update方法最终调用的是 patch 函数,该函数核心是 DOM-Diff 算法,计算出需要更新的 VNode,以达到尽量少的渲染节点的目的;DOM-Diff 算法计算完成后,调用 createElm 或 patchVnode 函数更新节点,其本质上是调用原生节点的 node.appendChild、insertBefore 方法。
虚拟节点(VNode)
new VNode() 用一实例化一个虚拟节点,其调用栈:mountComponent -> vm._render -> Vue.prototype._render,最终返回一个 VNode 对象。
render 函数的函数体长这样:
其中 with(this) 是指定语句块中的作用域,也就是 render.call(vm._renderProxy, vm.$createElement) 中的 vm._renderProxy,_c、_v 就是 vm._renderProxy 下的方法。
_c 是一个简单的、返回 createElement$1 函数运行结果的函数,createElement$1 -> _createElement -> new VNode()。
VNode 是一个创建虚拟节点的构造函数:
DOM-Diff(patch)
DOM-Diff 用对比变化前后的虚拟节点,计算出需要更新的节点,以达到尽可能少的操作DOM的目的。DOM-Diff 是在 patch 函数中实现的。
patch 函数的调用栈:mountComponent -> vm._update -> Vue.prototype._update -> __patch__ -> Vue.prototype.__patch__ -> patch -> createPatchFunction。
patch 函数逻辑如下:
如果 vnode 不存在,但是 oldVnode 存在,则销毁 oldVnode;
如果 vnode 存在,但是 oldVnode 不存在,则创建 vnode;
如果 oldVnode 不是真实节点,且 vnode 和 oldVnode 的 key、tag 等相同,则调用 patchVnode 方法,即更新 oldVnode。
patchVnode 方法逻辑如下:
如果 oldVnode 和 vnode 指向同一节点,则不处理;
如果 VNode 和 oldVNode 均为静态节点,则不处理;
如果 vnode 有 text,则直接替换文本内容;
如果 oldVnode 和 vnode 都有 children,且两者 children 不是同一对象,则调用 updateChildren 方法;
如果 只有 vnode 都有 children,则调用 addVnodes 新增子节点;
如果 只有 oldVnode 都有 children,则调用 removeVnodes 删除子节点;
如果 vnode 没有 text,则将节点文本置为空字符。
特别注意: cbs.update[i](oldVnode, vnode) 包含 updateAttrs、updateClass、updateDOMListeners、updateDOMProps、updateStyle 等函数的执行。这是因为,当虚拟节点是可 "修补" 的时,需要根据 VNode 的 data 数据,更新 oldVnode 下的真实 DOM 的属性、类名、样式、事件监听器等。
updateChildren 函数用于更新子节点,内部算法是为了复用一些没有变化的元素:
响应式实现
响应式的核心原理是 Object.defineProperty,核心函数是 defineReactive,其实现主要依赖三个构造函数:Observer、Dep、Watcher。
defineReactive 函数首先会实例化一个 Dep 对象,在读取数据时,get 方法会将 Dep.target(一个 Watcher 对象)存放到 Dep 对象的 subs 中;在更新数据时,set 方法会调用 Dep 对象的 notify 方法通知它内部所有的依赖执行 update。
注意: 只有触发 get 方法,Dep.target 才会被收集,也就是说,如果该属性没有被用到,则不收集到 subs。
Observer 构造函数其实是对 defineReactive 函数封装。它接收一个对象,并对对象中的每个属性调用 defineReactive 函数,深度遍历每一项,定义每个子对象中属性的 get 和 set 方法。
Dep 构造函数定义的是观察者模式中的被观察者类。它很简单,主要工作是维护一个依赖数组,提供添加、删除以及通知各依赖的方法。
Watcher 构造函数定义的是观察者模式中的观察者类:添加依赖到 subs, 以及提供 update 函数。
总结来说(以 render 函数为例):defineReactive 给组件的 data 选项的所有属性绑定 get 和 set 存取器;调用 render 函数时,先实例化一个 Watcher 对象,该对象在 get 方法中调用 render 函数,并将自己赋值给全局变量 Dep.target;如果 render 函数对 data 有读值操作,则将 Dep.target 添加到 subs;如果 render 函数有写值操作,则通过 dep.notify 通知 subs 下所有子元素执行 update 方法,即 Watcher 实例的 update 方法,重新调用 render 函数。
响应式数据绑定
data 选项的响应性初始化:Vue -> this._init -> initState -> initData:
computed 选项初始化,添加监听器到 vm._computedWatchers:Vue -> this._init -> initState -> initComputed$1:
water 选项初始化:Vue -> this._init -> initState -> initWatch:
一个简单的 Vue
根据 Vue 的思路,模拟一个简单的响应式系统。
响应式系统应该实现以下三点:
将传参 data 处理成响应式;
一个将模板解析为 DOM 的渲染函数;
data 改变时,触发视图改变,即自动调用渲染函数。
相关问题
为什么说 Vue 没有完全遵循 MVVM 模型?
MVVM(Model–view–viewmodel)模型的定义:
MVVM 由 3 个部分组成:Model(模型)是数据和逻辑;View(视图)是用户在屏幕上看到的结构、布局和外观,也称UI; ViewModel(视图模型)是一个绑定器,能和 View 层和 Model 层进行通信。
MVVM 的核心实现是由 ViewModel 层数据绑定,核心思想是分离,即通过 ViewModel 让 View 层和 Model 层解耦。
严格的 MVVM 要求 View 不能和 Model 直接通信,而 Vue 在组件提供了 $refs 这个属性,让 Model 可以直接操作 View,违反了这一规定,所以说 Vue 没有完全遵循 MVVM。
为什么要使用 Virtual DOM ?
具备跨平台的优势:由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
运行效率高:DOM 操作的执行速度远不如 Javascript 的运算速度快。因此,把大量的 DOM 操作转为 Javascript 计算,运用 DOM-Diff 算法计算出真正需要更新的节点,最大限度地减少 DOM 操作,从而显著提高性能。
提升渲染性能:Virtual DOM 的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。
注意: 关于性能方面,在首次渲染的时候肯定不如直接操作 DOM,因为要维护一层额外的 Virtual DOM。但如果后续有频繁操作 DOM 的操作,就可能会有性能的提升。
key 在虚拟节点中的作用?
key 主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。
key 作用的原理是:Vue 在 patch 过程中通过 key 可以精准判断两个节点是否是同一个,从而避免频繁更新不同元素,使得整个 patch 过程更加高效,减少 Dom 操作量,提高性能。
另外,若不设置 key 还可能在列表更新时候引发一些隐藏的 bug;key 也可以用于强制替换元素/组件而不是重复使用它,比如,完整地触发组件的生命周期钩子、触发过渡。
有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。
参考资料
最后更新于
这有帮助吗?