一文带你深入剖析vue3的响应式

常见问题

一文带你深入剖析vue3的响应式

2023-03-28 14:30


本篇文章带你深度剖析vue3响应式(附脑图),本文的目标是实现一个基本的vue3的响应式,包含最基础的情况的处理。

                                            

本篇文章带你深度剖析vue3响应式(附脑图),本文的目标是实现一个基本的vue3的响应式,包含最基础的情况的处理。

本文你将学到

  • 一个基础的响应式实现 ✅
  • Proxy ✅
  • Reflect ✅
  • 嵌套effect的实现 ✅
  • computed ✅
  • watch ✅
  • 浅响应与深响应 ✅
  • 浅只读与深只读 ✅
  • 处理数组长度 ✅
  • ref ✅
  • toRefs ✅

1.png

一. 实现一个完善的响应式

所谓的响应式数据的概念,其实最主要的目的就是为数据绑定执行函数,当数据发生变动的时候,再次触发函数的执行。(学习视频分享:vue视频教程)

例如我们有一个对象data,我们想让它变成一个响应式数据,当data的数据发生变化时,自动执行effect函数,使nextVal变量的值也进行变化:

// 定义一个对象
let data = {
  name: 'pino',
  age: 18
}

let nextVal
// 待绑定函数
function effect() {
  nextVal = data.age + 1
}

data.age++

上面的例子中我们将data中的age的值进行变化,但是effect函数并没有执行,因为现在effect函数与data这个对象不能说是没啥联系,简直就是半毛钱的关系都没有。

那么怎么才能使这两个毫不相关的函数与对象之间产生关联呢?

因为一个对象最好可以绑定多个函数,所以有没有可能我们为data这个对象定义一个空间,每当data的值进行变化的时候就会执行这个空间里的函数?

答案是有的。

1. Object.defineProperty()

js在原生提供了一个用于操作对象的比较底层的api:Object.defineProperty(),它赋予了我们对一个对象的读取和拦截的操作。

Object.defineProperty()方法直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象。

  Object.defineProperty(obj, prop, descriptor)

参数

obj 需要定义属性的对象。 prop 需被定义或修改的属性名。 descriptor (描述符) 需被定义或修改的属性的描述符。

其中descriptor接受一个对象,对象中可以定义以下的属性描述符,使用属性描述符对一个对象进行拦截和控制:

  • value——当试图获取属性时所返回的值。

  • writable——该属性是否可写。

  • enumerable——该属性在for in循环中是否会被枚举。

  • configurable——该属性是否可被删除。

  • set()——该属性的更新操作所调用的函数。

  • get()——获取属性值时所调用的函数。

另外,数据描述符(其中属性为: enumerableconfigurablevaluewritable )与存取描述符(其中属性为 enumerableconfigurableset()get() )之间是有互斥关系的。在定义了 set()get() 之后,描述符会认为存取操作已被 定义了,其中再定义 valuewritable 会引起错误。

 let obj = {
   name: "小花"
 }

 Object.defineProperty(obj, 'name', {
   // 属性读取时进行拦截
   get() { return '小明'; },
   // 属性设置时拦截
   set(newValue) { obj.name = newValue; },
   enumerable: true,
   configurable: true
 });

上面的例子中就已经完成对一个对象的最基本的拦截,这也是vue2.x中对对象监听的方式,但是由于Object.defineProperty()中存在一些问题,例如:

  • 一次只能对一个属性进行监听,需要遍历来对所有属性监听

  • 对于对象的新增属性,需要手动监听

  • 对于数组通过pushunshift方法增加的元素,也无法监听

那么vue3版本中是如何对一个对象进行拦截的呢?答案是es6中的Proxy

由于本文主要是vue3版本的响应式的实现,如果想要深入了解Object.defineProperty(),请移步:MDN Object.defineProperty

2. Proxy

proxyes6版本出现的一种对对象的操作方式,Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

