JavaScript设计模式里,最容易“被名字耽误”的,我觉得就是代理模式。
很多前端同学一听到“代理”两个字,第一反应就是 ES6 的 Proxy,觉得这玩意儿无非就是给对象包一层,能监听属性变化,再高级一点就是“和响应式系统有关系”。可真到项目中要用它的时候,绝大多数人又退回老路子:接口缓存用全局变量硬塞,图片懒加载靠 scroll 事件手动节流,权限控制写一堆 if/else。这篇文章想做的就是把这些“本该用代理模式解决,却一直没用起来”的场景,一个个摊开讲清楚。内容包括:代理模式真正的设计意图、ES6 Proxy 和 Reflect 的配套用法、五个可以直接抄进项目的落地场景、与装饰器/中介者模式的边界判断,以及我在真实项目里踩过的五个大坑。不管你是刚接触设计模式的新人,还是写过两三年业务代码但没系统整理过代理模式的开发,读完都能直接照着落地。
1. 别急着写代码:先搞清楚代理模式“管”的是什么
1.1 一个很容易被误解的模式
代理模式的定义用一句话概括:通过一个中间对象去控制对目标对象的访问。听起来很抽象,换一个生活场景就好懂了。你去找一位行业专家咨询问题,并不会直接联系本人,而是先通过他的助理。助理负责过滤掉无意义的问题、整理你的诉求,再决定要不要把问题递过去;如果专家正在忙,助理还能告诉你“等一会儿再回复”。在这个过程中,你接触的是助理,真正干活的是专家。
对应到代码里,目标和代理的角色就非常清晰:真实对象干核心业务,代理负责在调用方和真实对象之间插入一道闸门。这道闸门可以做的事情非常多——缓存、鉴权、日志、惰性初始化、参数校验、访问限制,全都可以在这层做掉。这也是代理模式在经典设计模式分类里被归为结构型的原因:它不改变对象本身的职责,而是在对象的访问路径上做文章。
这里有个常见的误解,就是“代理模式就是 ES6 的 Proxy”。严格来说,ES6 Proxy 只是代理模式在 JavaScript 里的一种实现手段,而且是比较底层的手段。早年间没有 Proxy 的时候,前端同样可以用工厂函数、包装函数、Object.defineProperty 去模拟代理效果,只不过写起来很绕。有了 Proxy 之后,拦截变得更干净、更全面,所以现在讨论 JS 的代理模式,基本绕不开 Proxy。但你应该把 Proxy 看成工具,把代理模式看成设计意图,意图不成立时,工具再强也不该硬上。
1.2 三条判断标准:你的场景到底适不适合上代理
我在评审代码时,经常遇到为了用 Proxy 而用 Proxy 的写法。最稳妥的方式,是在动手前用三条标准过一遍,至少命中一条再考虑引入代理模式。
第一,访问控制。读取或修改对象之前,必须做额外处理,比如校验数据合法性、判断登录态、记录访问日志、阻止私有字段被外部读取。这类场景,代理是天然收口点。
第二,性能优化。真实对象的创建、计算或网络请求开销很大,但调用方不一定每次都需要真实结果。比如复杂计算结果可以缓存,大图可以延时加载,远程接口可以做成内存缓存,这些都可以用代理把开销挡在真正产生价值之前。
第三,关注点分离。业务代码不应该关心“数据有没有缓存”“权限够不够”“参数合不合法”这些横切逻辑,只希望拿到正确结果。代理能把横切逻辑从业务代码里抽出去,让调用方继续写很干净的代码。
还有一个很有效的自查方法:如果你设想一下去掉代理,发现需要把所有调用方的代码都改一遍才能恢复原有逻辑,那代理模式就是有意义的。如果只是换一个函数包装就能同样实现,那说明你的场景可能用不上代理。
2. 核心实现细节:ES6 Proxy 和 Reflect 的正确打开方式
2.1 从一次“丢失的 this”说起:get 里为什么要写 Reflect
刚开始用 Proxy 的时候,我踩过一个非常隐蔽的坑:在 get 拦截器里直接写return target[key],看起来没问题,但一旦目标对象的属性是 getter,this 的指向就会出问题。
const target = { price: 100, get double() { return this.price * 2; } }; const handler = { get(target, key) { // 错误写法:直接访问 target return target[key]; } }; const proxy = new Proxy(target, handler); console.log(proxy.double); // 200这个例子碰巧能正常运行,因为 double 这个 getter 内部的 this 指向了 target,而 target.price 就是 100。问题出在更复杂的场景里:如果外部创建了一个基于 proxy 的新对象,或者通过Object.create(proxy)让后面对象继承 proxy,又或者你在 getter 内部访问的属性本身也被代理层修饰过,直接用target[key]就会绕开代理上下文,拿到一个“全部基于原始对象”的结果。
正确的写法是始终使用Reflect.get(target, key, receiver)。
const handler = { get(target, key, receiver) { return Reflect.get(target, key, receiver); } };第三个参数 receiver 会作为 getter 内部 this 的指向,一般情况下它就是这个代理对象本身。这样读到的数据才会和目标对象的访问上下文保持一致。这个原则不只适用于 get,set、has 等 trap 也一样,凡是反射调用,能带上 receiver 就带上,省得后面追查奇怪的 this 问题。
2.2 五个常用 trap 的返回值与隐藏约束
Proxy 的 handler 提供了不少拦截点,实际开发里高频使用的主要是下面这几个。我整理成一张表,方便对照着看。
| trap | 触发时机 | 必须返回 | 容易踩的坑 |
|---|---|---|---|
| get | 读取属性 | 任意值 | 不带 receiver 导致 getter 的 this 错乱 |
| set | 写入属性 | 布尔值 | 严格模式返回 false 会直接抛 TypeError |
| has | in 检查 | 布尔值 | 返回值和 Object.keys 结果冲突会破坏语义 |
| deleteProperty | delete 操作 | 布尔值 | 删除不可配置属性返回 false 否则会抛错 |
| apply | 调用函数 | 任意值 | 要把 thisArg 透传给原函数 |
这里最关键的是 set 的返回值。很多人写完target[key] = value之后忘了 return true,结果在严格模式下赋值直接报错,后面我会在排障章节详细展开。
除了每个 trap 的返回值,还要知道一个叫做“不变量”的概念。引擎会校验你的拦截结果是否和对象本身的属性描述符冲突。举个例子,目标对象上如果有一个不可配置且不可写的属性,你在 get trap 里返回一个不同的值,引擎在严格校验时就会抛错,因为它不允许代理随意改变这种属性的语义。平时写业务可能很少碰到这么底层的约束,但如果你要做框架级封装,碰上了会排查很久,提前了解没坏处。
2.3 用 proxy.revocable 给代理加一个“总开关”
有些场景下,代理不是永久存在的,需要在某个时刻彻底关闭对目标对象的访问。这时候可以用Proxy.revocable。
const target = { name: '张三' }; const { proxy, revoke } = Proxy.revocable(target, { get(target, key, receiver) { return Reflect.get(target, key, receiver); } }); console.log(proxy.name); // 张三 revoke(); console.log(proxy.name); // TypeError: Cannot perform 'get' on a proxy that has been revoked这个能力非常实用。比如组件卸载之后,我不希望任何异步回调再往组件状态里写数据,可以直接调用 revoke,后续所有通过代理的读写操作都会立刻抛错,问题就会提前暴露在最接近错误发生的代码位置,而不是等到界面出现诡异数据后再反查。
权限临时回收、沙箱隔离这类场景,revoke 都是一个很好的安全兜底。不过要注意,revoke 之后这个代理实例就彻底废了,没有任何恢复手段,所以使用前一定要确认生命周期结束点。
3. 实际项目里最值得复制的五个落地场景
3.1 缓存代理:让重复请求和重计算直接消失
业务系统里最典型的浪费,是同一个函数用相同参数被反复执行,尤其是一些复杂的格式化计算,或者体感很慢的远程接口。传统做法是在函数外层包一层缓存逻辑,但这会污染函数本身的职责。用代理做缓存,可以把“缓存”彻底隔离出去。
function createCacheProxy(fn, cache = new Map()) { return new Proxy(fn, { apply(target, thisArg, args) { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key); } const result = Reflect.apply(target, thisArg, args); cache.set(key, result); return result; } }); } function fetchUserInfo(id) { // 模拟接口请求 return new Promise((resolve) => { setTimeout(() => resolve({ id, name: '用户' + id }), 1000); }); } const cachedFetchUserInfo = createCacheProxy(fetchUserInfo); cachedFetchUserInfo(1).then(console.log); cachedFetchUserInfo(1).then(console.log); // 第二次不再发请求这段代码把缓存逻辑放在 apply trap 里,以参数序列化后的字符串作为 key,命中直接返回。实际用的时候有几个注意点:JSON.stringify 生成的 key 在参数顺序变化时会不同,所以如果参数的顺序有商业含义,建议自行设计稳定的 key;如果函数返回的是 Promise,是否要缓存失败结果也需要单独设计,一般的做法是只缓存成功状态。
需要缓存的任务不一定都是异步函数,用 Map 做纯计算缓存也一样。我还会在缓存代理里加一个容量上限,避免用户不断传新参数把 Map 撑爆,这个细节在小程序端尤其重要。
3.2 虚拟代理:图片懒加载的正确打开姿势
虚拟代理的核心思想是:真实对象的创建开销很大,但我们不一定立刻需要它,所以先用一个轻量替身占位,等真正需要时再加载真实对象。图片懒加载是广告素材平台里最常见的例子,DOM<img>一旦设置了 src,浏览器就会立刻开始拉取图片,即使图片还在屏幕外面。
function createLazyImageProxy() { const realImg = new Image(); const holder = 'data:image/svg+xml;base64,占位符内容'; return new Proxy(realImg, { set(target, key, value) { if (key === 'src') { target.src = holder; const loader = new Image(); loader.onload = () => { target.src = value; }; loader.src = value; } else { Reflect.set(target, key, value); } return true; } }); } const img = createLazyImageProxy(); document.getElementById('box').appendChild(img); img.src = 'https://example.com/big.jpg';在只设置 src、图片还未真正加载完成之前,真实img元素始终显示占位图,等 loader 预加载结束后才替换成真实图片地址。这样带来的好处是:业务代码只需要一行赋值,不用关心加载状态、占位切换这些琐碎逻辑。
不过要注意,上面这段代码是为了讲清楚虚拟代理的思路,真正项目里如果要实时感知加载比例和失败回调,需要把 onload、onerror 也放到代理层统一管理,否则加载失败时图片会永远停留在占位状态。
3.3 保护代理:把私有属性的访问收口成一屏代码
很多团队约定私有属性用下划线开头,比如_password、_internalState。约定归约定,只要对象能被外部直接访问,总有人会不小心读到这些字段。保护代理可以在对象出口处挡住这种访问。
const user = { name: '张三', _password: '123456', _token: 'abcdef' }; const safeUser = new Proxy(user, { get(target, key, receiver) { if (key.startsWith('_')) { throw new Error(`私有属性 ${key} 不允许访问`); } return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { if (key.startsWith('_')) { throw new Error(`私有属性 ${key} 不允许修改`); } return Reflect.set(target, key, value, receiver); } }); console.log(safeUser.name); // 张三 console.log(safeUser._password); // 抛错需要强调一点,这种保护不是绝对安全,因为原始对象 user 如果直接暴露出去,照样能访问_password。所以正确的做法是:所有需要对外暴露的对象,一律导出代理,原始对象只保留在模块内部。这样外部拿到的所有数据都要经过代理这一关,把约定升级成了强制约束。
我在实际项目里还会把对象序列化接口一起拦掉,比如JSON.stringify会读取对象的 toJSON,可以在 get trap 里拦截toJSON,序列化前自动剔除下划线字段,避免不小心把敏感信息发到后端。
3.4 防抖代理:不需要修改函数主体就拥有防抖能力
防抖和节流在前端是高频需求,搜索框输入、按钮点击、滚动事件都要用。常见的写法是封装一个 debounce 工具函数,但用代理同样可以实现,而且代码非常简洁。
function debounce(fn, wait = 300) { let timer = null; return new Proxy(fn, { apply(target, thisArg, args) { if (timer) { clearTimeout(timer); } timer = setTimeout(() => { Reflect.apply(target, thisArg, args); }, wait); } }); } function handleSearch(keyword) { console.log('发起搜索', keyword); } const debouncedSearch = debounce(handleSearch, 300); debouncedSearch('设计模式'); debouncedSearch('设计模式实践'); // 只有最后一次调用会真正触发Proxy 的 apply trap 拦到函数调用时,直接用防抖逻辑包住原函数。好处是原函数不需要做任何改动,保持纯粹;坏处是防抖函数本身不返回同步值,所以如果调用方期待函数返回结果,这种场景并不适合改成代理防抖,要么取消防抖,要么改写返回 Promise 的延迟执行方案。
3.5 校验代理:数据写入源头做一刀切校验
表单校验通常是在提交时统一校验一遍,但有些数据是伴随着用户交互一点点写入的,比较理想的做法是在写入那一刻就拦截非法值。尤其当数据源被多个组件共享时,校验代理能把规则收敛到一个地方。
const userForm = new Proxy({}, { set(target, key, value, receiver) { if (key === 'age') { if (typeof value !== 'number') { throw new TypeError('age 必须是数字'); } if (value < 0 || value > 150) { throw new RangeError('age 必须在 0 到 150 之间'); } } if (key === 'email' && !/.+@.+\..+/.test(value)) { throw new Error('邮箱格式不正确'); } return Reflect.set(target, key, value, receiver); } }); userForm.age = 25; // 正常 userForm.age = -1; // 抛错 userForm.email = 'bad'; // 抛错这种写法的价值在于,校验不散落在各个事件回调里,而是跟着数据模型走。数据模型在哪,校验规则就在哪。缺点是异常处理需要调用方做好,set 抛错时,界面需要捕获并给出提示,不能让错误静默丢失。
4. 代理模式与装饰器、中介者:边界在哪里
4.1 代理和装饰器:一个控制入口,一个增强能力
代理模式和装饰器模式非常容易搞混,因为它们都是通过包装对象来介入原有逻辑。但核心区别,一句话就能说清楚:装饰器给对象增加新能力,代理限制和过滤对原有能力的访问。
用一个吃饭的例子来类比。装饰器是“给面包涂上果酱、夹上培根”,面包还是面包,但它的能力变丰富了;代理是“餐厅门口站了个保安,检查你的用餐凭证”,餐厅的菜还是那些菜,你只是多了道准入关卡。
在代码层面,装饰器也常返回一个新对象或新函数,但这个新对象内部通常会把目标的方法改造成更强大的版本,调用方甚至感知不到原本是谁。代理则不同,它拦截访问但不改变目标对象的本质,你在 get/set 里做的是“先检查,再放行”或“缓存结果”,而不是在原始功能上叠加功能。
| 特征 | 代理模式 | 装饰器模式 |
|---|---|---|
| 核心目的 | 控制访问 | 增强能力 |
| 是否改变原始接口 | 不改变 | 不改变 |
| 典型动作 | 缓存、鉴权、校验、日志 | 增加日志、增加缓存、增加重试 |
| 调用方感知 | 感知到中间层存在 | 感知到功能变强 |
这个表只是参考,实际代码里两者经常搭配使用:装饰器加能力,代理管入口。判断的时候问自己一句话:如果去掉这层包装,目标对象自身的能力是否受影响?如果受影响,那是装饰器;如果只是访问路径变了,那就是代理。
4.2 代理和中介者:别把“桥梁”和“看门”混在一起
中介者模式也经常被拿来和代理模式对比,因为它们都涉及对象与对象之间的协调。但它们的结构完全不同:代理是一对一的控制关系,中介者是一对多的解耦关系。
代理模式里,真实对象和调用方之间只有一道门,这道门就是代理。中介者模式里,多个对象谁都不直接引用谁,它们都只跟一个中介者打交道。打个比方,代理模式是“你想找某个人,先过助理这一关”;中介者模式是“小区业主、物业、快递员都通过物业前台沟通,没有前台大家谁也不联系谁”。
所以,当你只有一个对象需要被控制访问,用代理;当你有几个对象互相需要通信,而且希望去掉对象之间的直接耦合,用中介者。这个判断后面,几乎没有模糊地带。
5. 实战排障实录:代理对象上的五个大坑
5.1 set 里漏了返回值:严格模式直接给你脸色
我先说一个最常见的坑。很多人写 set trap,会这样写:
const proxy = new Proxy({}, { set(target, key, value) { target[key] = value; // 忘了 return true } }); proxy.a = 1;这段代码在严格模式下会抛TypeError: 'set' on proxy: trap returned falsish for property 'a'。原因在于,set 的返回值用于告诉引擎赋值操作是否成功。你返回 undefined,就会被当作 false,即赋值失败。而在严格模式下给对象赋值失败,规范要求直接抛错。
如果你写的代码文件默认开启严格模式,或者通过构建工具转译后加了严格模式,这个问题会立刻暴露。解决方式很简单:set trap 最后补上return true,表示赋值成功。而且要注意一点,如果 set 里做了校验并拒绝赋值,你要返回 false,但调用方的赋值语句在严格模式下会抛错,所以实际业务中,我更推荐在拒绝赋值时直接 throw 一个语义明确的错误,而不是返回 false,这样错误信息更清晰。
5.2 handler 里的 this 指向,不是你想的那样
Proxy 的 handler 方法被调用时,内部的 this 指向的是 handler 对象本身,不是 target,也不是 proxy。这个细节特别反直觉,我见过不少代码因此在 set trap 里把数据写到了错误的位置。
const handler = { set(target, key, value) { // 错误:this 是 handler,不是 target this[key] = value; return true; } }; const proxy = new Proxy({}, handler); proxy.name = '张三'; // target 没变,数据写到了 handler 里上面这种写法,赋值动作没有报错,但目标对象里根本没有 name 这个属性。如果你的代码后面又从 target 里取 name,会拿到 undefined。排错时很难想到是代理这里出了问题。
所以我的建议是:handler 里老老实实通过第一个参数 target 读写原始对象,不要使用 this 去操作数据。this 这个关键字在代理的 handler 里,基本没有用武之地。
5.3 内置对象的内部槽位:代理数组和 Date 要格外小心
做面向对象设计时,我们习惯把对象都统一成普通对象来看待,但 JavaScript 内置对象有一些内部槽位,比如数组的[[ArrayLength]]、Date 的时间内部值、Map 的[[MapData]]。代理对象默认没有这些内部槽位,当你通过代理调用内置方法时,方法内部的 this 校验会失败。
const arr = [1, 2, 3]; const proxyArr = new Proxy(arr, {}); proxyArr.push(4); // TypeError: Method Array.prototype.push called on incompatible receiver具体会不会报错,取决于运行时和调用方式,但在某些环境下,直接代理数组然后调用数组方法,就会触发这类错误。我的经验是:不要轻易直接代理内置对象,尤其是数组、Map、Set、Date 这类有内部槽位的结构。如果业务逻辑实在需要通过代理去监听数组变化,可以把数组包在代理对象里,而不是让代理对象本身代替数组:
const state = new Proxy({ list: [] }, { get(target, key, receiver) { return Reflect.get(target, key, receiver); } }); state.list.push(4); // 这里 push 作用于真实数组,没问题5.4 递归陷阱:一不小心就把代理套进代理
代理 handler 里如果操作的是代理本身,而不是原始对象,就会形成无限递归。最经典的错误写法:
const handler = { set(target, key, value) { // 错误:这里用了 proxy 而不是 target proxy[key] = value; return true; } }; const proxy = new Proxy({}, handler); proxy.a = 1; // RangeError: Maximum call stack size exceeded第一层给 proxy.a 赋值,触发 set trap;trap 内部给 proxy.a 赋值,又触发 set trap;于是无限循环,直到栈溢出。修复方法很简单:把proxy[key] = value改成target[key] = value,target 是原始对象,对它赋值不会触发代理的 trap。
如果项目中使用了多层代理,比如代理对象又被重新代理,就要特别注意每一层 handler 操作的是哪一层的实体。代理套代理本身不是问题,只要每层 handler 都只操作自己对应的 target,不要混用变量。
5.5 原型链上的属性也会触发 get trap
最后这个坑比较隐蔽。当你用Object.create(proxy)创建一个继承自代理的新对象,然后访问新对象上的属性时,get trap 会参与原型链查找过程。
const base = new Proxy({}, { get(target, key, receiver) { return '来自代理的默认值'; } }); const child = Object.create(base); console.log(child.name); // 如果 child 自身没有 name,会打印“来自代理的默认值”child.name 在自身属性上找不到,于是沿着原型链去 base 上找,这期间触发了 base 的 get trap。如果你的 get trap 逻辑只适用于“访问代理自身属性”的场景,这种继承关系就会造成误拦截。
这种情况通常出现在组件继承或 mixin 混入的场景。解决思路是,在 get trap 里判断属性是否真的存在于目标对象或原型链上,再决定是否放行,不要无脑返回默认值。如果你能保证代理对象不会被其他对象继承,这个问题可以暂时无视,但知道了这个机制,再遇到奇怪问题时就能少走很多弯路。
6. 一组可以直接抄的轻量工具封装
6.1 一个带过期时间的缓存包装器
前面第 3.1 节的缓存代理没有处理缓存失效,实际业务里我更常用带过期时间的版本:
function createTimeoutCacheProxy(fn, ttl = 5000) { const cache = new Map(); return new Proxy(fn, { apply(target, thisArg, args) { const key = JSON.stringify(args); const hit = cache.get(key); if (hit && Date.now() - hit.time < ttl) { return hit.value; } const value = Reflect.apply(target, thisArg, args); cache.set(key, { time: Date.now(), value }); return value; } }); } const getPrice = createTimeoutCacheProxy((id) => { return id * 100; }, 3000); console.log(getPrice(1)); // 100 console.log(getPrice(1)); // ttl 内命中,不重新计算这个工具非常适合用于频繁触发的数据汇总计算。TTL 单位是毫秒,传 0 可以做成永久缓存。注意如果函数是异步的,缓存里存的会是 Promise,命中时调用方拿到的也是同一个 Promise 实例,这一点通常没问题,但如果有竞态需求,建议还是按 Promise.resolve 的结果做缓存。
6.2 一个自动加日志的审计代理
排查线上问题时,最难的是搞不清状态是“哪一段代码改的”。我做一个简单的调试代理,可以给对象的读写加日志,定位问题特别好用。
function createAuditProxy(obj, label = 'state') { return new Proxy(obj, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); console.log(`[audit][${label}] 读取 ${String(key)} ->`, value); return value; }, set(target, key, value, receiver) { console.log(`[audit][${label}] 修改 ${String(key)} ->`, value); return Reflect.set(target, key, value, receiver); } }); } const state = createAuditProxy({ count: 0 }, 'counter'); state.count = 1; console.log(state.count);在正式环境把这个代理关掉就行,平时开着它可以快速看到每次读写的调用链。如果还需要知道具体由哪个调用栈触发,可以在日志里追加new Error().stack,这样能直接把污染数据的真凶揪出来。
6.3 代理模式在响应式生态里的位置:不是只有框架才能用
每次聊到 Proxy,总会有人提“响应式系统”。确实,现在主流前端框架的响应式实现,底层都大量使用了 Proxy 的能力。但我想说的是,响应式系统只是代理模式的一个典型应用,不代表代理模式必须长成框架那样。
框架把对象包成响应式代理,拦截属性读取和变更,然后自动触发界面更新。这套机制很强大,但它在框架内部是作为基础设施存在的。普通业务代码里,代理模式同样能施展拳脚,就像前面写的缓存代理、防抖代理、校验代理,它们不需要响应式系统,只是把一个横切关注点从业务代码里抽离出来。所以不要一提到代理模式,就觉得这是框架作者才需要掌握的东西。
我个人平时写代码的习惯是:先用普通对象快速实现业务功能,等发现某个横切逻辑已经在多个地方重复出现,而且严重干扰主业务流程时,再回过头来抽象代理层。代理模式是用来收敛复杂度的,不是为了显摆手法的。如果你能把代理封装成一个语义明确的工具函数,让业务代码完全不出现new Proxy字样,那我觉得这套用法就是成功的。反之,如果代理层比业务逻辑本身还要难懂,那就得停下来重新评估:到底是代理封装得不够好,还是这个场景根本不适合用代理。