《Vue.js设计与实现》第五章-非原始值的响应式方案(无代码版)

理解Proxy和Rflect

(这个书真难 代码写的像扭麻花一样😅) Vue.js的响应式数据都是基于Proxy实现的,那么我们必须的了解Proxy以及与之相关联的Reflect。

js 复制代码
// 给出一个对象obj,可以对它进行一些操作,例如读取属性值、设置属性值
obj.foo // 读取属性foo的值
obj.foo++ // 读取和设置属性的值

它可以使用Proxy拦截:

js 复制代码
const p = new Proxy(obj, {
  // 拦截读取属性操作
  get(){/*..*/}
  // 拦截设置属性操作
  set(){/*..*/}
})

Proxy构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象,这个对象是一组trap。其中get是用来拦截读取操作,set函数是用来拦截设置操作。

在JS的世界里,万物皆是对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:

js 复制代码
const fn = (name) => {
  console.log("我是:", name)
}
// 调用函数是对对象的基本操作
fn()

因此,我们可以用Proxy来拦截函数的调用操作,这里我们使用apply拦截函数的调用:

js 复制代码
const p2 = new Proxy(fn, {
  apply(target, thisArg, argArray){
    target.call(thisArg, ...argArray)
  }
})

p2('zs')

上面两个例子说明了什么是基本操作。那什么是非基本操作 呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作

调用一个对象下的方法,是有两个基本语义,第一个基本语义是get,即先通过get操作得到obj.fn属性。第二个基本语义是函数调用,即通过get得到obj.fn的值后再调用它。

再来讨论Reflect。Reflect是一个全局对象。它有很多方法。方法名和Proxy相同。

js 复制代码
Reflect.get()
Reflect.set()
Reflect.apply()
//...

举一个🌰,例如Refect.get方法。

js 复制代码
const obj = {foo: 1}

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

到底有啥差别呢🤔其实是Reflect.get函数还能接受第三个参数,即指定接收者,你可以理解为this。举个🌰:

js 复制代码
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', {foo: 2})) // 输出的是 2,不是 1

回顾一下上一章中实现响应式数据的基本代码。在get和set拦截函数中,我们都是直接使用原始对象target来完成对属性的读取和设置操作。

当我们修改一下obj对象。

js 复制代码
const obj = {
  foo: 1, 
  get bar(){
    return this.foo
  }
}

bar属性是一个访问器属性,它返回了this.foo属性的值。我们在effect副作用函数中通过代理对象p返回bar属性:

js 复制代码
effect(() => {
  console.log(p.bar) // 1
})

分析一下这个过程发生了什么?

js 复制代码
p.foo++

执行上面代码,会发生什么?为什么呢?该怎么解决呢? 为什么和书上说的不一样???p.foo++还是会触发effect重新执行

换一个🌰

js 复制代码
let user = {
  _name: "张三",
  get name() {
    return this._name;
  },
};

let userProxy = new Proxy(user, {
  get(target, key, receiver) {
    return target[key];
  },
});

console.log(userProxy.name); // 张三

但是一个对象admin从user继承后,我们可以观察到❌的行为:

js 复制代码
let user = {
  _name: "张三",
  get name() {
    return this._name;
  },
};

let userProxy = new Proxy(user, {
  get(target, key, receiver) {
    return target[key];
  },
});

let admin = {
  __proto__: userProxy,
  _name: "李四",
};

console.log(admin.name); // undefined

问题出在第10行。

  1. 当我们读取admin.name,由于admin对象没有对应的属性,搜索将转到其原型。
  2. 原型是userProxy。
  3. 当调用target[key]时,target是user。

为了解决这种情况,我们需要用到第三个参数receiver。用它来传递admin。Reflect.get可以做到。

js 复制代码
let user = {
  _name: "张三",
  get name() {
    return this._name;
  },
};

let userProxy = new Proxy(user, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);
  },
});

let admin = {
  __proto__: userProxy,
  _name: "李四",
};

console.log(admin.name); // 李四

JavaScript对象及Proxy的工作原理

给出一个对象obj,如何区分它是普通对象还是函数?

js 复制代码
obj.foo

引擎内部会调用[[Get]]这个内部方法来读取属性值。 一共有13种内部方法,参考第131页。

基本对象 :这13种内部对象必须使用ECMA规范中定义实现。否则都是异质对象所以Proxy是一个异质对象。