通过proxy我们可以实现对一个对象的读取,设置等等操作进行拦截,而且直接对对象进行整体拦截,内部提供了多达13种拦截方式。

  • get(target, propKey, receiver) :拦截对象属性的读取,比如 proxy.fooproxy['foo']

  • set(target, propKey, value, receiver) :拦截对象属性的设置,比如 proxy.foo = vproxy['foo'] = v ,返回一个布尔值。

  • has(target, propKey) :拦截 propKey in proxy 的操作,返回一个布尔值。

  • deleteProperty(target, propKey) :拦截 delete proxy[propKey] 的操作,返回一个布尔值。

  • ownKeys(target) :拦截 Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in 循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。

  • getOwnPropertyDescriptor(target, propKey) :拦截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回属性的描述对象。

  • defineProperty(target, propKey, propDesc) :拦截 Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs) ,返回一个布尔值。

  • preventExtensions(target) :拦截 Object.preventExtensions(proxy) ,返回一个布尔值。

  • getPrototypeOf(target) :拦截 Object.getPrototypeOf(proxy) ,返回一个对象。

  • isExtensible(target) :拦截 Object.isExtensible(proxy) ,返回一个布尔值。

  • setPrototypeOf(target, proto) :拦截 Object.setPrototypeOf(proxy, proto) ,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

  • apply(target, object, args) :拦截 Proxy (代理) 实例作为函数调用的操作,比如 proxy(...args)proxy.call(object, ...args)proxy.apply(...)

  • construct(target, args) :拦截 Proxy (代理) 实例作为构造函数调用的操作,比如 new proxy(...args)

如果想要详细了解proxy,请移步:es6.ruanyifeng.com/#docs/proxy…

 let obj = {
   name: "小花"
 }
 // 只使用get和set进行演示
 let obj2 = new Proxy(obj, {
   // 读取拦截
   get: function (target, propKey) {
     return target[propKey]
   },
   // 设置拦截
   set: function (target, propKey, value) {
     // 此处的value为用户设置的新值
     target[propKey] = value
   }
 });

3. 一个最简单的响应式

有了proxy,我们就可以根据之前的思路实现一个基本的响应式功能了,我们的思路是这样的:在对象被读取时把函数收集到一个“仓库”,在对象的值被设置时触发仓库中的函数。

由此我们可以写出一个最基本的响应式功能:

// 定义一个“仓库”,用于存储触发函数
let store = new Set()
// 使用proxy进行代理
let data_proxy = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 收集依赖函数
    store.add(effect)
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    target[key] = newVal
    // 取出所有的依赖函数,执行
    store.forEach(fn => fn())
  }
})

我们创建了一个用于保存依赖函数的“仓库”,它是Set类型,然后使用proxy对对象data进行代理,设置了setget拦截函数,用于拦截读取和设置操作,当读取属性时,将依赖函数effect存储到“仓库”中,当设置属性值时,将依赖函数从“仓库”中取出并重新执行。

还有一个小问题,怎么触发对象的读取操作呢?我们可以直接调用一次effect函数,如果在effect函数中存在需要收集的属性,那么执行一次effect函数也是比较符合常理的。

// 定义一个对象
let data = {
  name: 'pino',
  age: 18
}

let nextVal
// 待绑定函数
function effect() {
  // 依赖函数在这里被收集
  // 当调用data.age时,effect函数被收集到“仓库”中
  nextVal = data.age + 1
  console.log(nextVal)
}
// 执行依赖函数
effect() // 19

setTimeout(()=>{
  // 使用proxy进行代理后,使用代理后的对象名
  // 触发设置操作,此时会取出effect函数进行执行
  data_proxy.age++ // 2秒后输出 20
}, 2000)

一开始会执行一次effect,然后函数两秒钟后会执行代理对象设置操作,再次执行effect函数,输出20。

2.gif

此时整个响应式流程的功能是这样的:

阶段一,在属性被读取时,为对象属性收集依赖函数:

3.png

阶段二,当属性发生改变时,再次触发依赖函数

4.png

这样就实现了一个最基本的响应式的功能。

4. 完善

问题一

其实上面实现的功能还有很大的缺陷,首先最明显的问题是,我们把effect函数给固定了,如果用户使用的依赖函数不叫effect怎么办,显然我们的功能就不能正常运行了。

所以先来进行第一步的优化:抽离出一个公共方法,依赖函数由用户来传递参数

我们使用effect函数来接受用户传递的依赖函数:

// effect接受一个函数,把这个匿名函数当作依赖函数
function effect(fn) {
  // 执行依赖函数
  fn()
}

// 使用
effect(()=>{
  nextVal = data.age + 1
  console.log(nextVal)
})

