手写 Vue3 响应式系统:核心就一个数据结构
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手写 Vue3 响应式系统:核心就一个数据结构相关的知识,希望对你有一定的参考价值。
参考技术A 响应式是 Vue 的特色,如果你简历里写了 Vue 项目,那基本都会问响应式实现原理。而且不只是 Vue,状态管理库 Mobx 也是基于响应式实现的。
那响应式是具体怎么实现的呢?
与其空谈原理,不如让我们来手写一个简易版吧。
响应式
首先,什么是响应式呢?
响应式就是被观察的数据变化的时候做一系列联动处理。
就像一个 社会 热点事件,当它有消息更新的时候,各方媒体都会跟进做相关报道。
这里 社会 热点事件就是被观察的目标。
那在前端框架里,这个被观察的目标是什么呢?
很明显,是状态。
状态一般是多个,会通过对象的方式来组织。所以,我们观察状态对象的每个 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 响应式的核心:
图片
手写实现vue的MVVM响应式原理
MVVM响应式实现原理:
1.模板编译
2.数据劫持
3.watcher
文中应用到的数据名词:
MVVM ------------------ 视图-----模型----视图模型 三者与 Vue
的对应:view
对应 template
,vm
对应 new Vue({…})
,model
对应 data
nodeType 判断节点是否是元素节点
querySelector 创建一个元素节点
createDocumentFragment 文档碎片
attributes 获取元素属性集合
textContent 获取文本内容
reduce( prev,next,currentIndex){} 一个可以用上一个元素和当前元素做处理的方法
defineProperty(obj,key,value){} 数据拦截的主要方法
首先建立一个vue的实例,建立mvvm.js ,构建 mvvm类。 获取el的节点 和 data 放入实例中,在将Observer.js(数据劫持)和Compile.js(模板编译) 放入mvvm的js ,全部在index页面运行.
第一步:模板编译
我首先制作Compile.js ,也就是模板编译 。
首先需要获取el 这个属性的值 用nodeType === 1判断是不是元素节点. 如果不是则用 queryselector() 生成一个节点 。 这样做的目的是,有些人el:#app 有些人是document.getElementById(‘app‘)。 不管俩者如何,我们都要生成一个节点来供后续使用。
随后判断el节点是否存在,如果存在。则进行编译 , 这里编译最好不要在dom里进行遍历编译,非常耗性能 。 我推荐的是用 createDocumentFragment() 方法. 建立一个虚拟节点对象, 在这个虚拟节点对象里进行遍历以及对应的操作。
那么说到虚拟节点, 我们需要将我们获取的el节点整个放入进去 ,进行遍历,将app里的每一个子节点都搬到fragement 变量中。
然后进行节点的编译。这里的节点又分为元素节点和文本节点。 还是用刚刚的nodeType判断区分吗,然后做对应的操作。
接下来我们先编译元素节点 首先我们需要知道,获取元素节点要做什么,为什么获取元素节点。 我是希望通过获取元素节点上的关于vue的指令,比如:v-model,v-html,v-for 。等等... 那么这些指令是放在元素节点上的属性里,所以我们用 attributes 获取元素节点的属性名的集合 ,也就是我们说的v-model 。通过遍历这个attr属性名的集合,获取每个属性名。通过isDirective函数判断attrName包含 v- 的属性,这里我做给假设,好方便理解。 这里通过上面的过滤,可以得出attrName 是一个指令名。那我假设这个指令名为v-model。 我首先获取v-model的值,也就是expr。然后做一个解耦对象CompileUtil ,方便后面制作其他的指令。所以这里调用的是CompileUtil[model]{node,this.v,,expr};
调用model的指令后,在model这个函数里做相对应的处理。这里的watcher构造函数先不用管,后面的事情。 这里的uptate[‘modelUptate‘]和model一样放在CompileUtil 中,方便管理。 如果updateFn存在的话,则执行updateFn(),将v-model的值赋予input节点的value.下图中的getVal 是防止 v-model=’messge.a‘ 这种嵌套对象的。这个函数里,首先利用split将messge.a 拆分成[messge,a] 数组。然后利用reduce方法 放回 上一个元素[当前元素],而最下面的vm.$data 是reduce方法遍历的初始值。也就是 data 。
因为data:{ messge:{ a:‘hello.world‘ } }.这样的编译,元素节点就可以编译出来了,可以将data的值编译到元素节点上了。
接下来编译文本节点,那文本节点,我们首先获取文本节点里的值,然后利用正则的test找{{ a }} , 和之前的元素节点一样,执行对应的函数。,执行对应的行数。这里第86-90 可以先不管,不过这里的textVal和上面的getVal 函数不一样,首先是需要将符合条件的元素里的变量取出来 也就是 {{ a }}里的a ,argments[1] 就是a变量 。 在考虑到对象嵌套,就执行上面的getVal。然后就可以将data里的值替换到文本里了。
这样元素节点和文本节点都编译完成了。然后将整个虚拟节点丢回dom树里去 。MVVM的编译就结束了
第二步:数据劫持,函数很少。但比较绕.这里执行observe,利用递归遍历,将data里的键值对全部拿出来处理,执行defineReactive函数,这里18行可以先不看。 看下面的最重点的Object.defineProperty()。这里要传入劫持的对象,劫持的键,以及回调函数。这里回调函数里俩个参数在下图。
然后,get函数是取值是做对应的操作,set函数是设置值做对应的操作。至此数据劫持就完成了
第三步:watcher 监察者 ,一旦变化执行对应的操作。也就是将模板编译和数据劫持俩个函数联系在一起。有衔接。
这里创建watcher类,将需要的参数获取。 vm是实例,expr是值,cb是回调函数callback。watcher实例里的value = get方法的返回值,value执行一次嵌套处理返回。这里监察者作用主要是 一 更新值,二是执行callback回调函数cb。三将自己的实例,放入dep的target里。那么watcher监察者就制作好了。
最后的连接部分,首先data里的每个属性值都被加上了set和get
获取值
在最开始编译的时候,编译节点的文本节点处理和元素节点处理的时候执行watcher函数,在watcher函数里的get函数中将 watcher函数自己放入dep的target中。然后也在访问值的时候,则会执行get函数,将 每个watcher放入dep数组中 。
修改值
在修改值的时候,会触发Observer.js 的defineProperty的set函数,set函数里比较新的值和旧的值,value是编译时候的值,newValue是set函数的第一个参数,也就是修改后的新值 。 将俩者比较,如果不同,就执行Dep构造函数的notify函数。notify则会遍历全部存在的dep数组里的watcher的update方法。在watcher的update方法中,比较值的不同,如果不同就则执行回调函数,将视图更新。这个回调函数是嵌套在处理文本节点和元素节点的方法里。
v-model的双向绑定
至于v-model的双向绑定,其实是绑定输入框的输入事件。将输入事件新的值赋值给input节点的value值,然后值的改变,执行set函数,将视图改变。视图的改变,会执行wacther的回调函数,文本节点也会重新赋值。
这就是mvvm响应式原理的实现,如果有残缺讲不清楚的地方,欢迎指出。谢谢。
以上是关于手写 Vue3 响应式系统:核心就一个数据结构的主要内容,如果未能解决你的问题,请参考以下文章