1

JavaScript 的 Proxy

前言

从本质上讲,Proxy 提供了一种为对象的基本操作定制行为的方法。将其视为中间人,位于代码和对象之间,拦截并可能改变对象的交互方式。允许开发人员为读取属性、分配值甚至确定属性是否存在等操作定义自定义行为。除此之外,代理的真正吸引力在于它们的潜在应用,从数据验证和属性监视到对象虚拟化等更高级的模式,下面我来一一探索

什么是 Proxy

在 JavaScript 中 Proxy 对象类似于一个保护罩或一个包裹另一个对象的仲裁者,我们将其称为“目标”。这种包装允许代理拦截并控制在目标对象上执行的各种基本操作。这就像有一个监护人监督我们如何与数据交互,让我们有能力重新定义或定制这些交互

基本语法

创建代理很简单,但了解其结构对于有效利用至关重要。构造函数 Proxy 需要两个主要参数
  • target:原始对象
  • handler:定义目标操作的自定义行为
const target = {};
let proxy = new Proxy(target, {
  get: function (target, property, receiver) {
    return property in target ? target[property] : "Not Found";
  },
});

proxy.name = "Riki";

console.log(proxy.name); // Outputs: "Riki"
console.log(proxy.age); // Outputs: "Not Found"

可撤销代理

假设我们有一个资源,并且希望随时关闭对其的访问,我们就可以把它包装成一个可撤销的代理
  1. 基本使用
const object = {
  data: "Valuable data",
};

const { proxy, revoke } = Proxy.revocable(object, {});

//将代理的对象传递到某个地方
console.log(proxy.data);

// 通过调用revoke关闭代理
revoke();

// 代理对象不再可以反问
console.log(proxy.data); // Error

调用 revoke()是将代理中对目标对象的所有内部引用删除,因此它们不再连接,revoke 与是分开的 proxy 是分开的,通常我们需要在离开当前作用域时同时进行传递,可以将其绑定到代理 proxy.revoke = revoke,但这样不太高级的样子

  1. 高级使用我们知道 WeakMap 不会阻止垃圾收集,如果使用 WeakMap,当代理对象变得“无法访问”时我们就不再需要为其做善后工作
const revokes = new WeakMap();

let object = {
  data: "Valuable data",
};

let { proxy, revoke } = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// 当你需要禁用代理的时候
const revokeProxy = revokes.get(proxy);

revokeProxy();

console.log(proxy.data); // Error (revoked)

应用

对于对象上的大多数操作,JavaScript 规范中有一个“内部方法”。例如 [[Get]]读取对象属性时调用、[[Set]]写入属性值时候调用。我们不能直接通过方法名来调用它们,下面是一些常见的映射关系
内部方法处理程序方法触发时机
[[Get]]get读取属性
[[Set]]set写入属性
[[HasProperty]]hasin 操作员
[[Delete]]deleteProperty delete操作员
[[Call]]apply函数调用
[[Construct]]constructnew 操作员
[[GetPrototypeOf]]getPrototypeOf对象 getPrototypeof
[[SetPrototypeOf]]setPrototypeOf对象.setPrototypeOf
[[IsExtensible]]isExtensible对象可扩展
[[PreventExtensions]]preventExtensions对象.preventExtensions
[[DefineOwnProperty]]defineProperty对象.defineProperty ,对象.defineProperties
[[GetOwnProperty]]getOwnPropertyDescriptor对象.getOwnPropertyDescriptor , for..in,Object.keys/values/entries
[[OwnPropertyKeys]]ownKeys对象.getOwnPropertyNames ,对象.getOwnPropertySymbols , for..in,Object.keys/values/entries

注意
内部方法和陷阱必须满足的条件

  • [[Set]] [[Delete]] 如果值写入成功则必须返回 true,否则 false
  • [[GetPrototypeOf]],读取代理的原型必须始终返回目标对象的原型

接下来介绍内部方法的代理

  1. get 代理

    拦截属性读取,handler 应该有一个方法 get(target, property, receiver)。

    它在读取属性时触发,带有以下参数

    • target 是目标对象,作为第一个参数传递给 new Proxy,
    • property 属性名称,
    • receiver 如果目标属性是 getter,那么 receiver 就是将 this 在其调用中使用的对象。通常这是 proxy 对象本身
let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    // 如果数组中存在即返回 不存在返回 0
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // default value
    }
  },
});