但是effect函数内部只是执行了,在get函数中怎么能知道用户传递的依赖函数是什么呢,这两个操作并不在一个函数内啊?其实可以使用一个全局变量activeEffect来保存当前正在处理的依赖函数。

修改后的effect函数是这样的:

let activeEffect // 新增

function effect(fn) {
  // 保存到全局变量activeEffect
  activeEffect = fn // 新增
  // 执行依赖函数
  fn()
}

// 而在get内部只需要�收集activeEffect即可
get(target, key) {
  store.add(activeEffect)
  return target[key]
},

调用effect函数传递一个匿名函数作为依赖函数,当执行时,首先会把匿名函数赋值给全局变量activeEffect,然后触发属性的读取操作,进而触发get拦截,将全局变量activeEffect进行收集。

问题二

从上面我们定义的对象可以看到,我们的对象data中有两个属性,上面的例子中我们只给age建立了响应式连接,那么如果我现在也想给name建立响应式连接怎么办呢?那好说,那我们直接向“仓库”中继续添加依赖函数不就行了吗。

其实这会带来很严重的问题,由于 “仓库”并没有与被操作的目标属性之间建立联系,而上面我们的实现只是将整个“仓库”遍历了一遍,所以无论哪个属性被触发,都会将“仓库”中所有的依赖函数都取出来执行一遍,因为整个执行程序中可能有很多对象及属性都设置了响应式联系,这将会带来很大的性能浪费。所谓牵一发而动全身,这种结果显然不是我们想要的。

let data = {
  name: 'pino',
  age: 18
}

5.png

所以我们要重新设计一下“仓库”的数据结构,目的就是为了可以在属性这个粒度下和“仓库”建立明确的联系。

就拿我们上面进行操作的对象来说,存在着两层的结构,有两个角色,对象data以及属性name``age

let data = {
 name: 'pino',
 age: 18
}

他们的关系是这样的:

data
       -> name
               -> effectFn

// 如果两个属性读取了同一个依赖函数
data
       -> name
               -> effectFn
       -> age
               -> effectFn

// 如果两个属性读取了不同的依赖函数
data
       -> name
               -> effectFn
       -> age
               -> effectFn1

// 如果是两个不同的对象
data
       -> name
               -> effectFn
       -> age
               -> effectFn1
data2
       -> addr
               -> effectFn

接下来我们实现一下代码,为了方便调用,将设置响应式数据的操作封装为一个函数reactive

let newObj = new Proxy(obj, {
  // 读取拦截
  get: function (target, propKey) {
  },
  // 设置拦截
  set: function (target, propKey, value) {
  }
});

// 封装为

function reactive(obj) {
  return new Proxy(obj, {
    // 读取拦截
    get: function (target, propKey) {
    },
    // 设置拦截
    set: function (target, propKey, value) {
    }
  });
}
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 收集依赖
      track(target, key)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      // 触发依赖
      trigger(target, key)
    }
  })
}

function track(target, key) {
  // 如果没有依赖函数,则不需要进行收集。直接return
  if (!activeEffect) return

  // 获取target,也就是对象名,对应上面例子中的data
  let depsMap = store.get(target)
  if (!depsMap) {
    store.set(target, (depsMap = new Map()))
  }
  // 获取对象中的key值,对应上面例子中的name或age
  let deps = depsMap.get(key)

  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 收集依赖函数
  deps.add(activeEffect)
}

function trigger(target, key) {
  // 取出对象对应的Map
  let depsMap = store.get(target)
  if(!depsMap) return
  // 取出key所对应的Set
  let deps = depsMap.get(key)
  // 执行依赖函数
  deps && deps.forEach(fn => fn());
}

我们将读取操作封装为了函数track,触发依赖函数的动作封装为了trigger方便调用,现在的整个“仓库”结构是这样的:

6.png

WeakMap

可能有人会问了,为什么设置“仓库”要使用WeakMap呢,我使用一个普通对象来创建不行吗? -

WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合。

WeakMapMap 的区别有两点。

首先, WeakMap 只接受对象作为键名( null 除外),不接受其他类型的值作为键名。

const map = new WeakMap();
map.set(1, 2)
// TypeError: 1 is not an object!
map.set(Symbol(), 2)
// TypeError: Invalid value used as weak map key
map.set(null, 2)
// TypeError: Invalid value used as weak map key

