阅读 1294
手写简易版vue-next响应式原理

手写简易版vue-next响应式原理

前言

本文将从零开始手写一份vue-next中的响应式原理,出于篇幅和理解的难易程度,我们将只实现核心的api并忽略一些边界的功能点

本文将实现的api包括

  • track
  • trigger
  • effect
  • reactive
  • watch
  • computed

项目搭建

我们采用最近较火的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的四个痛点

  1. 无法拦截在对象上属性的新增和删除
  2. 无法拦截在数组上调用push pop shift unshift等对当前数组会产生影响的方法
  3. 拦截数组索引过大的性能开销
  4. 无法拦截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被改变了并触发了回调函数
复制代码

可以看到当值改变的时候,输出了对应的日志

但这个列子肯定是有问题的,问题还不止一处,让我们一步一步来升级它

回调函数的收集

上面的列子中ab都对应了一个回调函数notice,可实际的场景中,ab可能对应分别不同的回调函数,如果我们单单用一个简单的全局变量存储回调函数,很明显这是不合适的,如果有后者则会覆盖前者,那么怎么才能让回调函数和各个值之间对应呢?

很容易想到的就是js中的key-value的对象,属性ab分别作为对象的key值则可以区分各自的value

但用对象收集回调函数是有问题的

上列中我们有一个test对象,它的属性有ab,当我们存在另外一个对象test1它要是也有ab属性,那不是重复了吗,这又会触发我们之前说到的重复的问题

有同学可能会说,那再包一层用testtest1作为属性名不就好了,这种方法也是不可行的,在同一个执行上下文中不会出现两个相同的变量名,但不同执行上下文可以,这又导致了上面说到的重复的问题

处理这个问题要用到js对象按引用传递的特点

// 1.js
const obj = {
  a: 1,
};

// 2.js
const obj = {
  a: 1,
};
复制代码

我们在两个文件夹定义了名字属性数据结构完全一样的对象obj,但我们知道这两个obj并不是相等的,因为它们的内存指向不同地址

所以如果我们能直接把对象作为key值,那么是不是就可以区分看似"相同"的对象了呢?

答案肯定是可以的,不过我们得换种数据结构,因为js中对象的key值是不能为一个对象的

这里我们就要用到es6新增的一种数据结构MapWeakMap

我们通过举例来说明这种数据结构的存储模式

假设现在我们有两个数据结构“相同”的对象obj,它们都有各自的属性ab,各个属性的改变会触发不同的回调函数

// 1.js
const obj = {
  a: 1,
  b: 2
};

// 2.js
const obj = {
  a: 1,
  b: 2
};
复制代码

MapWeakMap来存储就如下图所示

我们将存储回调函数的全局变量targetMap定义为一个WeakMap,它的key值是各个对象,在本列中就是两个obj,targetMapvalue值是一个Map,本列中两个obj分别拥有两个属性ab,Mapkey就是属性ab,Mapvalue就是属性ab分别对应的Set回调函数集合

可能大家会有疑问为什么targetMapWeakMap而各个对象的属性存储用的Map,这是因为WeakMap只能以对象作为key,Map是对象或字符串都可以,像上面的列子属性ab只能用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.aSet集合中,所以我们需要拿到对象test和当前对象的属性a,如果仅通过() => test.a,我们只能拿到test.a的值,无法得知具体的对象和属性

但其实这里读取了test.a的值,就变相的拿到了具体的对象和属性

大家还记得我们在前面用proxy拦截了test.a的读取吗,get拦截的第一个参数就是当前读取的对象,第二个参数就是当前读取的属性

所以回调函数的收集是在proxyget拦截中处理

现在让我们用代码实现刚刚理好的思路

首先我们创建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中存在keyb,这个Mapvalue便是回调函数的集合Set,现在就只有一个我们写死的() => console.log('我是一个回调函数')

用图形结构就是这样

大家可能觉得要收集回调函数要读取一次test.b是反人类的操作,这是因为我们还没有讲到对应的api,平常读取的操作不需要这么手动式的调用,api会自己处理

watch

上面的列子存在一个很大的问题,就是我们没有自定义回调函数,回调函数在代码中直接被写死了

现在我们将通过watch去实现自定义的回调函数

watchvue-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.aSet集合中

所以在() => 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方法和tracktrigger方法不在同一个文件,所以我们用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中存在keya,这个Map的value便是回调函数(val) => { console.log(val); }

targetMap的图形结构如下

computed

watch的其他api补充我们将放到后面,在感受到响应式原理的思维后,我们趁热打铁再来实现computed的功能

同样的computed这个apivue-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中存在keya,这个Map的value便是回调函数function changeDirty() { dirty = true; }

targetMap的图形结构如下

提取effect

watchcomputed中我们都经历过 设置回调函数=>读取值(存储回调函数)=>清空回调函数 这三个步骤

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如图所示

图形结构如图所示

同样我们更改computedwatch中的写法,用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;
复制代码

在上面的列子中添加deeptrue可以看见回调触发了

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();
        }
      });
    }
  }
}
复制代码

然后我们处理typeadd的情况,当typeadd且对象为数组的时候,我们便去读取length上存储的回调函数

可以看到这么一改写,列子就可以正常运行了

总结

其实读完本文后,你会发现本文不是一篇vue源码解剖,我们全程没有贴出vue-next中对应的源码,因为我觉得从零开始的思路去思考如何实现会比从源码解读去思考为什么这么实现会好点

当然本文也只实现了简易的响应式原理,如果你想查看完整的代码可以点击这里,虽然很多功能点也没实现,但大体思路都是一致的,如果你能读懂本问讲解的思路,你肯定能看懂vue-next中对应的源码

文章分类
前端
文章标签