js面试第三梯队

0 阅读19分钟

这一梯队属于中高频问题,虽然不像前两梯队那样每面必问,但在中等规模公司面试、大厂二面/三面、或者作为追问延伸时经常出现。掌握这些知识点能让你的回答更有深度,展现出扎实的基础。


第三梯队:中高频(进阶基础,深度追问)


  1. JavaScript 数组有哪些常用方法?

深入理解:数组方法的分类体系

JavaScript 数组方法超过 30 个,按功能可分为 8 大类:

数组方法分类图谱
├── 添加/删除元素(改变原数组)
│   ├── 尾部:push / pop
│   ├── 头部:unshift / shift
│   └── 任意位置:splice
├── 查找元素(不改变原数组)
│   ├── 按值查找:indexOf / lastIndexOf / includes
│   └── 按条件查找:find / findIndex / findLast / findLastIndex
├── 遍历处理(不改变原数组,返回新值)
│   ├── 映射:map
│   ├── 过滤:filter
│   ├── 归约:reduce / reduceRight
│   └── 遍历:forEach(无返回值)
├── 切片与连接(不改变原数组)
│   ├── 切片:slice
│   └── 连接:concat
├── 排序与反转(改变原数组)
│   ├── 排序:sort
│   └── 反转:reverse
├── 测试方法(返回布尔值)
│   ├── 全部满足:every
│   ├── 部分满足:some
│   └── 包含满足:at(ES2022)
├── 扁平化与填充(ES2019+)
│   ├── 扁平化:flat / flatMap
│   └── 填充:fill / copyWithin
└── 静态方法(Array 构造函数上)
    ├── 创建:Array.from / Array.of
    └── 判断:Array.isArray

重点方法深度解析

splice 的万能性(改变原数组的"瑞士军刀"):

const arr = ['a', 'b', 'c', 'd'];

// 删除:从索引1开始,删除2个
arr.splice(1, 2);        // 返回 ['b', 'c'],arr 变为 ['a', 'd']

// 插入:从索引1开始,删除0个,插入'x','y'
arr.splice(1, 0, 'x', 'y'); // arr 变为 ['a', 'x', 'y', 'd']

// 替换:从索引1开始,删除1个,插入'z'
arr.splice(1, 1, 'z');   // arr 变为 ['a', 'z', 'y', 'd']

reduce 的强大之处(数组"万能方法"):

// 不仅仅是求和
const arr = [1, 2, 3, 4];

// 1. 求和
const sum = arr.reduce((acc, cur) => acc + cur, 0);

// 2. 转对象(分组统计)
const count = arr.reduce((acc, cur) => {
  acc[cur] = (acc[cur] || 0) + 1;
  return acc;
}, {});

// 3. 嵌套数组扁平化
const nested = [[1, 2], [3, 4], [5]];
const flat = nested.reduce((acc, cur) => acc.concat(cur), []);

// 4. 管道函数(函数组合)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const add5 = x => x + 5;
const multiply2 = x => x * 2;
const addThenMultiply = pipe(add5, multiply2);
console.log(addThenMultiply(10)); // (10+5)*2 = 30

find 与 indexOf 的本质区别:

特性indexOffind
查找依据严格相等(===自定义条件函数
查找对象引用(对象数组)
返回索引(-1表示未找到)元素值(undefined表示未找到)
NaN 处理找不到(NaN !== NaN可以找到
const arr = [NaN, {id: 1}, {id: 2}];

arr.indexOf(NaN);        // -1(找不到)
arr.find(x => Number.isNaN(x)); // NaN(找到)

arr.indexOf({id: 1});    // -1(不同引用)
arr.find(x => x.id === 1); // {id: 1}(按条件找到)

ES2023 新增方法(面试加分项)

const arr = [1, 2, 3, 4, 5];

// toSorted - 不改变原数组的排序
const sorted = arr.toSorted((a, b) => b - a); // [5,4,3,2,1]
console.log(arr); // [1,2,3,4,5](原数组不变)

// toReversed - 不改变原数组的反转
const reversed = arr.toReversed(); // [5,4,3,2,1]

// toSpliced - 不改变原数组的 splice
const spliced = arr.toSpliced(1, 2, 'a', 'b'); // [1,'a','b',4,5]

// with - 不改变原数组的修改(替换指定索引)
const replaced = arr.with(2, 'x'); // [1,2,'x',4,5]

面试标准回答模板

"JavaScript 数组方法丰富,按功能可分为添加删除(push/pop/unshift/shift/splice)、查找(indexOf/find/includes)、遍历处理(map/filter/reduce/forEach)、切片连接(slice/concat)、排序反转(sort/reverse)、测试(every/some)、扁平化(flat/flatMap)等类别。

重点区分:改变原数组的方法有 push/pop/shift/unshift/splice/sort/reverse/fill/copyWithin;不改变原数组的方法有 map/filter/slice/concat/find/findIndex 等。ES2023 新增了 toSorted/toReversed/toSpliced/with 等不改变原数组的版本,类似于 React 的不可变数据理念。

reduce 是最强大的数组方法,可以实现 map、filter、flat 等功能,常用于数据转换、分组统计、函数管道等场景。"


  1. 哪些数组方法会改变原数组?

深入理解:可变 vs 不可变的设计哲学

为什么要有改变原数组的方法?

早期 JavaScript 设计追求性能和简洁。在内存有限的年代,原地修改避免创建新数组,节省内存和 GC 压力。

现代趋势:不可变性(Immutability)

React、Redux 等框架推广了不可变数据理念,因为:

  • 可预测性:数据不意外变化,减少 bug
  • 性能优化:配合虚拟 DOM,通过引用比较快速判断变化
  • 时间旅行调试:保存状态历史,方便调试

ES2023 的解决方案:提供不可变版本

可变方法          不可变版本(ES2023)
─────────────────────────────────────
sort()     →    toSorted()
reverse()  →    toReversed()
splice()   →    toSpliced()
直接索引赋值  →   with(index, value)

完整对照表

改变原数组(9个)不改变原数组(常用)
push() - 尾部添加concat() - 连接数组
pop() - 尾部删除slice() - 切片
unshift() - 头部添加map() - 映射
shift() - 头部删除filter() - 过滤
splice() - 任意位置增删改find() - 查找元素
sort() - 排序findIndex() - 查找索引
reverse() - 反转indexOf() - 查找索引
fill() - 填充includes() - 包含判断
copyWithin() - 内部复制join() - 转字符

注意陷阱:

const arr = [3, 1, 2];

// 看似没改变,实际改变了!
const sorted = arr.sort(); 
console.log(arr); // [1, 2, 3] - 原数组被排序了!

// 正确做法:先复制
const sorted = [...arr].sort();
// 或 ES2023
const sorted = arr.toSorted();

面试标准回答模板

"会改变原数组的方法有 9 个:push、pop、unshift、shift、splice、sort、reverse、fill、copyWithin。其他常用方法如 map、filter、slice、concat、find 等都不会改变原数组。

在现代开发中,特别是在 React、Redux 等框架中,推荐保持数据不可变性。ES2023 新增了 toSorted、toReversed、toSpliced、with 等方法,提供不改变原数组的操作版本,可以在需要不可变性时替代传统方法。"


  1. for...in 和 for...of 的区别

深入理解:迭代协议的设计差异

for...in:遍历对象的「可枚举属性」

const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
  console.log(key); // 'a', 'b', 'c'(字符串键)
}

// 遍历数组的问题
const arr = ['a', 'b', 'c'];
for (const index in arr) {
  console.log(index, arr[index]); 
  // '0' 'a', '1' 'b', '2' 'c' - index 是字符串!
  // 还会遍历原型链上的可枚举属性(如果扩展了 Array.prototype)
}

for...of:遍历「可迭代对象的值」

const arr = ['a', 'b', 'c'];
for (const value of arr) {
  console.log(value); // 'a', 'b', 'c' - 直接获取值
}

// 不能用于普通对象(不可迭代)
const obj = { a: 1, b: 2 };
for (const value of obj) {
  // TypeError: obj is not iterable
}

底层机制:迭代器协议

可迭代对象(Iterable)必须实现:

const iterable = {
  // Symbol.iterator 方法返回迭代器
  [Symbol.iterator]() {
    let i = 0;
    const data = this.data;
    
    // 迭代器对象必须有 next 方法
    return {
      next() {
        if (i < data.length) {
          return { value: data[i++], done: false };
        }
        return { done: true };
      }
    };
  },
  data: [1, 2, 3]
};

for (const value of iterable) {
  console.log(value); // 1, 2, 3
}

内置可迭代对象:

  • Array、String、Map、Set、TypedArray、arguments、NodeList

对比总结表

特性for...infor...of
遍历目标对象的可枚举属性(包括继承的)可迭代对象的值
返回内容键(字符串)值(任意类型)
适用对象普通对象、数组(不推荐)数组、Map、Set、String 等可迭代对象
数组索引字符串 '0', '1', '2'不需要索引,直接取值
原型链会遍历原型链(需 hasOwnProperty 过滤)不会
性能较慢(需检查原型链)较快(直接迭代)

面试标准回答模板

"for...in 遍历对象的可枚举属性,返回字符串键,会遍历原型链,适合普通对象但不推荐用于数组,因为获取的是字符串索引且可能包含原型属性。for...of 遍历可迭代对象的值,直接获取元素值,适用于数组、Map、Set、String 等实现了迭代器协议的对象。

for...of 底层使用迭代器协议(Symbol.iterator),通过 next 方法逐个获取值,直到 done 为 true。普通对象没有默认迭代器,所以不能用 for...of,但可以通过 Object.keys/values/entries 配合 for...of 使用。"


  1. map 和 forEach 的区别

深入理解:函数式 vs 过程式

核心区别:返回值与链式调用

const arr = [1, 2, 3];

// map:返回新数组,支持链式调用
const doubled = arr.map(x => x * 2)
                     .filter(x => x > 2)
                     .reduce((a, b) => a + b, 0);

// forEach:无返回值,不支持链式调用
let sum = 0;
arr.forEach(x => {
  sum += x; // 副作用:修改外部变量
});
// 无法继续 .filter().map()

关键差异对比:

特性mapforEach
返回值新数组(映射结果)undefined
链式调用支持不支持
原数组不改变(除非回调中修改)不改变(除非回调中修改)
跳过空位不跳过,会执行回调返回 undefined跳过空位,不执行回调
中断遍历不能(除非 throw)不能(除非 throw)
使用场景数据转换、函数式编程副作用操作(打印、修改外部变量)

空位处理差异(稀疏数组):

const sparse = [1, , 3]; // 索引1是空位,不是 undefined

// map 不跳过空位,但保留空位
sparse.map(x => x * 2); // [2, , 6] - 空位还是空位,但执行了回调(返回 undefined)

// forEach 跳过空位
sparse.forEach(x => console.log(x)); // 只输出 1 和 3

性能考量:

现代引擎优化下,两者性能差异极小。选择依据应是语义清晰度:

  • 需要转换数据 → 用 map
  • 需要副作用(修改外部状态、DOM 操作、打印日志)→ 用 forEach

面试标准回答模板

"map 和 forEach 都用于遍历数组,但 map 返回新数组,支持链式调用,适合数据转换场景;forEach 无返回值,适合执行副作用操作如修改外部变量、DOM 操作、打印日志等。

两者都不能中断遍历(break/return 无效),如需中断应使用 for 循环或 some/every。另外,处理稀疏数组时,map 会保留空位但返回 undefined,forEach 会跳过空位不执行回调。"


  1. reduce 的使用场景

深入理解:reduce 是"万能归约"

reduce 的本质:将数组"折叠"为单个值

数组 [1, 2, 3, 4]
     ↓
   归约过程
     ↓
  单个值 10(求和)
  或对象 {count: 4, sum: 10, max: 4}
  或新数组 [2, 4, 6, 8](相当于 map)
  或树结构、Promise 链等

六大经典使用场景

  1. 基础聚合(求和、求积、最值)
const nums = [3, 1, 4, 1, 5];

// 求和
const sum = nums.reduce((acc, cur) => acc + cur, 0);

// 求最大值
const max = nums.reduce((acc, cur) => cur > acc ? cur : acc, -Infinity);

// 同时计算多个统计值(一次遍历)
const stats = nums.reduce((acc, cur) => ({
  count: acc.count + 1,
  sum: acc.sum + cur,
  min: Math.min(acc.min, cur),
  max: Math.max(acc.max, cur)
}), { count: 0, sum: 0, min: Infinity, max: -Infinity });
  1. 数组转对象(分组、映射)
const users = [
  { id: 1, name: 'Tom', group: 'A' },
  { id: 2, name: 'Jerry', group: 'B' },
  { id: 3, name: 'Spike', group: 'A' }
];

// 按 group 分组
const grouped = users.reduce((acc, user) => {
  acc[user.group] = acc[user.group] || [];
  acc[user.group].push(user);
  return acc;
}, {});
// { A: [Tom, Spike], B: [Jerry] }

// 转 id 映射对象(快速查找)
const userMap = users.reduce((acc, user) => {
  acc[user.id] = user;
  return acc;
}, {});
// { 1: {id:1, name:'Tom'}, 2: {...}, 3: {...} }
  1. 数组扁平化
const nested = [[1, 2], [3, 4], [5, [6, 7]]];

// 一层扁平化
const flat = nested.reduce((acc, cur) => acc.concat(cur), []);

// 深度扁平化(递归)
const deepFlat = arr => arr.reduce((acc, cur) => 
  acc.concat(Array.isArray(cur) ? deepFlat(cur) : cur), []
);
  1. 实现 map 和 filter(展示 reduce 的万能性)
// 用 reduce 实现 map
const map = (arr, fn) => arr.reduce((acc, cur, i) => {
  acc.push(fn(cur, i));
  return acc;
}, []);

// 用 reduce 实现 filter
const filter = (arr, fn) => arr.reduce((acc, cur) => {
  if (fn(cur)) acc.push(cur);
  return acc;
}, []);
  1. 函数管道(函数组合)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);