上面代码中,如果将数值 1Symbol 值作为 WeakMap 的键名,都会报错。

其次, WeakMap 的键名所指向的对象,不计入垃圾回收机制。

WeakMap 的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。

const e1 = document.getElementById('foo');
const e2 = document.getElementById('bar');
const arr = [
    [e1, 'foo 元素'],
    [e2, 'bar 元素'],
];

上面代码中, e1e2 是两个对象,我们通过 arr 数组对这两个对象添加一些文字说明。这就形成了 arre1e2 的引用。

一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放 e1e2 占用的内存。

// 不需要 e1 和 e2 的时候
// 必须手动删除引用
arr [0] = null;
arr [1] = null;

上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。

它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。

如果我们上文中target对象没有任何引用了,那么说明用户已经不需要用到它了,这时垃圾回收器会自动执行回收,而如果使用Map来进行收集,那么即使其他地方的代码已经对target没有任何引用,这个target也不会被回收。

Reflect

在vue3中的实现方式和我们的基本实现还有一点不同就是在vue3中是使用Reflect来操作数据的,例如:

function reactive(obj) {
 return new Proxy(obj, {
   get(target, key, receiver) {
     track(target, key)
     // 使用Reflect.get操作读取数据
     return Reflect.get(target, key, receiver)
   },
   set(target, key, value, receiver) {
     trigger(target, key)
     // 使用Reflect.set来操作触发数据
     Reflect.set(target, key, value, receiver)
   }
 })
}

那么为什么要使用Reflect来操作数据呢,像之前一样直接操作原对象不行吗,我们先来看一下一种特殊的情况:

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  }
}

effect依赖函数中通过代理对象p访问bar属性:

effect(()=>{
  console.log(p.bar) // 1
})

可以分析一下这个过程发生了什么,当effect函数被调用时,会读取p.bar属性,他发现p.bar属性是一个访问器属性,因此会执行getter函数,由于在getter函数中通过this.foo读取了foo属性的值,因此我们会认为副作用函数与属性foo之间也会建立联系,当修改p.foo的值的时候因该也能够触发响应,使依赖函数重新执行才对,然而当修改p.foo的时候,并没有触发依赖函数:

p.foo++

实际上问题就出在bar属性中的访问器函数getter上:

get bar() {
  // 这个this究竟指向谁?
  return this.foo
}

当通过代理对象p访问p.bar,这回触发代理对象的get拦截函数执行:

const p = new Proxt(obj, {
  get(target, key) {
    track(target, key)
    return target[key]
  }
})

可以看到在get的拦截函数中,通过target[key]返回属性值,其中target是原始对象obj,而key就是字符串'bar',所以target[key]就相当于obj.bar。因此当我们使用p.bar访问bar属性时,他的getter函数内的this其实指向原始对象obj,这说明我们最终访问的是obj.foo。所以在依赖函数内部通过原始对象访问他的某个属性是不会建立响应联系的:

effect(()=>{
  // obj是原始数据,不是代理对象,不会建立响应联系
  obj.foo
})

那么怎么解决这个问题呢,这时候就需要用到 Reflect出场了。

先来看一下Reflect是啥:

Reflect函数的功能就是提供了访问一个对象属性的默认行为,例如下面两个操作是等价的:

const obj = { foo: 1 }

// 直接读取
console.log(obj.foo) //1

// 使用Reflect.get读取
console.log(Reflect.get(obj, 'foo')) // 1

实际上Reflect.get函数还能接受第三个函数,即制定接受者receiver,可以把它理解为函数调用过程中的this

const obj = { foo: 1 }

console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1

在这段代码中,指定了第三个参数receiver为一个对象{ foo: 2 },这是读取到的值时receiver对象的foo属性。

而我们上文中的问题的解决方法就是在操作对象数据的时候通过Reflect的方法来传递第三个参数receiver,它代表谁在读取属性:

const p = new Proxt(obj, {
  // 读取属性接收receiver
  get(target, key, receiver) {
    track(target, key)
    // 使用Reflect.get返回读取到的属性值
    return Reflect.get(target, key, receiver)
  }
})

当使用代理对象p访问bar属性时,那么receiver就是p,可以把它理解为函数调用中的this

所以我们改造一下reactive函数的实现:

function reactive(obj) {
 return new Proxy(obj, {
   get(target, key, receiver) {
     track(target, key)
     return Reflect.get(target, key, receiver)
   },
   set(target, key, value, receiver) {
     trigger(target, key)
     Reflect.set(target, key, value, receiver)
   }
 })
}

扩展

Proxy -> get()

get 方法用于拦截某个属性的读取操作,可以接受三个参数,依次为目标对象、属性名和 proxy (代理) 实例本身(严格地说,是操作行为所针对的对象),其中最后一个参数可选。

Reflect.get(target, name, receiver)

Reflect.get 方法查找并返回 target 对象的 name 属性,如果没有该属性,则返回 undefined

var myObject = {
foo: 1,
bar: 2,
get baz() {
  return this.foo + this.bar;
},
}

Reflect.get(myObject, 'foo') // 1
Reflect.get(myObject, 'bar') // 2
Reflect.get(myObject, 'baz') // 3

如果 name 属性部署了读取函数( getter ),则读取函数的 this 绑定 receiver

var myObject = {
foo: 1,
bar: 2,
get baz() {
  return this.foo + this.bar;
},
};

var myReceiverObject = {
foo: 4,
bar: 4,
};

Reflect.get(myObject, 'baz', myReceiverObject) // 8

如果第一个参数不是对象, Reflect.get 方法会报错。

Reflect.get(1, 'foo') // 报错
Reflect.get(false, 'foo') // 报错

Reflect.set(target, name, value, receiver)

Reflect.set 方法设置 target 对象的 name 属性等于 value

var myObject = {
foo: 1,
set bar(value) {
  return this.foo = value;
},
}

myObject.foo // 1

Reflect.set(myObject, 'foo', 2);
myObject.foo // 2

Reflect.set(myObject, 'bar', 3)
myObject.foo // 3

如果 name 属性设置了赋值函数,则赋值函数的 this 绑定 receiver

var myObject = {
foo: 4,
set bar(value) {
  return this.foo = value;
},
};

var myReceiverObject = {
foo: 0,
};

Reflect.set(myObject, 'bar', 1, myReceiverObject);
myObject.foo // 4
myReceiverObject.foo // 1

注意,如果 Proxy 对象和 Reflect 对象联合使用,前者拦截赋值操作,后者完成赋值的默认行为,而且传入了 receiver ,那么 Reflect.set 会触发 Proxy.defineProperty 拦截。

let p = {
a: 'a'
};

let handler = {
set(target, key, value, receiver) {
  console.log('set');
  Reflect.set(target, key, value, receiver)
},
defineProperty(target, key, attribute) {
  console.log('defineProperty');
  Reflect.defineProperty(target, key, attribute);
}
};

let obj = new Proxy(p, handler);
obj.a = 'A';
// set
// defineProperty

上面代码中, Proxy.set 拦截里面使用了 Reflect.set ,而且传入了 receiver ,导致触发 Proxy.defineProperty 拦截。这是因为 Proxy.setreceiver 参数总是指向当前的 Proxy 实例(即上例的 obj ),而 Reflect.set 一旦传入 receiver ,就会将属性赋值到 receiver 上面(即 obj ),导致触发 defineProperty 拦截。如果 Reflect.set 没有传入 receiver ,那么就不会触发 defineProperty 拦截。

let p = {
a: 'a'
};

let handler = {
set(target, key, value, receiver) {
  console.log('set');
  Reflect.set(target, key, value)
},
defineProperty(target, key, attribute) {
  console.log('defineProperty');
  Reflect.defineProperty(target, key, attribute);
}
};

let obj = new Proxy(p, handler);
obj.a = 'A';
// set

如果第一个参数不是对象, Reflect.set 会报错。

Reflect.set(1, 'foo', {}) // 报错
Reflect.set(false, 'foo', {}) // 报错

到这里,一个非常基本的响应式的功能就完成了,整体代码如下:

// 定义仓库
let store = new WeakMap()
// 定义当前处理的依赖函数
let activeEffect

function effect(fn) {
  // 将操作包装为一个函数
  const effectFn = ()=> {
    activeEffect = effectFn
    fn()
  }
  effectFn()
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 收集依赖
      track(target, key)
      return Reflect.get(target, key, receiver)

    },
    set(target, key, newVal, receiver) {
      // 触发依赖
      trigger(target, key)
      Reflect.set(target, key, newVal, receiver)
    }
  })
}

