写给林妹妹的Proxy与Reflect

436 阅读10分钟

作为林妹妹的"松弟"(兄弟),指望她自己发挥主观能动性总结下不会的知识点,是不现实的(而在她这周又千里迢迢跑到湖南考试的大时代背景下,更是天方夜谭)。
所以,我只好就勉为其难,补充补充自己的知识盲区了。
希望她看到了以后,有种醍醐灌顶的感觉,正所谓:“听君一席话,如听一席话...”

简述: Proxy和Reflect概要

Proxy和Reflect是ES6推出的两个重要的API,这两个API经常配合使用,他们相当于将以前JS引擎底层的功能暴漏了一部分出来,使得开发者可以使用这些功能。这这一部分,我们首先要搞清的问题就是,这两个东西是干嘛的,为什么要有这些东西。

Proxy

Proxy暴露了在对象上的内部工作,代理是一种封装,能够拦截并改变 JS 引擎的底层操作。 这句话来自Nicholas C. Zakas的《深入理解ES6》这本书(翻译版本)。

这句话值得我们仔细分析,得到了和Proxy有关的几个关键信息:

  1. 是什么呢:暴露了在对象上的内部工作。
  • 在对象上,这个对象,点明了操作对象是object,那么我们就知道,基本类型肯定不行,比如number、string、boolean....这些都不行,不能使用Proxy;那么函数可以吗,当然可以,函数在JS中本质也是对象。
  • 内部工作:什么工作是内部工作?我们刚才已经澄清了操作对象是object,那么这里的内部工作就是那些引擎做的事情,比如obj.age = 12,这是一个赋值语句,设置obj这个对象,是一种对object操作行为,但是这个设置究竟是怎么设置的?JS程序猿不用关心,交给JS引擎就好了,那么这些交给JS引擎的事情就是所谓“内部工作”。 至于这些“内部工作”都有哪些,后文讨论。
  1. 用来做什么:代理是一种封装,能够拦截并改变 JS 引擎的底层操作 这句话就就比较好理解了:代理用来拦截JS引擎的底层操作,这里的“底层操作”差不多就是我们上文中“内部工作”,但我理解其实更底层,这不重要。JS引擎的工作被拦截了,我们自然可以在拦截器里面定制一些行为,比如加点逻辑,比如直接返回,拒绝执行等。

上面咬文嚼字的分析完,差不多可以得到一个简练的结论了: 代理就是对象的操作行为拦截器

结论就是这么的爽脆~

对象的操作总结

接着,简单总结下对象操作:我们都能对对象做什么(上文中的“内部工作有哪些”)? 假设存在一个对象let obj = { name : 'yxnne', age: 3 },我们在代码中能做什么呢?

  1. 获得对象属性值
obj.name
  1. 设置对象属性值
obj.name = 'Muscular Man'
  1. 判断某个属性是不是存在
console.log('name' in obj) //true

  1. 删除属性
delete obj.name
  1. 获取某对象的原型对象
Object.getPrototypeOf(obj)

Object.getPrototypeOf()和obj.__proto__拿到的应该是一致的

  1. 设置某个对象的原型对象
Object.setPrototypeOf(obj, { favor: 'muscle building' });

  1. 查看对象是不是可以添加属性
Object.isExtensible(obj)
  1. 设置对象不可添加属性
Object.preventExtensions(obj)
  1. 都有哪些属性
Object.getOwnPropertyNames(obj); // ['name', 'age' ]
Object.getOwnPropertySymbols(obj); // []
Object.keys(obj) // ['name', 'age' ]

上面这些方法都能得到属性key对应的数组。Object.getOwnProperyNames() 、Object.keys() 方法会将Symbol类型的属性过滤出去。 Object.getOwnPropertySymbols() 只会返回Symbol类型的属性。

  1. 定义对象的属性

const defaultSettings = {
	writable:true,
	enumerable:true,
	configurable:true
};
Object.defineProperty(obj, 'hasWife', {			
	value: true,
  ...defaultSettings,
})
Object.defineProperties(obj,{
	isMarried: {
		value: function(){ return this.hasWife },
    ...defaultSettings,
	},
	getBasicInfo: {
		value:function(){ return `${this.name} / ${this.age} / ${this.favor}` },
    ...defaultSettings,
	}	
})

  1. 获得某个属性的描述对象
