前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

ES6系列之Proxy的拦截机制与实战场景

首页2018-12-21 10:00:24Front-End
ES6JavaScriptProxy元编程

Vue 2 到 Vue 3 最大的一次底层重写,就是把响应式系统从 Object.defineProperty 换成了 Proxy。这次替换解决的问题非常具体:Vue 2 里给对象新增属性不会触发更新,得靠 $set;数组直接改下标也不生效,得靠重写七个数组方法。这些补丁的根源都在于 defineProperty 只能劫持已经存在的属性,它拦的是「某个 key 的读写」,而不是「这个对象上的所有操作」。

Proxy 换了个思路。它不改动目标对象本身,而是在外面套一层壳,所有进出的操作都要先过这层壳。这篇把 13 个 trap 的分工列清楚,然后用六个实际场景展示它能干什么,最后聊聊它和 defineProperty 的取舍。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Proxy 的基本结构,target 和 handler 各管什么
  • 13 个 trap 分别在什么操作时被触发
  • 用 Proxy 实现真正的私有变量(两种拦截思路)
  • 把校验逻辑从业务类里抽离出来
  • 访问日志、废弃预警、请求去重三个中间件式的用法
  • Proxy.revocable 撤销代理,以及它适合的场景
  • Proxy 相比 Object.defineProperty 的优势和代价

# 一、Proxy 概述

先看看它的兼容性,这张图是 2018 年前后的情况。

Proxy 在各浏览器中的兼容性支持情况

图里能看到 IE 全线飘红,这也是 Vue 3 直接放弃 IE11 的原因之一。Proxy 的行为无法被 polyfill,因为它拦截的是语言层面的基础操作,用 JS 自己实现不出来。这一点和大多数新特性不一样,别的都能靠转译或垫片兼容,Proxy 只能靠运行时原生支持。

proxy 在目标对象的外层搭建了一层拦截,外界对目标对象的某些操作,必须通过这层拦截。

var proxy = new Proxy(target, handler);
@前端进阶之旅: 代码已经复制到剪贴板

new Proxy() 表示生成一个 Proxy 实例,target 参数表示所要拦截的目标对象,handler 参数也是一个对象,用来定制拦截行为。

var target = {
   name: 'poetries'
 };
 var logHandler = {
   get: function(target, key) {
     console.log(`${key} 被读取`);
     return target[key];
   },
   set: function(target, key, value) {
     console.log(`${key} 被设置为 ${value}`);
     target[key] = value;
     return true;
   }
 }
 var targetWithLog = new Proxy(target, logHandler);

 targetWithLog.name; // 控制台输出:name 被读取
 targetWithLog.name = 'others'; // 控制台输出:name 被设置为 others

 console.log(target.name); // 控制台输出: others
@前端进阶之旅: 代码已经复制到剪贴板

读取 targetWithLog 的属性值时,实际上执行的是 logHandler.get,在控制台输出信息,并且读取被代理对象 target 的属性。设置属性值时执行的是 logHandler.set,输出信息之后再去设置 target 上的值。

原文的 set 里没有 return true,我补上了。这不是可有可无的细节:严格模式下 set trap 返回假值会抛 TypeError: 'set' on proxy: trap returned falsish。ES 模块默认就是严格模式,所以这个坑现在几乎必踩。

最后一行 console.log(target.name) 输出 others,说明改动落到了原对象上。代理不是复制,target 和 proxy 操作的是同一份数据。

拦截函数可以完全不理会 target:

// 由于拦截函数总是返回 35,所以访问任何属性都得到 35
var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

proxy.time // 35
proxy.name // 35
proxy.title // 35
@前端进阶之旅: 代码已经复制到剪贴板

Proxy 实例也可以作为其他对象的原型对象:

var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

let obj = Object.create(proxy);
obj.time // 35
@前端进阶之旅: 代码已经复制到剪贴板

proxy 对象是 obj 对象的原型,obj 对象本身并没有 time 属性,所以根据原型链会在 proxy 对象上读取该属性,导致被拦截。

这里有个很容易踩的边界:必须通过 proxy 访问才会触发拦截。上面那个例子里如果你直接 target.time,什么都不会发生。所以用 Proxy 做响应式的时候,一定要保证外部拿到的是 proxy 而不是原对象,一旦某个地方漏出了 target 的引用,那条路径上的改动就全部丢失了。

对于代理模式,Proxy 的作用主要体现在三个方面:

  • 拦截和监视外部对对象的访问
  • 降低函数或类的复杂度
  • 在复杂操作前对操作进行校验,或者对所需资源进行管理

# 二、Proxy 所能代理的范围

handler 本身是 ES6 新设计的一个对象,作用是自定义代理对象的各种可代理操作。它一共有 13 种方法,每种方法都可以代理一种操作。