function track(target, key) {
  // 如果没有依赖函数,则不需要进行收集。直接return
  if (!activeEffect) return

  // 获取target,也就是对象名
  let depsMap = store.get(target)
  if (!depsMap) {
    store.set(target, (depsMap = new Map()))
  }
  // 获取对象中的key值
  let deps = depsMap.get(key)

  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 收集依赖函数
  deps.add(activeEffect)
}

function trigger(target, key) {
  // 取出对象对应的Map
  let depsMap = store.get(target)
  if (!depsMap) return
  // 取出key所对应的Set
  const effects = depsMap.get(key)
  // 执行依赖函数
  // 为避免污染,创建一个新的Set来进行执行依赖函数
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
      effectsToRun.add(effectFn)
  })

  effectsToRun.forEach(effect => effect())
}

二. 嵌套effect

在日常的工作中,effect函数并不是单独存在的,比如在vue的渲染函数中,各个组件之间互相嵌套,那么他们在组件中所使用的effect是必然会发生嵌套的:

effect(function effectFn1() {
  effect(function effectFn1() {
    // ...
  })
})

当组件中发生嵌套时,此时的渲染函数:

effect(()=>{
  Father.render()

  //嵌套子组件
  effect(()=>{
    Son.render()
  })
})

但是此时我们实现的effect并没有这个能力,执行下面这段代码,并不会出现意料之中的行为:

const data = { foo: 'pino', bar: '在干啥' }
// 创建代理对象
const obj = reactive(data)

let p1, p2;
// 设置obj.foo的依赖函数
effect(function effect1(){
  console.log('effect1执行');
  // 嵌套,obj.bar的依赖函数
  effect(function effect2(){
    p2 = obj.bar

    console.log('effect2执行')
  })
  p1 = obj.foo
})

在这段代码中,定义了代理对象obj,里面有两个属性foobar,然后定义了收集foo的依赖函数,在依赖函数的内部又定义了bar的依赖函数。 在理想状态下,我们希望依赖函数与属性之间的关系如下:

obj
        -> foo
                -> effect1
        -> bar
                -> effect2

当修改obj.foo的值的时候,会触发effect1函数执行,由于effect2函数在effect函数内部,所以effect2函数也会执行,而当修改obj.bar时,只会触发effect2函数。接下来修改一下obj.foo

const data = { foo: 'pino', bar: '在干啥' }
// 创建代理对象
const obj = reactive(data)

let p1, p2;
// 设置obj.foo的依赖函数
effect(function effect1(){
  console.log('effect1执行');
  // 嵌套,obj.bar的依赖函数
  effect(function effect2(){
    p2 = obj.bar

    console.log('effect2执行')
  })
  p1 = obj.foo
})

// 修改obj.foo的值
obj.foo = '前来买瓜'

看一下执行结果:

7.png

可以看到effect2函数竟然执行了两次?按照之前的分析,当obj.foo被修改后,应当触发effect1这个依赖函数,但是为什么会effect2会被再次执行呢? 来看一下我们effect函数的实现:

function effect(fn) {
  // 将依赖函数进行包装
  const effectFn = ()=> {
    activeEffect = effectFn
    fn()
  }
  effectFn()
}

其实在这里就已经很容易看出问题了,在接受用户传递过来的值时,我们直接将activeEffect这个全局变量进行了覆盖!所以在内部执行完后,activeEffect这个变量就已经是effect2函数了,而且永远不会再次变为effect1,此时再进行收集依赖函数时,永远收集的都是effect2函数。

那么如何解决这种问题呢,这种情况可以借鉴栈结构来进行处理,栈结构是一种后进先出的结构,在依赖函数执行时,将当前的依赖函数压入栈中,等待依赖函数执行完毕后将其从栈中弹出,始终activeEffect指向栈顶的依赖函数。

// 增加effect调用栈
const effectStack = [] // 新增

function effect(fn) {
  let effectFn = function () {
    activeEffect = effectFn
    // 入栈
    effectStack.push(effectFn) // 新增
    // 执行函数的时候进行get收集
    fn()
    // 收集完毕后弹出
    effectStack.pop() // 新增
    // 始终指向栈顶
    activeEffect = effectStack[effectStack.length - 1] // 新增
  }

  effectFn()
}