console.log(numbers[1]); // 1
console.log(numbers[123]); // 0 (没有索引为123的项 返回 0)

// 正如所见,我们可以用来Proxy实现“默认”值的任何逻辑
  1. set 代理

    当写入属性时会触发 set(target, property, value, receiver)
    • target 是目标对象,作为第一个参数传递给 new Proxy
    • property 属性名称
    • value 适当的价值
    • receiver 与 gettrap 类似,仅对 setter 属性重要
let numbers = [];

numbers = new Proxy(numbers, {
  set(target, prop, val) {
    // 仅当值为number类型复制成功
    if (typeof val == "number") {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  },
});

numbers.push(1); // 添加成功
numbers.push(2); // 添加成功
console.log("Length is: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' on proxy returned false)

// 对于set,它必须返回true才能成功写入。如果我们忘记执行此操作或返回任何错误值,则会触发该操作TypeError

我们不必重写诸如 push 和 unshift 之类的增值数组方法来在其中添加检查,因为它们在内部使用[[Set]]被代理拦截的操作

  1. 使用“ownKeys”和“getOwnPropertyDescriptor”代理对象迭代

    Object.keys、for..in 和大多数其他迭代对象属性的方法使用[[OwnPropertyKeys]]内部方法来获取属性列表。

    这些方法在细节上有所不同:

    • Object.getOwnPropertyNames(obj)返回非符号键
    • Object.getOwnPropertySymbols(obj)返回符号键
    • Object.keys/values()返回带有标志的非符号键/值 enumerable(属性标志在文章属性标志和描述符中进行了解释)
    • for..in 使用标志循环非符号键 enumerable 以及原型键

    下面的我们代理 ownKeys,并且在使用 Object.keys、Object.values 时来跳过以“下划线”开头的属性

let user = {
  name: "riki",
  age: 30,
  _password: "***",
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter((key) => !key.startsWith("_"));
  },
});

// 使用 for 循环
for (let key in user) console.log(key); // name, age

console.log(Object.keys(user)); // [name, age]
console.log(Object.values(user)); // [riki, 30]

到目前为止没有任何问题,但如果我们返回对象中不存在的键呢?猜猜会发生什么?

let user = {};
user = new Proxy(user, {
  ownKeys(target) {
    return ["a", "b", "c"];
  },
});

console.log(Object.keys(user)); // <empty>

// 原因是:Object.keys仅返回带有 enumerable 标志的属性。为了检查它,它调用每个属性的内部方法[[GetOwnProperty]]来获取其描述符。在这里,由于没有属性,它的描述符是空的,没有enumerable标志,所以它被跳过。

let user = {};

user = new Proxy(user, {
  ownKeys(target) {
    return ["a", "b", "c"];
  },
  getOwnPropertyDescriptor(target, prop) {
    return {
      enumerable: true, // 看这里
      configurable: true,
    };
  },
});

console.log(Object.keys(user)); // a, b, c
  1. 使用 deleteProperty 保护属性

    有一个普遍的约定,即以下划线前缀的属性和方法是内部的。不应从对象外部访问它们,让我们来实现他
let user = {
  name: "riki",
  _password: "***",
};

user = new Proxy(user, {
  // 读取
  get(target, prop) {
    if (prop.startsWith("_")) {
      throw new Error("Access denied");
    }
    let value = target[prop];
    // 注意 当值是函数时,我们绑定它,这样它就可以使用 this
    return typeof value === "function" ? value.bind(target) : value;
  },
  // 写入
  set(target, prop, val) {
    if (prop.startsWith("_")) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  // 删除
  deleteProperty(target, prop) {
    if (prop.startsWith("_")) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  // 遍历
  ownKeys(target) {
    return Object.keys(target).filter((key) => !key.startsWith("_"));
  },
});

try {
  console.log(user._password); // Error: Access denied
} catch (e) {
  console.log(e.message);
}

try {
  user._password = "test"; // Error: Access denied
} catch (e) {
  console.log(e.message);
}

try {
  delete user._password; // Error: Access denied
} catch (e) {
  console.log(e.message);
}

for (let key in user) console.log(key); // name
  1. in 操作符拦截

    我们想使用 in 运算符来检查数字是否在某个范围
let range = {
  start: 1,
  end: 10,
};

range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  },
});

console.log(5 in range); // true
console.log(50 in range); // false
  1. apply 代理

    假设我们现在需要一个延迟函数
  • 通常的做法
