如何手写Vue-next响应式呢?本文详解
前言
1.本文将从零开始手写一份vue-next
中的响应式原理,出于篇幅和理解的难易程度,我们将只实现核心的api
并忽略一些边界的功能点
本文将实现的api
包括
- track
- trigger
- effect
- reactive
- watch
- computed
2.最近很多人私信我问前端问题,博客登陆的少没及时回复,为此我建了个前端扣扣裙 519293536 大家以后有问题直接群里找我。都会尽力帮大家,博客私信我很少看
项目搭建
我们采用最近较火的vite
创建项目
本文演示的版本
- node
v12.16.1
- npm
v6.14.5
- yarn
v1.22.4
我们首先下载模板
yarn create vite-app vue-next-reactivity
复制代码
模板下载好后进入目录
cd vue-next-reactivity
复制代码
然后安装依赖
yarn install
复制代码
然后我们仅保留src
目录下的main.js
文件,清空其余文件并创建我们要用到的reactivity
文件夹
整个文件目录如图所示,输入npm run dev
项目便启动了
手写代码
响应式原理的本质
在开始手写前,我们思考一下什么是响应式原理呢?
我们从vue-next
的使用中来解释一下
vue-next
中用到的响应式大概分为三个
- template或render
在页面中使用到的变量改变后,页面自动
进行了刷新
- computed
当计算属性函数中用到的变量发生改变后,计算属性自动
进行了改变
- watch
当监听的值发生改变后,自动
触发了对应的回调函数
以上三点我们就可以总结出响应式原理的本质
当一个值改变后会自动
触发对应的回调函数
这里的回调函数就是template
中的页面刷新函数,computed
中的重新计算属性值的函数以及本来就是一个回调函数的watch
回调
所以我们要去实现响应式原理现在就拆分为了两个问题
- 监听值的改变
- 触发对应的回调函数
我们解决了这两个问题,便写出了响应式原理
监听值的改变
javascript
中提供了两个api
可以做到监听值的改变
一个是vue2.x
中用到的Object.defineProperety
const obj = {};
let aValue = 1;
Object.defineProperty(obj, 'a', {
enumerable: true,
configurable: true,
get() {
console.log('我被读取了');
return aValue;
},
set(value) {
console.log('我被设置了');
aValue = value;
},
});
obj.a; // 我被读取了
obj.a = 2; // 我被设置了
复制代码
还有一个方法就是vue-next
中用到的proxy
,这也是本次手写中会用到的方法
这个方法解决了Object.defineProperety
的四个痛点
- 无法拦截在对象上属性的新增和删除
- 无法拦截在数组上调用
push
pop
shift
unshift
等对当前数组会产生影响的方法 - 拦截数组索引过大的性能开销
- 无法拦截
Set
Map
等集合类型
当然主要还是前两个
关于第三点,vue2.x
中数组索引的改变也得通过this.$set
去设置,导致很多同学误认为Object.defineProperety
也没法拦截数组索引,其实它是可以的,vue2.x
没做的原因估计就是因为性价比不高
以上4点proxy
就可以完美解决,现在让我们动手开始写一个proxy
拦截吧!
proxy拦截
我们在之前创建好的reactivity
目录创建两个文件
utils.js
存放一些公用的方法
reactive.js
存放proxy
拦截的方法
我们先在utils.js
中先添加将要用到的判断是否为原生对象的方法
reactivity/utils.js
// 获取原始类型
export function toPlain(value) {
return Object.prototype.toString.call(value).slice(8, -1);
}
// 是否是原生对象
export function isPlainObject(value) {
return toPlain(value) === 'Object';
}
复制代码
reactivity/reactive.js
import { isPlainObject } from './utils';
// 本列只有数组和对象才能被观测
function canObserve(value) {
return Array.isArray(value) || isPlainObject(value);
}
// 拦截数据
export function reactive(value) {
// 不能监听的数值直接返回
if (!canObserve(value)) {
return;
}
const observe = new Proxy(value, {
// 拦截读取
get(target, key, receiver) {
console.log(`${key}被读取了`);
return Reflect.get(target, key, receiver);
},
// 拦截设置
set(target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver);
console.log(`${key}被设置了`);
return res;
},
});
// 返回被观察的proxy实例
return observe;
}
复制代码
reactivity/index.js
导出方法
export * from './reactive';
复制代码
main.js
import { reactive } from './reactivity';
const test = reactive({
a: 1,
});
const testArr = reactive([1, 2, 3]);
// 1
test.a; // a被读取了
test.a = 2; // a被设置了
// 2
test.b; // b被读取了
// 3
testArr[0]; // 0被读取了
// 4
testArr.pop(); // pop被读取了 length被读取了 2被读取了 length被设置了
复制代码
可以看到我们添加了一个reactive
方法用于将对象和数组进行proxy
拦截,并返回了对应的proxy
实例
列子中的1
2
3
都很好理解,我们来解释下第4个
我们调用pop
方法首先会触发get拦截,打印pop被读取了
然后调用pop
方法后会读取数组的长度触发get拦截,打印length被读取了
pop
方法的返回值是当前删除的值,会读取数组索引为2的值触发get拦截,打印2被读取了
pop
后数组长度会被改变,会触发set
拦截,打印length被设置了
大家也可以试试其他改变数组的方法
可以归纳为一句话
对数组的本身有长度影响的时候length
会被读取和重新设置,对应改变的值的索引也会被读取或重新设置(push
unshift
)
添加回调函数
我们通过了proxy
实现了对值的拦截解决了我们提出的第一个问题
但我们并没有在值的改变后触发回调函数,现在让我们来补充回调函数
reactivity/reactive.js
import { isPlainObject } from './utils';
// 本列只有数组和对象才能被观测
function canObserve(value) {
return Array.isArray(value) || isPlainObject(value);
}
+ // 假设的回调函数
+ function notice(key) {
+ console.log(`${key}被改变了并触发了回调函数`);
+ }
// 拦截数据
export function reactive(value) {
// 不能监听的数值直接返回
if (!canObserve(value)) {
return;
}
const observe = new Proxy(value, {
// 拦截读取
get(target, key, receiver) {
- console.log(`${key}被读取了`);
return Reflect.get(target, key, receiver);
},
// 拦截设置
set(target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver);
- console.log(`${key}被设置了`);
+ // 触发假设的回调函数
+ notice(key);
return res;
},
});
// 返回被观察的proxy实例
return observe;
}
复制代码
我么以最直观的方法在值被改变的set
拦截中触发了我们假设的回调
main.js
import { reactive } from './reactivity';
const test = reactive({
a: 1,
b: 2,
});
test.a = 2; // a被改变了并触发了回调函数
test.b = 3; // b被改变了并触发了回调函数
复制代码
可以看到当值改变的时候,输出了对应的日志
但这个列子肯定是有问题的,问题还不止一处,让我们一步一步来升级它
回调函数的收集
上面的列子中a
和b
都对应了一个回调函数notice
,可实际的场景中,a
和b
可能对应分别不同的回调函数,如果我们单单用一个简单的全局变量存储回调函数,很明显这是不合适的,如果有后者则会覆盖前者,那么怎么才能让回调函数和各个值之间对应呢?
很容易想到的就是js
中的key-value
的对象,属性a
和b
分别作为对象的key
值则可以区分各自的value
值
但用对象收集回调函数是有问题的
上列中我们有一个test
对象,它的属性有a
和b
,当我们存在另外一个对象test1
它要是也有a
和b
属性,那不是重复了吗,这又会触发我们之前说到的重复的问题
有同学可能会说,那再包一层用test
和test1
作为属性名不就好了,这种方法也是不可行的,在同一个执行上下文中不会出现两个相同的变量名,但不同执行上下文可以,这又导致了上面说到的重复的问题
处理这个问题要用到js
对象按引用传递的特点
// 1.js
const obj = {
a: 1,
};
// 2.js
const obj = {
a: 1,
};
复制代码
我们在两个文件夹定义了名字属性数据结构完全一样的对象obj
,但我们知道这两个obj
并不是相等的,因为它们的内存指向不同地址
所以如果我们能直接把对象作为key
值,那么是不是就可以区分看似"相同"的对象了呢?
答案肯定是可以的,不过我们得换种数据结构,因为js
中对象的key
值是不能为一个对象的
这里我们就要用到es6
新增的一种数据结构Map
和WeakMap
我们通过举例来说明这种数据结构的存储模式
假设现在我们有两个数据结构“相同”的对象obj
,它们都有各自的属性a
和b
,各个属性的改变会触发不同的回调函数
// 1.js
const obj = {
a: 1,
b: 2
};
// 2.js
const obj = {
a: 1,
b: 2
};
复制代码
用Map
和WeakMap
来存储就如下图所示
我们将存储回调函数的全局变量targetMap
定义为一个WeakMap
,它的key
值是各个对象,在本列中就是两个obj
,targetMap
的value
值是一个Map
,本列中两个obj
分别拥有两个属性a
和b
,Map
的key
就是属性a
和b
,Map
的value
就是属性a
和b
分别对应的Set
回调函数集合
可能大家会有疑问为什么targetMap
用WeakMap
而各个对象的属性存储用的Map
,这是因为WeakMap
只能以对象作为key
,Map
是对象或字符串都可以,像上面的列子属性a
和b
只能用Map
存
我们再以实际api
来加深对这种存储结构的理解
- computed
const c = computed(() => test.a)
复制代码
这里我们需要将() => test.a
回调函数放在test.a
的集合中,如图所示
- watch
watch(() => test.a, val => { console.log(val) })
复制代码
这里我们需要将val => { console.log(val) }
回调函数放在test.a
的集合中,如图所示
- template
createApp({
setup() {
return () => h('div', test.a);
},
});
复制代码
这里我们需要将dom
刷新的函数放在test.a
中,如图所示
上面我们已经知道了存储回调函数的方式,现在我们来思考如何将回调函数放到我们定义好的存储结构中
还是拿上面的列子举列
watch(() => test.a, val => { console.log(val) })
复制代码
这个列子中,我们需要将回调函数val => { console.log(val) })
放到test.a
的Set
集合中,所以我们需要拿到对象test
和当前对象的属性a
,如果仅通过() => test.a
,我们只能拿到test.a
的值,无法得知具体的对象和属性
但其实这里读取了test.a
的值,就变相的拿到了具体的对象和属性
大家还记得我们在前面用proxy
拦截了test.a
的读取吗,get
拦截的第一个参数就是当前读取的对象,第二个参数就是当前读取的属性
所以回调函数的收集是在proxy
的get
拦截中处理
现在让我们用代码实现刚刚理好的思路
首先我们创建effect.js
文件,这个文件用于存放回调函数的收集方法和回调函数的触发方法
reactivity/effect.js
// 回调函数集合
const targetMap = new WeakMap();
// 收集回调函数
export function track(target, key) {
}
// 触发回调函数
export function trigger(target, key) {
}
复制代码
然后改写proxy
中的拦截内容
reactivity/reactive.js
import { isPlainObject } from './utils';
+ import { track, trigger } from './effect';
// 本列只有数组和对象才能被观测
function canObserve(value) {
return Array.isArray(value) || isPlainObject(value);
}
- // 假设的回调函数
- function notice(key) {
- console.log(`${key}被改变了并触发了回调函数`);
- }
// 拦截数据
export function reactive(value) {
// 不能监听的数值直接返回
if (!canObserve(value)) {
return;
}
const observe = new Proxy(value, {
// 拦截读取
get(target, key, receiver) {
+ // 收集回调函数
+ track(target, key);
return Reflect.get(target, key, receiver);
},
// 拦截设置
set(target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver);
+ // 触发回调函数
+ trigger(target, key);
- // 触发假设的回调函数
- notice(key);
return res;
},
});
// 返回被观察的proxy实例
return observe;
}
复制代码
这里还没补充effect
中的内容是让大家可以清晰的看见收集和触发的位置
现在我们来补充track
收集回调函数和trigger
触发回调函数
reactivity/effect.js
// 回调函数集合
const targetMap = new WeakMap();
// 收集回调函数
export function track(target, key) {
// 通过对象获取每个对象的map
let depsMap = targetMap.get(target);
if (!depsMap) {
// 当对象被第一次收集时 我们需要添加一个map集合
targetMap.set(target, (depsMap = new Map()));
}
// 获取对象下各个属性的回调函数集合
let dep = depsMap.get(key);
if (!dep) {
// 当对象属性第一次收集时 我们需要添加一个set集合
depsMap.set(key, (dep = new Set()));
}
// 这里添加回调函数
dep.add(() => console.log('我是一个回调函数'));
}
// 触发回调函数
export function trigger(target, key) {
// 获取对象的map
const depsMap = targetMap.get(target);
if (depsMap) {
// 获取对应各个属性的回调函数集合
const deps = depsMap.get(key);
if (deps) {
// 触发回调函数
deps.forEach((v) => v());
}
}
}
复制代码
然后运行我们的demo
main.js
import { reactive } from './reactivity';
const test = reactive({
a: 1,
b: 2,
});
test.b; // 读取收集回调函数
setTimeout(() => {
test.a = 2; // 没有任何触发 因为没收集回调函数
test.b = 3; // 我是一个回调函数
}, 1000);
复制代码
我们来看看此时的targetMap
结构
targetMap
中存在key
值{ a: 1,b: 2 }
,它的value
值也是一个Map
,这个Map
中存在key
值b
,这个Map
的value
便是回调函数的集合Set
,现在就只有一个我们写死的() => console.log('我是一个回调函数')
用图形结构就是这样
大家可能觉得要收集回调函数要读取一次test.b
是反人类的操作,这是因为我们还没有讲到对应的api
,平常读取的操作不需要这么手动式的调用,api
会自己处理
watch
上面的列子存在一个很大的问题,就是我们没有自定义回调函数,回调函数在代码中直接被写死了
现在我们将通过watch
去实现自定义的回调函数
watch
在vue-next
中的api
还蛮多的,我们将实现其中一部分类型,这足以让我们理解响应式原理
我们将实现的demo
如下
export function watch(fn, cb, options) {}
const test = reactive({
a: 1,
});
watch(
() => test.a,
(val) => { console.log(val); }
);
复制代码
watch
接受三个参数
第一个参数是一个函数,表达被监听的值
第二个参数是一个函数,表达监听值改变后要触发的回调,第一个参数是改变后的值,第二个参数是改变前的值
第三个参数是一个对象,只有一个deep
属性,deep
表深度观察
现在我们需要做的就是把回调函数(val) => { console.log(val); }
放到test.a
的Set
集合中
所以在() => test.a
执行读取test.a
前,我们需要将回调函数用一个变量存储
当读取test.a
触发track
函数的时候,可以在track
函数中获取到这个变量,并将它存储到对应属性的集合Set
中
reactivity/effect.js
// 回调函数集合
const targetMap = new WeakMap();
+ // 当前激活的回调函数
+ export let activeEffect;
+ // 设置当前回调函数
+ export function setActiveEffect(effect) {
+ activeEffect = effect;
+ }
// 收集回调函数
export function track(target, key) {
// 没有激活的回调函数 直接退出不收集
if (!activeEffect) {
return;
}
// 通过对象获取每个对象的map
let depsMap = targetMap.get(target);
if (!depsMap) {
// 当对象被第一次收集时 我们需要添加一个map集合
targetMap.set(target, (depsMap = new Map()));
}
// 获取对象下各个属性的回调函数集合
let dep = depsMap.get(key);
if (!dep) {
// 当对象属性第一次收集时 我们需要添加一个set集合
depsMap.set(key, (dep = new Set()));
}
// 这里添加回调函数
- dep.add(() => console.log('我是一个回调函数'));
+ dep.add(activeEffect);
}
// 触发回调函数
export function trigger(target, key) {
// 省略
}
复制代码
因为watch
方法和track
、trigger
方法不在同一个文件,所以我们用export
导出变量activeEffect
,并提供了一个方法setActiveEffect
修改它
这也是一个不同模块下使用公共变量的方法
现在让我们创建watch.js
,并添加watch
方法
reactivity/watch.js
import { setActiveEffect } from './effect';
export function watch(fn, cb, options = {}) {
let oldValue;
// 在执行fn获取oldValue前先存储回调函数
setActiveEffect(() => {
// 确保回调函数触发 获取到的是新值
let newValue = fn();
// 触发回调函数
cb(newValue, oldValue);
// 新值赋值给旧值
oldValue = newValue;
});
// 读取值并收集回调函数
oldValue = fn();
// 置空回调函数
setActiveEffect('');
}
复制代码
很简单的几行代码,在执行fn
读取值前把回调函数通过setActiveEffect
设置以便在读取的时候track
函数中可以拿到当前的回调函数activeEffect
,读取完后再制空回调函数,就完成了
同样我们需要导出watch
方法
reactivity/index.js
export * from './reactive';
+ export * from './watch';
复制代码
main.js
import { reactive, watch } from './reactivity';
const test1 = reactive({
a: 1,
});
watch(
() => test1.a,
(val) => {
console.log(val) // 2;
}
);
test1.a = 2;
复制代码
可以看到列子正常执行打印出了2
,我们来看看targetMap
的结构
targetMap
存在一个key
值{a:1}
,它的value
值也是一个Map
,这个Map
中存在key
值a
,这个Map的value
便是回调函数(val) => { console.log(val); }
targetMap
的图形结构如下
computed
watch的其他api
补充我们将放到后面,在感受到响应式原理的思维后,我们趁热打铁再来实现computed
的功能
同样的computed
这个api
在vue-next
中也有多种写法,我们将只实现函数返回值的写法
export function computed(fn) {}
const test = reactive({
a: 1,
});
const w = computed(() => test.a + 1);
复制代码
但如果我们仅实现computed
传入函数的写法,其实在vue-next
中和响应式原理没多大关系
因为vue-next
中提供的api
读取值不是直接读取的w
而是w.value
我们创建computed.js
,补充computed
函数
reactivity/computed.js
export function computed(fn) {
return {
get value() {
return fn();
},
};
}
复制代码
可以看到就几行代码,每次读取value
重新运行一次fn
求值就行了
reactivity/index.js
我们再导出它
export * from './reactive';
export * from './watch';
+ export * from './computed';
复制代码
main.js
import { reactive, computed } from './reactivity';
const test = reactive({
a: 1,
});
const w = computed(() => test.a + 1);
console.log(w.value); // 2
test.a = 2;
console.log(w.value); // 3
复制代码
可以看到列子完美运行
这里带来了两个问题
- 为什么
api
的写法不是直接读取w
而是w.value
的形式
这个和为啥有ref
是一个道理,proxy
无法拦截基础类型,所以要加一层value
包装成对象
vue-next
中的computed
真的和响应式原理没关系了吗
其实有关系,在仅实现computed
传入函数的写法中,响应式原理启优化作用
可以看到如果按我们之前的写法,即便w.value
的值没有变化,我们读取的时候也会去执行一次fn
,当数据量多起来的时候,对性能的影响就大了
那我们怎么优化呢?
容易想到的就是执行一次fn
对比新老值,但这和之前其实就一样了,因为我们仍然执行了一次fn
这里我们就可以运用响应式原理,只要内部的影响值test.a
被修改了,我们就重新执行fn
获取一次值,不然就读取之前的存储的值
reactivity/computed.js
import { setActiveEffect } from './effect';
export function computed(fn) {
// 变量被改变后此值才会为true 第一次进来时候为true
let dirty = true;
// 返回值
let value;
// 设置为true表达下次读取需要重新获取
function changeDirty() {
dirty = true;
}
return {
get value() {
// 当标志为true代表变量需要更改
if (dirty) {
dirty = false;
// 将变量控制设置为
setActiveEffect(changeDirty);
// 获取值
value = fn();
// 制空依赖
setActiveEffect('');
}
return value;
},
};
}
复制代码
我们定义了一个变量dirty
用于表达这个值是否被修改过,修改过就为true
同样的,我们再每次读取值之前,将回调函数() => { dirty = true }
赋值给中间变量activeEffect
,然后再执行fn
读取,此时回调被收集,当对应的属性更改的时候,dirty
也就更改了
我们再运行上面的列子,程序仍然正常运行了
我们来看看targetMap
的结构,targetMap
存在一个key
值{a:1}
,它的value
值也是一个Map
,这个Map
中存在key
值a
,这个Map的value
便是回调函数function changeDirty() { dirty = true; }
targetMap
的图形结构如下
提取effect
在watch
和computed
中我们都经历过 设置回调函数=>读取值(存储回调函数)=>清空回调函数 这三个步骤
在vue-next
的源码中这个步骤被提取为了一个公用函数,为了符合vue-next
的设计我们将这个步骤提取出来,取名effect
函数的第一个参数是一个函数,函数执行后,会触发函数中各个变量的读取,并收集对应的回调函数
函数的第二个参数是一个对象
有一个schedular
属性,表达特殊指定的回调函数,如果没有这个属性,回调函数就是第一个参数
有一个lazy
属性,为true
时代表第一个参数传入的函数不用立即执行,默认为false
,即立即指定第一个参数传入的函数
reactivity/effect.js
// 回调函数集合
const targetMap = new WeakMap();
// 当前激活的回调函数
export let activeEffect;
- // 设置当前回调函数
- export function setActiveEffect(effect) {
- activeEffect = effect;
- }
+ // 设置当前回调函数
+ export function effect(fn, options = {}) {
+ const effectFn = () => {
+ // 设置当前激活的回调函数
+ activeEffect = effectFn;
+ // 执行fn收集回调函数
+ let val = fn();
+ // 制空回调函数
+ activeEffect = '';
+ return val;
+ };
+ // options配置
+ effectFn.options = options;
+ // 默认第一次执行函数
+ if (!options.lazy) {
+ effectFn();
+ }
+ return effectFn;
+ }
// 收集回调函数
export function track(target, key) {
// 省略
}
// 触发回调函数
export function trigger(target, key) {
// 获取对象的map
const depsMap = targetMap.get(target);
if (depsMap) {
// 获取对应各个属性的回调函数集合
const deps = depsMap.get(key);
if (deps) {
// 触发回调函数
- deps.forEach((v) => v());
+ deps.forEach((v) => {
+ // 特殊指定回调函数存放在了schedular中
+ if (v.options.schedular) {
+ v.options.schedular();
+ }
+ // 当没有特意指定回调函数则直接触发
+ else if (v) {
+ v();
+ }
+ });
}
}
}
复制代码
reactivity/index.js
导出effect
export * from './reactive';
export * from './watch';
export * from './computed';
+ export * from './effect';
复制代码
main.js
import { reactive, effect } from './reactivity';
const test = reactive({
a: 1,
});
effect(() => {
document.title = test.a;
});
setTimeout(() => {
test.a = 2;
}, 1000);
复制代码
effect
第一次自执行,将() => { document.title = test.a; }
这个回调函数放入了test.a
中,当test.a
改变,触发对应回调函数
targetMap
如图所示
图形结构如图所示
同样我们更改computed
和watch
中的写法,用effect
替代
reactivity/computed.js
import { effect } from './effect';
export function computed(fn) {
// 变量被改变后此值才会为true 第一次进来时候为true
let dirty = true;
let value;
const runner = effect(fn, {
schedular: () => {
dirty = true;
},
// 第一次不用执行
lazy: true,
});
// 返回值
return {
get value() {
// 当标志为true代表变量需要更改
if (dirty) {
value = runner();
// 制空依赖
dirty = false;
}
return value;
},
};
}
复制代码
reactivity/watch.js
import { effect } from './effect';
export function watch(fn, cb, options = {}) {
let oldValue;
const runner = effect(fn, {
schedular: () => {
// 当这个依赖执行的时候 获取到的是新值
let newValue = fn();
// 触发回调函数
cb(newValue, oldValue);
// 新值赋值给旧值
oldValue = newValue;
},
// 第一次不用执行
lazy: true,
});
// 读取值并收集依赖
oldValue = runner();
}
复制代码
main.js
import { reactive, watch, computed } from './reactivity';
const test = reactive({
a: 1,
});
const w = computed(() => test.a + 1);
watch(
() => test.a,
(val) => {
console.log(val); // 2
}
);
console.log(w.value); // 2
test.a = 2;
console.log(w.value); // 3
复制代码
可以看到代码正常执行,targetMap
如图所示,属性a
中存放了两个回调函数
targetMap
图形结构如图所示
补充watch的options
我们来看看这个列子
import { watch, reactive } from './reactivity';
const test = reactive({
a: {
b: 1,
},
});
watch(
() => test.a,
(val) => {
console.log(val); // 没有触发
}
);
test.a.b = 2;
复制代码
我们用watch
观察了test.a
,当我们去改变test.a.b
的时候,观察的回调并没有触发,用过vue
的同学都会知道,这种情况应该用deep
属性就可以解决
那么deep
是如何实现的呢
我们再来回忆一下回调函数收集的过程
test.a
被读取时,回调函数被收集进了test.a
中,但这里test.a.b
并没有被读取,所以回调函数自然就没有被收集进test.a.b
所以我们只用在回调函数收集的时候,深度遍历一下test
,去读取一下各个属性即可
这里还需要注意一点,我们用reactive
拦截对象的时候,是不会拦截对象的第二层的
const test = {
a: {
b: 1,
},
};
const observe = new Proxy(test, {
get(target, key, receiver) {
return Reflect.set(target, key, receiver);
},
});
test.a // 触发拦截
test.a.b // 不会触发拦截
复制代码
所以我们需要递归的将拦截值用proxy
代理
reactivity/reactive.js
const observe = new Proxy(value, {
// 拦截读取
get(target, key, receiver) {
// 收集回调函数
track(target, key);
+ const res = Reflect.get(target, key, receiver);
+ return canObserve(res) ? reactive(res) : res;
- return Reflect.get(target, key, receiver);
},
// 拦截设置
set(target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver);
// 触发回调函数
trigger(target, key);
return res;
},
});
复制代码
reactivity/watch.js
import { effect } from './effect';
+ import { isPlainObject } from './utils';
+ // 深度遍历值
+ function traverse(value) {
+ if (isPlainObject(value)) {
+ for (const key in value) {
+ traverse(value[key]);
+ }
+ }
+ return value
+ }
export function watch(fn, cb, options = {}) {
+ let oldValue;
+ let getters = fn;
+ // 当存在deep属性的时候 深度遍历值
+ if (options.deep) {
+ getters = () => traverse(fn());
+ }
+ const runner = effect(getters, {
- const runner = effect(fn, {
schedular: () => {
// 当这个依赖执行的时候 获取到的是新值
let newValue = runner();
// 触发回调函数
cb(newValue, oldValue);
// 新值赋值给旧值
oldValue = newValue;
},
// 第一次不用执行
lazy: true,
});
// 读取值并收集回调函数
oldValue = runner();
}
复制代码
main.js
import { watch, reactive } from './reactivity';
const test = reactive({
a: {
b: 1,
},
});
watch(
() => test.a,
(val) => {
console.log(val); // { b: 2 }
},
{
deep: true,
}
);
test.a.b = 2;
复制代码
targetMap
如下,我们不仅在对象{ a: { b: 1 } }
上添加了回到函数,也在{ b: 1 }
上添加了
targetMap
图形结构如图所示
可以看到加入deep
属性后便可深度观察数据了,上面的列子中我们都是用的对象,其实深度观察对数组也是需要的,不过数组的处理有一点不同我们来看看不同点
数组的处理
import { watch, reactive } from './reactivity';
const test = reactive([1, 2, 3]);
watch(
() => test,
(val) => {
console.log(val); // 没有触发
}
);
test[0] = 2;
复制代码
上面的列子是不会触发的,因为我们只读取了test
,targetMap
里面啥也没有
所以在数组的情况下,我们也属于deep
深度观察范畴,深度遍历的时候,需要读取数组的每一项
reactivity/watch.js
// 深度遍历值
function traverse(value) {
// 处理对象
if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key]);
}
}
+ // 处理数组
+ else if (Array.isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ traverse(value[i]);
+ }
+ }
return value;
}
复制代码
main.js
import { watch, reactive } from './reactivity';
const test = reactive([1, 2, 3]);
watch(
() => test,
(val) => {
console.log(val); // [2, 2, 3]
},
{
deep: true
}
);
test[0] = 2;
复制代码
在上面的列子中添加deep
为true
可以看见回调触发了
targetMap
如图所示
第一项Set
是一个Symbol(Symbol.toStringTag)
,我们不用管它
我们将数组的每一项都进行了回调函数的储存,且也在数组的length
属性上也进行了存储
我们再来看一个列子
import { watch, reactive } from './reactivity';
const test = reactive([1, 2, 3]);
watch(
() => test,
(val) => {
console.log(val); // 没有触发
},
{
deep: true,
}
);
test[3] = 4;
复制代码
上面的列子不会触发,细心的同学可能记得,我们targetMap
里面只收集了索引为0
1
2
的三个位置,新增的索引为3
的并没有收集
我们应该如何处理这种临界的情况呢?
大家还记得我们最初讲到的在proxy
下数组pop
方法的解析吗,当时我们归纳为了一句话
对数组的本身有长度影响的时候length
会被读取和重新设置
现在我们通过索引新增值其实也是改变了数组本身的长度,所以length
会被重新设置,现在就有方法了,我们在新增索引上找不到回调函数的时候,我们可以去读取数组length
上存储的回调函数
reactivity/reactive.js
const observe = new Proxy(value, {
// 拦截读取
get(target, key, receiver) {
// 收集回调函数
track(target, key);
const res = Reflect.get(target, key, receiver);
return canObserve(res) ? reactive(res) : res;
},
// 拦截设置
set(target, key, newValue, receiver) {
+ const hasOwn = target.hasOwnProperty(key);
+ const oldValue = Reflect.get(target, key, receiver);
const res = Reflect.set(target, key, newValue, receiver);
+ if (hasOwn) {
+ // 设置之前的属性
+ trigger(target, key, 'set');
+ } else if (oldValue !== newValue) {
+ // 添加新的属性
+ trigger(target, key, 'add');
+ }
- // 触发回调函数
- trigger(target, key);
return res;
},
});
复制代码
我们用hasOwnProperty
判断当前属性是否在对象上,对于数组的新增索引很明显是不在的,此时会走到trigger(target, key, 'add');
这个函数
reactivity/effect.js
// 触发回调函数
export function trigger(target, key, type) {
// 获取对象的map
const depsMap = targetMap.get(target);
if (depsMap) {
// 获取对应各个属性的回调函数集合
- const deps = depsMap.get(key);
+ let deps = depsMap.get(key);
+ // 当数组新增属性的时候 直接获取length上存储的回调函数
+ if (type === 'add' && Array.isArray(target)) {
+ deps = depsMap.get('length');
+ }
if (deps) {
// 触发回调函数
deps.forEach((v) => {
// 特殊指定回调函数存放在了schedular中
if (v.options.schedular) {
v.options.schedular();
}
// 当没有特意指定回调函数则直接触发
else if (v) {
v();
}
});
}
}
}
复制代码
然后我们处理type
为add
的情况,当type
是add
且对象为数组的时候,我们便去读取length
上存储的回调函数
可以看到这么一改写,列子就可以正常运行了
总结
1.其实读完本文后,你会发现本文不是一篇vue
源码解剖,我们全程没有贴出vue-next
中对应的源码,因为我觉得从零开始的思路去思考如何实现会比从源码解读去思考为什么这么实现会好点
2.当然本文也只实现了简易的响应式原理,如果你想查看完整的代码可以点击这里,虽然很多功能点也没实现,但大体思路都是一致的,如果你能读懂本问讲解的思路,你肯定能看懂vue-next
中对应的源码
3.最近很多人私信我问前端问题,博客登陆的少没及时回复,为此我建了个前端扣扣裙 519293536 大家以后有问题直接群里找我。都会尽力帮大家,博客私信我很少看
本文的文字及图片来源于网络加上自己的想法,仅供学习、交流使用,不具有任何商业用途,版权归原作者所有,如有问题请及时联系我们以作处理