怎么手写Vue3响应式系统
响应式
首先,什么是响应式呢?
响应式就是被观察的数据变化的时候做一系列联动处理。就像一个社会热点事件,当它有消息更新的时候,各方媒体都会跟进做相关报道。这里社会热点事件就是被观察的目标。那在前端框架里,这个被观察的目标是什么呢?很明显,是状态。状态一般是多个,会通过对象的方式来组织。所以,我们观察状态对象的每个 key 的变化,联动做一系列处理就可以了。
我们要维护这样的数据结构:
状态对象的每个 key 都有关联的一系列 effect 副作用函数,也就是变化的时候联动执行的逻辑,通过 Set 来组织。
每个 key 都是这样关联了一系列 effect 函数,那多个 key 就可以放到一个 Map 里维护。
这个 Map 是在对象存在的时候它就存在,对象销毁的时候它也要跟着销毁。(因为对象都没了自然也不需要维护每个 key 关联的 effect 了)
而 WeakMap 正好就有这样的特性,WeakMap 的 key 必须是一个对象,value 可以是任意数据,key 的对象销毁的时候,value 也会销毁。
所以,响应式的 Map 会用 WeakMap 来保存,key 为原对象。
这个数据结构就是响应式的核心数据结构了。
比如这样的状态对象:
const obj = { a: 1, b: 2 }
它的响应式数据结构就是这样的:
const depsMap = new Map(); const aDeps = new Set(); depsMap.set('a', aDeps); const bDeps = new Set(); depsMap.set('b', bDeps); const reactiveMap = new WeakMap() reactiveMap.set(obj, depsMap);
创建出的数据结构就是图中的那个:
然后添加 deps 依赖,比如一个函数依赖了 a,那就要添加到 a 的 deps 集合里:
effect(() => { console.log(obj.a); });
也就是这样:
const depsMap = reactiveMap.get(obj); const aDeps = depsMap.get('a'); aDeps.add(该函数);
这样维护 deps 功能上没啥问题,但是难道要让用户手动添加 deps 么?那不但会侵入业务代码,而且还容易遗漏。
所以肯定不会让用户手动维护 deps,而是要做自动的依赖收集。那怎么自动收集依赖呢?读取状态值的时候,就建立了和该状态的依赖关系,所以很容易想到可以代理状态的 get 来实现。通过 Object.defineProperty 或者 Proxy 都可以:
const data = { a: 1, b: 2 } let activeEffect function effect(fn) { activeEffect = fn fn() } const reactiveMap = new WeakMap() const obj = new Proxy(data, { get(targetObj, key) { let depsMap = reactiveMap.get(targetObj); if (!depsMap) { reactiveMap.set(targetObj, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) return targetObj[key] } })
effect 会执行传入的回调函数 fn,当你在 fn 里读取 obj.a 的时候,就会触发 get,会拿到对象的响应式的 Map,从里面取出 a 对应的 deps 集合,往里面添加当前的 effect 函数。
这样就完成了一次依赖收集。
当你修改 obj.a 的时候,要通知所有的 deps,所以还要代理 set:
set(targetObj, key, newVal) { targetObj[key] = newVal const depsMap = reactiveMap.get(targetObj) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) }
基本的响应式完成了,我们测试一下:
打印了两次,第一次是 1,第二次是 3。effect 会先执行一次传入的回调函数,触发 get 来收集依赖,这时候打印的 obj.a 是 1然后当 obj.a 赋值为 3 后,会触发 set,执行收集的依赖,这时候打印 obj.a 是 3
依赖也正确收集到了:
结果是对的,我们完成了基本的响应式!当然,响应式不会只有这么点代码的,我们现在的实现还不完善,还有一些问题。比如,如果代码里有分支切换,上次执行会依赖 obj.b 下次执行又不依赖了,这时候是不是就有了无效的依赖?
这样一段代码:
const obj = { a: 1, b: 2 } effect(() => { console.log(obj.a ? obj.b : 'nothing'); }); obj.a = undefined; obj.b = 3;
第一次执行 effect 函数,obj.a 是 1,这时候会走到第一个分支,又依赖了 obj.b。把 obj.a 修改为 undefined,触发 set,执行所有的依赖函数,这时候走到分支二,不再依赖 obj.b。
把 obj.b 修改为 3,按理说这时候没有依赖 b 的函数了,我们执行试一下:
第一次打印 2 是对的,也就是走到了第一个分支,打印 obj.b
第二次打印 nothing 也是对的,这时候走到第二个分支。但是第三次打印 nothing 就不对了,因为这时候 obj.b 已经没有依赖函数了,但是还是打印了。
打印看下 deps,会发现 obj.b 的 deps 没有清除
所以解决方案就是每次添加依赖前清空下上次的 deps。怎么清空某个函数关联的所有 deps 呢?记录下就好了。
我们改造下现有的 effect 函数:
let activeEffect function effect(fn) { activeEffect = fn fn() }
记录下这个 effect 函数被放到了哪些 deps 集合里。也就是:
let activeEffect function effect(fn) { const effectFn = () => { activeEffect = effectFn fn() } effectFn.deps = [] effectFn() }
对之前的 fn 包一层,在函数上添加个 deps 数组来记录被添加到哪些依赖集合里。
get 收集依赖的时候,也记录一份到这里:
这样下次再执行这个 effect 函数的时候,就可以把这个 effect 函数从上次添加到的依赖集合里删掉:
cleanup 实现如下:
function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 }
effectFn.deps 数组记录了被添加到的 deps 集合,从中删掉自己。全删完之后就把上次记录的 deps 数组置空。
我们再来测试下:
无限循环打印了,什么鬼?
问题出现在这里:
set 的时候会执行所有的当前 key 的 deps 集合里的 effect 函数。
而我们执行 effect 函数之前会把它从之前的 deps 集合中清掉:
执行的时候又被添加到了 deps 集合。这样 delete 又 add,delete 又 add,所以就无限循环了。
解决的方式就是创建第二个 Set,只用于遍历:
这样就不会无限循环了。
再测试一次:
现在当 obj.a 赋值为 undefined 之后,再次执行 effect 函数,obj.b 的 deps 集合就被清空了,所以需改 obj.b 也不会打印啥。
看下现在的响应式数据结构:
确实,b 的 deps 集合被清空了。那现在的响应式实现是完善的了么?也不是,还有一个问题:
如果 effect 嵌套了,那依赖还能正确的收集么?
首先讲下为什么要支持 effect 嵌套,因为组件是可以嵌套的,而且组件里会写 effect,那也就是 effect 嵌套了,所以必须支持嵌套。
我们嵌套下试试:
effect(() => { console.log('effect1'); effect(() => { console.log('effect2'); obj.b; }); obj.a; }); obj.a = 3;
按理说会打印一次 effect1、一次 effect2,这是最开始的那次执行。然后 obj.a 修改为 3 后,会触发一次 effect1 的打印,执行内层 effect,又触发一次 effect2 的打印。也就是会打印 effect1、effect2、effect1、effect2。
我们测试下:
打印了 effect1、effet2 这是对的,但第三次打印的是 effect2,这说明 obj.a 修改后并没有执行外层函数,而是执行的内层函数。为什么呢?
看下这段代码:
我们执行 effect 的时候,会把它赋值给一个全局变量 activeEffect,然后后面收集依赖就用的这个。
当嵌套 effect 的时候,内层函数执行后会修改 activeEffect 这样收集到的依赖就不对了。
怎么办呢?嵌套的话加一个栈来记录 effect 不就行了?
也就是这样:
执行 effect 函数前把当前 effectFn 入栈,执行完以后出栈,修改 activeEffect 为栈顶的 effectFn。
这样就保证了收集到的依赖是正确的。
这种思想的应用还是很多的,需要保存和恢复上下文的时候,都是这样加一个栈。
我们再测试一下:
现在的打印就对了。至此,我们的响应式系统就算比较完善了。
全部代码如下:
const data = { a: 1, b: 2 } let activeEffect const effectStack = []; function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn); fn() effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; } effectFn.deps = [] effectFn() } function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } const reactiveMap = new WeakMap() const obj = new Proxy(data, { get(targetObj, key) { let depsMap = reactiveMap.get(targetObj) if (!depsMap) { reactiveMap.set(targetObj, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) activeEffect.deps.push(deps); return targetObj[key] }, set(targetObj, key, newVal) { targetObj[key] = newVal const depsMap = reactiveMap.get(targetObj) if (!depsMap) return const effects = depsMap.get(key) // effects && effects.forEach(fn => fn()) const effectsToRun = new Set(effects); effectsToRun.forEach(effectFn => effectFn()); } })
总结
响应式就是数据变化的时候做一系列联动的处理。
核心是这样一个数据结构:
最外层是 WeakMap,key 为对象,value 为响应式的 Map。这样当对象销毁时,Map 也会销毁。Map 里保存了每个 key 的依赖集合,用 Set 组织。
我们通过 Proxy 来完成自动的依赖收集,也就是添加 effect 到对应 key 的 deps 的集合里。 set 的时候触发所有的 effect 函数执行。
这就是基本的响应式系统。
但是还不够完善,每次执行 effect 前要从上次添加到的 deps 集合中删掉它,然后重新收集依赖。这样可以避免因为分支切换产生的无效依赖。并且执行 deps 中的 effect 前要创建一个新的 Set 来执行,避免 add、delete 循环起来。此外,为了支持嵌套 effect,需要在执行 effect 之前把它推到栈里,然后执行完出栈。解决了这几个问题之后,就是一个完善的 Vue 响应式系统了。当然,现在虽然功能是完善的,但是没有实现 computed、watch 等功能,之后再实现。
最后,再来看一下这个数据结构,理解了它就理解了 vue 响应式的核心:
以上是怎么手写Vue3响应式系统的详细内容。更多信息请关注PHP中文网其他相关文章!

热AI工具

Undresser.AI Undress
人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover
用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool
免费脱衣服图片

Clothoff.io
AI脱衣机

AI Hentai Generator
免费生成ai无尽的。

热门文章

热工具

记事本++7.3.1
好用且免费的代码编辑器

SublimeText3汉化版
中文版,非常好用

禅工作室 13.0.1
功能强大的PHP集成开发环境

Dreamweaver CS6
视觉化网页开发工具

SublimeText3 Mac版
神级代码编辑软件(SublimeText3)

热门话题

vue3+vite:src使用require动态导入图片报错和解决方法vue3+vite动态的导入多张图片vue3如果使用的是typescript开发,就会出现require引入图片报错,requireisnotdefined不能像使用vue2这样imgUrl:require(’…/assets/test.png’)导入,是因为typescript不支持require所以用import导入,下面介绍如何解决:使用awaitimport

tinymce是一个功能齐全的富文本编辑器插件,但在vue中引入tinymce并不像别的Vue富文本插件一样那么顺利,tinymce本身并不适配Vue,还需要引入@tinymce/tinymce-vue,并且它是国外的富文本插件,没有通过中文版本,需要在其官网下载翻译包(可能需要翻墙)。1、安装相关依赖npminstalltinymce-Snpminstall@tinymce/tinymce-vue-S2、下载中文包3.引入皮肤和汉化包在项目public文件夹下新建tinymce文件夹,将下载的

Vue实现博客前端,需要实现markdown的解析,如果有代码则需要实现代码的高亮。Vue的markdown解析库有很多,如markdown-it、vue-markdown-loader、marked、vue-markdown等。这些库都大同小异。这里选用的是marked,代码高亮的库选用的是highlight.js。具体实现步骤如下:一、安装依赖库在vue项目下打开命令窗口,并输入以下命令npminstallmarked-save//marked用于将markdown转换成htmlnpmins

想要实现页面的局部刷新,我们只需要实现局部组件(dom)的重新渲染。在Vue中,想要实现这一效果最简便的方式方法就是使用v-if指令。在Vue2中我们除了使用v-if指令让局部dom的重新渲染,也可以新建一个空白组件,需要刷新局部页面时跳转至这个空白组件页面,然后在空白组件内的beforeRouteEnter守卫中又跳转回原来的页面。如下图所示,如何在Vue3.X中实现点击刷新按钮实现红框范围内的dom重新加载,并展示对应的加载状态。由于Vue3.X中scriptsetup语法中组件内守卫只有o

vue3项目打包发布到服务器后访问页面显示空白1、处理vue.config.js文件中的publicPath处理如下:const{defineConfig}=require('@vue/cli-service')module.exports=defineConfig({publicPath:process.env.NODE_ENV==='production'?'./':'/&

前言无论是vue还是react,当遇到多处重复代码的时候,我们都会想着如何复用这些代码,而不是一个文件里充斥着一堆冗余代码。实际上,vue和react都可以通过抽组件的方式来达到复用,但如果遇到一些很小的代码片段,你又不想抽到另外一个文件的情况下,相比而言,react可以在相同文件里面声明对应的小组件,或者通过renderfunction来实现,如:constDemo:FC=({msg})=>{returndemomsgis{msg}}constApp:FC=()=>{return(

最终效果安装VueCropper组件yarnaddvue-cropper@next上面的安装值针对Vue3的,如果时Vue2或者想使用其他的方式引用,请访问它的npm官方地址:官方教程。在组件中引用使用时也很简单,只需要引入对应的组件和它的样式文件,我这里没有在全局引用,只在我的组件文件中引入import{userInfoByRequest}from'../js/api'import{VueCropper}from'vue-cropper&

vue3+ts+axios+pinia实现无感刷新1.先在项目中下载aiXos和pinianpmipinia--savenpminstallaxios--save2.封装axios请求-----下载js-cookienpmiJS-cookie-s//引入aixosimporttype{AxiosRequestConfig,AxiosResponse}from"axios";importaxiosfrom'axios';import{ElMess