js 复制代码
const p = new Proxy(obj, {/*...*/})
p.foo

如果创建代理对象时没有指定对应的拦截函数,假如没有指定get()拦截函数,当我们访问属性值时,会调用原始对象的内部方法[[Get]]来获取属性值。

当我们要代理删除属性操作时。

js 复制代码
const obj = {
  foo: 1,
};
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    return Reflect.deleteProperty(target, key);
  },
});
console.log(p.foo);
delete p.foo;
console.log(p.foo);

如何代理Object

前面使用get拦截器去拦截对属性的读取操作。但是在响应系统中,"读取"是一个很宽泛 的概念,比如使用in操作符检查对象上是否有给定的key也属于读取操作。

js 复制代码
effect(() => {
  'foo' in obj
})

一个普通对象的所有可能的读取操作:

  1. 访问属性: obj.foo
  2. 判断对象或原型上是否存在给定的key: key in obj
  3. 使用for...in循环遍历对象:for(const key in obj){}

in操作符,应该如何拦截呢?

in操作符通过调用一个叫作HasProperty的抽象方法。HasProperty的返回值是通过调用对象的内部方法[[HasProperty]]得到的。内部方法[[HasProperty]]对应的拦截函数是has。(顺藤摸瓜)

当我们在副作用函数中通过in操作符操作响应式数据时,就能够建立依赖关系。

// 代码自己写

如何拦截for...in循环?

for...in不会触发get方法!

有一个抽象方法EnumerateObjectProperties,该方法返回一个迭代器对象。

我们可以使用Reflect.ownKeys拦截函数来操作。 首先我们来看一个问题:

js 复制代码
const obj = { foo: 1}
const p =new Proxy(obj, {/*...*/})

effect(() => {
  //for...in循环
  for(const key in p){
    console.log(key) // foo
  }
})

添加属性

当我们尝试为对象p添加新的属性bar时,并没有触发副作用函数执行,这是为什么呢?

js 复制代码
p.bar = 2

看一下现在的set拦截函数的实现:

js 复制代码
const proxy = new Proxy(obj, {
  set(target, get, newVal, receiver){
    // 设置属性
    const res = Reflect.set(target, get, newVal, receiver)
    
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
    return res
  }
})

当为对象p添加新的属性bar时,会触发set拦截函数执行。set调用trigger函数时,也只是触发了与bar相关联的副作用函数重新执行。当我们在ownKeys将key和副作用函数相关联时,并没有bar

那我们怎么解决?在for...in中我们不知道具体操作的是哪一个key,那么就需要用到Symbol来建立一个唯一标识。

  1. 先用Symbol来建立一个唯一标识ITERATE_KEY
  2. track(target,ITERATE_KEY)
  3. trigger(target,ITERATE_KEY)

修改属性

和添加属性不同,修改属性对for...in循环来说只会循环一次,不需要触发副作用函数重新执行,否则会造成不必要的性能开销。无论是添加属性还是修改已有的属性,都会通过[[Set]]拦截。

所以想在set拦截函数中区分添加属性 还是修改属性

删除属性

delete操作符依赖[[Delete]]内部方法,该内部方法可以使用deleteProperty拦截。删除属性,属性变化影响了for...in循环,所以也需要[[Set]]拦截。

合理地触发响应

设置了值,但值没变

首先当值没有发生变化时,应该不需要触发响应吧...

js 复制代码
const obj = { foo: 1 }
const p = new Proxy(obj, {/*...*/})

effect(() => {
  console.log(p.foo)
})

p.foo = 1 // 设置p.foo的值,但值没有变化

所以我们需要在set拦截函数中,在调用trigger函数触发响应之前,需要检查值是否真的发生了变化

还得对NaN处理。

js 复制代码
// 如果用===比较时
NaN === NaN // false
NaN !== NaN // true

如果p.foo的初始值是NaN,后续又设置了NaN作为新值,那么全等比较的缺陷就暴露了。

为什么NaN === NaN会是false啊?