const add5 = x => x + 5;
const multiply2 = x => x * 2;
const toString = x => String(x);

const transform = pipe(add5, multiply2, toString);
transform(10); // '30'
  1. Promise 顺序执行(串行异步)
const tasks = [fn1, fn2, fn3]; // 返回 Promise 的函数数组

// 串行执行,前一个完成再执行下一个
tasks.reduce((promise, task) => {
  return promise.then(result => task(result));
}, Promise.resolve(initialValue));

面试标准回答模板

"reduce 是数组的归约方法,将数组元素通过回调函数累积为单个值。常见使用场景包括:数据聚合(求和、最值、统计)、数组转对象(分组、快速查找映射)、数组扁平化、实现 map/filter 展示函数式编程能力、构建函数管道(函数组合)、以及串行执行 Promise 等。

reduce 接受两个参数:回调函数(接收累积值、当前值、索引、原数组)和初始值。提供初始值是最佳实践,否则数组为空时会抛出 TypeError,且类型推断更困难。"


  1. 数组去重有哪些方法?

深入理解:去重的复杂度与适用场景

方法对比表:

方法代码优点缺点适用场景
Set(推荐)[...new Set(arr)]简洁、O(n)、保持插入顺序只能处理原始值原始值数组
filter + indexOfarr.filter((v,i) => arr.indexOf(v) === i)兼容性好O(n²)、indexOf 每次遍历小数组
reducearr.reduce((a,b) => a.includes(b) ? a : [...a,b], [])函数式O(n²)、创建新数组开销函数式场景
Map 对象用 Map 存储已见值O(n)、可处理对象(引用)代码较多对象数组
排序后去重arr.sort().filter((v,i,a) => v !== a[i-1])O(n log n)改变原数组顺序需要排序时