function delay(f, ms) {
  return function () {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  console.log(`Hello, ${user}!`);
}
console.log(sayHi.length); // 1

sayHi = delay(sayHi, 3000);

sayHi("riki"); // Hello, riki! (after 3 seconds)

// 这样功能当然能实现,但是delay函数不会转发属性读/写操作,比如函数的name、length等等
console.log(sayHi.length); // 0
  • 更高级的使用代理实现
function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    },
  });
}

function sayHi(user) {
  console.log(`Hello, ${user}!`);
}

sayHi = delay(sayHi, 3000);

console.log(sayHi.length); // 1

sayHi("riki"); // Hello, riki! (after 3 seconds)
  1. Reflect 内置对象

    前面说过,内部方法,例如[[Get]]、[[Set]]等都是不能直接调用。Reflect 让其成为可能
let user = {};

Reflect.set(user, "name", "Riki");

console.log(user.name); // Riki

对于每个可由 Proxy 捕获的内部方法,在 Reflect 中都有一个相应的方法,其名称和参数与陷阱相同

let user = {
  name: "Riki",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, val, receiver) {
    return Reflect.set(target, prop, val, receiver);
  },
});

console.log(user.name); // Riki
user.name = "Pete";
console.log(user.name); // Pete

但似乎不使用 Reflect 我们也能实现上面的例子。的确是这样,那么为什么为我们提供了 Reflect 呢?
让我们看一个例子来说明为什么 Reflect.get 更好!

// 这里有一个 user 对象
let user = {
  _name: "Guest",
  get name() {
    return this._name;
  },
};

// userProxy 是一个代理,它拦截 user 对象的所有读取操作
let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop];
  },
});

// admin继承自user
let admin = {
  __proto__: userProxy,
  _name: "Admin",
};

console.log(admin.name); // Guest; (why??)

预期中 admin.name 应该返回"Admin";发生了什么?

  • 当我们读取 时 admin.name,由于 admin 对象没有自己的属性,因此搜索将转到其原型
  • 原型 proto 是 userProxy 然后非预期的信息出现了

Reflect.get 如果我们使用它,一切都会正常工作

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  },
};

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

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

console.log(admin.name); // Admin; (
  1. 限制

    许多内置对象,例如 Map、Set、Date、Promise 使用所谓的“内部槽”,这些类似与属性,但保留用于内部为了规范用途
let map = new Map();
// 当Mapp被代理的时候
let proxy = new Proxy(map, {});

proxy.set("test", 1); // Error (set方法失效了)

幸运的是,有一种方法可以解决这个问题

let map = new Map();

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == "function" ? value.bind(target) : value;
  },
});

proxy.set("test", 1);
console.log(proxy.get("test")); // 1 (这是可以正常工作的)

// 类似的还有对象的私有属性,也需要通过上面的方法转发

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

几个小应用

  1. 读取对象不存在的属性报错
let user = {
  name: "Riki",
};

function wrap(target) {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      } else {
        throw new ReferenceError(`Property doesn't exist: "${prop}"`);
      }
    },
  });
}

user = wrap(user);

console.log(user.name); // Riki
console.log(user.age); // ReferenceError: Property doesn't exist: "age"
  1. 访问负数组[-1]
let array = [1, 2, 3];

array = new Proxy(array, {
  get(target, prop, receiver) {
    if (prop < 0) {
      prop = Number(prop) + target.length;
    }
    return Reflect.get(target, prop, receiver);
  },
});

console.log(array[-1]); // 3
console.log(array[-2]); // 2
  1. Observable
let handlers = Symbol("handlers");

function makeObservable(target) {
  // 添加一个处理函数列表
  target[handlers] = [];

  // 添加 一个 observe 方法
  target.observe = function (handler) {
    this[handlers].push(handler);
  };

  // 然后代理他
  return new Proxy(target, {
    set(target, property, value, receiver) {
      // 这里我们又使用了Reflect 防止非预期的行为
      let success = Reflect.set(...arguments);

      // 当赋值成功后一起调研观察者函数
      if (success) {
        target[handlers].forEach((handler) => handler(property, value));
      }
      return success;
    },
  });
}

let user = {};

user = makeObservable(user);

user.observe((key, value) => {
  console.log(`SET ${key}=${value}`);
});

user.name = "Riki"; // SET name=Riki

关注公众号 回家不迷路

OTT前端技术


Riki一二三
6.1k 声望1.2k 粉丝