Object.getOwnPropertyDescriptor(obj,'name')

得到的name属性的描述对象就是:{value: "yxnne", writable: true, enumerable: true, configurable: true}

  1. function对象可以执行
fn(); // 执行

fn.call(null, 1, 2, 3) 

fn.apply(null, [1, 2, 3])
  1. function作为构造函数
function Dog(){}
let luDan = new Dog();

Proxy用法

Proxy是一个构造函数,用法就是new Proxy(target, handler)。(还有一种用法是通过Proxy.revocable()创建一个可撤销代理,let { proxy, revoke } = Proxy.revocable(target, {});如果撤销则调用revoke(),这个不是重点) Proxy返回了不同于目标target的对象的另外一个对象,称之为代理对象,通常情况下,我们希望对目标对象的操作行为,经过被代理对象拦截。

  • target就是目标对象
  • handler就是对象操作行为的拦截器,有时候也被称为“陷阱”。

我们再回看上文的总结“代理就是对象的操作行为拦截器。”这句话需要再明晰下,代理其实是另一个对象,而拦截行为是作用于另一个对象自己的,本质上不是被拦截原对象的。 而我们期望作用到原对象身上,所以,代理和原对象之间需要有一定的依赖关系。 举例说吧:

const a = { age: 12 }

const proxy_a = new Proxy(a, {
  set(target, k, v, receiver) {
    target[k] = v + 1 // 虚岁 +1
  }
})

a和proxy_a是两个对象,proxy_a是基于a的代理对象。当设置proxy_a.age的时候,proxy_a拦截自身的set行为,所以走了我们handler中的set方法,在set方法中我们的逻辑是把target的age属性修改(k属性设置赋值为v)。 那其实完全可以在set方法中写其他逻辑,比如:

const proxy_a = new Proxy(a, {
  set(target, k, v, receiver) {
    proxy_a.age = 0 
  }
})

虽然几乎没有这样用的,但是不代表不可以。这样的话对proxy_a的赋值就无法作用在a上。因为我们没有使用代理和原对象之间的依赖关系。 话说回来,既然我们通常希望通过代理去拦截对原始对象的操作行为,这个依赖关系是一定要使用的。就是这个参数target,它代表了原始对象。

Reflect

概念

如果你接触过Java,并且对反射的概念先入为主,你可能会感到吃力,因为发现似乎ES6中的Reflect和Java反射不太一样。 在类似Java这种强类型语言中,反射的概念是指运行时动态的加载类,可以动态的获得方法信息,并调用的能力。(正常方法是通过一个类创建对象,反射方法就是通过一个对象找到一个类的信息。) 从这个角度来看,Java的反射倒是有点像JS中Object.getOwnPropertyNames()或者Object.keys()这样的方法。

ES的Reflect要是按照先入为主的Java反射理解就摸不着头脑。

那么,重新理解ES6中的Reflect,在ES6中,Reflect就是针对对象的操作行为的方法,由JS引擎暴露出来的API。比如说,对对象的属性赋值操作,这个行为底层的操作,和你调用Reflect.set()是一致的。

const obj = { name: 'yxnne' }

// 下面两件事其实做了同一件事情
obj.name = 'strong'

Reflect.set(obj, 'name', 'man')

Why Reflect?

上面的概念的解释很平淡,而且例子很奇怪,看上去Reflect并没什么特别的还很麻烦很多余。 那么,为什么是要有Reflect?(查阅了一些资料)

  1. 将Object对象的一些明显属于语言内部的方法(比如 Object.defineProperty),放到 Reflect 对象上。目前都这些方法在Reflect和Object都存在。

  2. 对对象操作都变成函数。类似delete这种命令给他函数调用化:Reflect.deleteProperty(), in 操作符也一样 Reflect.has()

  3. Object一些方法返回结果更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而 Reflect.defineProperty(obj, name, desc)则会返回 false。

  4. Reflect、 Proxy的方法一一对应。这个可能是最重要的原因了知乎:ES6设计反射Reflect的意义是什么?(除了把key in obj、delete这些方式函数化)?[译]深入理解 ES6 中的反射

Proxy/Reflect API: 十三道峰味