处理对象数组(按属性去重):

const users = [
  { id: 1, name: 'Tom' },
  { id: 2, name: 'Jerry' },
  { id: 1, name: 'Tom Clone' } // id 重复
];

// 按 id 去重,保留最后一个
const unique = [...new Map(users.map(u => [u.id, u])).values()];
// [{id: 2, name: 'Jerry'}, {id: 1, name: 'Tom Clone'}]

// 原理:Map 的键唯一,后值覆盖前值,最后转回数组

处理 NaN(特殊值):

const arr = [1, NaN, 2, NaN, 3];

// Set 能正确处理 NaN(NaN !== NaN,但 Set 认为相等)
[...new Set(arr)]; // [1, NaN, 2, 3]

// indexOf 无法找到 NaN
arr.filter((v, i) => arr.indexOf(v) === i); // [1, 2, 3](NaN 丢失!)

面试标准回答模板

"数组去重最常用的方法是 Set:[...new Set(arr)],时间复杂度 O(n),代码简洁,能正确处理 NaN,保持插入顺序,适合原始值数组。对于对象数组,可以使用 Map 按唯一属性去重:[...new Map(arr.map(v => [v.id, v])).values()]

兼容性要求低的环境可以用 filter 配合 indexOf,但时间复杂度 O(n²) 不适合大数组。如果数组已排序,可以先排序再用一次遍历去重,复杂度 O(n log n)。"


  1. 数组扁平化如何实现?

