Skip to content

✨ 数据拦截的本质 👌

要点速览

  • 数据拦截指的是在“读/写/删除/查看原型等操作”中途打断,插入自定义逻辑。
  • 两条主线:Object.defineProperty(Vue 1/2) vs Proxy(Vue 3)。
  • 广度差异:defineProperty拦截“特定属性的读写”;Proxy拦截“整个对象的多种操作”。
  • 深度拦截都可实现,但 defineProperty 需预遍历属性且新增属性不生效;数组需额外处理。
  • 性能结论要看场景:少量属性、特定点拦截时 defineProperty 可能够用;全面拦截更适合 Proxy

快速上手

下面用最小例子直观理解“拦截”的含义,以及两种实现方式的差异。

js
// 方式一:Object.defineProperty - 拦截特定属性的读写
const obj = {};
let _name = "张三";
Object.defineProperty(obj, "name", {
  get() {
    console.log("读取 name 被拦截");
    return _name;
  },
  set(value) {
    console.log("设置 name 被拦截", value);
    _name = typeof value === "number" ? "张三" : value;
  },
  enumerable: true,
  configurable: true,
});
console.log(obj.name);
obj.name = "李四";
console.log(obj.name);
js
// 方式二:Proxy - 针对整个对象,拦截更广泛的操作
const target = { name: "张三" };
const p = new Proxy(target, {
  get(obj, prop) {
    console.log(`get:${String(prop)}`);
    return obj[prop];
  },
  set(obj, prop, value) {
    console.log(`set:${String(prop)} ->`, value);
    obj[prop] = typeof value === "number" ? "张三" : value;
    return true;
  },
});
console.log(p.name); // 张三
p.name = "李四";
console.log(p.name); // 李四

认知与前提

  • Proxy 并非“天然更快”,实际取舍取决于“拦截范围”和“操作种类”。
  • IE 等老环境不支持 Proxy 且不可 polyfill;需要降级策略或编译时方案。
  • 如果只需在少量、固定属性上做简单拦截,defineProperty 也能胜任。

核心概念

什么是“拦截”?

  • 在“操作数据的过程”中插入自定义逻辑:例如读值前记录日志、写值前做校验、删除属性前做权限检查、查看原型时统计调用等。
  • 在框架中,这种能力用于响应式依赖收集、变更调度、DevTools 探测等。

两种实现主线

  • Object.defineProperty(obj, prop, descriptor):为“特定属性”添加访问器(getter/setter),拦截读/写操作。
  • new Proxy(target, handler):针对“整个对象”,可拦截多种操作(get/set/deleteProperty/has/getPrototypeOf/setPrototypeOf/apply/construct 等)。
js
// defineProperty 的典型形态:属性级拦截
Object.defineProperty(obj, prop, {
  get() {
    /* 读时拦截 */
  },
  set(v) {
    /* 写时拦截 */
  },
});

// Proxy 的典型形态:对象级拦截
new Proxy(target, {
  get(obj, prop) {
    /* 读任何属性时拦截 */
  },
  set(obj, prop, v) {
    /* 写任何属性时拦截 */
  },
  deleteProperty(obj, prop) {
    /* 删除属性时拦截 */
  },
  // ...更多 handler
});

对象字面量/类:直接写 get/set 访问器

除了通过 defineProperty 显式定义访问器外,还可以在“对象字面量”或“类”里直接声明 get / set,本质上也是为“特定属性”提供拦截逻辑。

js
// 对象字面量:为特定属性定义访问器
const user = {
  _name: "张三",
  get name() {
    console.log("读取 name 被拦截");
    return this._name;
  },
  set name(v) {
    console.log("设置 name 被拦截", v);
    this._name = typeof v === "number" ? "张三" : v;
  },
};
console.log(user.name);
user.name = "李四";
console.log(user.name);
js
// 类:为实例属性提供访问器
class Student {
  constructor() {
    this._name = "张三";
  }
  get name() {
    console.log("读取 name 被拦截");
    return this._name;
  }
  set name(v) {
    console.log("设置 name 被拦截", v);
    this._name = typeof v === "number" ? "张三" : v;
  }
}
const s = new Student();
console.log(s.name);
s.name = "李四";
console.log(s.name);

defineProperty 的关系

  • 语法更简洁,但本质仍是“属性级拦截”的访问器;等价于对该属性做 defineProperty 的访问器描述符。
  • 只能作用于“声明的那些属性”,无法覆盖新增属性、删除属性、原型相关、函数调用等更广泛的操作。
  • 无法统一拦截数组索引写入与长度变化;复杂结构与动态字段仍更适合 Proxy

框架中的用途

  • Vue 1/2:以 defineProperty 为基础实现响应式,需要预遍历对象、为数组方法做“包裹”。
  • Vue 3:以 Proxy 为基础,天然支持新增/删除属性拦截,设计更简洁,覆盖更全面。

深度拦截与数组差异

defineProperty 的深度拦截

defineProperty 做深度拦截需要“递归遍历 + 逐属性定义访问器”,新增属性默认不受控:

js
// 预遍历对象:为每个现有属性定义访问器(新增属性默认不受控)
function deepDefineProperty(obj) {
  for (const key of Object.keys(obj)) {
    const val = obj[key];

    // 若是对象/数组,先递归处理其子属性
    if (val && typeof val === "object") {
      deepDefineProperty(val);
    }

    // 使用闭包变量作为属性的真实存储,避免 getter/setter 递归
    let _value = val;

    Object.defineProperty(obj, key, {
      get() {
        console.log("读取属性", String(key));
        return _value;
      },
      set(value) {
        console.log("设置属性", String(key), value);
        // 如果新值是对象/数组,递归为其子属性也做拦截
        if (value && typeof value === "object") {
          deepDefineProperty(value);
        }
        _value = value; // 只更新闭包值,避免触发自身 setter
      },
      enumerable: true,
      configurable: true,
    });
  }
}