上文中列举了13种对object的操作方法,这些方法对应的,Reflect、 Proxy也都是对应的。 另外,需要说明的是,下文中的每一个方法都存在Proxy和Reflect对应的同名方法(这就是拦截器,也可以称之为陷阱函数),通用的套路就是在Proxy的某陷阱函数中直接return Reflect[某陷阱函数]就相当于什么都没做,JS的默认行为。 那么就简单过一下,看看这十三道峰味吧。

get

get的Proxy、Reflect对应的参数都是3个:

  • trapTarget :将接收属性的对象(即代理的目标对象);
  • key :需要写入的属性的键(字符串类型或符号类型);
  • receiver :操作发生的对象(通常是代理对象)。 下面代码中定义了一个target对象的代理对象proxy,proxy对get操作进行拦截,当试图去读取对象不存在的属性值时,会抛出错误。如果待读取属性是存在的,利用Reflect拿到target的属性值。
let target = { };

let proxy = new Proxy(target, { 
  get(trapTarget, key, receiver) { 
    if (!(key in receiver)) { 
      throw new TypeError("Property " + key + " doesn't exist."); 
    } 
    return Reflect.get(trapTarget, key, receiver); 
  } 
}); 

我们验证下:

// 添加属性的功能正常 
proxy.name = "proxy"; 
console.log(proxy.name); // "proxy" 
// 读取不存在属性会抛出错误 
console.log(proxy.nme); // 抛出错误

在Nicholas C. Zakas的《深入理解ES6》中提到说:

在多数语言中,试图读取 target.name 属性都会抛出错误,因为该属性并不存在;但 JS 语 言却会使用 undefined 。如果你曾经在大型代码库上进行过工作,那么你可能明白这种行为 会导致严重的问题,尤其是当属性名称存在书写错误时。使用代理进行对象外形验证,可以 帮你从这个错误中拯救出来。

set

set的Proxy、Reflect对应的参数都是4个:

  • trapTarget :将接收属性的对象(即代理的目标对象);
  • key :需要写入的属性的键(字符串类型或符号类型);
  • value :将被写入属性的值;
  • receiver :操作发生的对象(通常是代理对象)。 下面代码定义了一个代理,用于对目标target的属性赋值行为拦截验证,其验证的逻辑是,新增属性的话,必须是number类型,不然就报错。
let target = { name: "target" };
let proxy = new Proxy(target, {
  set(trapTarget, key, value, receiver) { // 忽略已有属性,避免影响它们 
    if (!trapTarget.hasOwnProperty(key)) {
      if (isNaN(value)) {
        throw new TypeError("Property must be a number.");
      }
    }
    // 添加属性 
    return Reflect.set(trapTarget, key, value, receiver);
  }
});

测试:

// 添加一个新属性 
proxy.count = 1; 
console.log(proxy.count); // 1 
console.log(target.count); // 1 // 你可以为 name 赋一个非数值类型的值,因为该属性已经存在 

proxy.name = "proxy"; 
console.log(proxy.name); // "proxy" 
console.log(target.name); // "proxy" 

// 抛出错误 
proxy.anotherName = "proxy";

has

has会在使用 in 运算符的情况下被调用。会被传入两个参数:

  • trapTarget :需要读取属性的对象(即代理的目标对象);
  • key :需要检查的属性的键(字符串类型或符号类型); 用法和上面的差不多。 下面代码的效果相当于屏蔽了某些属性:
let target = { 
  name: "target", value: 42 
}; 
let proxy = new Proxy(target, { 
  has(trapTarget, key) { 
    if (key === "value") { 
      return false; 
    } else { 
      return Reflect.has(trapTarget, key); 
    } 
  } 
}); 


console.log("value" in proxy); // false 
console.log("name" in proxy); // true 
console.log("toString" in proxy); // true

本来value 与 toString 均存在于 object 对象中,因此 in 运算符都会返回 true 。(value 是对象自身的属性,而 toString 则是原型属性(从 Object 对象上继承而来))。但是,我们的代理对象proxy使用has拦截了in操作,在in中屏蔽了value,所以导致结果value in proxy返回的是false。

deleteProperty

deleteProperty 会在使用 delete 运算符去删除对象属性时下被调用,并且会被传 入两个参数:

  • trapTarget :需要删除属性的对象(即代理的目标对象);
  • key :需要删除的属性的键(字符串类型或符号类型)。