js 复制代码
const obj = {
  foo: NaN,
};
const p = new Proxy(obj, {/**.../})

effect(() => {
  console.log("key--->", p.foo);
});
p.foo = NaN; // 仍然会触发副作用函数

所以我们得增加一个条件:在新值和旧值不全等的情况下,要保证他们都不是NaN。思考🤔,对象类型呢?

接下来我们要讨论从原型链继承属性的情况。首先封装一个reactive函数。

创建一个🌰

js 复制代码
const obj = {};
const proto = { bar: 1 };
const child = reactive(obj);
const parent = reactive(proto);

// 使用parent作为child的原型
Object.setPrototypeOf(child, parent);

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

// 修改child.bar的值
child.bar = 2; // 会导致副作用函数重新执行两次

为什么副作用函数会执行两次?

分析问题:

当读取child.bar属性值时,由于child代理的对象obj本身是没有bar属性的,因此会获取对象obj的原型,也就是parent对象,所以最终得到的是parent.bar的值。

但是parent本身也是响应式数据,因此在副作用函数中访问parent.bar的值时,会被track。所以是child.bar和parent.bar都与副作用函数建立了响应关系。

接下来分析设置操作的具体流程。因为child没有bar属性,那么会取原型的,也就是调用parent的[[set]]方法。虽然我们操作的是child.bar,但这也会导致parent代理的set拦截函数被执行。所以修改了child.bar的值会导致副作用函数重新执行两次。

解决方法: 我们能够在set拦截函数中区分这两次更新就可以了。无论什么情况下,receiver都是child,而target则是变化的。因此我们可以通过判断receiver是否是target的代理对象。

js 复制代码
// child的set拦截函数
set(target, key, value, receiver){
  // target 是child原始对象obj
  // receiver是代理对象child
}

get方法中,增加代理对象能通过raw属性访问原始数据的功能。

js 复制代码
child.raw === obj // true
parent.raw === proto // true

代理对象通过raw属性读取原始数据。

只有当receiver是target的代理对象时才触发更新。

浅响应与深响应

目前我们实现的reactive是浅响应的。

js 复制代码
const obj = reactive({ foo: { bar: 1 } });

effect(() => {
  console.log(obj.foo.bar);
});

obj.foo.bar = 2; // 并不能触发响应
js 复制代码
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 代理对象通过raw属性访问原始数据
      if (key === "raw") {
        return target;
      }
      track(target, key);
      return Reflect.get(target, key);
    },
  }
}

我们先来看一下现在的代码。能看出什么问题吗?

解决方法略。

并非所有情况下我们都希望深响应,这就出现了浅响应。浅响应,指的是只有对象的第一层属性是响应的,来一个🌰

js 复制代码
const obj = reactive({ foo: { bar: 1 } });

effect(() => {
  console.log(obj.foo.bar);
});
// obj.foo是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 };
// obj.foo.bar不响应的,不能触发副作用函数重新执行
obj.foo.bar = 2;

重写了一遍,删掉多余的代码,可以实现了。

浅readonly和深readonly

浅readonly

我们希望一些数据是只读的,当用户尝试修改只读数据时,会收到一条警告信息。这样就实现了对数据的保护,例如组件接收到的props对象应该是一个只读数据。readonly函数,它能将一个数据变成只读的。

js 复制代码
const obj = readonly({foo: 1})
// 尝试修改数据,会得到警告
obj.foo = 2

只读本质上也是对数据对象的代理,我们可以用createReactive来实现。

  1. set方法中判断readonly。
  2. deleteProperty方法中判断readonly。
  3. get方法中也不用track。

深readonly

上面的代码中只做到了浅readonly

js 复制代码
const obj = readonly({ foo: { bar: 1 } });
// 尝试修改数据,依然会得到警告,和书中例子结果不同(我不太理解)???(重写了一遍确实没有警告)
obj.foo.bar = 2;

为了实现深只读,我们还应该在get拦截函数中递归地调用readonly将数据包装成只读的代理对象,并将其作为返回值返回。

代理数组

数组和普通对象有什么不同? 数组是一个异质对象,因为数组对象的[[DefineOwnProperty]]内部方法与常规对象不同。数组对象除了[[DefineOwnProperty]]这个内部方法之外,其他内部方法的逻辑都与常规对象相同。当实现数组的代理时,用于代理普通对象的大部分代码可以继续使用。

js 复制代码
const arr = reactive(["foo"]);

effect(() => {
  console.log(arr[0]);
});
arr[0] = "bar"; // 能够触发响应,打印foo

但是对数组的操作和对普通对象的操作仍然存在不同,下面来看一下数组的读取操作。

数组元素或属性的读取操作 代码
通过索引访问数组元素值 arr[0]
访问数组的长度 arr.legnth
把数组作为对象 使用for...in循环遍历
遍历数组 使用for...of遍历数组
数组的原型方法 concat/join/everty/some/find/findIndex/includes
数组元素或属性的设置操作
通过索引修改数组元素值 arr[1] = 3
修改数组的长度 arr.length = 0
数组的栈方法 push/pop/shift/unshift
修改原数组的原型方法 splice/fill/sort

数组的索引与length

js 复制代码
const arr = reactive(["foo"]);

effect(() => {
  console.log(arr.length); // 只打印出1
});
arr[1] = "bar"; // 设置索引1的值,会导致数组的长度变为2,未触发副作用函数重新执行

解决方法:

  1. set拦截函数中加判断是否是数组的逻辑。
  2. trigger方法中加判断是否是数组的逻辑。

// 代码略

如果修改数组的length属性呢?

js 复制代码
const arr = reactive(["foo"]);
effect(() => {
  console.log(arr[0]);
});
arr.length = 0;

上面的🌰中,在副作用函数中访问了数组的第0个元素。接着把数组的length改成0,所有的元素都会被删除;当我们将length设置成100,这并不会影响到数组的第0个元素,所以也就不需要触发副作用函数重新执行。总结一下:当修改length时,只有索引值大于或等于新的length时才需要触发响应。

解决方法:

  1. 修改set拦截函数。在调用trigger时,应该把新的属性值传递过去。
  2. 在trigger方法中增加条件:当修改length时,只有索引值大于或等于新的length时才需要触发响应。

遍历数组

foo...in

既然数组也是对象,同样也可以使用for...in循环遍历:

js 复制代码
const arr = reactive(["foo"]);

effect(() => {
  for (const key in arr) {
    console.log(key);
  }
});

使用for...in遍历数组和普通对象没有差异,因此也可以使用之前的ownKeys拦截函数。

回忆一下之前追踪普通对象的for...in操作,只有添加或删除属性值时才会影响for...in循环的结果。对于数组,哪些操作会影响for...in循环的结果呢?

  • 添加新元素:arr[100] = 'bar'
  • 修改数组长度:arr.length = 0

其实无论是添加新元素,还是直接修改数组的长度,本质都是因为修改了数组的length属性。

解决方法:

ownKeys拦截函数中,判断target是否是数组,如果是,则使用length作为key去建立响应联系。

// 代码略

for...of

for...in不同,for...of是用来遍历可迭代对象(iterable object)的。

如果一个对象实现了Symbol.iterator方法,那么这个对象就是可以迭代的。如下面的🌰

js 复制代码
const obj = {
  val: 0,
  [Symbol.iterator]() {
    return {
      next() {
        return {
          value: obj.val++,
          done: obj.val > 10 ? true : false,
        };
      },
    };
  },
};

该对象实现了Symbol.iterator的方法,因此可以使用for...of循环遍历它。

js 复制代码
for (const value of obj) {
  console.log(value); // 0,1,2,3,4,5,6,7,8,9
}
js 复制代码
const arr = [1, 2, 3, 4, 5];

const itr = arr[Symbol.iterator]();

console.log(itr.next()); // {value: 1, done: false}
console.log(itr.next()); // {value: 2, done: false}
console.log(itr.next()); // {value: 3, done: false}
console.log(itr.next()); // {value: 4, done: false}
console.log(itr.next()); // {value: 5, done: false}
console.log(itr.next()); // {value: undefined, done: true}

数组迭代器的执行会读取数组的length。迭代器的模拟实验如下:

js 复制代码
const arr = [1, 2, 3, 4, 5];

arr[Symbol.iterator] = function () {
  const target = this;
  const len = target.this;
  let index = 0;

  return {
    next() {
      return {
        value: index < len ? target[index] : undefined,
        done: index++ < len ? false : true,
      };
    },
  };
};

我们不需要增加任何代码就能正常运行。

js 复制代码
const arr = reactive([1, 2, 3, 4, 5]);

effect(() => {
  for (const val of arr) {
    console.log(val);
  }
});

arr[1] = "bar"; // 能够触发响应
arr[5] = "new"; // 能够触发响应
arr.length = 0; // 能够触发响应

无论是使用for...of循环,还是调用values方法,都能正常工作。

js 复制代码
const arr = reactive([1, 2, 3, 4, 5]);

effect(() => {
  for (const val of arr.values()) {
    console.log(val);
  }
});

arr[1] = "bar"; // 能够触发响应
arr[5] = "new"; // 能够触发响应
arr.length = 0; // 能够触发响应

因为它们都会读取数组的Symbol.iterator属性。该属性是一个Symbol值,为了避免发生意外的错误,以及性能上的考虑,不应该在副作用函数和Symbol.iterator这类Symbol值之间建立响应联系,因此需要修改get拦截函数。

解决方法:

在get拦截函数中,调用track方法之前,需要增加一个判断条件。 // 如果key的类型是symbol,则不track。

数组的查找方法

数组的内部方法其实都依赖了对象的基本语义。所以大部分情况下,我们不需要做特殊处理。

js 复制代码
const arr = reactive([1, 2, 3, 4, 5]);

effect(() => {
  console.log(arr.includes(1)); // 初始打印true
});
arr[0] = 3; // 副作用函数重新执行,并打印false

但是includes方法并不总是按照预期工作。

js 复制代码
const obj = {};
const arr = reactive([obj]);

console.log(arr.includes(arr[0])); // 应该返回true却返回false

这是为什么呢?

js 复制代码
// get拦截函数
if(typeof res === 'object' && res !== null){
  // 如果值可以被代理,则返回代理对象
  return isReadonly ? readonly(res) : reactive(res)
}

arr.include(arr[0])。其中,arr[0]得到的是一个代理对象,而在includes方法内部也通过arr访问数组元素,从而也得到一个代理对象,问题是这两个代理对象是不同的。这是因为每次调用reactive函数时都会创建一个新的代理对象:

js 复制代码
function reactive(obj){
  // 每次调用reactive时,都会创建新的代理对象
  return createReactive(obj)
}

解决方法

定义一个reactiveMap,用来存储原始对象到代理对象的映射。每次调用reactive函数创建代理对象之前,优先检查是否已经存在相应的代理对象,如果存在,则直接返回已有的代理对象。

js 复制代码
const obj = {};
const arr = reactive([obj]);

console.log(arr.includes(obj)); // false

上面🌰中,为什么会打印false呢?includes内部的this指向的是代理对象arr,并且在获取数组元素时得到的也是代理对象,所以用原始对象obj去查找肯定会返回false。

我们需要重写数组的includes方法才能解决这个问题。

js 复制代码
const obj = {};
const arr = reactive([obj]);

console.log(arr.includes(obj)); // false

解决方法:

  1. 首先我们来看如何重写includes方法:修改get拦截函数:arr.includes可以理解为读取代理对象arr的includes属性,这就会触发get拦截函数。在该函数内检查target是否是数组,如果是数组并且读取的键值存在于arrayInstrumentations上,则返回定义在arrayInstrumentations对象上响应的值。当执行arr.includes时,就是在执行arrayInstrumentations上的includes函数,这样就实现了重写。
  2. 自定义includes函数。其中的this指的是代理对象,我们先在代理对象中查找,这就是arr.includes(obj)的默认行为。如果找不到,通过this.raw拿到原始数组,再去其中查找,最后返回结果。

除了includes方法之外,还需要类似处理的数组方法有indexOflastIndexOf

隐式修改数组长度的原型方法

主要是指的是像push/pop/shift/unshift数组的栈方法。

js 复制代码
const arr = reactive([]);

effect(() => {
  arr.push(1);
});

effect(() => {
  arr.push(1);
});

如果在浏览器中运行上面这个🌰,会得到栈溢出的错误。

为什么会这样呢?

  • 第一个副作用函数执行。调用arr.push方法。push会去读数组的length属性。所以会和length属性建立响应联系。
  • 第二个副作用函数执行。它也会与length属性建立响应联系。arr.push方法不仅会间接读取length,还会间接设置length。
  • 第二个函数的arr.push设置了length。于是响应系统把与length相关的副作用函数全部取出并执行,其中包括第一个副作用函数。第二个副作用函数还未执行完毕,就要再次执行第一个副作用函数了。
  • 第一个副作用函数再次执行。于是响应系统把与length相关的副作用函数全部取出并执行,其中包括第二个副作用函数。
  • 如此循环往复,最终导致调用栈溢出。

这个问题的原因是push方法会间接读取length属性

解决思路:

我们只要屏蔽对length属性的读取,从而避免在它与副作用函数之间建立响应联系。我们需要重写数组的push方法。 也就是执行push时,不track副作用函数。

ps: 除了push方法之外,pop、shift、unshift、splice等方法都需要做类似的处理。

代理Set和Map

集合类型数据与普通对象存在很大的不同。

Set的原型属性和方法 用法
size 返回集合中元素的数量
add(value) 向集合中添加给定的值
clear() 清空集合
delete(value) 从集合中删除给定的值
has(value) 判断集合中是否存在给定的值
keys() 返回一个迭代器对象。可用于for...of循环,迭代器对象产生的值为集合中的元素
values() 和keys()相同
entries() 返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值[value, value]
forEach(callback[, thisArg]) forEach函数会遍历集合中的所有元素,并对每一个元素调用callback函数,thisArg用于指定callback函数执行时的this值。
Map的原型属性和方法 用法
size 返回Map数据中的键值对数量
clear() 清空Map
delete(key) 删除指定key的键值对
has(key) 判断Map中是否存在指定key的键值对
get(key) 读取指定key对应的值
set(key) 为Map设置新的键值对
keys() 返回一个迭代器对象。迭代过程会产生键值对的key值
values() 返回一个迭代器对象。迭代过程会产生键值对的value值
entries() 返回一个迭代器对象。迭代过程会产生[key, value]组成的数组值
forEach(callback[, thisArg]) forEach函数会遍历Map中的所有键值对,并对每一个键值对调用callback函数,thisArg用于指定callback函数执行时的this值。

如何代理Set和Map

普通对象和get/set的读取和设置操作差异:

js 复制代码
// 普通对象的读取和设置操作
const obj = { foo: 1 };
obj.foo; // 读取数据
obj.foo = 2; // 设置数据

// 用get/set方法操作Map数据
const map = new Map();
map.set("key", 1); // 设置数据
map.get("key"); // 读取数据

我们先来了解关于使用Proxy代理Set或Map类型数据的注意事项。

js 复制代码
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {});

console.log(p.size);

执行上面的代码会报错。

报错原因: 代理对象p来访问size属性,回去检查是否存在内部槽[[]SetData]]。很显然,代理对象不存在内部槽[[SetData]]

为了修复这个问题,我们需要修正访问器属性的getter函数执行时的this指向。

js 复制代码
const s = new Set([1, 2, 3]);

const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === "size") {
      // this指向原始对象
      return Reflect.get(target, key, target);
    }
    return Reflect.get(target, key, receiver);
  },
});
console.log(s.size); // 3

接着我们尝试从p中删除数据。也会报错。size是一个访问器属性,而delete是一个方法。无论怎么修改receiver,delete方法执行时this都会指向代理对象p,而不是指向原始Set对象。

解决方法:

只需要把delete方法与原始数据对象绑定。

js 复制代码
const p = new Proxy(s, {
    get(target, key, receiver) {
        if (key === "size") {
          return Reflect.get(target, key, target);
        }
        // 将方法与原始数据对象target绑定后返回
        
        },
    }
);

建立响应联系

js 复制代码
const p = reactive(new Set([1, 2, 3]));

effect(() => {
  console.log(p.size);
});

// 添加值为1的元素,应该触发响应
p.add(1);

如果想实现上面的🌰。我们需要在访问size时调用track函数。然后在add方法执行时调用trigger函数。

解决方法: 1.get拦截函数中,key === size时去track; 2.重写add方法,在add方法中trigger;

优化代码 如果调用add时添加的元素重复了,就不需要触发响应了。

相同的思路可以实现delete

避免污染原始数据

借助Map类型数据的set和get来学习什么是"避免污染原始数据"及其原因。

js 复制代码
const p = reactive(new Map([["key", 1]]));

effect(() => {
  console.log(p.get("key"));
});

p.set("key", 2); // 触发响应

自己来重写set和get方法。

上面的🌰能正常工作,但它仍然存在问题,即set方法会污染原始对象。

js 复制代码
const m = new Map();
const p1 = reactive(m);
const p2 = reactive(new Map());
p1.set("p2", p2);

effect(() => {
  // 注意这里通过原始数据m访问p2
  console.log(m.get("p2").size);
});

// 注意这里通过原始数据m为p2设置一个键值对foo-->1
m.get("p2").set("foo", 1);

打印出两个0。当我们通过原始数据m设置数据值时,副作用函数又重新执行了。这不是我们所期望的行为,因为原始数据不应该具有响应式数据的能力。否则就意味着用户既能操作原始数据,又能操作响应式数据,这样一来代码就会乱套了。

导致问题的原因是什么呢? 之前实现set方法时,将value原样设置到了原始数据target上。如果value是响应式数据,设置到原始对象上的也会是响应式数据。这就造成了数据污染

解决方法: 在调用target.set函数之前,需要判断value是否是响应式数据。如果是响应式数据,那么就通过raw获取原始数据,再调用target.set函数。

处理forEach

集合类型的forEach方法类似于数组的forEach。

js 复制代码
const m = new Map([[{ key: 1 }, { value: 1 }]]);

const p = reactive(m);
effect(() => {
  p.forEach(function (value, key, m) {
    console.log(value);
    console.log(key);
  });
});

任何会修改Map对象键值对数量的操作都应该触发副作用函数重新执行,例如delete和add方法等。

解决方法:

  1. 重写forEach方法。
  2. forEach方法中,让副作用函数与ITERATE_KEY建立响应联系。
js 复制代码
const m = new Map([[{ key: 1 }, { value: 1 }]]);

const p = reactive(m);
effect(() => {
  p.forEach(function (value, key, m) {
    console.log(value);
    console.log(key);
  });
});
p.set({ key: 2 }, { value: 2 });

上面的🌰可以正常工作了。但是重写的forEach方法仍然存在缺陷。重写的forEach方法内,通过原始数据对象调用了原生的forEach方法,即:

js 复制代码
target.forEach(cb)

传递给cb回调函数的参数是非响应式数据。下面这个🌰就不能正常工作了。

js 复制代码
const key = { key: 1 };
const value = new Set([1, 2, 3]);

const p = reactive(new Map([[key, value]]));

effect(() => {
  p.forEach(function (value, key) {
    console.log(value.size);
  });
});

p.get(key).delete(1);

导致问题的原因就是:当通过value.size访问size属性时,这里的value是原始数据对象,即new Set([1,2,3]),因此无法建立响应联系。reactive本身是深响应,forEach方法的回调接受的参数应该是响应式数据才行。

解决方法:

  1. 重写forEach函数: 将传递给callback函数的参数包装成响应式的。
  2. 检查重写get函数是否正常返回。

出于严谨性,我们还需要做一些补充。因为forEach还可以接受第二个参数,该参数可以用来执行callback函数执行时的this值

无论使用for...in还是forEach循环遍历一个对象,它们的响应联系都是建立在ITERATE_KEY与副作用函数之间的。但是它们存在本质的不同 ,当使用for...in循环时,它只关心对象的键,而不关心对象的值。

js 复制代码
effect(() => {
  for(const key in obj){
      console.log(key)
  }
})

只有当新增、删除对象的key时,才需要重新执行副作用函数。我们在trigger函数内判断type是否是ADDDELETE,进而知道是否需要触发依赖。对于SET类型来说,它不会改变对象的键的数量,所以不需要触发依赖。

但这个规则不适用于Map类型的forEach。

js 复制代码
const p = reactive(new Map([["key", 1]]));

effect(() => {
  p.forEach(function (value, key) {
    // forEach不仅关心Map的键,还关心集合的值
    console.log(value);
  });
});
p.set("key", 2); // 即使type是SET,也应该触发响应

解决方法:

trigger方法中增加一个判断条件 :如果操作的目标对象是Map类型,则typeSET时应该触发那些与ITERATE_KEY相关联的副作用函数重新执行。

迭代器方法

前面讲过for...of循环遍历数组:

  1. entries
  2. keys
  3. values
js 复制代码
const itr = m[Symbol.iterator]()
console.log(itr.next()) // {value: ["key1", "value1"], done: false}
console.log(itr.next()) // {value: ["key2", "value2"], done: false}

for...of循环迭代m.entriesm会到相同的结果。

下面这个🌰

js 复制代码
const m = new Map([
  ["key1", "value1"],
  ["key2", "value2"],
]);

for (const [key, value] of m.entries()) {
  // TypeError: p is not iterable
  console.log(key, value);
}

p.set("key3", "value3");

当我们使用for...of循环迭代一个代理对象时,内部会尝试从代理对象p上读取p[Symbol.iterator]属性,这个操作会触发get拦截函数。

之前重写forEach方法时,传递给callback的参数是包装后的响应式数据。

使用for...of也应该是包装后的响应式数据。

让副作用函数与ITERATE_KEY建立联系的作用 : 迭代器操作与集合元素的数量有关,只要集合的size发生变化,就应该触发依赖。

解决方法:

  1. 我们需要重写Symbol.iterator方法。
  2. 传递给callback的参数是包装后的响应式数据。
  3. 为了追踪for...of对数据的迭代操作,还需要调用track函数,让副作用函数与ITERATE_KEY建立联系。

由于p.entriesp[Symbol.iterator]等价,我们也可以实现对p.entries函数的拦截(理论上)。

js 复制代码
effect(() => {
  for (const [key, value] of p.entries()) {
    console.log(key, value);
  }
});

当我们运行上面的🌰时,发现报错了:p.entries的返回值不是一个可迭代的对象。p.entries函数的返回值是一个对象,该对象带有next方法,但不具有Symbol.iterator方法,因此它确实不是一个可迭代对象。

有两个概念不要混淆了:

  • 可迭代协议:一个对象实现了Symbol.iterator方法。
  • 迭代器协议:一个对象实现了next方法。

但一个对象可以同时实现可迭代协议迭代器协议

js 复制代码
const obj = {
  // 迭代器协议
  next(){}
  // 可迭代协议
  [Symbol.iterator](){
    return this
  }
}

解决方案: return:迭代器协议可迭代协议

values和keys方法

values方法的实现与entries方法类似,不同的是:

js 复制代码
effect(() => {
  for (const [key, value] of m.entries()) {
    console.log("entries--->", key, value);
  }
  for (const value of m.values()) {
    console.log("values--->", value);
  }
});

解决方法: valuesIterationMethod和iterationMethod有两点区别:

  1. iterationMethod通过target[Symbol.iterator]获取迭代器对象,而valuesIterationMethod通过target.values获取迭代器对象。
  2. iterationMethod处理的是键值对,即[wrap(value[0]), wrap(value[1])],而valuesIterationMethod只处理值,即wrap(value)

keys方法和values方法非常相似。不同点在于,keys处理的是键而不是值。改完按理论上可行。但是下面这个🌰

js 复制代码
 const m = new Map([
  ["key1", "value1"],
  ["key2", "value2"],
]);
const p = reactive(m);

effect(() => {
  for (const value of p.keys()) {
    console.log("values--->", value); // key1 key2 
  }
});

p.set("key2", "value3"); // 这是一个SET类型的操作,它修改了key2的值

p.set("key2", "value3")修改了key2的值。Map类型数据的所有键都没有发生变化,所以副作用函数不应该执行。

这是因为上面我们在trigger方法中加了一个SET的判断条件。

这对于values或entries等方法来说是必需的,但对于keys方法没有必要。

解决方法:

  1. 我们新建一个MAP_KEY_ITERATE_KEY替代ITERATE_KEY
  2. values和entries等方法依然依赖MAP_KEY_ITERATE_KEY,keys方法则依赖ITERATE_KEY
  3. 当SET类型操作时,只会触发ITERATE_KEY相关联的依赖。
  4. 当ADD和DELETE类型操作时,需要触发MAP_KEY_ITERATE_KEYITERATE_KEY相关联的依赖。
相关推荐
cronaldo91几秒前
研发效能DevOps: Vite 使用 Element Plus
vue.js·vue·devops
敲啊敲95277 分钟前
5.npm包
前端·npm·node.js
brrdg_sefg22 分钟前
Rust 在前端基建中的使用
前端·rust·状态模式
m0_748230941 小时前
Rust赋能前端: 纯血前端将 Table 导出 Excel
前端·rust·excel
qq_589568101 小时前
Echarts的高级使用,动画,交互api
前端·javascript·echarts
黑客老陈2 小时前
新手小白如何挖掘cnvd通用漏洞之存储xss漏洞(利用xss钓鱼)
运维·服务器·前端·网络·安全·web3·xss
正小安2 小时前
Vite系列课程 | 11. Vite 配置文件中 CSS 配置(Modules 模块化篇)
前端·vite
编程百晓君2 小时前
一文解释清楚OpenHarmony面向全场景的分布式操作系统
vue.js
暴富的Tdy2 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js