原文这里写的是「13 中方法」,是个错别字,应为「13 种」。另外原文的 andler.defineProperty() 少了个 h,一起改了。

// 在读取代理对象的原型时触发,比如执行 Object.getPrototypeOf(proxy) 时
handler.getPrototypeOf()

// 在设置代理对象的原型时触发,比如执行 Object.setPrototypeOf(proxy, null) 时
handler.setPrototypeOf()

// 在判断一个代理对象是否可扩展时触发,比如执行 Object.isExtensible(proxy) 时
handler.isExtensible()

// 在让一个代理对象不可扩展时触发,比如执行 Object.preventExtensions(proxy) 时
handler.preventExtensions()

// 在获取代理对象某个属性的属性描述时触发,比如执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时
handler.getOwnPropertyDescriptor()

// 在定义代理对象某个属性的属性描述时触发,比如执行 Object.defineProperty(proxy, "foo", {}) 时
handler.defineProperty()

// 在判断代理对象是否拥有某个属性时触发,比如执行 "foo" in proxy 时
handler.has()

// 在读取代理对象的某个属性时触发,比如执行 proxy.foo 时
handler.get()

// 在给代理对象的某个属性赋值时触发,比如执行 proxy.foo = 1 时
handler.set()

// 在删除代理对象的某个属性时触发,比如执行 delete proxy.foo 时
handler.deleteProperty()

// 在获取代理对象的所有属性键时触发,比如执行 Object.getOwnPropertyNames(proxy) 时
handler.ownKeys()

// 在调用一个目标对象为函数的代理对象时触发,比如执行 proxy() 时
handler.apply()

// 在给一个目标对象为构造函数的代理对象构造实例时触发,比如执行 new proxy() 时
handler.construct()
@前端进阶之旅: 代码已经复制到剪贴板

十三个看着多,实际按用途分成四组就好记了。

日常最常用的是属性读写组:get、set、has、deleteProperty、ownKeys。响应式框架、私有变量、访问控制基本都在这五个里打转。

其次是描述符组:defineProperty、getOwnPropertyDescriptor。它们负责属性元信息,多数时候你不主动写它们,但 set 内部可能会间接触发(Reflect 那篇会展开)。

再往下是扩展性组:isExtensible、preventExtensions、getPrototypeOf、setPrototypeOf,属于比较冷门的语言机制。

最后是函数组:apply 和 construct,只有在被代理对象本身是函数或类的时候才会用上,做函数埋点、参数校验、单例工厂都靠它们。

有个约束要记住:trap 不能想返什么就返什么。比如 target 上有一个 configurable: false, writable: false 的属性,get trap 就必须返回和原值相同的东西,否则引擎直接抛 TypeError。这些叫不变量(invariants),是标准为了保证语言基本假设不被破坏定的规矩,写复杂 handler 时被它拦住是很常见的事。

# 三、Proxy 的六个实战场景

# 3.1 实现私有变量

约定用下划线开头表示私有,这个惯例大家都懂,但它没有任何强制力。

var target = {
   name: 'poetries',
   _age: 22
}

var logHandler = {
  get: function(target, key){
    if(key.startsWith('_')){
      console.log('私有变量不能被访问')
      return undefined
    }
    return target[key];
  },
  set: function(target, key, value) {
     if(key.startsWith('_')){
      console.log('私有变量不能被修改')
      return false
    }
     target[key] = value;
     return true
   }
}
var targetWithLog = new Proxy(target, logHandler);

// 正常返回 'poetries'
targetWithLog.name;

// 打印「私有变量不能被访问」
targetWithLog._age;

// 打印「私有变量不能被修改」
targetWithLog._age = 30;
@前端进阶之旅: 代码已经复制到剪贴板

原文这段的示例有点乱:注释写着「私有变量 age 不能被访问」,但访问的是 name,实际根本不会触发那个分支。上面改成访问 _age 才对得上注释,同时把 get 里的 return false 改成了 return undefined(读一个禁止访问的属性返回 false 语义很怪),并给 set 的成功分支补上 return true。

再看一个更贴近真实的例子。下面的代码声明了一个私有的 apiKey,便于 api 这个对象内部的方法调用,但不希望从外部也能够访问 api._apiKey:

fe
  • 一、Proxy 概述
  • 二、Proxy 所能代理的范围
  • 三、Proxy 的六个实战场景
    • 3.1 实现私有变量
    • 3.2 抽离校验模块
    • 3.3 访问日志
    • 3.4 预警和拦截
    • 3.5 过滤操作
    • 3.6 中断代理
  • 四、和 Object.defineProperty 的取舍
  • 总结
  • 参考

← ES6系列之Reflect的设计意图与API全览React setState 原理分析,批量更新与状态队列的完整链路 →