这里需要注意下,delete操作符是有返回的,表示删除成功与否:let result = delete target.name;比如某个属性设置成不可配置的了,却还去删除,那结果就是false:

Object.defineProperty(target, "name", { configurable: false });

// 试图删除不可配置属性
let result = delete target.name; 
console.log(result); // false

Proxy、Reflect的deleteProperty使用举例如下:

let proxy = new Proxy(target, { 
  deleteProperty(trapTarget, key) { 
    if (key === "value") { 
      return false;  // 表示不可删除
    } else { 
      return Reflect.deleteProperty(trapTarget, key); 
    } 
  } 
});

getPrototypeOf/setPrototypeOf

获取和设置某对象的原型对象,这两个放一起介绍。对应的Object方法是Object.getPrototypeOf() 和 Object.setPrototypeOf()。对应的Proxy和Reflect中的函数就叫getPrototypeOf/setPrototypeOf参数如下:

  • trapTarget :需要设置原型的对象(即代理的目标对象);
  • proto :需用被用作原型的对象。

还有一些需要注意的点:

  • getPrototypeOf 陷阱函数的返回值必须是一个对象或者 null ,其他任何类型会抛出错误;
  • setPrototypeOf 必须在操作没有成 功的情况下返回 false

最基本的使用:

let target = {}; 
let proxy = new Proxy(target, { 
  getPrototypeOf(trapTarget) { 
    return Reflect.getPrototypeOf(trapTarget); 
  }, 
  setPrototypeOf(trapTarget, proto) { 
    return Reflect.setPrototypeOf(trapTarget, proto); 
  } 
}); 

下面这段代码则是利用了代理将目标对象的原型隐藏起来,同时也使它不可修改:

let target = {}; 
let proxy = new Proxy(target, { 
  getPrototypeOf(trapTarget) { 
    return null; 
  }, 
  setPrototypeOf(trapTarget, proto) { 
    return false; 
  } 
}); 
let targetProto = Object.getPrototypeOf(target); 
let proxyProto = Object.getPrototypeOf(proxy); 
console.log(targetProto === Object.prototype); // true 
console.log(proxyProto === Object.prototype); // false 
console.log(proxyProto); // null 
// 成功 
Object.setPrototypeOf(target, {}); 
// 抛出错误 
Object.setPrototypeOf(proxy, {});

preventExtensions/isExtensible

可扩展性就是,查看对象是不是可以添加属性,以及阻止对象添加属性。对应的Object方法是:Object.isExtensible()、Object.preventExtensions()

let target = {}; 
let proxy = new Proxy(target, { 
  isExtensible(trapTarget) { 
    return Reflect.isExtensible(trapTarget); 
  }, 
  preventExtensions(trapTarget) { 
    return Reflect.preventExtensions(trapTarget); 
  } 
});

defineProperty

Proxy的defineProperty要求你返回一个布尔值用于表示操作是否已成功。 当它返回 true 时,Object.defineProperty() 会正常执行; 返回 false ,则 Object.defineProperty() 会抛出错误; 下面这段代码的作用是,不允许定义Symbol类型的属性:

let proxy = new Proxy({}, { 
  defineProperty(trapTarget, key, descriptor) { 
    if (typeof key === "symbol") { 
      return false; 
    }
    return Reflect.defineProperty(trapTarget, key, descriptor); 
  } 
});

参数中 descriptor是一个对象,其可用的属性有:enumerable 、 configurable 、 value 、 writable 、 get 、 set; 当给proxy定义一个Symbol的属性,就会抛错:

let nameSymbol = Symbol("name"); // 抛出错误 
Object.defineProperty(proxy, nameSymbol, { value: "proxy" });

getOwnPropertyDescriptor

要求返回值必须是 null 、 undefined ,或者是一个对象。 当返回对象时,只允许该对象拥有 enumerable 、 configurable 、 value 、 writable 、 get 或 set 这些自有属性。如果你返回的对象存在其他属性,则会报错:

let proxy = new Proxy({}, { 
  getOwnPropertyDescriptor(trapTarget, key) { 
    return { name: "proxy" }; 
  } 
}); 
// 抛出错误 
let descriptor = Object.getOwnPropertyDescriptor(proxy, "name");