深入理解:递归 vs 迭代的权衡

三种实现方式对比:

const arr = [1, [2, 3], [4, [5, 6]]];

// 1. 递归实现(深度优先)
function flatDeep(arr, depth = 1) {
  return depth > 0
    ? arr.reduce((acc, val) => 
        acc.concat(Array.isArray(val) ? flatDeep(val, depth - 1) : val), 
      [])
    : arr.slice();
}

// 2. 迭代实现(广度优先,使用栈)
function flatDeepIter(arr, depth = 1) {
  const stack = arr.map(item => [item, depth]);
  const result = [];
  
  while (stack.length > 0) {
    const [item, d] = stack.pop();
    if (Array.isArray(item) && d > 0) {
      stack.push(...item.map(sub => [sub, d - 1]));
    } else {
      result.push(item);
    }
  }
  return result.reverse(); // 栈是后进先出,需要反转
}

// 3. 使用原生 flat(ES2019)
arr.flat(2); // [1, 2, 3, 4, 5, 6]
arr.flat(Infinity); // 完全扁平化

性能考量:

  • 递归:代码简洁,但深度大时有栈溢出风险
  • 迭代:安全,但代码复杂,需要反转结果
  • 原生 flat:最优选择,引擎优化,无栈溢出风险

面试标准回答模板

"数组扁平化可以用递归或迭代实现。递归方案使用 reduce 配合 concat,遇到数组则递归处理,代码简洁但深度大时有栈溢出风险;迭代方案使用栈模拟递归,安全但代码较复杂。

生产环境推荐使用 ES2019 的原生 flat 方法,支持指定深度或 Infinity 完全扁平化,引擎优化性能好。如果需要在扁平化时处理元素,可以使用 flatMap,它等价于 map 后接 flat(1)。"


  1. 如何判断一个对象是空对象?

深入理解:不同场景的判断标准

三种方法对比:

const obj = {};

// 1. for...in(最准确,考虑可枚举属性)
function isEmpty(obj) {
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) return false;
  }
  return true;
}

// 2. Object.keys(常用,只考虑自有可枚举属性)
Object.keys(obj).length === 0;

// 3. JSON.stringify(简单,但性能差,有边界情况)
JSON.stringify(obj) === '{}';

边界情况处理:

// 特殊情况 1:对象有不可枚举属性
const obj = {};
Object.defineProperty(obj, 'hidden', { value: 'secret', enumerable: false });
Object.keys(obj).length === 0; // true(但对象不是"空"的)
for (const key in obj) {} // 也遍历不到

// 特殊情况 2:继承属性
const child = Object.create({ inherited: 'value' });
Object.keys(child).length === 0; // true(没有自有属性)
for (const key in child) { console.log(key); } // 'inherited'

// 特殊情况 3:Symbol 属性
const sym = Symbol('secret');
const obj = { [sym]: 'value' };
Object.keys(obj).length === 0; // true(Symbol 属性不算)
Object.getOwnPropertySymbols(obj).length === 0; // false

最严谨的判断(考虑所有情况):

function isTrulyEmpty(obj) {
  // 1. 排除 null 和非对象
  if (obj === null || typeof obj !== 'object') return false;
  
  // 2. 检查自有可枚举属性
  if (Object.keys(obj).length > 0) return false;
  
  // 3. 检查自有不可枚举属性(除非明确要忽略)
  if (Object.getOwnPropertyNames(obj).length > 0) return false;
  
  // 4. 检查 Symbol 属性
  if (Object.getOwnPropertySymbols(obj).length > 0) return false;
  
  return true;
}

