普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月5日首页

JS-手写系列:树与数组相互转换

2026年2月5日 11:48

前言

在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。

一、 核心概念:结构对比

  • 数组结构:每一项通过 parentId 指向父级。

      const nodes = [
        { id: 3, name: '节点C', parentId: 1 },
        { id: 6, name: '节点F', parentId: 3 },
        { id: 0, name: 'root', parentId: null },
        { id: 1, name: '节点A', parentId: 0 },
        { id: 8, name: '节点H', parentId: 4 },
        { id: 4, name: '节点D', parentId: 1 },
        { id: 2, name: '节点B', parentId: 0 },
        { id: 5, name: '节点E', parentId: 2 },
        { id: 7, name: '节点G', parentId: 2 },
        { id: 9, name: '节点I', parentId: 5 },
      ];
    
  • 树形结构:父级通过 children 数组包裹子级。

      let tree = [
        {
          id: 1,
          name: 'text1',
          parentId: 1,
          children: [
            {
              id: 2,
              name: 'text2',
              parentId: 1,
              children: [
                {
                  id: 4,
                  name: 'text4',
                  parentId: 2,
                },
              ],
            },
            {
              id: 3,
              name: 'text3',
              parentId: 1,
            },
          ],
        },
      ];
    

二、 数组转树

1. 递归思路

原理

  1. 首先需要传递给函数两个参数:数组、当前的父节点id
  2. 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
  3. 将这个子项的id作为父节点id传入函数,继续遍历
  4. 将遍历的结果作为children返回,并给当前项添加children
  5. 将这个当前项,插入到res里面,并返回

注意:如果不想影响原数组,需要先深拷贝一下数组。const cloneArr = JSON.parse(JSON.stringify (arr))

  const nodes = [
    { id: 3, name: '节点C', parentId: 1 },
    { id: 6, name: '节点F', parentId: 3 },
    { id: 0, name: 'root', parentId: null },
    { id: 1, name: '节点A', parentId: 0 },
    { id: 8, name: '节点H', parentId: 4 },
    { id: 4, name: '节点D', parentId: 1 },
    { id: 2, name: '节点B', parentId: 0 },
    { id: 5, name: '节点E', parentId: 2 },
    { id: 7, name: '节点G', parentId: 2 },
    { id: 9, name: '节点I', parentId: 5 },
  ];
  //递归写法
  const arrToTree1 = (arr, id) => {
    const res = [];
    arr.forEach((item) => {
      if (item.parentId === id) {
        const children = arrToTree1(arr, item.id);
        //如果希望每个元素都有children属性,可以直接赋值
        if (children.length !== 0) {
          item.children = children;
        }
        res.push(item);
      }
    });
    return res;
  };
  console.log(arrToTree1(nodes, null));

2. 非递归思路

原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差(O(n2)O(n^2))。

  1. 函数只需要接受一个参数,也就是需要转换的数组arr
  2. 第一层过滤数组,直接返回一个parentId为根id的元素
  3. 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
  const arrToTree2 = (arr) => {
    return arr.filter((father) => {
      const childrenArr = arr.filter((children) => {
        return children.parentId === father.id;
      });
      //如果希望每个元素都有children属性,可以直接赋值
      if (childrenArr.length !== 0) {
        father.children = childrenArr;
      }
      return father.parentId === null;
    });
  };
  console.log(arrToTree2(nodes));

3. Map 对象方案(O(n)O(n) 时间复杂度)

原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。

  const arrToTree3 = (arr) => {
    const map = {};
    const res = [];

    // 1. 建立映射表
    arr.forEach((item) => {
      map[item.id] = { ...item, children: [] };
    });

    // 2. 组装树结构
    arr.forEach((item) => {
      const node = map[item.id];
      if (item.parentId === null) {
        res.push(node);
      } else {
        if (map[item.parentId]) {
          map[item.parentId].children.push(node);
        }
      }
    });
    return res;
  };
  console.log(arrToTree3(nodes));

三、 树转数组

1. 递归遍历思路

原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。

  1. 首先定义一个结果数组res,遍历传入的树
  2. 直接将当前项的id、name、parentId包装在一个新对象里插入
  3. 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
  let tree = [
    {
      id: 1,
      name: 'text1',
      parentId: 1,
      children: [
        {
          id: 2,
          name: 'text2',
          parentId: 1,
          children: [
            {
              id: 4,
              name: 'text4',
              parentId: 2,
            },
          ],
        },
        {
          id: 3,
          name: 'text3',
          parentId: 1,
        },
      ],
    },
  ];
  const treeToArr = (tree) => {
    const res = [];
    tree.forEach((item) => {
      const loop = (data) => {
        res.push({
          id: data.id,
          name: data.name,
          parseId: data.parentId,
        });
        if (data.children) {
          data.children.forEach((itemChild) => {
            loop(itemChild);
          });
        }
      };
      loop(item);
    });
    return res;
  };
  console.log(treeToArr(tree));