8.png

此时两个属性所对应的依赖函数便不会发生错乱了。

三. 避免无限循环

如果现在将effect函数中传递的依赖函数改一下:

// 定义一个对象
let data = {
  name: 'pino',
  age: 18
}
// 将data更改为响应式对象
let obj = reactive(data)

effect(() => {
  obj.age++
})

在这段代码中,我们将代理对象objage属性执行自增操作,但是执行这段代码,却发现竟然栈溢出了?这是怎么回事呢?

9.png

其实在effect中处理依赖函数时,obj.age++的操作其实可以看做是这样的:

effect(()=>{
  // 等式右边的操作是先执行了一次读取操作
  obj.age = obj.age + 1
})

这段代码的执行流程是这样的:首先读取obj.foo的值,这会触发track函数进行收集操作,也就是将当前的依赖函数收集到“仓库”中,接着将其加1后再赋值给obj.foo,此时会触发trigger操作,即把“仓库”中的依赖函数取出并执行。但是此时该依赖函数正在执行中,还没有执行完就要再次开始下一次的执行。就会导致无限的递归调用自己。

解决这个问题,其实只需要在触发函数执行时,判断当前取出的依赖函数是否等于activeEffect,就可以避免重复执行同一个依赖函数。

function trigger(target, key) {
  // 取出对象对应的Map
  let depsMap = store.get(target)
  if (!depsMap) return
  // 取出key所对应的Set
  const effects = depsMap.get(key)
  // // 执行依赖函数
  // 因为删除又添加都在同一个deps中,所以会产生无限执行
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
    // 如果trigger出发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
    }
  })

  effectsToRun.forEach(effect => effect())
}

四.computed

computed是vue3中的计算属性,它可以根据传入的参数进行响应式的处理:

const plusOne = computed(() => count.value + 1)

根据computed的用法,我们可以知道它的几个特点:

  • 懒执行,值变化时才会触发

  • 缓存功能,如果值没有变化,就会返回上一次的执行结果 在实现这两个核心功能之前,我们先来改造一下之前实现的effect函数。

怎么能使effect函数变成懒执行呢,比如计算属性的这种功能,我们不想要他立即执行,而是希望在它需要的时候才执行。

这时候我们可以在effect函数中传递第二个参数,一个对象,用来设置一些额外的功能。

function effect(fn, options = {}) { // 修改

  let effectFn = function () {
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 只有当非lazy的时候才直接执行
  if(!options.lazy) {
    effectFn()
  }
  // 将依赖函数组为返回值进行返回
  return effectFn // 新增
}

这时,如果传递了lazy属性,那么该effect将不会立即执行,需要手动进行执行:

const effectFn = effect(()=>{
  console.log(obj.foo)
}, { lazy: true })

// 手动执行
effectFn()

但是如果我们想要获取手动执行后的值呢,这时只需要在effect函数中将其返回即可。

function effect(fn, options = {}) {

  let effectFn = function () {
    activeEffect = effectFn
    effectStack.push(effectFn)
    // 保存返回值
    const res = fn() // 新增
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]

    return res // 新增
  }
  // 只有当非lazy的时候才直接执行
  if(!options.lazy) {
    effectFn()
  }
  // 将依赖函数组为返回值进行返回
  return effectFn
}

接下来开始实现computed函数:

function computed(getter) {
  // 创建一个可手动调用的依赖函数
  const effectFn = effect(getter, {
    lazy: true
  })
  // 当对象被访问的时候才调用依赖函数
  const obj = {
    get value() {
      return effectFn()
    }
  }

  return obj
}

但是此时还做不到对值进行缓存和对比,增加两个变量,一个存储执行的值,另一个为一个开关,表示“是否可以重新执行依赖函数”:

function computed(getter) {
  // 定义value保存执行结果
  // isRun表示是否需要执行依赖函数
  let value, isRun = true; // 新增
  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      // 增加判断,isRun为true时才会重新执行
      if(isRun) {  // 新增
        // 保存执行结果
        value = effectFn() // 新增
        // 执行完毕后再次重置执行开关
        isRun = false // 新增
      }

      return value
    }
  }

  return obj
}

但是上面的实现还有一个问题,就是好像isRun执行一次后好像永远都不会变成true&a


标签:
  • 响应式
  • vue3