面试标准回答模板

"判断空对象最常用的方法是 Object.keys(obj).length === 0,它检查对象是否有自有可枚举属性。如果需要考虑继承属性,可以用 for...in 配合 hasOwnProperty 过滤。

边界情况包括:对象可能有不可枚举属性(Object.defineProperty 定义)、Symbol 属性(Object.getOwnPropertySymbols 检查)、或继承自原型链的属性。最严谨的判断需要结合 Object.keys、Object.getOwnPropertyNames 和 Object.getOwnPropertySymbols。"


  1. Object 和其他数据类型的本质区别

深入理解:JavaScript 的类型系统架构

两种类型系统的视角:

视角 1ECMAScript 规范(8 种类型)
┌─────────────────────────────────────┐
│            数据类型                   │
│  ┌─────────────┐  ┌─────────────┐    │
│  │  原始类型    │  │   对象类型   │    │
│  │  (7种)      │  │  (Object)   │    │
│  │             │  │             │    │
│  │ • Undefined │  │ • 普通对象   │    │
│  │ • Null      │  │ • 数组      │    │
│  │ • Boolean   │  │ • 函数      │    │
│  │ • String    │  │ • 日期      │    │
│  │ • Symbol    │  │ • 正则      │    │
│  │ • Number    │  │ • 错误对象   │    │
│  │ • BigInt    │  │ • ...       │    │
│  └─────────────┘  └─────────────┘    │
└─────────────────────────────────────┘

视角 2:运行时行为(typeof 操作符)
┌─────────────────────────────────────┐
│ typeof 返回值(7 种字符串)          │
│                                     │
│ "undefined" | "boolean" | "number" │
│ "string"    | "symbol"  | "bigint" │
│ "object"    | "function"            │
│                                     │
│ 注意:null"object"(历史 bug)  │
│      函数是 "function"(特殊处理)   │
└─────────────────────────────────────┘

Object 的本质特征:

// 1. 引用类型(存储引用地址)
const obj1 = { a: 1 };
const obj2 = obj1;
obj2.a = 2;
console.log(obj1.a); // 2(共享引用)

// 2. 可扩展性(动态添加属性)
obj1.b = 3; // 随时添加新属性

// 3. 原型链继承
console.log(obj1.toString); // 继承自 Object.prototype

// 4. 装箱与拆箱(原始类型的对象包装)
const str = "hello"; // 原始字符串
str.toUpperCase(); // 自动装箱为 String 对象,调用方法后拆箱

// 5. 作为哈希表(键值对存储)
const map = {};
map['key'] = 'value';
map[123] = 'number key'; // 键会被转为字符串 '123'

Object 与原始类型的核心差异:

特性 原始类型 Object 存储 栈(值本身) 堆(通过引用访问) 比较 值比较(内容相同即相等) 引用比较(同一对象才相等) 可变性 不可变(重新赋值是替换) 可变(修改属性) 属性 无 可动态添加、删除 继承 无(包装对象有原型) 有原型链 typeof 返回具体类型 "object"(函数除外)

面试标准回答模板

"Object 是 JavaScript 中唯一的引用类型,与 7 种原始类型的本质区别在于存储方式、可变性和行为特征。原始类型存储在栈中,值不可变,比较时进行值比较;Object 存储在堆中,通过引用访问,值可变,比较时进行引用比较。

Object 具有动态属性系统,可以添加、删除、修改属性,通过原型链继承方法和属性。函数、数组、日期等都是 Object 的特殊类型,typeof 会特殊识别函数返回'function',其他都返回'object'。"


  1. Object.defineProperty 和 Proxy 的区别

深入理解:属性拦截的两种范式

Object.defineProperty(ES5):属性级别的劫持

const obj = {};
let internalValue;

Object.defineProperty(obj, 'name', {
  // 数据描述符
  get() {
    console.log('Getting name');
    return internalValue;
  },
  set(newVal) {
    console.log('Setting name to', newVal);
    internalValue = newVal;
  },
  enumerable: true,
  configurable: true
});

obj.name = 'Tom'; // Setting name to Tom
console.log(obj.name); // Getting name → Tom

Proxy(ES6):对象级别的代理