四、 注意事项:深拷贝的必要性

在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。

  • 快捷方案const cloneArr = JSON.parse(JSON.stringify(arr))
  • 避坑点:如果数组项中包含 Date 对象、RegExpFunctionJSON.parse 会导致数据失真,此时应使用其他深拷贝方案。

JS-手写系列:call、apply、bind

2026年2月5日 11:11

前言

在 JavaScript 中,this 的指向总是让人捉摸不透。callapplybind 作为改变 this 指向的三大杀手锏,其底层实现原理是面试中的高频考点。本文将带你通过手写实现,彻底搞懂它们背后的逻辑。

一、 手写 call

1. 核心思路

利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则。

  • 将函数设为目标对象的一个属性。
  • 执行该函数。
  • 删除该临时属性,返回结果。

2. 实现

 Function.prototype.myCall = function (target, ...args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...args);
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.call(); // undefined undefined
  obj.getName.call({ name: 'b' }, 1, 2, 3); // b 1,2

  obj.getName.myCall(); // undefined undefined
  obj.getName.myCall({ name: 'b' }, 1, 2, 3); // b,1,2
};

二、 手写 apply

思路与call一致,都是利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则

1. 实现

  //唯一区别:参数处理方式,call需要使用...展开
  Function.prototype.myApply = function (target, args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...(args || []));
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.apply(); // undefined undefined
  obj.getName.apply({ name: 'b' }, [1, 2, 3]); // b 1,2

  obj.getName.myApply(); // undefined undefined
  obj.getName.myApply({ name: 'b' }, [1, 2, 3]); // b,1,2

二、 手写 bind

bind 的实现比前两者复杂,因为它涉及两个核心特性:闭包返回函数支持 new 实例化。当 bind 返回的函数被用作 new 构造函数时:

  • this 绑定失效:生成的实例 this 应该指向 new 创建的对象,而非 bind 绑定的对象。
  • 原型链继承:实例需要能够访问到原函数原型(prototype)上的属性和方法。

2. 实现

  Function.prototype.myBind = function (fn, ...args1) {
    const self = this; // 保存原函数
    const bound = function (...args2) {
      // 如果 this 是 bound 的实例,说明是 new 调用,此时 fn 应该失效
      return self.apply(this instanceof bound ? this : fn, [
        ...args1,
        ...args2,
      ]);
    };
    // 修改原型链,使实例能继承原函数原型, 使用 Object.create 避免直接修改导致相互影响
    bound.prototype = Object.create(self.prototype);
    bound.prototype.constructor = self;
    return bound;
  };

  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };

  const boundGetName1 = obj.getName.bind({ name: 'b' }, 7, 8);
  const boundGetName2 = obj.getName.myBind({ name: 'b' }, 7, 8);
  boundGetName1(); // b 7 8
  boundGetName2(); // b 7 8

  let newFunc1 = obj.getName.bind({ name: 'aa' }, 7, 8);
  let newFunc2 = obj.getName.myBind({ name: 'aa' }, 7, 8);
  newFunc1(); // aa 7 8
  newFunc2(); // aa 7 8

三、 总结与核心差异

方法 参数传递 返回值 核心原理
call 参数列表 (obj, a, b) 函数执行结果 临时属性挂载(隐式绑定)
apply 数组/类数组 (obj, [a, b]) 函数执行结果 临时属性挂载(隐式绑定)
bind 参数列表 (obj, a) 返回新函数 闭包 + apply

JS-手写系列:new操作符

2026年2月5日 10:02

前言

在 JavaScript 中,new 关键字就像是一个“工厂加工器”。虽然它看起来只是简单地创建了一个实例,但其背后涉及到了原型链接、上下文绑定以及返回值的特殊处理。掌握 new 的实现原理,是通往 JS 高级开发者的必经之路。

一、 new 操作符的 4 个核心步骤

当我们执行 new Constructor() 时,JavaScript 引擎在后台完成了以下四件事:

  1. 开辟空间:创建一个全新的空对象。
  2. 原型链接:将该对象的隐式原型(__proto__)指向构造函数的显式原型(prototype)。
  3. 绑定 this:执行构造函数,并将其内部的 this 绑定到这个新对象上。
  4. 返回结果:根据构造函数的返回值类型,决定最终返回的对象。

