前言
vue3 beta
版本已经发布快两个月了,相信大家或多或少都有去了解一些vue3
的新特性,也有一部分人调侃学不动了,在我看来,技术肯定是不断更迭的,新的技术出现能够提高生产力,落后的技术肯定是要被淘汰的,五年前会JQ一把梭就能找到一份还行的工作,现在只会JQ应该很少公司会要了吧。刚好前两天尤大也发了一篇文章讲述了vue3
的制作历程,有兴趣的同学可以点击链接前往查看,文章是全英文的,英文不是很好的同学可以借助翻译插件阅读。好了,废话不多说,本篇的主题是手写vue3的响应式功能。
vue3的代码实例
在写代码前,不妨来看看如何使用vue3吧,我们可以先去 github.com/vuejs/vue-n… clone一份代码,使用npm install && npm run dev后,会生成一个packages -> vue -> dist -> vue.global.js文件,这样我们就可以使用vue3了,在vue文件夹新建一个index.html文件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue3示例</title>
</head>
<body>
<div id="app"></div>
<button id="btn">按钮</button>
<script src="./dist/vue.global.js"></script>
<script>
const { reactive, computed, watchEffect } = Vue;
const app = document.querySelector('#app');
const btn = document.querySelector('#btn');
const year = new Date().getFullYear();
let person = reactive({
name: '烟花渲染离别',
age: 23
});
let birthYear = computed(() => year - person.age);
watchEffect(() => {
app.innerHTML = `<div>我叫${person.name},今年${person.age}岁,出生年是${birthYear.value}</div>`;
});
btn.addEventListener('click', () => {
person.age += 1;
});
</script>
</body>
</html>
可以看到,我们每次点击一次按钮,触发person.age += 1;
,然后watchEffect
自动执行,计算属性也相应更新,现在我们的目标就很明确了,就是实现reactive
、watchEffect
、computed
方法。
reactive方法
我们知道vue3
是基于proxy
来实现响应式的,对proxy
不熟悉的可以去看看阮一峰老师的es6教程:es6.ruanyifeng.com/#docs/proxy
reflect
也是es6
新提供的API,具体作用也可以参考阮一峰老师的es6教程:es6.ruanyifeng.com/#docs/refle… ,简单来说他提供了一个操作对象的新API
,将Object对象属于语言内部的方法放到Reflect
对象上,将老Object方法报错的情况改成返回false
值。
下面我们来看看具体的代码吧,它对对象的get
、set
、del
操作进行了代理。
function isObject(target) {
return typeof target === 'object' && target !== null;
}
function reactive() {
// 判断是否对象,proxy只对对象进行代理
if (!isObject(target)) {
return target;
}
const baseHandler = {
set(target, key, value, receiver) { // receiver:它总是指向原始的读操作所在的那个对象,一般情况下就是 Proxy 实例
trigger(); // 触发视图更新
return Reflect.set(target, key, value, receiver);
},
get(target, key, receiver) {
return Reflect.get(target, key, value, receiver);
},
del(target, key) {
return Reflect.deleteProperty(target, key);
}
};
let observed = new Proxy(target, baseHandler);
return observed;
}
添加更新限制
上面的代码看上去好像没啥问题,但是在代理数组的时候,添加、删除数组的元素,除了能监听到数组本身要设置的元素变化,还会监听到数组长度length
属性修改的变化,如下图:
所以我们应该只在新增属性的时候去触发更新,我们添加hasOwnProperty
判断与老值和新值比较判断,只有修改自身对象的属性或者修改了自身属性并且值不同的时候才去更新视图。
set(target, key, value, receiver) {
const oldValue = target[key];
if (!target.hasOwnProperty(key) || oldValue !== value) { // 新增属性或者设置属性老值不等于新值
trigger(target, key); // 触发视图更新函数
}
return Reflect.set(target, key, value, receiver);
}
深层级对象监听
上面我们只对对象进行了一层代理,如果对象的属性对应的值还是对象的话,它并没有被代理过,此时我们去操作该对象的时候,就不会触发set
,也就不会更新视图了。如下图:
那么我们应该怎么进行深层次的代理呢?
我们观察一下person.hair.push(4)
这个操作,当我们去取person.hair
的时候,会去调用person
的get
方法,拿到属性hair
的值,那么我们就可以再它拿到值之后判断是否是对象,再去进行深层次的监听。
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? reactive(res) : res;
},
缓存已代理对象
代理过的对象再去执行reactive
方法的时候,会去重新设置代理,我们应该避免这种情况,通过hashmap
缓存代理过的对象,这样在再次代理的时候,判断对象存在hashmap
中,直接返回该结果即可。
- 进行多次代理示例
let obj = {
name: '烟花渲染离别',
age: 23,
hair: [1,2,3]
}
let person = reactive(obj);
person = reactive(obj);
person = reactive(obj);
- 定义
hashmap
缓存代理对象
我们使用WeakMap
缓存代理对象,它是一个弱引用对象,不会导致内存泄露。 es6.ruanyifeng.com/#docs/set-m…
const toProxy = new WeakMap(); // 代理后的对象
const toRaw = new WeakMap(); // 代理前的对象
function reactive(target) {
// 判断是否对象,proxy只对对象进行代理
if (!isObject(target)) {
return target;
}
let proxy = toProxy.get(target); // 当前对象在代理表中,直接返回该对象
if (proxy) {
return proxy;
}
if (toRaw.has(target)) { // 当前对象是代理过的对象
return target;
}
let observed = new Proxy(target, baseHandler);
toProxy.set(target, observed);
toRaw.set(observed, target);
return observed;
}
let obj = {
name: '烟花渲染离别',
age: 23,
hair: [1,2,3]
}
let person = reactive(obj);
person = reactive(obj); // 再去代理的时候返回的就是从缓存中取到的数据了
这样reactive
方法就基本已经实现完了。
收集依赖,自动更新
我们先来瞅瞅之前是怎么渲染DOM的。
watchEffect(() => {
app.innerHTML = `<div>我叫${person.name},今年${person.age}岁,出生年是${birthYear.value}</div>`;
});
在初始化默认执行一次watchEffect
函数后,渲染DOM数据,之后依赖的数据发生变化,会自动再次执行,也就会自动更新我们的DOM内容了,这就是我们常说的收集依赖,响应式更新。
那么我们在哪里进行依赖收集,什么时候通知依赖更新呢?
- 我们在用到数据进行展示的时候,它就会触发我们创建好的
proxy
对象的get
方法,这个时候我们就可以收集依赖了。 - 在数据发生变化的时候同样会触发我们的
set
方法,我们在set
中通知依赖更新。 这其实是一种设计模式叫做发布订阅。
我们在get
中收集依赖:
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key); // 收集依赖,如果目标上的key变化,执行栈中的effect
return isObject(res) ? reactive(res) : res;
}
在set
中通知依赖更新:
set(target, key, value, receiver) {
if (target.hasOwnProperty(key)) {
trigger(target, key); // 触发更新
}
return Reflect.set(target, key, value, receiver);
}
可以看到我们在get
中执行了一个track
方法进行收集依赖,在set
中执行trigger
触发更新,这样我们知道了它的过程后,再来看看怎么实现watchEffect
方法。
watchEffect方法
我们传入到watchEffect
方法里的函数就是我们要收集的依赖,我们将收集到的依赖用栈保存起来,栈是一种先进后出的数据结构,具体我们看看下面代码实现:
let effectStack = []; // 存储依赖数据effect
function watchEffect(fn, options = {}) {
// 创建一个响应式的影响函数,往effectsStack push一个effect函数,执行fn
const effect = createReactiveEffect(fn, options);
return effect;
}
function createReactiveEffect(fn) {
const effect = function() {
if (!effectsStack.includes(effect)) { // 判断栈中是否已经有过该effecy,防止重复添加
try {
effectsStack.push(effect); // 将当前的effect推入栈中
return fn(); // 执行fn
} finally {
effectsStack.pop(effect); // 避免fn执行报错,在finally里执行,将当前effect出栈
}
}
}
effect(); // 默认执行一次
}
关联effect和对应对象属性
上面我们只是收集了fn
存到effectsStack
中,但是我们还没将fn
和对应的对象属性关联,下面步我们要实现track
方法,将effect
和对应的属性关联。
let targetsMap = new WeakMap();
function track(target, key) { // 如果taeget中的key发生改变,执行栈中的effect方法
const effect = effectsStack[effectsStack.length - 1];
// 最新的effect,有才创建关联
if (effect) {
let depsMap = targetsMap.get(target);
if (!depsMap) { // 第一次渲染没有,设置对应的匹配值
targetsMap.set(target, depsMap = new Map());
}
let deps = depsMap.get(key);
if (!deps) { // 第一次渲染没有,设置对应的匹配值
depsMap.set(key, deps = new Set());
}
if (!deps.has(effect)) {
deps.add(effect); // 将effect添加到当前的targetsMap对应的target的存放的depsMap里key对应的deps
}
}
}
function trigger(target, key, type) {
// 触发更新,找到依赖effect
let depsMap = targetsMap.get(target);
if (depsMap) {
let deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => {
effect();
});
}
}
}
targetsMap
的数据结构较为复杂,它是一个WeakMap
对象,targetsMap
的key
就是我们target
对象,在targetsMap
中该target
对应的值是一个Map
对象,该Map
对象的key
是target
对象的属性,Map
对象对应的key
的值是一个Set
数据结构,存放了当前该target.key
对应的effect
依赖。看下面的代码可能会比较清晰点:
let person = reactive({
name: '烟花渲染离别',
});
targetsMap = {
person: {
'name': [effect]
}
}
// {
// target: {
// key: [dep1, dep2]
// }
// }
执行流程
- 收集流程:执行
watchEffect
方法,将fn
也就是effect
push到effectStack
栈中,执行fn
,如果fn
中有用到reactive
代理过的对象,此时会触发该代理对象的get
方法,而我们在get
方法中使用了track
方法收集依赖,track
方法首先从effectStack
中取出最后一个effect
,也就是我们刚刚push到栈中的effect
,然后判断它是否存在,如果存在的话,我们从targetMap
取出对应的target
的depsMap
,如果depsMap
不存在,我们手动将当前的target
作为key
,depsMap = new Map()
作为值设置到targetMap
中,然后我们再从depsMap
中取出当前代理对象key
对应的依赖deps
,如果不存在则存放一个新Set
进去,然后将对应的effect
添加到该deps
中。 - 更新流程:修改代理后的对象,触发
set
方法,执行trigger
方法,通过传入的target
在targetsMap
中找到depsMap
,通过key
在depsMap
中找到对应的deps
,循环执行里面保存的effect
。
computed方法
写computed
之前我们也来回顾下它的用法:
let person = reactive({
name: '烟花渲染离别',
age: 23
});
let birthYear = computed(() => 2020 - person.age);
person.age += 1;
可以看到computed
接受一个函数,然后返回一个经过处理后的值,在依赖的数据发生了修改后,computed
也会重新计算一次。
实际computed
它也是一个watchEffect
函数,不过它比较特殊,这里在调用watchEffect
时候传入了两个参数,一个是computed
的fn
,还有一个就是我们要给watchEffect
的参数{ lazy: true, computed: true }
,我们之前写watchEffect
的时候并没有对这些参数进行处理,所以现在我们还得进行处理。
function computed(fn) {
let computedValue;
const computedEffect = watchEffect(fn, {
lazy: true,
computed: true
});
return {
effect: computedEffect,
get value() {
computedValue = computedEffect();
trackChildRun(computedEffect);
return computedValue;
}
}
}
function trackChildRun(childEffect) {
if (!effectsStack.length) return;
const effect = effectsStack[effectsStack.length - 1];
for (let i = 0; i < childEffect.deps.length; i++) {
const dep = childEffect.deps[i];
if (!dep.has(effect)) {
dep.add(effect);
effect.deps.push(dep);
}
}
}
修改watchEffect
方法,接收一个opstion
参数并且添加lazy
属性判断,当lazy
为true
时不立即执行传入的函数,因为computed
方法是不会立即执行的。
function watchEffect(fn, options = {}) {
// 创建一个响应式的影响函数,往effectsStack push一个effect函数,执行fn
const effect = createReactiveEffect(fn, options);
// start: 添加的代码
if (!options.lazy) {
effect()
}
// end: 添加的代码
return effect;
}
修改createReactiveEffect
方法,添加options
参数,并且给当前的effect
添加deps
用于收集被计算的属性的依赖,在本文的实例中就是age
属性的依赖集合,保存computed
、lazy
属性。
function createReactiveEffect(fn, options) {
const effect = function() {
// 判断栈中是否已经有过该effect,避免递归循环重复添加,比如在监听函数中修改依赖数据
if (!effectsStack.includes(effect)) {
try {
effectsStack.push(effect); // 将当前的effect推入栈中
return fn(); // 执行fn
} finally {
effectsStack.pop(effect); // 避免fn执行报错,在finally里执行,将当前effect出栈
}
}
}
// start: 添加的代码
effect.deps = [];
effect.computed = options.computed;
effect.lazy = options.lazy;
// end: 添加的代码
return effect;
}
在track
方法将收集到的属性依赖集合添加到effect
的deps
。
function track(target, key) { // 如果taeget中的key发生改变,执行栈中的effect方法
const effect = effectsStack[effectsStack.length - 1];
// 最新的effect,有才创建关联
if (effect) {
let depsMap = targetsMap.get(target);
if (!depsMap) { // 第一次渲染没有,设置对应的匹配值
targetsMap.set(target, depsMap = new Map());
}
let deps = depsMap.get(key);
if (!deps) { // 第一次渲染没有,设置对应的匹配值
depsMap.set(key, deps = new Set());
}
if (!deps.has(effect)) {
deps.add(effect);
// start: 添加的代码
effect.deps.push(deps); // 将属性的依赖集合挂载到effect
// end: 添加的代码
}
}
}
在trigger
方法通过之前保存在effect
的computed
属性区分是computed
函数还是普通的函数,然后分别保存起来,然后先执行普通的effect
函数,在执行computed
函数。
function trigger(target, key, type) {
// 触发更新,找到依赖effect
let depsMap = targetsMap.get(target);
if (depsMap) {
let effects = new Set();
let computedRunners = new Set();
let deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => {
if (effect.computed) {
computedRunners.add(effect);
} else {
effects.add(effect);
}
});
}
if ((type === 'ADD' || type === 'DELETE') && Array.isArray(target)) {
const iterationKey = 'length';
const deps = depsMap.get(iterationKey);
if (deps) {
deps.forEach(effect => {
effects.add(effect);
});
}
}
computedRunners.forEach(computed => computed());
effects.forEach(effect => effect());
}
}
总结computed
执行流程
我们来根据下面的代码来分析执行流程。
const value = reactive({ count: 0 });
const cValue = computed(() => value.count + 1);
let dummy;
watchEffect(() => {
dummy = cValue.value;
console.log(dummy)
});
value.count = 1;
第一步:先将count
对象转换成响应式的对象。
第二步:执行computed
方法,computed
内部会执行watchEffect
,并且传入lazy
、computed
属性,由于传入了lazy
为true
,所以并不会立即执行生成的effect
,为了区分,下面统称这个effect
为计算effect
,将传入的fn
称为计算fn
,也就是不会往栈中添加数据,此时cValue
保存的是一个包含计算effect
和get
方法的对象。
第三步:执行watchEffect
方法:这也是最关键的一步,执行watchEffect
方法,由于没有带lazy
属性,所以此时会立刻执行effect
方法,往effectsStack
中添加当前的effect
,然后执行fn
。
第四步:执行fn
,执行fn
中会去获取cValue
的值,此时触发了computed
的get
方法,然后执行第二步保存的计算effect
。
第五步:执行计算effect
,将计算effect
添加到effectsStack
中(此时的effectsStack
为[普通effect, 计算effect]
),然后执行计算fn
。
第五步:执行计算fn
,计算fn
依赖了响应式对象value
,此时读取value
的count
属性,触发value
对象的get
方法,get
方法中执行track
方法收集依赖。
第六步:执行track
方法,拿到栈中最后一个元素也就是计算effect
,初始化targetsMap
和depsMap
,然后将计算effect
保存到count
对应的deps
中,同时也将deps
保存到计算effect
的deps
中,下一步要用,这样就形成了一个双向收集的关系,计算effect
保存了count
的所有依赖,count
也存了计算effect
的依赖,track
方法执行完执行下一步,返回获取到的value.count
的值,存到computedValue
中,然后我们继续往下执行。
第六步:执行trackChildRun
,计算fn
执行完则将计算effect
从栈中推出,此时effectsStack
的栈顶为普通effect
,首先我们在trackChildRun
中拿到栈尾元素也就是剩下的普通effect
,然后循环传入的计算effect
的deps
数据,我们在上一步执行track
的时候,在计算effect
的deps
中保存了count
属性对应的依赖集合,此时的deps
中只有一个元素[计算effect]
,现在将普通effect
也添加到dep
中,所以此时depsMap
为{ count: [计算effect, 普通effect] }
。
第七步:执行value.count = 1
,触发set
方法,执行trigger
方法,获取到count
对应的deps
也就是[计算effect, 普通effect]
,循环deps
分别存储普通的effect
和计算effect
,然后先后执行计算effect
和普通effect
。
致谢
感谢小伙伴们看到了这里,觉得本文写的不错的点个赞再走呗。(^o^)/~