如何手写Vue-next响应式呢?本文详解

前言

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

本文将实现的api包括前端

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

    2.最近不少人私信我问前端问题,博客登录的少没及时回复,为此我建了个前端扣扣裙 519293536  你们之后有问题直接群里找我。都会尽力帮你们,博客私信我不多看

项目搭建

咱们采用最近较火的vite建立项目vue

本文演示的版本java

  • node v12.16.1
  • npm v6.14.5
  • yarn v1.22.4

咱们首先下载模板node

yarn create vite-app vue-next-reactivity
复制代码

模板下载好后进入目录react

cd vue-next-reactivity 复制代码

而后安装依赖git

yarn install
复制代码

而后咱们仅保留src目录下的main.js文件,清空其他文件并建立咱们要用到的reactivity文件夹es6

 

 

整个文件目录如图所示,输入npm run dev项目便启动了github

 

 

手写代码

响应式原理的本质

在开始手写前,咱们思考一下什么是响应式原理呢?web

咱们从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上存储的回调函数

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

总结

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

2.固然本文也只实现了简易的响应式原理,若是你想查看完整的代码能够点击这里,虽然不少功能点也没实现,但大致思路都是一致的,若是你能读懂本问讲解的思路,你确定能看懂vue-next中对应的源码
3.最近不少人私信我问前端问题,博客登录的少没及时回复,为此我建了个前端扣扣裙 519293536  你们之后有问题直接群里找我。都会尽力帮你们,博客私信我不多看

本文的文字及图片来源于网络加上本身的想法,仅供学习、交流使用,不具备任何商业用途,版权归原做者全部,若有问题请及时联系咱们以做处理

相关文章
相关标签/搜索