二、 代码实现

在实现中,我们不仅要处理常规逻辑,还要兼容构造函数可能返回引用类型的情况。

  function myNew(Constructor, ...args) {
    // 1. 创建一个空对象,并将其原型指向构造函数的 prototype
      const obj = {};
      obj.__proto__ = Constructor.prototype;
    // 2. 执行构造函数,并将 this 绑定到新创建的对象上
    const result = Constructor.apply(obj, args);

    // 3. 处理返回值逻辑:如果构造函数显式返回了一个对象或函数,则返回该结果; 否则,返回我们创建的新对象 obj
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function';

    return (isObject || isFunction) ? result : obj;
  }

  // 测试用例
  function Person(name, age) {
    this.name = name;
    this.age = age;
  }

  const per1 = new (Person)('ouyange', 23);
  const per2 = myNew(Person, 'ouyange', 23);

  console.log('原生 new 结果:', per1);
  console.log('手写 myNew 结果:', per2);

三、 细节解析

1. 构造函数返回值的坑

  • 如果构造函数 return 123(原始类型),new 会忽略它,依然返回实例对象。

  • 如果构造函数 return { a: 1 }(对象类型),new 会丢弃原本生成的实例,转而返回这个对象。

昨天 — 2026年2月4日首页

Vue-深度解读代理技术:Object.defineProperty 与 Proxy

2026年2月3日 17:06

前言

在 Vue 的进化史中,从 Vue 2 到 Vue 3 的跨越,最核心的变革莫之过于响应式系统的重构。而这场重构的主角,正是 Object.definePropertyProxy。本文将带你从底层描述符到 Reflect 陷阱,深度拆解这两大对象代理技术。

一、 ES5 时代的功臣:Object.defineProperty

Object.defineProperty 用于在一个对象上定义或修改属性。Vue 2 的响应式基础正是建立在其“存取描述符”之上的。

1. 基础语法

Object.defineProperty(obj, prop, descriptor);

  • obj:目标对象
  • prop:要定义或修改的属性名(字符串或 Symbol)
  • descriptor:属性描述符,是一个配置对象(包含数据描述符与存取描述符)

2. descriptor描述符分类

它可分为两类,一类为数据描述符、一类为存取描述符

属性描述符不能同时包含 value/writable(数据描述符)和 get/set(存取描述符)。

  • 数据描述符

    字段 类型 默认值 说明
    value any undefined 属性的值
    writable boolean false 是否可写(能否被重新赋值)
    enumerable boolean false 是否可枚举(能否在 for...inObject.keys 中出现)
    configurable boolean false 是否可配置(能否被删除或修改描述符)
  • 存取描述符:

    字段 类型 说明
    get function 读取属性时调用的函数
    set function 设置属性时调用的函数

注意❗:一个描述符不能同时包含 value/writableget/set,否则会报错。

3. 局限性分析(Vue 2 的痛点)

  • 无法监听新增/删除:必须预先定义好属性,动态添加的属性(data.b = 2)无法响应。

  • 数组支持差:无法拦截索引修改(arr[0] = x)及 length 变更。

  • 性能开销:必须通过递归遍历对象的所有属性进行拦截。

4. 使用示例:

// 封装一个劫持对象所有属性的函数
function observe(obj) {
  // 遍历对象的自有属性
  Object.keys(obj).forEach((prop) => {
    let value = obj[prop]; // 存储原始值
    Object.defineProperty(obj, prop, {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`读取 ${prop} 属性:${value}`);
        return value;
      },
      set(newValue) {
        console.log(`给 ${prop} 赋值:${newValue}`);
        value = newValue;
      },
    });
  });
}

// 测试
const person = { name: "张三", gender: "男" };
observe(person);

person.name = "李四"; // 输出:给 name 赋值:李四
console.log(person.gender); // 输出:读取 gender 属性:男 → 男

二、 ES6 时代的巅峰:Proxy

Proxy 是ES6引入的一个新对象,用于创建一个对象的代理,从而拦截并自定义这个对象的基本操作(比如属性读取、赋值、删除、遍历等)。它是 Vue 3 实现高效响应式的基石。

1. 基本语法

  • 语法:const proxy = new Proxy(target, handler);

    • target:要代理的目标对象(可以是普通对象、数组、函数,甚至是另一个 Proxy)。

    • handler:一个配置对象,包含多个陷阱函数(traps),每个陷阱函数对应一种对目标对象的操作(比如读取属性对应get陷阱,赋值对应set陷阱)

    • proxy:返回的代理对象,后续操作都通过这个代理对象进行,而非直接操作原对象。

