浅谈Vue响应式原理
这是我参与 8 月更文挑战的第 24 天,活动详情查看: 8月更文挑战
前言
本文我们主要来聊聊Vue,从日常使用到核心原理实现,一步步揭开Vue响应式原理的本质。
基本概念
首先,在一套完整的响应式系统中,我们得先认识下这些概念:
Observer : 它的作用是给对象的属性添加 getter 和 setter,用于依赖收集和派发更新
Dep : 用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有一个 Dep 实例(里面 subs 是 Watcher 实例数组),当数据有变更时,会通过 dep.notify()通知各个 watcher。
Watcher : 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种
而Watcher实现的关键是需要知道依赖了哪些变量,在这些变量被更新的时候,去做一次调用通知,具体的细节可以参考我之前写的文章,在这里可以简单提一下:
依赖收集
-
initState 时,对 computed 属性初始化时,触发 computed watcher 依赖收集
-
initState 时,对侦听属性初始化时,触发 user watcher 依赖收集
-
render()的过程,触发 render watcher 依赖收集
-
re-render 时,vm.render()再次执行,会移除所有 subs 中的 watcer 的订阅,重新赋值。
派发更新
-
组件中对响应的数据进行了修改,触发 setter 的逻辑
-
调用 dep.notify()
-
遍历所有的 subs(Watcher 实例),调用每一个 watcher 的 update 方法。
computed
computed 本质是一个惰性求值的观察者。
computed 内部实现了一个惰性的 watcher,也就是 computed watcher,computed watcher 不会立刻求值,同时持有一个 dep 实例。
其内部通过 this.dirty 属性标记计算属性是否需要重新求值。
当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher,
computed watcher 通过 this.dep.subs.length 判断有没有订阅者,
Proxy
我们都知道在Vue3中,放弃了之前的Object.defineProperty,而采用了Proxy。那么为何要做这种取舍呢?
Object.defineProperty 本身有一定的监控到数组下标变化的能力,但是在 Vue 中,从性能/体验的性价比考虑,尤大大就弃用了这个特性(Vue 为什么不能检测数组变动 )。为了解决这个问题,经过 vue 内部处理后可以使用以下几种方法来监听数组
push();pop();shift();unshift();splice();sort();reverse();
由于只针对了以上 7 种方法进行了 hack 处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。
Object.defineProperty 只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Vue 2.x 里,是通过 递归 + 遍历 data 对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历,显然如果能劫持一个完整的对象是才是更好的选择。
Proxy 可以劫持整个对象,并返回一个新的对象。Proxy 不仅可以代理对象,还可以代理数组。还可以代理动态增加的属性。
nextTick
这是我们在比较常用的API,用于在视图更新后再去做一些事情。那么nextTick内部又是如何工作的呢?首先,我们不得不先得聊聊JS的运行机制
JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
event-loop
主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。
在浏览器环境中 :
常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate
常见的 micro task 有 MutationObsever 和 Promise.then
在 vue2.5 的源码中,macrotask 降级的方案依次是:setImmediate、MessageChannel、setTimeout
好了,我们来说下结论,vue 的 nextTick 方法的实现原理:
-
vue 用异步队列的方式来控制 DOM 更新和 nextTick 回调先后执行
-
microtask 因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕
-
考虑兼容问题,vue 做了 microtask 向 macrotask 的降级方案
小结
-
当创建 Vue 实例时,vue 会遍历 data 选项的属性,利用 Object.defineProperty 为属性添加 getter 和 setter 对数据的读取进行劫持(getter 用来依赖收集,setter 用来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化。
-
每个组件实例会有相应的 watcher 实例,会在组件渲染的过程中记录依赖的所有数据属性(进行依赖收集,还有 computed watcher,user watcher 实例),之后依赖项被改动时,setter 方法会通知依赖与此 data 的 watcher 实例重新计算(派发更新),从而使它关联的组件重新渲染。
-
Vue 采用数据劫持结合发布-订阅模式,通过 Object.defineproperty 来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发响应的监听回调。