Object.getOwnPropertyDescriptor() 的第一个参数key如果是基本类型,默认将该参数转换为一个对象。 Reflect.getOwnPropertyDescriptor() 的第一个参数key如果是基本类型的时候抛出错误:

let descriptor1 = Object.getOwnPropertyDescriptor(2, "name"); 
console.log(descriptor1); // undefined 
// 抛出错误 
let descriptor2 = Reflect.getOwnPropertyDescriptor(2, "name");

ownKeys

Proxy的ownKeys接受单个参数,即目标对象,同时必须返回一个数组或者一个类数组对象,不合要求的返回值会导致错误。返回的这个数组会被用于四个方法:

  • Object.keys()
  • Object.getOwnPropertyNames()
  • Object.getOwnPropertySymbols()
  • Object.assign() 方法, Object.assign() 方法会使用该数组来决定哪些属性会被复制。 另外,ownKeys 陷阱函数也能影响 for-in 循环,因为这种循环内部调用了Proxy陷阱函数来决定哪些值能够被用在循环内。 比如又过滤了Symbol属性:
let proxy = new Proxy({}, {
  ownKeys(trapTarget) {
    return Reflect.ownKeys(trapTarget).filter(key => {
      return typeof key !== "symbol";
    });
  }
});

const sy = Symbol('s')
proxy[sy] = 'my symbol props value'
Object.keys(proxy) // []

apply/construct

函数也是对象,拥有两个内部方法: [[Call]] 与 [[Construct]] ,前者会在函数被直接调用时执行,而后者会在函数被使用 new 运算符调用时执行。使用了函数的代理,会被视为函数。 Proxy和Reflect的apply函数都接受三个参数:

  • trapTarget :被执行的函数(即代理的目标对象);
  • thisArg :调用过程中函数内部的 this 值;
  • argumentsList :被传递给函数的参数数组。

当使用 new 去执行函数时, construct 陷阱函数会被调用并接收到下列两个参数:

  • trapTarget :被执行的函数(即代理的目标对象);
  • argumentsList :被传递给函数的参数数组。

下面是一个最基本的例子:

let target = function () { return 42 };
let proxy = new Proxy(target, { 
  apply: function (trapTarget, thisArg, argumentList) { 
    return Reflect.apply(trapTarget, thisArg, argumentList); 
  }, 
  construct: function (trapTarget, argumentList) { 
    return Reflect.construct(trapTarget, argumentList); 
  } 
}); 
// 使用了函数的代理,其目标对象会被视为函数 
console.log(typeof proxy); // "function" 
console.log(proxy()); // 42 

let instance = new proxy(); 
console.log(instance instanceof proxy); // true 
console.log(instance instanceof target); // true

当然,具体应用肯定没有这么无聊,后文再说。

应用

校验和过滤

set陷阱校验

set拦截的是赋值行为,这可能是程序中最高频的操作了,所以这也是Proxy/Reflect最根本、最核心、最常用的思路的。本质上你可以在set、get中添加任何你需要的逻辑。 下面代码的作用是在给对象添加新的属性时,限制其值必须是number。

let target = { name: "target" };
let proxy = new Proxy(target, {
  set(trapTarget, key, value, receiver) { // 忽略已有属性,避免影响它们 
    if (!trapTarget.hasOwnProperty(key)) {
      if (isNaN(value)) {
        throw new TypeError("Property must be a number.");
      }
    }
    // 添加属性 
    return Reflect.set(trapTarget, key, value, receiver);
  }
});

当然,这只是很小的一个DEMO,实际应该起来有无限种可能。

校验函数参数

函数参数校验可能过去都放在函数体里面,这很常规,但不见得最好,因为这样导致函数的不相关逻辑会越来越多。 下面的例子使用了代理校验函数参数(只能是number类型),同时限制了这个函数不能做为构造函数使用:

// 将所有参数相加 
function sum(...values) {
  return values.reduce((previous, current) => previous + current, 0);
}