1. 常见陷阱函数 (Traps)

Proxy 的强大在于它能拦截多种底层操作。

Trap 触发时机 示例
get(target, prop, receiver) 读取属性时 obj.foo
set(target, prop, value, receiver) 设置属性时 obj.foo = 'bar'
has(target, prop) 使用in 操作符时 'foo' in obj
deleteProperty(target, prop) 删除属性时 delete obj.foo
ownKeys(target) 获取自身属性名时 Object.keys(obj)
apply(target, thisArg, args) 调用函数时(仅当 target 是函数) fn()
construct(target, args) 使用new操作符时 new Obejct()

2. 使用示例

    // 1. 定义原始用户对象
    const user = {
      name: '张三',
      age: 20,
    };

    // 2. 创建 Proxy 代理对象
    const userProxy = new Proxy(user, {
      // 拦截属性读取操作(比如 userProxy.name)
      get(target, prop, receiver) {
        console.log(`读取属性${prop}`);
        // 核心逻辑:属性不存在时返回默认提示
        if (!Reflect.has(target, prop)) {
          return `属性${prop}不存在`;
        }
        return Reflect.get(target, prop, receiver); // 用 Reflect 保证 this 指向正确
      },

      // 拦截属性赋值操作(比如 userProxy.age = 25)
      set(target, prop, value, receiver) {
        console.log(`给属性${prop}赋值:${value}`);
        // 核心逻辑:属性合法性校验
        switch (prop) {
          case 'age':
            if (typeof value !== 'number' || value <= 0) {
              console.error(' 年龄必须是大于0的数字!');
              return false; // 返回 false 表示赋值失败
            }
            break;
          case 'name':
            if (typeof value !== 'string' || value.trim() === '') {
              console.error(' 姓名不能为空字符串!');
              return false;
            }
            break;
        }
        return Reflect.set(target, prop, value, receiver); // 合法则执行赋值,返回 true 表示成功
      },
    });

    // 3. 测试代理功能
    console.log('===== 测试属性读取 =====');
    console.log(userProxy.name); // 读取存在的属性
    console.log(userProxy.age); // 读取存在的属性
    console.log(userProxy.gender); // 读取不存在的属性

    console.log('\n===== 测试合法赋值 =====');
    userProxy.age = 25; // 合法的年龄赋值
    userProxy.name = '李四'; // 合法的姓名赋值
    console.log('赋值后 name:', userProxy.name);
    console.log('赋值后 age:', userProxy.age);

    console.log('\n===== 测试非法赋值 =====');
    userProxy.age = -5; // 非法的年龄(负数)
    userProxy.name = ''; // 非法的姓名(空字符串)
    console.log('非法赋值后 age:', userProxy.age); // 年龄仍为 25
   
// 打印结果:  
===== 测试属性读取 =====
 读取属性name
 张三
 读取属性age
 20
 读取属性gender
 属性gender不存在
===== 测试合法赋值 =====
 给属性age赋值:25
 给属性name赋值:李四
 读取属性name
 赋值后 name: 李四
 读取属性age
 赋值后 age: 25
 114 
===== 测试非法赋值 =====
 给属性age赋值:-5
 年龄必须是大于0的数字!
 给属性name赋值:
 姓名不能为空字符串!
 读取属性age
 非法赋值后 age: 25



三、 Reflect:Proxy 的最佳拍档

Reflect 是 ES6 引入的内置全局对象,不能通过 new 实例化(不是构造函数)。它的核心作用是把原本属于 Object 对象的底层操作(比如属性赋值、删除)提炼成独立的函数方法,同时能保证操作的 “正确性”—— 比如转发操作时保留正确的 this 指向。

1. 为什么一定要配合 Reflect?

核心原因:处理 this 指向问题。

当对象内部存在 getter 并依赖 this 时,如果直接使用 target[prop]this 将指向原始对象而非代理对象,导致后续的属性读取无法被 Proxy 拦截。

2. Reflect使用对比

