07.原理
07.原理
MVVM:Model-View-ViewModel模型视图模型的缩写,其中Model为数据,即业务逻辑;View为视图,也就是UI界面;ViewModel为数据模型,将视图和模型同步交互,是不需要手动操作dom的一种设计思想Vue很明显是基于MVVM思想的,这使开发者不需要关系数据的响应式更新
7.1 渲染机制与响应式实现原理
ViewModel能够监测到数据的变化,并自动更新视图;也能得知视图的变化,并通知数据更新
7.1.1 响应式建立
在
vue组件初始化时,其中的数据被劫持,通过Proxy代理(vue3中reactive使用)或Object.defineProperty(vue3中ref或vue2使用)实现跟踪,得到响应式的数据为了加快初始加载速度,子对象的代理是懒加载的,即在未访问变量属性时,不会进行代理,只有访问了才代理
使用
proxy时,如果你修改原始对象,代理不会知道,因此数据会变化,但视图不会更新;vue2时,将传入的变量转换为了一个新的对象,因此在原始对象上修改会导致数据变化和视图更新,但传入的数据被修改了,这是vue2的一个缺陷reactive// 实际上在源码中将代理的过程拆解到 MutableReactiveHandler 代理处理器 function reactive(obj) { return new Proxy(obj, { get(target: Target, key: string | symbol, receiver: object): any { console.log(`获取 ${key}:`, target[key]) const res = Reflect.get(target, key, receiver) // 跟踪依赖 track(target, TrackOpTypes.GET, key) return res }, set( target: Record<string | symbol, unknown>, key: string | symbol, value: unknown, receiver: object, ): boolean { console.log(`设置 ${key} = ${value}`) const result = Reflect.set(target, key, value, receiver) // 这里可以触发视图和相关依赖的更新 // 若目标位于原型链的顶层,则无需触发此操作 if (target === toRaw(receiver)) { if (!hadKey) { trigger(target, TriggerOpTypes.ADD, key, value) } else if (hasChanged(value, oldValue)) { trigger(target, TriggerOpTypes.SET, key, value, oldValue) } } return result }, // 在 使用 obj.has 或 'name' in obj 时,需要触发依赖收集 has(target: Record<string | symbol, unknown>, key: string | symbol): boolean { const result = Reflect.has(target, key) if (!isSymbol(key) || !builtInSymbols.has(key)) { track(target, TrackOpTypes.HAS, key) } return result }, deleteProperty( target: Record<string | symbol, unknown>, key: string | symbol, ): boolean { const hadKey = hasOwn(target, key) const oldValue = target[key] const result = Reflect.deleteProperty(target, key) if (result && hadKey) { trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue) } return result } }) }refclass RefImpl<T = any> { _value: T private _rawValue: T dep: Dep = new Dep() constructor(value: T) { this._rawValue = isShallow ? value : toRaw(value) this._value = isShallow ? value : toReactive(value) } get value() { this.dep.track() return this._value } set value(newValue) { const oldValue = this._rawValue if (hasChanged(newValue, oldValue)) { this._rawValue = newValue this._value = useDirectValue ? newValue : toReactive(newValue) this.dep.trigger() } } }
得到代理数据之后,就可以知道数据何时被获取和更新,接下来重要的是如何获取到正在修改的内容并进行依赖更新
依赖追踪
track:每个可能使用依赖的函数(称为作用Effect,实际上底层是ReactiveEffect类的一个函数)执行前,都会通过某种数据结构被记录下来,这样就知道当前哪些活跃的Effect使用了这个依赖,这些函数需要在当前数据更新后重新执行,也就是当前键更新应该带来的副作用vue3.4开始WeakMap<target, Map<key, Dep>>,之前通过WeakMap<target, Map<key, Set<effect>>>记录每个响应式对象所有的键对应的副作用,Dep底层实际上是双向链表实现的作用记录(删除成本低,只需要删除头节点,剩下的会自动回收)vue3.4开始,直接使用dep.track()可以自动添加活跃的作用,也可以直接调用全局的track()函数;之前getSubscribersForProperty(target, key)实际上就是获取对应的Set<effect>的函数,之后将当前还在活跃的effect添加到这个集合中,这些函数也相当于是更新的订阅者
依赖更新
trigger:在每次数据更新后,需要遍历查找所有的订阅者dep.notify(),借助作用实现类(比如ReactiveEffect)中的调度函数scheduler将这些作用依次放入对应的更新队列,或直接同步执行(无调度函数),常见的执行包括修改DOM、触发watch回调、触发computed回调- 更新时,除了
watch和组件更新钩子(通过ALLOW_RECURSE标志控制),不允许自己调用自己 - 每个作用在每轮事件循环中,只会被放置一次,去重(检查
QUEUED标志)
// Dep 的 trigger 方法,在自身修改后触发 class Dep { trigger(debugInfo?: DebuggerEventExtraInfo): void { this.version++ globalVersion++ this.notify(debugInfo) } notify(): void { // 实际上仅记录当前批量层级 batchDepth++ // 方便后续结束批次时判断是否正确 startBatch() try { for (let link = this.subs; link; link = link.prevSub) { if (link.sub.notify()) { // 如果 `notify()` 函数返回 `true`,则此为一个计算属性 // 同时,还需对它的依赖项调用 `notify` 函数 // 之所以在此处调用,而非在计算属性的 `notify` 函数内部调用,是为了降低调用栈的深度 ;(link.sub as ComputedRefImpl).dep.notify() } } } finally { // 在所有批次处理完毕后批量运行作用 // 在这里执行更新,主要完成 ;(e as ReactiveEffect).trigger() // 即调用 ReactiveEffect.trigger() 操作 endBatch() } } } // ReactiveEffect 的 trigger 方法,受 Dep.trigger() 调用 class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions { trigger(): void { if (this.flags & EffectFlags.PAUSED) { pausedQueueEffects.add(this) } else if (this.scheduler) { // 使用调度函数 this.scheduler() } else { // 直接执行 this.runIfDirty() } } runIfDirty(): void { if (isDirty(this)) { this.run() } } }- 更新时,除了
7.1.2 渲染机制
相关的模板
template内容被编译为渲染函数render,渲染函数中创建虚拟节点vue3在编译时,会为模板标注,部分永远不改变的内容会生成静态标签,在之后修改时不需要比较管理- 对于文本渲染,标注为
text,以后渲染时直接填入最新值 - 对于子组件、
v-if和v-for,将它们内部节点收集为一维数组,快速比较,寻找差异,可以发现实际上下方给出的渲染函数就是一个一维的数组
vue2需要遍历树,没有这些编译优化- 对于文本渲染,标注为
使用调试工具可以查看到这个函数,比如以下
// <div> // <p>1</p> // <img src="@/assets/logo.svg"></img> // </div> function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return _openBlock(), _createElementBlock( _Fragment, null, [ _createElementVNode("div", _hoisted_1, [..._cache[0] || (_cache[0] = [ _createElementVNode( "p", { "data-v-inspector": "src/App.vue:26:5" }, "1", -1 /* CACHED */ ), _createElementVNode( "img", { src: _imports_0, "data-v-inspector": "src/App.vue:27:5" }, null, -1 /* CACHED */ ) ])]), ], 64 /* STABLE_FRAGMENT */ ); }
第一次渲染时,运行时渲染器调用渲染函数,遍历返回的虚拟
DOM树,并基于它创建实际的DOM节点。这样便将虚拟DOM转换成真实DOM,绘制在页面上内容打平为数组:很多节点实际上不需要追踪,因此
vue3的_createElementBlock可以将树结构打平,只查看需要追踪的节点数组。同样的相邻静态节点可以合并,通过innerHTML一次挂载之后的视图更新前,通过依赖关系,可以知道哪个组件发生了变化,重新执行那个组件的渲染函数得到虚拟
DOM,再通过diff算法逐层比较虚拟DOM,找出需要更新的节点- 文本渲染内容
{{}}变化时,直接更新节点内容,不需要对比 - 节点标签类型变化,将旧节点删除,并添加新节点
- 节点增加移动删除,此问题需要基于最小编辑距离算法解决,找到最小需要操作的步骤,比较过程可以被树的标识
key关键属性优化,快速得知哪些节点需要更新
实际上在数据更新时,还会修改元素的更新类型标记,帮助确认更新的类型
渲染函数重新执行
由于渲染函数重新执行时会导致所有的内容渲染为最新,对原始数据直接修改产生的无响应式的属性也会同步更新
- 文本渲染内容
7.1.3 异步更新
- 实际上,在
vue中,数据依赖更新是异步的,对于一个响应式数据来说,修改可能触发computed属性,也可能触发watch回调,也可能触发DOM更新- 在同一个事件循环中相关的更新会被合并,放入异步队列中统一执行,确保多次修改仅触发一次依赖更新
computed是懒更新的,只有被读取到且依赖数据发生变化时才会重新更新并缓存结果watch更新一般父组件更新后当前组件更新前执行,但也可能在当前组件更新后执行,可以根据flush属性确定,pre表示在当前组件更新前执行,post表示在当前组件更新后执行。当然也可以设置为sync同步触发DOM更新也是一种更新任务,在当前事件循环结束前会进行,包括更新对比虚拟DOM和修改真实DOM
- 具体实现:在
vue中有一个主要的更新队列queue和有双缓冲的后处理的队列activePostFlushCbs、pendingPostFlushCbs- 前置队列:在早期还有双缓冲的前置处理队列,但最新版本已经删除(78c199d),前置处理被合并到主更新队列中,通过是否是前置处理,通过标识符进行标识
- 可以预见,对于一个响应式数据来说,一个修改可能触发多个作用,这些作用都将被添加到队列中
- 对于非同步的
watch或watchEffect,作用会根据flush属性进行分类,如果是pre,则添加到更新队列queue中,否则添加到后置更新队列中 - 对于
computed,每次读取computed属性,实际上都是在执行函数获取值,如果依赖变化,函数会被重新执行,因此不需要添加修改计算属性的作用。但是,可能有部分属性依赖计算属性,这会引起新的作用 - 对于页面渲染,虚拟
dom的修改和比较,会向queue中添加一个更新任务(作用),每个使用这个值的组件会分别添加一个任务
- 对于非同步的
- 任务添加流程
queueJob:- 如果当前任务没有被添加过(通过
job.flags判断),则需要添加到队列中 getId函数:获取任务的id,对于组件更新任务,任务id为组件的uid;对于pre类型的任务,id为-1;其他没有id的任务,id为infinity- 如果当前的
id不小于最后的任务的id,则添加到队列的末尾 - 否则寻找应该添加位置,确保队列中
id的顺序正确 - 如果是本轮事件循环中第一次添加更新任务,则添加一个通过
Promise.resolve().then(flushJobs)创建的微任务
- 如果当前任务没有被添加过(通过
- 队列任务的排序:队列中的任务会按照
id进行排序,id越小,任务越靠前- 由于组件的
id和生成顺序有关,父组件的id比子组件的id小,会先执行 - 对于
watch或watchEffect,id和所在的组件id相同,如果是全局watch,则依据监听类型被getId设置为-1 - 如果两个任务的
id相同,pre类型的watch的优先级最高(监听器应立即插入到更新任务之前),相同任务id依据插入顺序排序 - 排序的原理:队列的本质是一个排序的数组,在每次插入到队列中间部位时,都会先通过二分查找找到插入位置(
O(log n)),然后直接插入(O(n)),因为js对数组的优化足够,且一般队列长度不会太大,性能不会太差,这和没有选择更高阶的数据结构(比如优先队列)应该有一定的联系
- 由于组件的
- 队列的执行方式:当每次需要触发更新时,处理更新队列的微任务会在当前事件循环结束后执行,确保在渲染下一帧前更新,
flushJobs负责清理队列,并执行队列中的任务- 执行过程中如果出现错误,会终止,错误信息被抛出
- 执行区域后,需要经过
finally块,在这里会触发后置更新队列flushPostFlushCbs,处理完成后重新执行flushJobs,清空后置处理时添加的新任务
nextTick实际上就是将函数作为新的微任务执行,根据微任务的添加顺序,执行会在dom比较更新完成之后,真实页面渲染之前,真实dom的数据是最新的- 调度器代码见scheduler.ts