常见坑:新增属性与数组

  • 新增属性不会被既有访问器拦截,需要再次 defineProperty
  • 数组的“索引写入”和“长度变化”无法通过单纯属性访问器可靠拦截。
  • Vue 2 通过“包裹数组变更方法”(如 push/splice/sort)实现拦截与通知。

Proxy 的深度拦截

Proxy 可在 get 中按需“惰性地返回子对象的代理”,从而实现深度拦截:

js
function deepProxy(obj) {
  return new Proxy(obj, {
    get(o, prop) {
      const val = o[prop];
      console.log(`get:${String(prop)}`);
      return val && typeof val === "object" ? deepProxy(val) : val;
    },
    set(o, prop, v) {
      console.log(`set:${String(prop)}`);
      o[prop] = v;
      return true;
    },
    deleteProperty(o, prop) {
      console.log(`delete:${String(prop)}`);
      return delete o[prop];
    },
  });
}

惰性代理与缓存

  • 为避免重复创建代理,可用 WeakMap 缓存已代理对象:当 get 返回子对象时先查缓存。
  • 这样既保留“惰性”优势,又避免频繁的代理包装开销。

广度与能力:差异对比

维度definePropertyProxy
拦截范围特定属性整个对象(所有属性)
可拦截操作get/setget/set/delete/has/原型相关/函数调用等
新增/删除属性拦截需额外处理(默认不拦截)天然支持
数组方法需包裹变更方法直接在 set/get 中处理即可
深度拦截需预遍历递归可惰性递归 + 缓存
兼容性广(老环境可用)现代浏览器/Node,IE 不支持

何时选哪一个?

  • 新项目、现代环境、需要全面拦截:优先 Proxy(更简单更全面)。
  • 老旧环境或只需在少量属性上做简单拦截:defineProperty 亦可满足。

性能与取舍

  • 性能没有“一刀切”的答案:关键在“拦截操作的种类与范围”。
  • 少量、固定属性拦截:defineProperty 足够且开销小。
  • 复杂对象、动态属性增删、数组大量操作:Proxy 更合适,代码更简洁,维护成本更低。

经验法则

  • 代码复杂度与维护成本,也是“性能”外的重要考量。
  • 在响应式框架设计里,Proxy 的覆盖面与简洁性通常更能降低长期成本。

实战与陷阱

defineProperty:新增属性未被拦截

js
const obj = { a: 1 };
deepDefineProperty(obj);
obj.b = 2; // 直接新增,不会触发既有访问器
console.log(obj.b); // 未拦截读取

解决思路:新增后再 defineProperty;或在业务层避免动态结构变化;或切换到 Proxy

defineProperty:数组索引与长度

js
const arr = [1, 2, 3];
// 直接 arr[1] = 99; 或 arr.length = 1; 并不受 defineProperty 的统一拦截
// Vue 2 通过包裹 push/splice/sort 等方法,实现拦截与通知

Proxy:惰性代理重复创建

js
// 使用 WeakMap 缓存,避免重复创建代理;仅对对象进行代理
const cache = new WeakMap();
function getProxy(obj) {
  if (!obj || typeof obj !== "object") return obj;
  if (cache.has(obj)) return cache.get(obj);

  const p = new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      console.log(`get:${String(prop)}`);
      // 惰性代理:仅在读取到子对象时为其创建代理
      return value && typeof value === "object" ? getProxy(value) : value;
    },
    set(target, prop, value, receiver) {
      console.log(`set:${String(prop)} ->`, value);
      // 保持对象不变式:用 Reflect.set 执行真实赋值,并返回布尔结果
      return Reflect.set(target, prop, value, receiver);
    },
    deleteProperty(target, prop) {
      console.log(`delete:${String(prop)}`);
      return Reflect.deleteProperty(target, prop);
    },
    has(target, prop) {
      console.log(`has:${String(prop)}`);
      return Reflect.has(target, prop);
    },
    getPrototypeOf(target) {
      return Reflect.getPrototypeOf(target);
    },
    setPrototypeOf(target, proto) {
      return Reflect.setPrototypeOf(target, proto);
    },
  });

  cache.set(obj, p);
  return p;
}

兼容性注意

  • Proxy 不支持 IE,且无法 polyfill;如需兼容需采用降级策略或编译时生成命令式更新代码(类似 Svelte/部分 Vapor 路线)。

使用建议

  • 新项目用 Proxy,获得更全面的拦截能力与更低的维护复杂度。
  • 老项目或需要兼容 IE:保持 defineProperty,并合理地为数组方法做包裹、为新增属性做二次定义。
  • 做深度拦截时优先“惰性 + 缓存”(Proxy + WeakMap),减少开销。
  • 拦截里只做必要逻辑:记录、校验、调度;避免在拦截器中做重活,导致性能回退。

小结与后续

  1. 数据拦截的本质是“在操作中插入自定义逻辑”,服务于响应式与可观测性。
  2. defineProperty 适合属性级、固定结构的拦截;Proxy 适合对象级、动态结构的全面拦截。
  3. 选择的关键在场景与长期成本;必要时结合编译期方案,兼顾体验与性能。