vue3 与 vue2 主要差异之一无疑是响应式实现上的改变。本文主要阐述响应式原理的实现方式解析以及核心源码阅读的注释理解。
本文主要对响应式实现原理进行逻辑梳理,舍弃枯燥无味的代码,只用图解/文字进行功能描述,具体实现请自行阅读。保重!!!
如果问题,虚心求教,还请指出!!!
先回顾一下Vue2的响应式原理:
灰色-初始化声明,蓝色-收集副作用,红色-触发副作用
个人整理绘制,有误还望指出
Vue2响应式原理流程:
构建响应式数据:
以data选项为入口,对data对象使用Object.defineProperty 对每个字段设置 getter/setter 拦截器进行拦截,同时为每个字段都创建了一个dep集合,用于收集跟字段有关的所有依赖对象(watcher)。如果字段值是 arrar/object,进行递归处理。由于vue2无法监听数组长度、索引值的改变,只能通过扩展数组常用的api方法(push/pop...),进行 setter 拦截。
依赖收集:
依赖收集主要发生在 computed、watch、update(render) 过程。
如果声明 computed 选项,在初始化computed字段时,会为每个computed字段声明一个对应的watcher实例依赖对象,同时在vm实例上直接挂载computed字段,使用定义的computed函数作为字段的getter拦截器,并且默认执行一次来获取初始值。在计算时,如果有使用响应式字段,会触发字段的getter拦截器,这时就会发生依赖收集,将computed字段对应的watcher实例加入响应字段对应的 dep集合 中,同时也把 dep集合 存在 watcher实例中(双向绑定)。computed字段默认是响应式的,所以也维护了一个对应的dep集合,依赖收集同data对象字段。如果为computed字段设置指定 set,作为computed字段的setter拦截器。
computed字段具有缓存cache属性,当依赖的响应字段更新,并会立即触发依赖更新,只打上需要重新计算的标识(dirty=true),当依赖重新读取computed字段时,在重新计算获取最新值;如果直接通过set赋值,不需要computed重新计算依赖就能获取最新值。
如果声明 watch 选项 / $watch,在遍历每个watch时,会为每个watch都创建一个对应的watcher实例依赖对象,以及解析监听的响应字段,获取监听字段的初始值(getter),在获取字段初始值时,一样触发响应字段的getter拦截器发生依赖收集,将 wacth 对应的 watcher实例 加入响应字段对应的dep集合中,watcher实例也记录字段对应的dep集合(双向绑定)。
组件实例化都会得到一个_update函数,并为这个唯一的_update函数创建一个全局的watcher实例(vm._watcher),在执行第一次组件渲染时,在解析文本插值或者节点属性计算中如果获取响应字段值,都会触发响应字段的依赖收集,并将_update函数对应的_watcher实例加到字段对应的 dep集合中,保证每次响应字段更新,组件能改重新渲染。
依赖更新
当对响应字段进行赋值,会触发响应字段的setter拦截器,获取响应字段对应的 dep集合,从集合中得到响应字段的所有依赖(watcher实例),调用每个watcher实例的函数fn。如果是computed字段,fn是设置computed字段的dirty标识;如果是watch字段,则是获取监听响应字段值,对比判断是否触发更新回调;如果是_update函数,则就更新组件,重新渲染。
vue2 的一个响应式原理基本就是这样。
那接下来,来看看vue3的响应式原理。
同样,先来一张图:
灰色-初始化声明,蓝色-收集副作用,红色-触发副作用,紫色 异步更新,黄色-api调用
vue3中,更多的使用新名词 副作用 来代替vue2的名词 依赖。
个人觉得:
-
vue2中的依赖相对来说比较抽象,主要表达 响应字段与其他响应字段或者函数字段有关系,虽然概念上好理解,但代码实现上比较难理解,通过Watcher对象表示依赖关系,需要将带有依赖关系的函数转成一个内部值,维护在watcher实例里。
-
vue3中的副作用相对来说比较直观,主要表达 响应字段修改后会引起的作用(函数),比如依赖字段的更新,监听回调函数的执行。通过Effect对象来表示副作用,每个副作用都直接绑定一个副作用函数。
以下使用副作用/副作用函数说明,您可以直接理解成watcher,只是实现方式有所差异
Vue3响应式原理流程:
构造响应数据:
使用 reactive 、ref api声明响应式数据,reactive 和 ref 实现响应有些差别。
reactive 为传入的对象参数创建对应的 Proxy 代理对象,对字段的取值/赋值设置 getter/setter 拦截器,根据字段数据类型设置不同的 getter/setter,同时对 array/object 字段进行递归构建 reactive。
全局维护了Map类型用来记录 原始对象以及对应的Proxy代理对象,array 以及 Set/Map 数据类型,通过 Reflect.get 对常用方法push/pop/set/delete... 进行拦截处理。这与vue2直接扩展原生方法方式不同。通过 Proxy 代理,可以拦截到数组索引值的改变,以及 .length 的改变,弥补了 vue2 的缺陷。
浅reactive形式,就不会递归构建对象响应字段,这样只有修改对象根级字段才能触发依赖更新。
ref 构造响应式数据同vue2原理一样,返回一个ref对象,维护一个私有._value字段保存响应值,和一个私有._rawValue值记录原始数据,为ref对象提供一个公开.value字段,并对.value设置getter/setter拦截器代理._value,从而实现 .value 字段的响应式。如果ref参数值是object,通过 reactive 包装返回对应的Proxy代理对象,从而实现对象字段的响应式;
如果是浅ref形式,就不需要用 reactive 包装,只有直接修改 .value 才会触发依赖更新。
收集副作用:
同vue2,收集副作用主要发生在 computed、watch、watchEffect api 以及 update(render) 过程。vue3 多了一个 watchEffect 功能,是一个更加纯粹的副作用函数。
vue3收集副作用主要涉及公共方法 track,以及全局字段 targetMap/KeyToDepMap。 字段 targetMap/KeyToDepMap 用于维护响应对象中的响应字段与相关的副作用对象(effect)的关系(依赖)。
- targetMap:WeakMap<any, KeyToDepMap> 维护响应对象所有相关的副作用集合。
- KeyToDepMap:Map<any, Dep> 记录响应字段和对应副作用集合的映射。
- Dep:Set<RectiveEffect> 一个副作用对象集合。
收集过程主要是:
- 创建副作用对象effect ->
- 初始化调用副作用对象 `effect.run()`, 开启收集副作用 ->
- 将当前effect 入栈,设置为 activeEffect(栈顶)->
- 执行副作用函数,触发相关响应字段的getter拦截器 ->
- getter拦截器调用 track 将 activeEffect 收集到字段对应的dep集合 ->
- 收集完副作用对象effect后,当前 effect 出栈,继续收集其他副作用
computed api,返回一个 computedRef 对象,实现响应机制类似 ref,内部维护一个私有._value字段维护computed值,通过提供一个公开.value字段,并设置对应的getter/setter拦截器去代理._value字段,从而实现.value的响应式。
每个computed函数都是一个副作用函数(getter),为每个computed函数都创建一个对应的副作用对象(effect),computed函数存储在effect对象的字段fn,同时将effect与computedRef对象双向绑定。
初始化computed值,会默认执行一次副作用函数,这时触发响应字段的getter拦截器,拦截器调用track 收集computed对应的副作用对象effect,将响应字段与副作用对象的关系记录在KeyToDepMap中。
watch api,返回一个 unwatch 方法,用于关闭watch监听。watch会将第一参数(字符串或函数)会转成一个副作用函数(相当于getter函数)用于获取监听响应字段的值,根据这个副作用函数创建一个对应副作用对象effect,并记录响应值(维护在副作用对象.value 上)。
为watch副作用effect的执行创建一个调度任务scheduler:SchedulerJob,根据flush配置值控制此调度任务的执行顺序。调度任务主要用于重新获取响应字段值,并与旧值进行对比,如果有变更,就会调用回调cb。
当初始化watch对应的副作用对象后,如果配置 immediate:true,会默认执行一次调度任务(包含获取响应值,执行回调),否则默认执行一次副作用(effect.run)用于获取响应值(不会触发回调),在获取响应值时会触发响应字段的getter拦截器,拦截器调用track 收集watch对应的副作用对象effect,将响应字段与副作用对象的关系记录在KeyToDepMap中。
watchEffect api,返回一个 unwatch 方法,用于关闭watchEffect监听。相比 watch 功能语义更加纯粹,只是监听响应字段用于执行与响应字段有关的副作用。
watchEffect 的第一参数即为一个副作用函数,直接对其创建一个对应的副作用对象effect。同watch 也为副作用effect的执行创建一个调度任务scheduler:SchedulerJob,根据 flush 配置值控制此副作用的执行顺序。该调度任务就只是为了执行 effect.run。
默认 watchEffect 会在update之前执行一次副作用,执行副作用函数时候,触发关联的响应式字段的getter拦截器,拦截器调用 track 收集watchEffect对应的副作用对象effect,将响应字段与副作用对象的关系记录在KeyToDepMap中。
update 过程,为update函数创建一个副作用对象effect。同watch,update也会声明一个调度任务job去执行副作用。当副作用执行时(render过程),解析文本插值、动态属性中,如果有响应字段,会触发响应字段的getter拦截器,收集 update对应的副作用对象effect。
副作用对象effect 与 响应字段对应的副作用结合Dep 的关系也是双向绑定。
触发副作用:
vue3触发副作用主要涉及公共方法 trigger,以及全局字段 targetMap/KeyToDepMap。
当响应字段发生变化,触发响应字段的 setter 拦截器,拦截器会调用 trigger 方法去执行相关的副作用。
trigger 主要是从 全局字段 targetMap/KeyToDepMap 获取响应对象的响应字段对应的副作用集合Dep,在遍历Dep得到副作用对象effect,然后依次执行副作用。
执行副作用涉及副作用执行顺序:
-
computed 副作用:
computed值带缓存,执行computed副作用,不会立即重新计算computed值,而是利用副作用函数打上标识dirty,然后通知computed相关的副作用(trigger),当相关副作用执行后,重新读取computed值导致触发.value的getter拦截器,然后根据dirty标识判断是否需要重新计算,而后才返回computed值。
-
watch 副作用:
收集副作用提到watch会创建一个调度任务
scheduler,当执行watch副作用,将根据flush配置值去执行调度任务。如果是sync,会直接执行;如果是pre,会将调度任务加入调度任务队列queue等待执行;如果是post,会将调度任务加入post调度任务队列pendingPostFlushCbs,等待执行。调度任务具体执行内容前面已经提及,就不重复。 -
watchEffect 副作用:
执行同watch 副作用,两个差别主要是 调度任务执行的内容,所以就不重复描述。
-
update 副作用:
将执行update的调度任务加入调度任务队列queue中等待执行。
update 声明:
这里主要难理解的就是调度任务的执行顺序,这里简单说明一下:
Vue3 基于 Promise 控制调度任务的执行顺序。调度任务queue的执行先于post调度任务队列pendingPostFlushCbs,当往queue加入调度任务,就会默认开启调度任务清空操作(标识清空状态),通过 Promise 将清空操作放入微任务队列后(返回一个Promise),此时如果有新的调度任务加入,也不需要重复开启清空。
调度任务的优先级,主要通过调度任务内部值id维护,在将调度任务加入调度队列时,会根据id值选择合适的位置插入,而不是直接加入队尾(升序排序)。
当开始执行调度队列清空操作时,会依次读取调度任务,并维护当前执行任务的索引值flushIndex,保证在执行清空操作时如果有新的任务加入,能够在当前索引值之后加入,新任务能在同一个微任务下执行。如果有使用 nextTick,会在queue清空微任务Promise之后使用 Promise.then 接在微任务之后执行。
当执行完queue队列之后,会开启post队列清空,同时还原queue清空状态,能够重新开始等待调度任务执行。
调度任务的实现源码阅读理解