let sumProxy = new Proxy(sum, {
  apply: function (trapTarget, thisArg, argumentList) {
    argumentList.forEach((arg) => {
      if (typeof arg !== "number") {
        throw new TypeError("All arguments must be numbers.");
      }
    });
    return Reflect.apply(trapTarget, thisArg, argumentList);
  },
  construct: function (trapTarget, argumentList) {
    throw new TypeError("This function can't be called with new.");
  }

}); 
console.log(sumProxy(1, 2, 3, 4)); // 10 

// 抛出错误 
console.log(sumProxy(1, "2", 3, 4)); 

// 同样抛出错误 
let result = new sumProxy();

以上限制了函数不能作为构造函数,那么对应的,也可以限制为只能作为构造函数使用:


function Numbers(...values) { 
  this.values = values; 
} 
let NumbersProxy = new Proxy(Numbers, { 
  apply: function (trapTarget, thisArg, argumentList) { 
    throw new TypeError("This function must be called with new."); 
  }, 
  construct: function (trapTarget, argumentList) { 
    argumentList.forEach((arg) => { 
      if (typeof arg !== "number") { 
        throw new TypeError("All arguments must be numbers."); 
      } 
    }); 
    return Reflect.construct(trapTarget, argumentList); 
  } 
}); 

let instance = new NumbersProxy(1, 2, 3, 4); 
console.log(instance.values); // [1,2,3,4] 
// 抛出错误 
NumbersProxy(1, 2, 3, 4);

好,那下面这个思路又做了什么事情呢?

apply: function(trapTarget, thisArg, argumentsList) { 
  return Reflect.construct(trapTarget, argumentsList); 
}

答案是:调用函数构造器无需使用new关键字:


let instance = NumbersProxy(1, 2, 3, 4); 
console.log(instance.values); // [1,2,3,4] 

当然,以上又只是冰山一角了,用法还是有无限可能。

获取属性过滤

写属性的时候教研,那么读属性的时候就可能需要过滤。 比如,我们js程序猿以前在定义私有属性的时候,喜欢用下划线开头:

const linSister = {
  gender: 'female',
  _age: 27,
}

这是一种曾经的非官方约定,现在还有人保持这个习惯。但是JS引擎并不在乎(主不在乎...),所以,利用Proxy可以扩展功能,将这些私有属性过滤掉:

let proxy = new Proxy({}, {
  ownKeys(linSister) {
    return Reflect.ownKeys(trapTarget).filter(key => {
      return key[0] !== "_";
    });
  }
});

高级用法

参考这篇文章 Proxy 可以做哪些有意思的事情? 其中管道这个用法感觉挺有意思的,摘录一下:

在最新的 ECMA 提案中,出现了原生的管道操作符 |>,在 RxJS 和 NodeJS 中都有类似的 pipe 概念。 使用 Proxy 也可以实现 pipe 功能,只要使用 get 对属性访问进行拦截就能轻易实现,将访问的方法都放到 stack 数组里面,一旦最后访问了 execute 就返回结果。

const pipe = (value) => {
    const stack = [];
    const proxy = new Proxy({}, {
        get(target, prop) {
            if (prop === 'execute') {
                return stack.reduce(function (val, fn) {
                    return fn(val);
                }, value);
            }
            stack.push(window[prop]);
            return proxy;
        }
    })
    return proxy;
}
var double = n => n * 2;
var pow = n => n * n;
pipe(3).double.pow.execute;


社区知名用法

vue3数据绑定

众所周知,vue3的MVVM方案基于:Proxy + Weakmap + effect,数据响应这块放弃了vue2的Object.defineProperty。

如果只是简单使用proxy依旧存在问题:

  1. 属性是对象的引用问题,比如data.arr[3] = 'ccc';这个赋值,只写个proxy能监听到吗?监听不到...
  2. 和Object.defineProperty一样,对数组的可能操作index的操作,有可能会触发多次方法... 下面通过源码分析,简单的过一下流程,看看如何解决这些问题的。(我自己复习下,一年没看vue了)

vue3中的实现,这里面最重要的就是reactive.ts 和 effect.ts:功能上来说,reactive可以把数据变成响应式数据,返回代理对象,而effect接受一个函数,这个函数会在响应式数据变化的时候被触发, 大致就是这个意思:

const { reactive, effect } = VueReactivity;

const data = { count: 0 };
// 返回一个监听的新数据 proxy
const state = reactive(data); 

const fn = () => {
   const count = state.count;// ==>get   count:fn();
   console.log('当前的count:', count);
};

