JS Proxy 核心原理、实战痛点与常用解决方案
目录
- 1. 引言:你用的是原对象,还是代理对象?
- 2. 检测 Proxy 的多种方案
- 3. 从 Proxy 中提取原始 target 对象
- 4. Immer 库实战
- 5. Immer 的 original API 深度解析
- 6. Vue3 响应式原理延伸
- 7. Proxy 在前端生态的应用与常见问题
- 8. 总结:知识闭环
1. 引言:你用的是原对象,还是代理对象?
在日常前端开发中,Proxy 代理对象早已无处不在。
你在使用 Vue3 时,reactive() 返回的是一个代理对象;你在使用 Immer 时,produce() 回调中的 draft 也是一个代理对象;甚至很多状态管理库(Zustand、Redux Toolkit)底层都依赖 Proxy 来实现响应式或不可变更新。
但很多开发者会遇到这样的困惑:
const obj = { name: 'Alice', age: 25 };const proxyObj = new Proxy(obj, {});
console.log(proxyObj.name); // 'Alice' — 看起来和原对象一模一样?console.log(typeof proxyObj); // 'object' — 类型也是 object?console.log(proxyObj === obj); // false — 但它们不是同一个引用!问题来了:
- 为什么
proxyObj用起来和普通对象几乎一样? - 如何判断一个变量到底是普通对象还是 Proxy 代理对象?
- 如果拿到的是一个 Proxy,能否从中提取出原始的 target 对象?
- 主流框架是如何利用 Proxy 的?又有什么坑需要规避?
本文将由浅入深,带你彻底搞懂 Proxy 的核心原理与实战痛点。
2. 检测 Proxy 的多种方案
2.1 原生 JS 没有内置 isProxy() 方法
这是很多开发者踩过的第一个坑。与 Vue3 提供了 isProxy() 不同,原生 JavaScript 并没有提供任何内置方法来判断一个对象是否为 Proxy。
// ❌ 原生 JS 中不存在这个方法typeof isProxy; // 'undefined'
// Vue3 中有,但那是 Vue 自己封装的// import { isProxy } from 'vue';为什么 JS 不提供?从规范层面来说,Proxy 的设计初衷就是”透明代理”——它应该对使用者完全无感。如果暴露了检测手段,就破坏了这种透明性。
但现实业务中,我们确实有检测需求。下面介绍四种可行的方案。
2.2 方案一:利用 Proxy trap 副作用检测(设置标记属性)
原理: 在创建 Proxy 时,通过 get trap 拦截访问,给目标对象打上一个”我是被代理的”标记。检测时只需判断该标记是否存在。
// ========== 方案一:trap 副作用标记 ==========
const PROXY_MARKER = '__isProxy__';
function createMarkedProxy(target, handler = {}) { const originalGet = handler.get || (() => undefined);
const proxyHandler = { ...handler, get(t, prop, receiver) { // 拦截到对标记属性的访问时,返回 true if (prop === PROXY_MARKER) { return true; } return originalGet(t, prop, receiver); } };
return new Proxy(target, proxyHandler);}
function isProxyViaMarker(obj) { if (obj === null || typeof obj !== 'object') return false; try { return obj[PROXY_MARKER] === true; } catch { return false; }}
// ---- 验证 ----const target1 = { name: 'Alice' };const proxy1 = createMarkedProxy(target1);
console.log('=== 方案一验证 ===');console.log('isProxyViaMarker(target1):', isProxyViaMarker(target1)); // falseconsole.log('isProxyViaMarker(proxy1):', isProxyViaMarker(proxy1)); // trueconsole.log('isProxyViaMarker({}):', isProxyViaMarker({})); // false
// ⚠️ 注意:如果原对象本身就有 __isProxy__ 属性,会产生误判const trickyObj = { name: 'Bob', __isProxy__: true };console.log('isProxyViaMarker(trickyObj):', isProxyViaMarker(trickyObj)); // true(误判!)| 维度 | 说明 |
|---|---|
| 优点 | 实现简单,无需额外数据结构 |
| 缺点 | 可能和原对象属性名冲突产生误判;污染了访问路径 |
| 适用场景 | 团队内部约定,且能控制所有对象创建的场景 |
2.3 方案二:通过 WeakMap 注册表追踪
原理: 创建一个全局 WeakMap,每次创建 Proxy 时将 proxy → true 注册进去。检测时查表即可。使用 WeakMap 的好处是不会阻止 Proxy 被垃圾回收。
// ========== 方案二:WeakMap 注册表 ==========
const proxyRegistry = new WeakMap();
function createRegisteredProxy(target, handler = {}) { const proxy = new Proxy(target, handler); proxyRegistry.set(proxy, true); return proxy;}
function isProxyViaRegistry(obj) { return obj !== null && typeof obj === 'object' && proxyRegistry.has(obj);}
// ---- 验证 ----const target2 = { name: 'Charlie' };const proxy2 = createRegisteredProxy(target2);const normalObj = { name: 'David' };
console.log('\n=== 方案二验证 ===');console.log('isProxyViaRegistry(target2):', isProxyViaRegistry(target2)); // falseconsole.log('isProxyViaRegistry(proxy2):', isProxyViaRegistry(proxy2)); // trueconsole.log('isProxyViaRegistry(normalObj):', isProxyViaRegistry(normalObj)); // falseconsole.log('isProxyViaRegistry(42):', isProxyViaRegistry(42)); // falseconsole.log('isProxyViaRegistry(null):', isProxyViaRegistry(null)); // falseconsole.log('isProxyViaRegistry([]):', isProxyViaRegistry([])); // false
// WeakMap 的优势:proxy 被 GC 后,注册表自动清理// 不会造成内存泄漏| 维度 | 说明 |
|---|---|
| 优点 | 100% 准确,不会误判;不污染目标对象;GC 友好 |
| 缺点 | 只能追踪你自己创建的 Proxy;无法检测第三方库创建的 Proxy |
| 适用场景 | 框架/库内部对自己创建的代理对象进行管理 |
2.4 方案三:利用 Proxy revocable 的 revoked 状态
原理: 使用 Proxy.revocable() 创建可撤销的代理。虽然这主要用于”撤销代理”场景,但我们也可以利用其 { proxy, revoke } 返回结构来判断。
// ========== 方案三:Proxy.revocable 状态检测 ==========
const revocableRegistry = new WeakMap();
function createRevocableTrackedProxy(target, handler = {}) { const { proxy, revoke } = Proxy.revocable(target, handler);
// 用一个不可枚举的标记来追踪 revocableRegistry.set(proxy, { revoked: false, originalRevoke: revoke });
// 包装 revoke,更新状态 const wrappedRevoke = () => { const entry = revocableRegistry.get(proxy); if (entry && !entry.revoked) { entry.revoked = true; entry.originalRevoke(); } };
return { proxy, revoke: wrappedRevoke };}
function isProxyViaRevocable(obj) { return revocableRegistry.has(obj);}
function isRevocableProxyRevoked(obj) { const entry = revocableRegistry.get(obj); return entry ? entry.revoked : null;}
// ---- 验证 ----const target3 = { secret: 'password123' };const { proxy: proxy3, revoke: revoke3 } = createRevocableTrackedProxy(target3);
console.log('\n=== 方案三验证 ===');console.log('isProxyViaRevocable(proxy3):', isProxyViaRevocable(proxy3)); // trueconsole.log('revoked before:', isRevocableProxyRevoked(proxy3)); // false
revoke3(); // 撤销代理
console.log('revoked after:', isRevocableProxyRevoked(proxy3)); // true
// 撤销后访问会抛出 TypeErrortry { console.log(proxy3.secret);} catch (e) { console.log('访问 revoked proxy 抛出:', e.message);}| 维度 | 说明 |
|---|---|
| 优点 | 可以追踪代理的撤销状态;适合需要动态回收代理权限的场景 |
| 缺点 | 实现复杂;只能用于 revocable proxy;同样只能追踪自己创建的 |
| 适用场景 | 临时代理、安全沙箱、需要回收访问权限的场景 |
2.5 方案四:通过 prototype 链或特殊标记检测
原理: 利用 Object.getPrototypeOf() 或 Object.prototype.toString 对 Proxy 的行为差异来间接判断。或者使用 Symbol 作为私有标记,避免属性名冲突。
// ========== 方案四:Symbol 私有标记 + prototype 检测 ==========
// 方式 A:Symbol 标记(推荐)const IS_PROXY_SYMBOL = Symbol.for('__isProxy_internal__');
function createSymbolMarkedProxy(target, handler = {}) { const originalGet = handler.get || (() => undefined);
const proxyHandler = { ...handler, get(t, prop, receiver) { if (prop === IS_PROXY_SYMBOL) { return true; } return originalGet(t, prop, receiver); } };
return new Proxy(target, proxyHandler);}
function isProxyViaSymbol(obj) { if (obj === null || typeof obj !== 'object') return false; try { return obj[IS_PROXY_SYMBOL] === true; } catch { return false; }}
// 方式 B:通过 Object.prototype.toString 间接判断// 注意:这不是可靠的检测方法,仅作知识补充function detectViaToString(obj) { try { // Proxy 会拦截 toString 调用,行为可能与普通对象不同 // 但大多数情况下 toString.call(proxy) === '[object Object]' return Object.prototype.toString.call(obj); } catch { return '[object Proxy]'; // 某些 proxy 会拦截失败 }}
// ---- 验证 ----const target4 = { name: 'Eve' };const proxy4 = createSymbolMarkedProxy(target4);const tricky4 = { name: 'Fake', [IS_PROXY_SYMBOL]: true };
console.log('\n=== 方案四验证 ===');console.log('isProxyViaSymbol(target4):', isProxyViaSymbol(target4)); // falseconsole.log('isProxyViaSymbol(proxy4):', isProxyViaSymbol(proxy4)); // true
// Symbol 的隐藏性:普通遍历看不到console.log('Object.keys(proxy4):', Object.keys(proxy4)); // ['name']console.log('JSON.stringify(proxy4):', JSON.stringify(proxy4)); // {"name":"Eve"}
// ⚠️ 但如果有人故意设置了同名 Symbol,仍会误判console.log('isProxyViaSymbol(tricky4):', isProxyViaSymbol(tricky4)); // true(误判!)
// toString 方式并不可靠console.log('toString(normal):', detectViaToString({})); // [object Object]console.log('toString(proxy):', detectViaToString(proxy4)); // [object Object](相同!)| 维度 | 说明 |
|---|---|
| 优点 | Symbol 具有全局唯一性和隐藏性,不易冲突 |
| 缺点 | Symbol.for 创建的全局 Symbol 仍可能被他人设置;toString 方式完全不可靠 |
| 适用场景 | Symbol 方案适合大多数内部标记场景 |
2.6 四种检测方案对比总结
| 方案 | 准确率 | 能否检测第三方 Proxy | 性能 | 推荐度 |
|---|---|---|---|---|
| trap 副作用标记 | ⚠️ 可能误判 | ❌ 不能 | 高 | ⭐⭐ |
| WeakMap 注册表 | ✅ 100% 准确 | ❌ 不能 | 高 | ⭐⭐⭐⭐⭐ |
| revocable 状态 | ✅ 100% 准确 | ❌ 不能 | 中 | ⭐⭐⭐ |
| Symbol 标记 | ⚠️ 可能误判 | ❌ 不能 | 高 | ⭐⭐⭐ |
核心结论: 原生 JS 中,你只能检测自己创建的 Proxy。对于第三方库(如 Vue3、Immer)创建的代理对象,必须使用它们提供的 API(如
isProxy()、original())。
3. 从 Proxy 中提取原始 target 对象
3.1 原生 JS 无法直接获取 Proxy 内部的 target
这是另一个常见痛点。一旦创建了 Proxy,没有任何原生 API 可以从 proxy 对象反向获取到原始的 target。
const target = { name: 'Alice' };const proxy = new Proxy(target, {});
// ❌ 没有这样的 API// const original = getTarget(proxy); // 不存在!// const original = proxy.__target__; // 不存在!Proxy 规范故意隐藏了 target,以维持代理的透明性和安全性。如果允许反向提取,攻击者就可以绕过代理的拦截逻辑,直接修改原始数据。
但在某些业务场景中,我们确实需要原始引用。下面介绍四种解决方案。
3.2 方案一:创建 Proxy 时用 WeakMap 保存映射关系
原理: 最经典的方案。在创建 Proxy 时,将 proxy → target 的映射存入 WeakMap。后续通过查表获取原始对象。
// ========== 方案一:WeakMap 映射表 ==========
const proxyToTarget = new WeakMap();
function createProxyWithMapping(target, handler = {}) { const proxy = new Proxy(target, handler); proxyToTarget.set(proxy, target); return proxy;}
function getOriginalTarget(proxy) { if (proxy === null || typeof proxy !== 'object') return undefined; return proxyToTarget.get(proxy);}
// ---- 验证 ----const user = { name: 'Alice', age: 25 };const userProxy = createProxyWithMapping(user, { get(t, prop) { console.log(`[Proxy] 访问属性: ${String(prop)}`); return t[prop]; }});
console.log('\n=== 方案一验证 ===');console.log('userProxy.name:', userProxy.name); // Alice (触发 get trap)
const original = getOriginalTarget(userProxy);console.log('original:', original); // { name: 'Alice', age: 25 }console.log('original === user:', original === user); // true ✅
// 修改原始对象不会影响映射关系original.name = 'Bob';console.log('userProxy.name after change:', userProxy.name); // Bob| 维度 | 说明 |
|---|---|
| 优点 | 实现简单,100% 可靠,GC 友好 |
| 缺点 | 只能提取自己创建的 Proxy;需要维护映射表 |
| 适用场景 | 最通用的方案,推荐作为默认选择 |
3.3 方案二:在 handler 的 get trap 中暴露获取原始对象的方法
原理: 在 Proxy 的 get trap 中拦截某个特殊属性名,当访问该属性时返回原始 target。
// ========== 方案二:get trap 暴露原始对象 ==========
const RAW_SYMBOL = Symbol('__raw__');
function createProxyWithGetRaw(target, handler = {}) { const originalGet = handler.get || (() => undefined);
const proxyHandler = { ...handler, get(t, prop, receiver) { // 拦截特殊符号,直接返回 target if (prop === RAW_SYMBOL) { return t; } return originalGet(t, prop, receiver); } };
return new Proxy(target, proxyHandler);}
function getTargetViaGetTrap(proxy) { try { return proxy[RAW_SYMBOL]; } catch { return undefined; }}
// ---- 验证 ----const config = { debug: true, version: '1.0' };const configProxy = createProxyWithGetRaw(config, { get(t, prop) { console.log(`[ConfigProxy] 读取: ${String(prop)}`); return t[prop]; }});
console.log('\n=== 方案二验证 ===');console.log('configProxy.debug:', configProxy.debug);
const raw = getTargetViaGetTrap(configProxy);console.log('raw:', raw); // { debug: true, version: '1.0' }console.log('raw === config:', raw === config); // true ✅
// 不会被普通遍历发现console.log('keys:', Object.keys(configProxy)); // ['debug', 'version']console.log('stringify:', JSON.stringify(configProxy)); // {"debug":true,"version":"1.0"}| 维度 | 说明 |
|---|---|
| 优点 | 无需额外数据结构;利用 Symbol 保证属性名唯一且不可枚举 |
| 缺点 | 如果外部代码恰好遍历 Symbol 属性(Object.getOwnPropertySymbols),可能意外获取 |
| 适用场景 | 轻量级方案,适合简单场景 |
3.4 方案三:使用 Symbol 作为私有标记存储原始引用
原理: 将原始引用存储在一个全局 Symbol 键下,挂载到目标对象本身。由于是 Symbol 键,普通遍历不可见。
// ========== 方案三:Symbol 私有标记存储 ==========
const TARGET_REF = Symbol.for('__proxy_target_ref__');
function createProxyWithSymbolRef(target, handler = {}) { // 在 target 上挂载 Symbol 自引用 // 注意:这会修改原对象 if (!target[TARGET_REF]) { try { Object.defineProperty(target, TARGET_REF, { value: target, writable: false, enumerable: false, configurable: false }); } catch (e) { // 冻结对象无法添加属性 } }
return new Proxy(target, handler);}
function getTargetViaSymbolRef(proxy) { try { return proxy[TARGET_REF]; } catch { return undefined; }}
// ---- 验证 ----const settings = { theme: 'dark', lang: 'zh' };const settingsProxy = createProxyWithSymbolRef(settings);
console.log('\n=== 方案三验证 ===');console.log('settingsProxy.theme:', settingsProxy.theme); // 'dark'
const rawSettings = getTargetViaSymbolRef(settingsProxy);console.log('rawSettings:', rawSettings);console.log('rawSettings === settings:', rawSettings === settings); // true ✅
// 不可枚举console.log('keys:', Object.keys(settingsProxy)); // ['theme', 'lang']
// ⚠️ 注意:原对象被修改了,多了一个 Symbol 属性console.log('ownSymbols:', Object.getOwnPropertySymbols(settings)); // [Symbol(__proxy_target_ref__)]| 维度 | 说明 |
|---|---|
| 优点 | 不依赖外部数据结构;映射关系随对象生命周期管理 |
| 缺点 | 修改了原始对象(添加 Symbol 属性);冻结对象不可用 |
| 适用场景 | 可以接受修改原对象的场景 |
3.5 方案四:利用 Proxy.revocable 在 revoke 前保存引用
原理: Proxy.revocable() 返回 { proxy, revoke }。在撤销之前,我们已经持有 target 的引用。可以设计一个模式,在特定条件下提取。
// ========== 方案四:revocable 模式 ==========
const revocableMap = new WeakMap();
function createRevocableProxy(target, handler = {}) { const { proxy, revoke } = Proxy.revocable(target, handler);
// 保存 target 和 revoke 函数的引用 revocableMap.set(proxy, { target, revoke, isRevoked: false });
// 包装 revoke const safeRevoke = () => { const info = revocableMap.get(proxy); if (info && !info.isRevoked) { info.isRevoked = true; revoke(); } };
return { proxy, revoke: safeRevoke };}
function extractTargetBeforeRevoke(proxy) { const info = revocableMap.get(proxy); if (!info) return undefined; if (info.isRevoked) { console.warn('⚠️ Proxy 已被撤销,无法获取 target'); return undefined; } return info.target;}
// ---- 验证 ----const secret = { apiKey: 'sk-123456', endpoint: 'https://api.example.com' };const { proxy: secretProxy, revoke } = createRevocableProxy(secret);
console.log('\n=== 方案四验证 ===');console.log('secretProxy.apiKey:', secretProxy.apiKey); // 'sk-123456'
// revoke 前可以提取const beforeRevoke = extractTargetBeforeRevoke(secretProxy);console.log('beforeRevoke:', beforeRevoke);console.log('beforeRevoke === secret:', beforeRevoke === secret); // true ✅
revoke(); // 撤销代理
// revoke 后无法提取const afterRevoke = extractTargetBeforeRevoke(secretProxy);console.log('afterRevoke:', afterRevoke); // undefined
// 尝试访问 revoked proxy 会抛错try { console.log(secretProxy.apiKey);} catch (e) { console.log('访问 revoked proxy 错误:', e.message);}| 维度 | 说明 |
|---|---|
| 优点 | 自带生命周期管理;撤销后自动保护原始数据 |
| 缺点 | 实现最复杂;只能用于 revocable proxy;撤销后无法再获取 |
| 适用场景 | 安全沙箱、临时访问令牌、需要严格控制生命周期的场景 |
3.6 四种提取方案对比总结
| 方案 | 可靠性 | 是否修改原对象 | 能否处理第三方 Proxy | 推荐度 |
|---|---|---|---|---|
| WeakMap 映射表 | ✅ 100% | ❌ 不修改 | ❌ 不能 | ⭐⭐⭐⭐⭐ |
| get trap 暴露 | ✅ 可靠 | ❌ 不修改 | ❌ 不能 | ⭐⭐⭐⭐ |
| Symbol 私有标记 | ✅ 可靠 | ⚠️ 修改原对象 | ❌ 不能 | ⭐⭐⭐ |
| revocable 模式 | ✅ 可靠 | ❌ 不修改 | ❌ 不能 | ⭐⭐⭐ |
4. Immer 库实战
4.1 为什么需要 Immer?
在 React/Redux 开发中,不可变数据更新是核心原则。但随着状态嵌套越来越深,手写不可变更新变得极其痛苦。
手写方式(痛苦面具 😫):
// 传统的不可变更新 —— 层层展开,噩梦般体验function oldSchoolReducer(state, action) { switch (action.type) { case 'UPDATE_USER_NAME': return { ...state, user: { ...state.user, profile: { ...state.user.profile, name: action.payload } } }; case 'ADD_TODO': return { ...state, todos: [ ...state.todos, { id: Date.now(), text: action.payload, completed: false } ] }; default: return state; }}
const initialState = { user: { profile: { name: 'Alice', age: 25 }, settings: { theme: 'dark' } }, todos: []};
const newState = oldSchoolReducer(initialState, { type: 'UPDATE_USER_NAME', payload: 'Bob'});
console.log('\n=== Immer 前置 - 传统方式 ===');console.log('原始状态:', JSON.stringify(initialState));console.log('新状态:', JSON.stringify(newState));console.log('state === newState:', initialState === newState); // falseconsole.log('state.user === newState.user:', initialState.user === newState.user); // falseconsole.log('state.todos === newState.todos:', initialState.todos === newState.todos); // true (未变)4.2 Immer 的 produce 函数
Immer 基于 Proxy + 结构共享(structural sharing),让你可以用可变的方式写出不可变的代码。
// ========== Immer 实战 ==========// 注意:实际项目中需要 npm install immer// 这里用 CDN 加载// import { produce } from 'immer';
// 以下代码可在浏览器控制台直接运行(需先加载 immer)// <script src="https://unpkg.com/immer/dist/immer.umd.production.min.js"></script>
// 模拟 produce 的简化理解版本(仅供演示原理,实际请用 immer 库)function simulateProduce(baseState, recipe) { const proxyToOriginal = new Map();
function createDraft(target) { if (typeof target !== 'object' || target === null) return target;
return new Proxy(target, { get(t, prop) { if (prop === Symbol.for('__is_draft__')) return true; const value = t[prop]; if (typeof value === 'object' && value !== null) { return createDraft(value); // 懒代理 } return value; }, set(t, prop, value) { // 任何修改都会触发"拷贝"行为 t[prop] = value; return true; } }); }
const draft = createDraft(baseState); recipe(draft);
// 实际 Immer 会通过 patch 机制生成新对象 // 这里简化为直接返回(不保证结构共享) return JSON.parse(JSON.stringify(baseState));}
console.log('\n=== Immer 原理模拟 ===');const baseState = { user: { name: 'Alice', age: 25 }, todos: [] };
const simulated = simulateProduce(baseState, draft => { draft.user.name = 'Bob'; draft.todos.push({ id: 1, text: 'Learn Proxy', done: false });});
console.log('原始:', JSON.stringify(baseState));console.log('模拟结果:', JSON.stringify(simulated));真实的 Immer 使用方式(需要安装库):
/*// 真实项目中:// npm install immer
import { produce } from 'immer';
// ===== Redux Reducer 场景 =====
const initialState = { user: { profile: { name: 'Alice', age: 25 }, settings: { theme: 'dark', notifications: true } }, todos: [ { id: 1, text: 'Learn Proxy', completed: false }, { id: 2, text: 'Master Immer', completed: true } ], meta: { lastUpdated: Date.now() }};
function todoReducer(state = initialState, action) { return produce(state, draft => { switch (action.type) { case 'UPDATE_USER_NAME': // ✅ 直接"修改",Immer 保证不可变性 draft.user.profile.name = action.payload; break;
case 'ADD_TODO': draft.todos.push({ id: Date.now(), text: action.payload, completed: false }); break;
case 'TOGGLE_TODO': const todo = draft.todos.find(t => t.id === action.payload); if (todo) { todo.completed = !todo.completed; } break;
case 'UPDATE_THEME': draft.user.settings.theme = action.payload; break;
case 'CLEAR_COMPLETED_TODOS': // 数组方法也能正常工作 draft.todos = draft.todos.filter(t => !t.completed); break; } });}
// ---- 验证 ----let state = initialState;
state = todoReducer(state, { type: 'UPDATE_USER_NAME', payload: 'Bob' });console.log(state.user.profile.name); // 'Bob'
state = todoReducer(state, { type: 'ADD_TODO', payload: 'Write Blog' });console.log(state.todos.length); // 3
state = todoReducer(state, { type: 'TOGGLE_TODO', payload: 1 });console.log(state.todos[0].completed); // true
// 验证不可变性console.log(state.todos === initialState.todos); // false (引用不同)console.log(state.todos[1] === initialState.todos[1]); // true (结构共享,未修改的部分复用)*/4.3 Immer 的核心优势
| 特性 | 手写展开符 | Immer |
|---|---|---|
| 代码简洁度 | 深层嵌套需多层展开,冗长 | 直接赋值,简洁明了 |
| 可读性 | 难以一眼看出修改了哪些字段 | 修改逻辑清晰直观 |
| 性能 | 每次都创建完整新对象(除非手动优化) | 结构共享,未修改部分复用引用 |
| 类型安全 | TypeScript 类型推导容易出错 | 配合 TypeScript 推导优秀 |
| 数组操作 | 需要用 concat/slice/filter 组合 | 可直接 push/splice/sort |
5. Immer 的 original API 深度解析
5.1 original() 的作用
Immer 的 original() 函数是处理 draft 代理对象时的核心 API。它的作用是:
从 Immer 的 draft(草稿代理)中获取到未经修改的原始对象。
import { produce, original } from 'immer';
const baseState = { users: [ { id: 1, name: 'Alice', age: 25 }, { id: 2, name: 'Bob', age: 30 } ], selectedUserId: 1};
produce(baseState, draft => { const user = draft.users[0];
// ❌ 这是 draft 代理对象,不是原始对象 console.log('user 是代理:', Object.prototype.toString.call(user));
// ✅ original() 返回原始对象(未修改的状态) const originalUser = original(user); console.log('originalUser:', originalUser); console.log('originalUser === baseState.users[0]:', originalUser === baseState.users[0]); // true});5.2 实际业务使用场景
场景一:条件判断时需要原始值
当你的逻辑需要对比”修改前”和”修改后”的状态时:
/*import { produce, original } from 'immer';
const state = { user: { name: 'Alice', role: 'user' } };
const newState = produce(state, draft => { const currentUser = draft.user; const originalUser = original(draft.user);
// 修改 currentUser.role = 'admin';
// 对比修改前后的值 if (originalUser.role !== currentUser.role) { console.log(`角色从 "${originalUser.role}" 变更为 "${currentUser.role}"`); // 触发审计日志、发送通知等 }});*/场景二:避免 draft 污染第三方库
有些第三方库不接受 Proxy 对象,需要原始对象:
/*import { produce, original } from 'immer';
const state = { config: { apiKey: 'sk-123', retries: 3 } };
produce(state, draft => { const rawConfig = original(draft.config);
// 假设某个第三方 HTTP 库不接受 Proxy 对象 // axios.post('/api', rawConfig); // ✅ 安全
// 如果用 draft.config 可能会出问题 // axios.post('/api', draft.config); // ⚠️ 某些库会报错});*/场景三:序列化/存储
/*import { produce, original } from 'immer';
const state = { data: { items: [1, 2, 3] } };
produce(state, draft => { draft.data.items.push(4);
// localStorage 存储时需要原始对象 const rawData = original(draft.data); localStorage.setItem('myData', JSON.stringify(rawData));
// 注意:如果直接 JSON.stringify(draft.data) // Immer 会自动处理,但 original() 确保拿到确定性的原始值});*/5.3 踩坑点与注意事项
⚠️ 坑点一:original() 只对被修改过的 draft 有意义
/*import { produce, original } from 'immer';
const state = { user: { name: 'Alice' } };
produce(state, draft => { // 如果 draft.user 没有被修改过 // original(draft.user) === baseState.user // true // 但如果修改了,original 返回的是修改前的快照 draft.user.name = 'Bob';
// original() 返回的是 produce 调用时的 baseState 中的值 const orig = original(draft.user); console.log(orig.name); // 'Alice' (修改前的值) console.log(draft.user.name); // 'Bob' (修改后的值)});*/⚠️ 坑点二:original() 有性能开销
/*import { produce, original } from 'immer';
const largeState = { items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: `item-${i}` }))};
produce(largeState, draft => { // ❌ 避免在循环中频繁调用 original() for (const item of draft.items) { const orig = original(item); // 性能差! }
// ✅ 正确做法:在循环外获取需要的原始引用 // 或者避免在 draft 中使用 original});*/⚠️ 坑点三:original() 返回的对象是只读的
/*import { produce, original } from 'immer';
produce({ user: { name: 'Alice' } }, draft => { const orig = original(draft.user);
// ❌ 不要修改 original 返回的对象! // 它指向原始状态树,修改它会破坏不可变性 // orig.name = 'Bob'; // 应该通过 draft 修改
// ✅ 正确做法 draft.user.name = 'Bob';});*/5.4 original() 的完整可运行示例
// ========== original() 完整示例 ==========// 使用 immer 的 UMD 构建版本// 在浏览器中运行:// <script src="https://unpkg.com/immer/dist/immer.umd.production.min.js"></script>// 然后在控制台运行:
/*const { produce, original, isDraft } = immer;
const baseState = { users: [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' } ], settings: { theme: 'light', lang: 'en' }};
console.log('\n=== Immer original() 完整示例 ===');
const newState = produce(baseState, draft => { // 检查是否为 draft console.log('isDraft(draft):', isDraft(draft)); // true console.log('isDraft(draft.users[0]):', isDraft(draft.users[0])); // true
// 获取原始对象 const originalUser = original(draft.users[0]); console.log('originalUser:', originalUser); console.log('originalUser === baseState.users[0]:', originalUser === baseState.users[0]); // true
// 修改 draft draft.users[0].name = 'Alice Modified'; draft.users[0].email = 'alice_new@example.com';
// original() 仍然返回修改前的值 const origAfterChange = original(draft.users[0]); console.log('original after change:', origAfterChange); // { id: 1, name: 'Alice', email: 'alice@example.com' }
console.log('draft user after change:', draft.users[0]); // { id: 1, name: 'Alice Modified', email: 'alice_new@example.com' }});
console.log('\n最终状态:', JSON.stringify(newState));console.log('原状态未变:', JSON.stringify(baseState));*/6. Vue3 响应式原理延伸
6.1 Vue3 核心响应式依赖 Proxy
Vue3 抛弃了 Vue2 的 Object.defineProperty,全面拥抱 Proxy 来实现响应式系统。
/*// Vue3 响应式核心原理简化版// 实际源码远比这复杂,但核心思想一致:
function reactive(target) { const handler = { get(obj, key) { // 收集依赖(追踪哪些组件用了这个属性) track(obj, key); const result = obj[key]; // 递归代理嵌套对象 if (typeof result === 'object' && result !== null) { return reactive(result); } return result; }, set(obj, key, value) { const oldValue = obj[key]; obj[key] = value; // 触发更新(通知依赖了该属性的组件重新渲染) if (oldValue !== value) { trigger(obj, key); } return true; } };
const observed = new Proxy(target, handler); // Vue 内部用 WeakMap 标记这个对象已被代理 toRawMap.set(observed, target); return observed;}*/6.2 Vue 内置的 isProxy() 和 toRaw()
与原生 JS 不同,Vue3 提供了完善的 API 来处理 Proxy 对象:
// ========== Vue3 API 示例 ==========// 以下代码在浏览器中运行需要 Vue3:// <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
/*const { reactive, readonly, shallowReactive, isProxy, isReactive, isReadonly, toRaw } = Vue;
// ---- 创建不同类型的响应式对象 ----
const reactiveObj = reactive({ name: 'Alice', age: 25 });const readonlyObj = readonly({ name: 'Bob', age: 30 });const shallowObj = shallowReactive({ user: { name: 'Charlie' } });const normalObj = { name: 'David', age: 35 };
console.log('\n=== Vue3 检测 API 验证 ===');
// isProxy: 只要是 proxy 就返回 trueconsole.log('isProxy(reactiveObj):', isProxy(reactiveObj)); // trueconsole.log('isProxy(readonlyObj):', isProxy(readonlyObj)); // trueconsole.log('isProxy(shallowObj):', isProxy(shallowObj)); // trueconsole.log('isProxy(normalObj):', isProxy(normalObj)); // false
// isReactive: 只有 reactive 创建的才返回 trueconsole.log('isReactive(reactiveObj):', isReactive(reactiveObj)); // trueconsole.log('isReactive(readonlyObj):', isReactive(readonlyObj)); // falseconsole.log('isReactive(shallowObj):', isReactive(shallowObj)); // true
// isReadonly: 只有 readonly 创建的才返回 trueconsole.log('isReadonly(reactiveObj):', isReadonly(reactiveObj)); // falseconsole.log('isReadonly(readonlyObj):', isReadonly(readonlyObj)); // true
// toRaw: 从 proxy 中提取原始对象const rawReactive = toRaw(reactiveObj);console.log('toRaw(reactiveObj) === original:', rawReactive === reactiveObj.__v_raw); // Vue 内部有 __v_raw
// 验证 toRaw 返回的是真正的原始对象const baseObj = { name: 'Eve', age: 28 };const reactiveBase = reactive(baseObj);console.log('toRaw(reactiveBase) === baseObj:', toRaw(reactiveBase) === baseObj); // true ✅*/6.3 Vue 封装方案 vs 原生手写方案
| 维度 | Vue3 封装方案 | 原生手写方案 |
|---|---|---|
| 检测 API | isProxy()、isReactive()、isReadonly() | 无,需要自己实现 |
| 提取原对象 | toRaw() | WeakMap 映射或其他方式 |
| 类型区分 | 内置类型判断,开箱即用 | 需要自定义标记逻辑 |
| 内部标记 | ReactiveFlags.IS_REACTIVE 等内部 Symbol | 自定义 Symbol 或属性 |
| 适用场景 | Vue3 生态内 | 通用场景,任何项目 |
6.4 Vue 内部的实现揭秘
/*// Vue3 内部如何区分 reactive、readonly、shallow?// 核心是通过在 Proxy handler 上挂载标记:
const enum ReactiveFlags { SKIP = '__v_skip', // 跳过响应式 IS_REACTIVE = '__v_isReactive', // 标记为 reactive IS_READONLY = '__v_isReadonly', // 标记为 readonly IS_SHALLOW = '__v_isShallow', // 标记为 shallow RAW = '__v_raw' // 指向原始对象}
// 在 mutableHandlers (reactive 的 handler) 上:const mutableHandlers = { get(target, key, receiver) { if (key === ReactiveFlags.IS_REACTIVE) return true; if (key === ReactiveFlags.RAW) return target; // ... 正常的 get 逻辑 }};
// 在 readonlyHandlers 上:const readonlyHandlers = { get(target, key, receiver) { if (key === ReactiveFlags.IS_READONLY) return true; if (key === ReactiveFlags.RAW) return target; // ... 正常的 get 逻辑 }};
// 这就是为什么 Vue 的 isProxy() 能区分类型:function isProxy(value) { return isReactive(value) || isReadonly(value);}
function isReactive(value) { if (isReadonly(value)) { return isReactive(value[ReactiveFlags.RAW]); } return !!(value && value[ReactiveFlags.IS_REACTIVE]);}*/7. Proxy 在前端生态的应用与常见问题
7.1 落地场景一览
| 库/框架 | Proxy 用途 | 核心 API |
|---|---|---|
| Vue3 | 响应式数据绑定 | reactive(), ref(), computed() |
| Immer | 不可变数据更新 | produce(), original() |
| MobX | 可观察状态(MobX 6+) | makeAutoObservable(), observable() |
| Zustand | 部分实现中用于中间件 | 可选的 proxy 中间件 |
| Redux Toolkit | createSlice 内部使用 Immer | createReducer() |
| Valtio | 基于 Proxy 的极简状态管理 | proxy(), useSnapshot() |
7.2 常见问题与解决方案
问题一:代理对象引用判断失效
// ========== 问题:引用判断失效 ==========
const obj = { name: 'test' };const proxy = new Proxy(obj, {});
console.log('\n=== 问题一:引用判断失效 ===');console.log('proxy === obj:', proxy === obj); // falseconsole.log('proxy.name === obj.name:', proxy.name === obj.name); // true (值相等)
// ❌ 常见错误:用 Object.is 或 === 比较function processUser(user) { if (user === obj) { // 永远为 false! console.log('是同一个对象'); }}processUser(proxy); // 不输出
// ✅ 解决方案 1:使用框架提供的工具// Vue3: toRaw(user) === toRaw(proxy)// Immer: original(draftUser) === baseUser
// ✅ 解决方案 2:统一使用 proxy,始终通过同一入口获取const userStore = { _user: obj, get user() { return this._user; // 始终返回同一个引用 }};
// ✅ 解决方案 3:用唯一标识代替引用比较const userWithId = { id: 'user-001', name: 'test' };const proxyWithId = new Proxy(userWithId, {});
function findUser(users, target) { return users.find(u => u.id === target.id); // ✅ 用 id 比较}问题二:JSON.stringify 对 Proxy 的处理
// ========== 问题:JSON.stringify 对 Proxy 的处理 ==========
console.log('\n=== 问题二:JSON.stringify 对 Proxy ===');
// 情况 1:空 handler —— JSON.stringify 正常const obj1 = { a: 1, b: 'hello', c: [1, 2, 3] };const proxy1 = new Proxy(obj1, {});console.log('空 handler:', JSON.stringify(proxy1)); // {"a":1,"b":"hello","c":[1,2,3]} ✅
// 情况 2:拦截了 ownKeys —— 可能导致序列化异常const proxyWithOwnKeys = new Proxy(obj1, { ownKeys(target) { // 只返回 'a',过滤掉其他属性 return ['a']; }});console.log('拦截 ownKeys:', JSON.stringify(proxyWithOwnKeys)); // {"a":1} ⚠️ 其他属性丢失!
// 情况 3:拦截了 get —— 可能改变序列化结果const proxyWithGet = new Proxy(obj1, { get(target, prop) { if (prop === 'a') return 999; // 修改了返回值 return target[prop]; }});console.log('拦截 get:', JSON.stringify(proxyWithGet)); // {"a":999,"b":"hello","c":[1,2,3]} ⚠️
// ✅ 解决方案:序列化前用原始对象// JSON.stringify(toRaw(proxy)) 或 JSON.stringify(original(draft))
// ✅ 或者确保 Proxy 的 handler 不会干扰序列化相关的方法问题三:第三方库兼容性
// ========== 问题:第三方库兼容性 ==========
console.log('\n=== 问题三:第三方库兼容性 ===');
// 有些库使用 Object.getPrototypeOf 或特殊方法检测对象类型// Proxy 可能干扰这些检测
const data = { items: [1, 2, 3], count: 3 };const dataProxy = new Proxy(data, { get(target, prop) { console.log(`[Proxy] get: ${String(prop)}`); return target[prop]; }});
// ❌ 问题:lodash 的某些方法可能触发大量 trap// _.cloneDeep(dataProxy) 会触发 n 次 get,性能差// _.isEqual(dataProxy, data) 可能因为 proxy 干扰而行为异常
// ❌ 问题:某些库会检查 constructorconsole.log('dataProxy.constructor === Object:', dataProxy.constructor === Object); // true (通常没问题)
// ❌ 问题:instanceof 检测class MyClass {}const myObj = new MyClass();const myProxy = new Proxy(myObj, {});console.log('myProxy instanceof MyClass:', myProxy instanceof MyClass); // true ✅ (Proxy 通常保持 instanceof)
// ✅ 解决方案:传给第三方库前用原始对象// someLibrary(toRaw(proxy))问题四:Map/Set 的 Proxy 陷阱
// ========== 问题:Map/Set 的 Proxy 陷阱 ==========
console.log('\n=== 问题四:Map/Set 的 Proxy 陷阱 ===');
// ❌ 错误示范:直接代理 Mapconst map = new Map([['key1', 'value1']]);const mapProxy = new Proxy(map, {});
try { // 这会抛出 TypeError! // 因为 map.get 内部依赖 this 绑定到原始 Map // 而 Proxy 的 this 指向 proxy 对象 console.log(mapProxy.get('key1'));} catch (e) { console.log('mapProxy.get 错误:', e.message); // TypeError: Method Map.prototype.get called on incompatible receiver #<Map>}
// ✅ 解决方案 1:在 handler 中正确绑定 thisconst safeMapProxy = new Proxy(map, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); // 如果获取到的是方法,绑定到原始 target if (typeof value === 'function') { return value.bind(target); } return value; }});
console.log('safeMapProxy.get("key1"):', safeMapProxy.get('key1')); // 'value1' ✅
// ✅ 解决方案 2:使用 Vue3 等库的封装// Vue3 的 reactive() 内部处理了 Map/Set 的代理问题// reactive(new Map()) 可以正常工作
// Set 同理const set = new Set([1, 2, 3]);const safeSetProxy = new Proxy(set, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return value.bind(target); } return value; }});
console.log('safeSetProxy.has(2):', safeSetProxy.has(2)); // true ✅console.log('safeSetProxy.size:', safeSetProxy.size); // 3 ✅7.3 常见问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
proxy === obj 为 false | Proxy 是新的对象实例 | 用 toRaw() / original() 提取原对象后再比较 |
JSON.stringify 异常 | handler 拦截了 ownKeys/get | 用原始对象序列化,或调整 handler 逻辑 |
lodash.cloneDeep 性能差 | 触发大量 trap | 传入原始对象,或使用 _.cloneDeep(toRaw(proxy)) |
| Map/Set 方法报错 | this 绑定丢失 | handler 中 bind(target) 方法,或使用库的封装 |
instanceof 检测失效 | 某些特殊 proxy 实现 | 通常 Proxy 保持 instanceof,特殊场景用 Symbol.hasInstance |
8. 总结:知识闭环
本文从日常开发中的困惑出发,完整地梳理了 Proxy 的核心知识链:
┌─────────────────────────────────────────────────────────┐│ Proxy 知识闭环 │├─────────────────────────────────────────────────────────┤│ ││ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ ││ │ Proxy │───▶│ 检测 Proxy │───▶│ 提取原始对象 │ ││ │ 创建 │ │ isProxy │ │ toRaw / │ ││ │ │ │ (原生无内置) │ │ original() │ ││ └──────────┘ └──────────────┘ └───────┬───────┘ ││ │ ││ ┌─────────────────────────┘ ││ ▼ ││ ┌──────────────────────────────────────────────────┐ ││ │ 主流库实践 │ ││ │ │ ││ │ • Vue3: isProxy() / isReactive() / toRaw() │ ││ │ • Immer: produce() / original() / isDraft() │ ││ │ • MobX: observable() / toJS() │ ││ │ • 自研: WeakMap 映射 / Symbol 标记 │ ││ └──────────────────────────────────────────────────┘ ││ ││ ┌──────────────────────────────────────────────────┐ ││ │ 常见问题与规避 │ ││ │ │ ││ │ • 引用比较失效 → 用唯一标识 / toRaw │ ││ │ • JSON 序列化异常 → 用原始对象 │ ││ │ • 第三方库兼容 → 传原始对象 / 调整 handler │ ││ │ • Map/Set 陷阱 → bind(target) / 库封装 │ ││ └──────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────┘核心要点回顾
- 原生 JS 没有
isProxy(),检测只能靠自己实现(WeakMap 注册表是最佳方案) - 无法从 Proxy 反向获取 target,需要在创建时保存映射关系
- Immer 的
original()是从 draft 中获取原始对象的标准方式 - Vue3 的
isProxy()和toRaw()是对原生 Proxy 的优雅封装 - Proxy 与第三方库交互时需要格外小心,必要时传入原始对象
- Map/Set 的 Proxy 代理需要注意
this绑定问题
Proxy 是现代前端生态的基石技术之一。理解它的原理、掌握它的痛点与解决方案,将帮助你在 Vue3、Immer、Redux Toolkit 等工具的实践中游刃有余。
💡 最佳实践建议:
- 如果你在项目中使用 Vue3,直接使用
isProxy()和toRaw(),不要重复造轮子- 如果你在使用 Immer,用
original()获取原始引用,用isDraft()判断是否为草稿- 如果你在自研框架,用 WeakMap 做注册表管理,这是最可靠的原生方案
- 传递数据给第三方库时,注意是否需要先解包为原始对象
本文所有代码示例均可直接在浏览器控制台或 Node.js 环境中运行验证。如使用需要外部库(immer、vue)的代码,请先通过 CDN 或 npm 安装对应依赖。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!