const person = {
      _name: '张三',
      get name() {
        console.log('getter 被调用,this:', this === person ? 'person' : this);
        return this._name;
      },

      introduce() {
        console.log('this', this)
        return `我叫${this.name}`;
      },
    };

    // 错误代理
    const badProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          const original = target[prop]; // 错误:直接获取
          return function () {
            return original(); // this 指向 badProxy
          };
        }
        return target[prop];
      },
    });

    // 正确代理
    const goodProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          return function () {
            return Reflect.apply(target[prop], receiver, arguments); // 正确
          };
        }
        return Reflect.get(target, prop, receiver);
      },
    });

    console.log('=== 测试错误代理 ===');
    console.log(badProxy.introduce());

    console.log('\n=== 测试正确代理 ===');
    console.log(goodProxy.introduce()

3. 打印结果分析

  1. 首先执行console.log(badProxy.introduce())

    • 它会读取badProxy.introduce属性,触发badProxyget 陷阱,参数target = personprop = 'introduce'receiver = badProxy
  2. 接着进入badProxyget陷阱函数,此时返回的新函数被赋值给badProxy.introduce,然后执行这个新函数。

    console.log(`拦截: ${prop}`);  // 输出:拦截: introduce
    if (prop === 'introduce') {
      const original = target[prop]; // 拿到 person.introduce 函数
      return function () { // 返回一个新函数
        return original(); // 关键错误:裸调用 original
      };
    }
    
  3. 执行返回的新函数original()(即person.introduce()

    • original是裸调用(没有对象前缀),所以introduce方法里的this指向window(非严格模式);
    • 输出:this window
    • 执行this.namewindow.name,不会触发personnamegetter(因为this不是person/badProxy),所以window._name不存在,返回undefined
    • 最终返回我叫undefined,控制台输出:我叫

  4. 执行console.log(goodProxy.introduce())

    • 它会读取goodProxy.introduce属性,触发goodProxyget 陷阱,参数:
    • target = personprop = 'introduce'receiver = goodProxy
  5. 第一次触发get陷阱(拦截introduce),此时返回的新函数被赋值给goodProxy.introduce,然后执行这个新函数

    console.log(`拦截: ${prop}`); // 输出:拦截: introduce → 第一次拦截
    if (prop === 'introduce') {
      return function () { // 返回一个新函数
        return Reflect.apply(target[prop], receiver, arguments); // 正确绑定 this
      };
    }
    
  6. 执行返回的新函数,Reflect.apply(target[prop], receiver, arguments),其中

    • target[prop]=person.introduce 函数;
    • receiver=goodProxy(把introduce方法的this绑定到goodProxy);
    • 执行person.introduce方法,此时方法内的this = goodProxy
  7. 执行 introduce 方法内部代码

    console.log('this', this); // 输出:this Proxy(Object) { _name: '张三' }(即 goodProxy)
    return `我叫${this.name}`; // 关键:读取 this.name → goodProxy.name
    
  8. 第二次触发get陷阱(拦截name),因为this = goodProxy,所以this.name等价于goodProxy.name,需要读取goodProxy.name属性,再次触发goodProxyget 陷阱,参数:

    • target = personprop = 'name'receiver = goodProxy
    • 进入get陷进函数
console.log(`拦截: ${prop}`); // 输出:拦截: name → 第二次拦截
if (prop === 'introduce') { /* 不执行 */ }
return Reflect.get(target, prop, receiver); // 调用 Reflect.get 读取 person.name

9. 调用Reflect.get(target, prop, receiver),触发person.name的 getter,此时 getter 里的thisreceiver绑定为goodProxy

get name() {
  console.log('getter 被调用,this:', this === person ? 'person' : this); 
  // 输出:getter 被调用,this: Proxy(Object) { _name: '张三' }
  return this._name; // this = goodProxy → 读取 goodProxy._name
}

10. 返回this._name(不是name!),这时会第三次触发goodProxy的get陷阱(prop = '_name'

console.log(`拦截: ${prop}`); // 输出:拦截: _name
return Reflect.get(target, '_name', receiver); // 返回 person._name = '张三'

11. 最终返回结果 我叫张三

![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cb44628ee3904c759428efdadbba9e90~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y-R546w5LiA5Y-q5aSn5ZGG55Oc:q75.awebp?rk3s=f64ab15b&x-expires=1770714393&x-signature=VN5mF0OKtlLwwfknHvfBPYIqpVE%3D)

四、 总结:Proxy 的降维打击

  1. 全方位拦截:不仅能拦截读写,还能拦截删除、函数调用、new 操作等。
  2. 性能优势:无需遍历属性,直接代理整个对象。
  3. 原生支持数组:完美解决 Vue 2 中数组监听的各种奇技淫巧(如重写数组原型方法)。
  4. 配合 Reflect:通过 receiver 参数完美转发 this 绑定,保证了响应式系统的严密性。
❌
❌