effect(fn);

reactive.ts:


export function reactive(target: object) {
  //只读数据,直接返回  readonly ==>只读数据
  if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
  }
  //调用创建响应式数据方法
  return createReactiveObject(
    target,
    false,
    mutableHandlers,//不同逻辑的处理情况
    mutableCollectionHandlers
  )
}

其核心逻辑是调用了createReactiveObject函数, mutableHandlers、mutableCollectionHandlers涉及更细致的具体的代理过程: createReactiveObject代码如下:


function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }

  // 已经是响应式数据,并且不是只读的  proxy 
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }

  // 原始数据已经有代理数据,直接找到之前代理后的数据
  const proxyMap = isReadonly ? readonlyMap : reactiveMap
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }


  //  判断数据是否可以被代理,比如VNode对象,component实例等
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  
  // 处理代理逻辑,内部有对应的get、set
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  );

  //代理之后,直接设置,避免下次重复代理一样的原始数据
  proxyMap.set(target, proxy);
  //返回数据
  return proxy
}

看到在这个函数中,首先先进行了几个判断:target已经是响应式数据吗?已经有代理数据吗?数据是否可以被代理?都通过之后,构建了代理对象,这时候具体的方法:


targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers

对于集合走collectionHandlers、对于数组和对象,走baseHandlers,重点关注下baseHandlers,这个东西其实是引入进来的mutableHandlers,位于包中baseHandlers.ts文件中,这里才是核心逻辑:

export const mutableHandlers: ProxyHandler<object> = {
  get, // get逻辑,收集依赖
  set, // set逻辑,发布订阅

  deleteProperty,
  has,
  ownKeys
}

先看get:

const get = /*#__PURE__*/ createGetter();

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    //对特殊的key,做了一层处理,判断对应数据是否什么响应类型数据,只读与响应式。或者获取原始数据
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (
      key === ReactiveFlags.RAW &&
      receiver === (isReadonly ? readonlyMap : reactiveMap).get(target)
    ) {
      return target
    }

    // 是数组吗?
    const targetIsArray = isArray(target)

    // 数组则借助此对象; arrayInstrumentations
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

    //其他情况,直接获取 
    const res = Reflect.get(target, key, receiver)

    //symbol不处理,直接返回
    if (
      isSymbol(key)
        ? builtInSymbols.has(key as symbol)
        : key === `__proto__` || key === `__v_isRef`
    ) {
      return res
    }

    // 不是只读数据,开始收集依赖:track
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key) 
    }

    if (shallow) {
      return res
    }

    if (isRef(res)) { // 如果获取到的值是ref数据,直接返回对应的value情况
      // ref unwrapping - does not apply for Array + integer key.
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }

   // 如果是对象,那就做一个懒代理,避免一开始就深度嵌套
   // 这里很有点意思
    if (isObject(res)) { 
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

这个get函数就很有点东西了~首先也是先做了一堆判断,然后判断了下是不是数组,数组暂且不表。如果不是数组,Reflect.get(target, key, receiver)通过反射拿到原始对象的值,res,之后,做了两件事情:1)track,收集依赖;2)判断res是不是object; 为什么要判断res是不是object,这里其实就是vue3对于对象嵌套监听不到的问题的答案。

当获取的值res,经过isObject(res)判断之后又是对象,这种情况下对res也代理下,再调用reactive(res),这样解决了对象嵌套监听不到的问题,同时,这个是懒代理,懒在哪里?懒在没有像vue2那样对所有属性就深度遍历,而是对属性真正访问的时候,才去做的这样的代理,这样必然节约计算成本,性能甚好,岂不美哉?

如果是数组怎么做?

// 重写数组,因为数组还是会存在修改一次数据,触发多次修改的情况,直接重写方法
const arrayInstrumentations: Record<string, Function> = {}
// values,可能会触发多次get的情况
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    //获取到数据的原始数据
    const arr = toRaw(this)
    for (let i = 0, l = this.length; i < l; i++) {
      track(arr, TrackOpTypes.GET, i + ''); // 收集一次依赖
    }
    // we run the method using the original args first (which may be reactive)
    const res = method.apply(arr, args);// 第一次执行,可能参数是响应式数据的情况
    if (res === -1 || res === false) {// 可能是数据被代理的情况,查找原始数据,重新处理
      // if that didn't work, run it again using raw values.
      return method.apply(arr, args.map(toRaw))
    } else {
      return res
    }
  }
})

