你好,我是大圣。
经过前面课程的学习,相信你对 Vue3 的实战和组件有了新的认识,也掌握了很多实战秘籍,从今天开始,我将带你进入 Vue 框架的内部世界,探究一下 Vue 框架的原理,让你能知其然,也知其所以然。
我们将手写一个迷你的 Vue 框架,实现 Vue3 的主要渲染和更新逻辑,项目就叫 weiyouyi,你可以在 GitHub 上看到所有的核心代码。
响应式
在第三讲的 Vue3 新特性中,我们剖析了 Vue3 的功能结构,就是下图所示的 Vue 核心模块,可以看到,Vue3 的组件之间是通过响应式机制来通知的,响应式机制可以自动收集系统中数据的依赖,并且在修改数据之后自动执行更新,极大提高开发的效率。
我们今天就要自己做一个迷你的响应式原型,希望你能通过自己手写,搞清楚响应式的实现原理。
根据响应式组件通知效果可以知道,响应式机制的主要功能就是,可以把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新。
所以,一个最简单的响应式模型,我们可以通过 reactive 或者 ref 函数,把数据包裹成响应式对象,并且通过 effect 函数注册回调函数,然后在数据修改之后,响应式地通知 effect 去执行回调函数即可。
整个流程这么概括地说,你估计不太理解,我们先通过一个简单的小例子直观感受一下响应式的效果。
Vue 的响应式是可以独立在其他平台使用的。比如你可以新建 test.js,使用下面的代码在 node 环境中使用 Vue 响应。以 reactive 为例,我们使用 reactive 包裹 JavaScript 对象之后,每一次对响应式对象 counter 的修改,都会执行 effect 内部注册的函数:
执行 node test.js 之后,你就可以看到 effect 内部的函数会一直调用,每次 count.value 修改之后都会执行。
看到这个 API 估计你有点疑惑,effect 内部的函数式如何知道 count 已经变化了呢?
我们先来看一下响应式整体的流程图,上面的代码中我们使用 reactive 把普通的 JavaScript 对象包裹成响应式数据了。
所以,在 effect 中获取 counter.num1 和 counter.num2 的时候,就会触发 counter 的 get 拦截函数;get 函数,会把当前的 effect 函数注册到一个全局的依赖地图中去。这样 counter.num1 在修改的时候,就会触发 set 拦截函数,去依赖地图中找到注册的 effect 函数,然后执行。
具体是怎么实现的呢?我们从第一步把数据包裹成响应式对象开始。先看 reactive 的实现。
reactive
我们进入到 src/reactivity 目录中,新建 reactive.spec.js
,使用下面代码测试 reactive 的功能,能够在响应式数据 ret 更新之后,执行 effect 中注册的函数:
之前讲过在 Vue3 中,reactive 是通过 ES6 中的 Proxy 特性实现的属性拦截,所以,在 reactive 函数中我们直接返回 newProxy 即可:
可以看到,下一步我们需要实现的就是 Proxy 中的处理方法 mutableHandles。
这里会把 Proxy 的代理配置抽离出来单独维护,是因为,其实 Vue3 中除了 reactive 还有很多别的函数需要实现,比如只读的响应式数据、浅层代理的响应式数据等,并且 reactive 中针对 ES6 的代理也需要单独的处理。
这里我们只处理 js 中对象的代理设置:
mutableHandles
好,看回来,我们剖析 mutableHandles。它要做的事就是配置 Proxy 的拦截函数,这里我们只拦截 get 和 set 操作,进入到 baseHandlers.js
文件中。
我们使用 createGetter 和 createSetters 来创建 set 和 get 函数,mutableHandles 就是配置了 set 和 get 的对象返回。
get 中直接返回读取的数据,这里的 Reflect.get 和 target[key]实现的结果是一致的;并且返回值是对象的话,还会嵌套执行 reactive,并且调用 track 函数收集依赖。
set 中调用 trigger 函数,执行 track 收集的依赖。
我们先看 get 的关键部分,track 函数是怎么完成依赖收集的。
track
具体写代码之前,把依赖收集和执行的原理我们梳理清楚,看下面的示意图:
在 track 函数中,我们可以使用一个巨大的 tragetMap 去存储依赖关系。map 的 key 是我们要代理的 target 对象,值还是一个 depsMap,存储这每一个 key 依赖的函数,每一个 key 都可以依赖多个 effect。上面的代码执行完成,depsMap 中就有了 num1 和 num2 两个依赖。
而依赖地图的格式,用代码描述如下:
好,有了大的设计思路,我们来进行具体的实现,在 reactive 下新建 effect.js
。
由于 target 是对象,所以必须得用 map 才可以把 target 作为 key 来管理数据,每次操作之前需要做非空的判断。最终把 activeEffect 存储在集合之中:
get 中关键的收集依赖的 track 函数我们已经讲完了,继续看 set 中关键的 trigger 函数。
trigger
有了上面 targetMap 的实现机制,trigger 函数实现的思路就是从 targetMap 中,根据 target 和 key 找到对应的依赖函数集合 deps,然后遍历 deps 执行依赖函数。
看实现的代码:
可以看到执行的是 effect 的 scheduler 或者 run 函数,这是因为我们需要在 effect 函数中把依赖函数进行包装,并对依赖函数的执行时机进行控制,这是一个小的设计点。
effect
然后我们来实现 effect 函数。
下面的代码中,我们把传递进来的 fn 函数通过 effectFn 函数包裹执行,在 effectFn 函数内部,把函数赋值给全局变量 activeEffect;然后执行 fn() 的时候,就会触发响应式对象的 get 函数,get 函数内部就会把 activeEffect 存储到依赖地图中,完成依赖的收集:
effect 传递的函数,比如可以通过传递 lazy 和 scheduler 来控制函数执行的时机,默认是同步执行。
scheduler 存在的意义就是我们可以手动控制函数执行的时机,方便应对一些性能优化的场景,比如数据在一次交互中可能会被修改很多次,我们不想每次修改都重新执行依次 effect 函数,而是合并最终的状态之后,最后统一修改一次。
scheduler 怎么用你可以看下面的代码,我们使用数组管理传递的执行任务,最后使用 Promise.resolve 只执行最后一次,这也是 Vue 中 watchEffect 函数的大致原理。
好了,绕了这么一大圈终于执行完了函数,估计你也看出来了封装了很多层。
之所以封装这么多层就是因为,Vue 的响应式本身有很多的横向扩展,除了响应式的封装,还有只读的拦截、浅层数据的拦截等等,这样,响应式系统本身也变得更加灵活和易于扩展,我们自己在设计公用函数的时候也可以借鉴类似的思路。
另一个选择 ref 函数
有了 track 和 trigger 的逻辑之后,我们用 ref 函数实现就变得非常简单了。
ref 的执行逻辑要比 reactive 要简单一些,不需要使用 Proxy 代理语法,直接使用对象语法的 getter 和 setter 配置,监听 value 属性即可。
看下面的实现,在 ref 函数返回的对象中,对象的 get value 方法,使用 track 函数去收集依赖,set value 方法中使用 trigger 函数去触发函数的执行。
你能很直观地看到,ref 函数实现的相对简单很多,只是利用面向对象的 getter 和 setter 拦截了 value 属性的读写,这也是为什么我们需要操作 ref 对象的 value 属性的原因。
值得一提的是,ref 也可以包裹复杂的数据结构,内部会直接调用 reactive 来实现,这也解决了大部分同学对 ref 和 reactive 使用时机的疑惑,现在你可以全部都用 ref 函数,ref 内部会帮你调用 reactive。
computed
Vue 中的 computed 计算属性也是一种特殊的 effect 函数,我们可以新建 computed.spec.js 来测试 computed 函数的功能,computed 可以传递一个函数或者对象,实现计算属性的读取和修改。比如说可以这么用:
怎么实现呢?我们新建 computed 函数,看下面的代码,我们拦截 computed 的 value 属性,并且定制了 effect 的 lazy 和 scheduler 配置,computed 注册的函数就不会直接执行,而是要通过 scheduler 函数中对 _dirty 属性决定是否执行。
总结
最后我们来回顾一下今天学到的内容。通过手写迷你的响应式原型,我们学习了 Vue 中响应式的地位和架构。
响应式的主要功能就是可以把普通的 JavaScript 对象封装成为响应式对象,在读取数据的时候通过 track 收集函数的依赖关系,把整个对象和 effect 注册函数的依赖关系全部存储在一个依赖图中。
定义的 dependsMap 是一个巨大的 Map 数据,effect 函数内部读取的数据都会存储在 dependsMap 中,数据在修改的时候,通过查询 dependsMap,获得需要执行的函数,再去执行即可。
dependsMap 中存储的也不是直接存储 effect 中传递的函数,而是包装了一层对象对这个函数的执行实际进行管理,内部可以通过 active 管理执行状态,还可以通过全局变量 shouldTrack 控制监听状态,并且执行的方式也是判断 scheduler 和 run 方法,实现了对性能的提升。
我们在日常项目开发中也可以借鉴响应式的处理思路,使用通知的机制,来调用具体数据的操作和更新逻辑,灵活使用 effect、ref、reactive 等函数把常见的操作全部变成响应式数据处理,会极大提高我们开发的体验和效率。
思考题
最后留一个思考题,Vue3.2 对响应式有一个性能的进一步提升,你都了解到有哪些呢?欢迎你在评论区分享自己的思考,我们下一讲再见。