const target = { name: 'Tom', age: 20 };

const proxy = new Proxy(target, {
  // 拦截 get 操作
  get(obj, prop) {
    console.log('Getting', prop);
    if (prop in obj) return obj[prop];
    return 'Property not found';
  },
  
  // 拦截 set 操作
  set(obj, prop, value) {
    console.log('Setting', prop, 'to', value);
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be number');
    }
    obj[prop] = value;
    return true; // 严格模式必须返回 true
  },
  
  // 拦截其他操作:has、deleteProperty、ownKeys 等
  has(obj, prop) {
    console.log('Checking', prop);
    return prop in obj;
  }
});

proxy.name; // Getting name → Tom
proxy.age = 25; // Setting age to 25
'age' in proxy; // Checking age → true
proxy.gender; // Getting gender → 'Property not found'

核心差异对比

特性 Object.defineProperty Proxy 拦截粒度 单个属性,需预先定义 整个对象,自动代理所有属性 新增属性 无法拦截,需递归定义 自动拦截,无需额外处理 数组操作 无法拦截索引操作、length 变化 完美拦截所有操作 嵌套对象 需深度递归定义 需配合递归代理,但机制更优雅 性能 较快(属性访问优化) 稍慢(代理层开销) 兼容性 IE9+ 现代浏览器,无 IE 支持 可撤销 不可 可用 Proxy.revocable 创建可撤销代理

Vue 框架的演进:Vue2 vs Vue3

// Vue 2 的响应式(Object.defineProperty 的局限)
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() { return val; },
    set(newVal) { 
      val = newVal; 
      notifyUpdate(); // 通知更新
    }
  });
  
  // 问题 1:无法检测新增属性,需 Vue.set
  // 问题 2:无法检测数组索引操作和 length 变化,需重写数组方法
}

// Vue 3 的响应式(Proxy 的优势)
function reactive(target) {
  return new Proxy(target, {
    get(obj, key) {
      track(obj, key); // 依赖收集
      return obj[key];
    },
    set(obj, key, value) {
      obj[key] = value;
      trigger(obj, key); // 触发更新
    }
    // 自动处理新增属性、数组操作,无需特殊处理
  });
}

面试标准回答模板

"Object.defineProperty 是 ES5 的属性级拦截,需要逐个属性定义 getter/setter,无法拦截新增属性、数组索引操作,需要递归处理嵌套对象。Proxy 是 ES6 的对象级代理,可以拦截对象的所有操作(get、set、has、delete 等),自动处理新增属性和数组操作,支持更多拦截器。

Vue 2 使用 Object.defineProperty,需要 Vue.set 处理新增属性,重写数组方法处理数组变更;Vue 3 改用 Proxy,解决了这些局限,代码更简洁,响应式能力更完整。但 Proxy 不兼容 IE,且性能略低于 Object.defineProperty。"


📋 第三梯队知识图谱

JavaScript 数组与对象进阶
├── 数组方法体系
│   ├── 分类记忆(8大类30+方法)
│   ├── 可变 vs 不可变(9个改变原数组)
│   ├── 遍历方法对比(for...in vs for...of, map vs forEach)
│   ├── reduce 万能场景(6大使用模式)
│   └── 实用操作(去重、扁平化)
├── 对象操作深入
│   ├── 空对象判断(边界情况处理)
│   ├── Object 本质特征(引用类型、可变性、原型链)
│   └── 属性拦截演进(defineProperty vs Proxy)
└── 迭代协议
    └── Symbol.iteratorfor...of 机制

第三梯队学习检查清单:

  • 能按功能分类说出 15 个以上数组方法
  • 能准确列出 9 个改变原数组的方法
  • 能对比 for...in 和 for...of 的适用场景和返回差异
  • 能解释 map 和 forEach 的核心区别(返回值、链式调用)
  • 能用 reduce 实现至少 3 种不同场景(聚合、分组、管道)
  • 能说出至少 3 种数组去重方法及适用场景
  • 能手写递归和迭代两种扁平化实现
  • 能处理空对象判断的边界情况(不可枚举属性、Symbol)
  • 能解释 Object 与原始类型的 5 个本质区别
  • 能对比 defineProperty 和 Proxy 的 6 个核心差异