// 改变数组的情况,可能导致多次get、set,特定处理
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ...args: unknown[]) {
    pauseTracking()
    const res = method.apply(this, args) // 执行方法
    resetTracking()
    return res
  }
});

和vue2思路是相同的,重新数组方法。但是这个重写的逻辑都是借用原始的方法,const method = Array.prototype[key] as any,然后拿到原始方法的值 const res = method.apply(this, args)

看下set

const set = /*#__PURE__*/ createSetter()
function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    // 获取到原来的值
    const oldValue = (target as any)[key]
    if (!shallow) {
      // 处理对应ref数据的情况
      value = toRaw(value)
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value; // ref的时候,需要修改value来触发对应数据的内部逻辑
        return true
      }
    } else {
      // in shallow mode, objects are set as-is regardless of reactive or not
    }

    // 判断这个key是新添加的,还是修改的
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
        // 触发修改逻辑
    const result = Reflect.set(target, key, value, receiver)

    // don't trigger if target is something up in the prototype chain of original
    // receiver 为Proxy或者继承Proxy的对象,这里需要处理原型链的情况,因为如果原型链继承的也是一个proxy,通过Reflect.set修改原型链上的属性会触发两次setter
    
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        //trigger 派发通知
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

set的核心逻辑就是要得到被代理对象的值,并且通知值已经产生变化,关注核心逻辑,const result = Reflect.set(target, key, value, receiver),得到result值,通过trigger派发通知(新增key和已存在的key调用trigger不太一样)

总结下来:

  1. 在get阶段,vue3通过懒代理的方式解决object嵌套问题、通过重写数组方法解决调用数组方法后多次触发的问题;这个阶段涉及track收集依赖;
  2. 在set阶段,涉及trigger,派发通知的过程;

(贴了这么多代码,好像有点跑题了,说回代理....)

proxy比Object.defineProperty好在哪里呢?Object.defineProperty的机制是对每一个属性进行重写,是从属性的视角来出发做一些动作,但是proxy是从对象本身的角度出发。 Object.defineProperty对于新增属性,那就要重新对这个属性进行监听,但是proxy就不用,因为对象就已经被监听(代理)过了。

微前端中的JS隔离方案

我们知道,微前端方案中要做JS隔离,因为不同项目之间来回切换的话,需要保证一个项目中用到的上下文不被另一个项目所污染,这就是所谓JS隔离。 qiankun中有两种JS隔离方案:快照、代理。 快照的原理就是通过遍历和对比,恢复每一个环境的现场,是比较暴力的方法, 操作的对象直接就是window对象,不太安全。 今天这个主题,重点看下代理:(qiankun中叫:ProxySandbox)

/**
 * 基于 Proxy 实现的沙箱
 */
export default class ProxySandbox implements SandBox {
    /** window 值变更记录 */
    private updatedValueSet; // 沙箱的独立状态池, 与遗留沙箱的差异点,其它基本方法属性相同
    name: string;
    proxy: WindowProxy;
    type: SandBoxType;
    sandboxRunning: boolean;
    active(): void;
    inactive(): void;
    constructor(name: string);
}


var proxy = new Proxy(fakeWindow, {
  // 旧版本则是通过 diff 算法还原 window 对象状态快照,子应用之间的状态是隔离的,而父子应用之间 window 对象会有污染
  set: function set(target, p, value) {
    if (sandboxRunning) {
      // @ts-ignore
      // 这里没有对window对象产生影响,只是操作updatedValueSet
      target[p] = value;
      updatedValueSet.add(p);
      interceptSystemJsProps(p, value);
      return true;
    }

    if (process.env.NODE_ENV === 'development') {
      console.warn("[qiankun] Set window.".concat(p.toString(), " while sandbox destroyed or inactive in ").concat(name, "!"));
    } // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误


    return true;
  },

ProxySandbox沙箱set值到updatedValueSet,取值也优先命中updatedValueSet,没有的话再看window中存在与否。 相比较而言,ProxySandbox更加完备性,完全隔离了window对象,相对安全。

参考