当前位置: 首页 > 科技观察

一张图搞清楚Vue3.0的响应式系统

时间:2023-03-12 08:09:31 科技观察

本文首发于我的博客:《一张图理清 Vue 3.0 的响应式系统》随着Vue3.0PreAlpha版本的发布,我们可以一窥其源码实现。Vue最巧妙的特性之一就是它的响应式系统,我们也可以在仓库的packages/reactivity模块下找到对应的实现。虽然源码量不多,网上的分析文章也不少,但要想清楚地理解响应式原理的具体实现过程,还是需要费一番脑筋的。经过一天的研究和整理,我将其响应式系统的原理总结成一张图,本文也将围绕这张图描述具体的实现过程。文中涉及的代码我也已经上传到仓库中,带着代码阅读本文会更流畅!一个基本的例子。Vue3.0的响应式系统是一个独立的模块,完全可以脱离Vue使用。因此,我们clone源码后,直接在packages/reactivity模块下调试即可。在项目根目录运行yarndevreactivity,然后进入packages/reactivity目录可以找到输出的dist/reactivity.global.js文件。新建一个index.html,写入如下代码:3.在浏览器中打开文件,执行状态安慰。count++,可以看到输出设置count为1。在上面的例子中,我们使用reactive()函数将origin对象转换为Proxy对象状态;使用effect()函数将fn()用作反应式回调。fn()在state.count改变时被触发。接下来,我们将通过这个例子结合上面的流程图来解释这个响应式系统是如何工作的。初始化阶段在初始化阶段,主要做了两件事。将原始对象转换为响应式代理对象状态。使用函数fn()作为反应效果函数。首先我们来分析第一件事。众所周知,Vue3.0使用Proxy替代了之前的Object.defineProperty(),重写了对象的getter/setter,完成了依赖收集和响应触发。但在这个阶段,我们暂时不关心它是如何重写对象的getter/setter的,这将在后续的“依赖收集阶段”中详细说明。为了简单起见,我们可以将这部分内容浓缩成一个reactive()函数,只有两行代码:这里的handler是getter/setter转换的关键,我们后面会讲解。接下来我们分析第二件事。当一个普通函数fn()被effect()包裹后,就会变成响应式效果函数,fn()会立即执行一次。由于在fn(??)中有引用Proxy对象的属性,这一步会触发对象的getter,从而启动依赖收集。另外,这个效果函数也会被压入一个名为“activeReactiveEffectStack”(这里是effectStack)的栈中,用于后续的依赖收集。我们看一下代码(完整代码见effect.js):exportfunctioneffect(fn){//构造一个effectconsteffect=functioneffect(...args){returnrun(effect,fn,args)}//立即执行aneffect()returneffect}exportfunctionrun(effect,fn,args){if(effectStack.indexOf(effect)===-1){try{//将当前effect放入pooleffectStack.push(effect)//执行fn()immediately//fn()执行过程会完成依赖收集,会使用effectreturnfn(...args)}finally{//完成依赖收集后将这个effect从池中扔掉。effectStack.pop()}}}至此,初始化阶段已经完成Finish。接下来就是整个系统最关键的一步——依赖收集阶段。该阶段依赖收集的触发时机是effect立即执行,其内部fn()触发Proxy对象的getter时。简单的说,只要执行state.count这样的语句,就会触发state的getter。依赖收集阶段最重要的目的是创建一个“依赖收集表”,也就是图中所示的“targetMap”。targetMap是一个WeakMap,key值为当前Proxy对象状态被代理前对象的origin,value为对象对应的depsMap。depsMap是一个Map,key值是getter被触发时的属性值(这里是count),value是已经被触发的属性值对应的effect。还是有点绕?那么让我们再举一个例子。假设有一个Proxy对象和effect如下:conststate=reactive({count:0,age:18})consteffecteffect1=effect(()=>{console.log('effect1:'+state.count)})consteffecteffect2=effect(()=>{console.log('effect2:'+state.age)})consteffecteffect3=effect(()=>{console.log('effect3:'+state.count,state.age)})那么这里的targetMap应该是这样的:这样就建立了{target->key->dep}的对应关系,依赖收集就完成了。代码如下:(target,(depsMap=newMap()))}letdep=depsMap.get(key)if(dep===void0){depsMap.set(key,(dep=newSet()))}if(!dep.has(effect)){dep.add(effect)}}}理解依赖集合表targetMap非常重要,因为这是整个响应式系统核心的核心。响应阶段回顾上一章的例子,我们拿到了一个{count:0,age:18}的Proxy,构造了三个effect。看看控制台上的效果:效果和预期的一样,那么是如何实现的呢?首先我们看一下这个阶段的示意图:当一个对象的某个属性值被修改时,会触发对应的setter。setter中的trigger()函数会从依赖收集表中找到当前属性对应的deps,然后将它们压入effects和computedEffects(计算属性)队列,最后通过scheduleRun()将里面的effects一一执行.由于已经建立了依赖收集表,所以很容易找到属性对应的dep。可以看看具体的代码实现:exportfunctiontrigger(target,operationType,key){//获取对应的depsMapconstdepsMap=targetMap.get(target)if(depsMap===void0){return}//获取对应的depconsteffects=newSet()if(key!==void0){constdep=depsMap.get(key)dep&&dep.forEach(effect=>{effects.add(effect)})}//scheduleRun的简化版,执行effecteffects.forEach(effect=>{effect()})}一个一个。这里的代码没有处理一些特殊情况,比如被修改的数组长度。有兴趣的读者可以查看vue-next对应的源码,或者这篇文章,看看这些情况是如何处理的。至此,响应阶段完成。总结和阅读源码的过程充满了挑战,但同时也时常被Vue的一些实现思路所折服,收获颇丰。本文根据响应式系统的运行过程,划分了“初始化”、“依赖收集”和“响应式”三个阶段,并分别解释了每个阶段所做的事情,应该能够更好的帮助读者理解其核心思想。最后附上文章示例代码的仓库地址。有兴趣的读者可以自己玩:tiny-reactive