在前端开发中,我们经常需要让一个数组的数据变化自动同步到本地存储或后端接口。传统的做法是在每次调用push、splice等方法后手动保存,但容易遗漏。利用JavaScript的Proxy机制,可以透明地拦截数组的所有读写操作,从而在变动发生时自动触发持久化等额外任务。

为什么选择Proxy而不是重写数组方法
早期实现数组监听的常见思路是修改Array.prototype上的push、pop、splice等方法,在调用前后插入自定义逻辑。这种做法会污染全局原型,影响页面中所有数组实例,并且在严格模式或第三方库冲突时容易出错。另外,直接通过索引赋值(如arr[0] = 1)或修改length属性并不会触发这些方法,导致监听失效。
Proxy是ES6引入的元编程特性,它能在对象外层包裹一个拦截器,捕获get、set、deleteProperty等基础操作。对于数组而言,无论是调用方法还是修改索引、length,最终都会转化为对属性的get或set访问,因此Proxy可以从根本上覆盖所有变动路径,且只作用于被代理的那个数组,不影响其他实例。
用Proxy实现数组变动监听的核心代码
下面演示如何创建一个被Proxy包装的数组,在任意变动后自动将最新数据写入localStorage。我们重点处理set陷阱,因为它是索引赋值、length修改和方法调用(内部也会set)的统一入口。
function createPersistentArray(key, initial = []) {
const storage = window.localStorage;
// 先从存储中恢复数据,否则用初始值
let raw = [];
try {
const saved = storage.getItem(key);
raw = saved ? JSON.parse(saved) : initial.slice();
} catch (e) {
raw = initial.slice();
}
const persist = () => {
storage.setItem(key, JSON.stringify(raw));
console.log('数组已持久化:', raw);
};
const handler = {
set(target, prop, value) {
// 执行默认的赋值行为
const result = Reflect.set(target, prop, value);
// 数组长度或元素变动后触发持久化
if (prop === 'length' || !isNaN(Number(prop))) {
persist();
}
return result;
}
};
return new Proxy(raw, handler);
}
// 使用示例
const list = createPersistentArray('my_list', [1, 2]);
list.push(3); // 触发set,自动保存
list[0] = 99; // 索引赋值,也会保存
list.length = 1; // 截断数组,同样保存
在上面的代码中,set陷阱通过Reflect.set完成原始赋值,然后判断属性名是否为length或数字索引。如果是,就调用persist函数将整个数组序列化到localStorage。这样无论用户如何修改数组,持久化任务都不会遗漏。
需要注意的是,数组方法如push内部会多次触发set(先设索引,再改length),可能导致persist被连续调用两次。如果持久化是轻量级的本地写入影响不大;若是远程请求,则应当做防抖处理,避免请求风暴。
添加防抖优化与嵌套对象支持
为了防止频繁变动带来性能问题,我们可以给持久化函数加一个防抖。同时,如果数组元素是对象,我们可能也希望对象属性变动也能触发保存,这时需要对元素也做Proxy深包装。
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function createSmartArray(key, initial = []) {
const storage = window.localStorage;
let raw = [];
try {
const saved = storage.getItem(key);
raw = saved ? JSON.parse(saved) : initial.slice();
} catch (e) {
raw = initial.slice();
}
const persist = debounce(() => {
storage.setItem(key, JSON.stringify(raw));
}, 200);
const handler = {
set(target, prop, value) {
const val = (typeof value === 'object' && value !== null) ? value : value;
const result = Reflect.set(target, prop, val);
if (prop === 'length' || !isNaN(Number(prop))) {
persist();
}
return result;
}
};
return new Proxy(raw, handler);
}
这段改进版代码引入了debounce,将多次连续变动合并为一次写入。对于嵌套对象,示例中仅做了浅层保留,实际项目中可递归使用Proxy包装子对象,实现深度响应式。但深度代理会增加内存与拦截开销,应根据业务规模权衡。
另外,当数组被结构化克隆或经过postMessage传递时,Proxy包裹层会丢失,接收方拿到的是普通数组。因此在跨窗口通信场景中,不能依赖Proxy自动持久化,而应在发送前手动保存或在接收端重新包装。
与其他方案的对比及适用场景
除了Proxy,开发者也可能使用Object.observe(已废弃)或框架如Vue的响应式系统。Vue3的reactive底层就是Proxy,但它附带了依赖收集与视图更新机制,若仅需持久化,直接写轻量Proxy更可控。下表列出几种方案差异:
| 方案 | 是否覆盖索引赋值 | 是否污染全局 | 额外体积 |
|---|---|---|---|
| 重写数组原型 | 否 | 是 | 小 |
| Proxy包装 | 是 | 否 | 极小 |
| Vue reactive | 是 | 否 | 较大 |
综合来看,如果需求只是监听数组变动并触发如持久化、上报等副作用,手写Proxy是最直接且副作用最小的方案。它不依赖框架,兼容现代浏览器,也易于封装成工具函数供多个模块复用。
在落地时,建议将Proxy数组封装为独立模块,暴露clear、reset等安全方法,并在set陷阱中对非法属性做校验,防止意外写入非数字键导致JSON序列化出现对象结构。这样能在保证自动任务触发的同时,维持数据结构稳定。