深入对比 JavaScript 中各种浅拷贝方法的实现原理、使用场景和性能差异,掌握正确的拷贝姿势。
一、背景与问题
在 JavaScript 中,对象和数组是引用类型,直接赋值只是复制引用:
1
2
3
4
5
| const obj = { name: 'Alice', age: 20 };
const copy = obj; // 只是复制引用
copy.age = 30;
console.log(obj.age); // 30 - 原对象也被修改!
|
浅拷贝:创建新对象,复制第一层属性,但嵌套对象仍是引用。
1
2
3
4
5
6
7
8
| const obj = { name: 'Alice', info: { city: 'Beijing' } };
const shallowCopy = { ...obj };
shallowCopy.name = 'Bob'; // 修改第一层
shallowCopy.info.city = 'Shanghai'; // 修改嵌套对象
console.log(obj.name); // 'Alice' - 未受影响
console.log(obj.info.city); // 'Shanghai' - 被修改了!
|
二、核心概念与定义
2.1 浅拷贝的定义
创建一个新对象/数组,将原对象/数组的第一层属性/元素复制过来:
- 基本类型(string、number、boolean、null、undefined、symbol、bigint):值复制
- 引用类型(object、array、function):引用复制
1
2
3
4
5
6
7
8
9
10
11
| 原对象 浅拷贝后
┌─────────────┐ ┌─────────────┐
│ name: 'Alice'│ ──────→ │ name: 'Alice'│ (值复制)
│ age: 20 │ ──────→ │ age: 20 │ (值复制)
│ info: ──────┼──┐ │ info: ──────┼──┐
└─────────────┘ │ └─────────────┘ │
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│city:'BJ' │ ← 同一引用 → │city:'BJ' │
└──────────┘ └──────────┘
|
2.2 浅拷贝 vs 深拷贝
| 特性 | 浅拷贝 | 深拷贝 |
|---|
| 第一层 | 值复制 | 值复制 |
| 嵌套对象 | 引用复制 | 递归值复制 |
| 性能 | 快 | 慢 |
| 实现复杂度 | 简单 | 复杂 |
| 适用场景 | 单层对象 | 多层嵌套对象 |
三、最小示例
3.1 对象浅拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const obj = { a: 1, b: 2, c: { d: 3 } };
// 方式一:Object.assign
const copy1 = Object.assign({}, obj);
// 方式二:展开运算符
const copy2 = { ...obj };
// 方式三:手动复制
const copy3 = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
copy3[key] = obj[key];
}
}
|
3.2 数组浅拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const arr = [1, 2, { a: 3 }];
// 方式一:slice
const copy1 = arr.slice();
// 方式二:concat
const copy2 = arr.concat();
// 方式三:展开运算符
const copy3 = [...arr];
// 方式四:Array.from
const copy4 = Array.from(arr);
// 方式五:copyWithin(需先复制长度)
const copy5 = [...arr];
|
四、核心知识点拆解
4.1 Object.assign
语法:Object.assign(target, ...sources)
1
2
3
4
5
6
7
8
9
10
11
| // 合并多个源对象到目标对象
const target = { a: 1 };
const source1 = { b: 2 };
const source2 = { c: 3 };
const result = Object.assign(target, source1, source2);
console.log(result); // { a: 1, b: 2, c: 3 }
console.log(target); // { a: 1, b: 2, c: 3 } - target 被修改!
// 浅拷贝:传入空对象作为 target
const copy = Object.assign({}, original);
|
特点:
- 会修改 target 对象
- 返回值是 target 对象
- 后面的源对象会覆盖前面的同名属性
- 会复制可枚举的自有属性
注意:
1
2
3
4
5
6
7
8
9
10
11
| // 不会复制 Symbol 属性?会!
const obj = { a: 1, [Symbol('b')]: 2 };
const copy = Object.assign({}, obj);
console.log(Object.getOwnPropertySymbols(copy)); // [Symbol(b)]
// 不会复制继承属性
const parent = { a: 1 };
const child = Object.create(parent);
child.b = 2;
const copy = Object.assign({}, child);
console.log(copy); // { b: 2 } - 没有 a
|
4.2 展开运算符(Spread Operator)
对象展开:{ ...obj }
1
2
3
4
5
6
7
8
9
10
11
| const obj = { a: 1, b: 2, c: 3 };
const copy = { ...obj };
// 合并多个对象(后者覆盖前者)
const merged = { ...obj1, ...obj2, ...obj3 };
// 添加新属性
const extended = { ...obj, d: 4 };
// 覆盖属性
const modified = { ...obj, a: 100 };
|
数组展开:[...arr]
1
2
3
4
5
6
7
8
| const arr = [1, 2, 3];
const copy = [...arr];
// 合并数组
const merged = [...arr1, ...arr2];
// 添加元素
const extended = [...arr, 4, 5];
|
Object.assign vs 展开运算符:
| 特性 | Object.assign | 展开运算符 |
|---|
| 语法 | Object.assign({}, obj) | { ...obj } |
| 可读性 | 较差 | 更好 |
| 性能 | 略慢 | 略快 |
| 修改 target | 会 | 不会 |
| Getter 处理 | 执行 getter | 执行 getter |
4.3 数组方法
slice()
1
2
3
4
5
6
7
| const arr = [1, 2, 3, 4, 5];
// 浅拷贝整个数组
const copy = arr.slice();
// 提取部分元素
const part = arr.slice(1, 3); // [2, 3]
|
concat()
1
2
3
4
5
6
7
| const arr = [1, 2, 3];
// 浅拷贝
const copy = arr.concat();
// 合并数组
const merged = arr.concat([4, 5]);
|
Array.from()
1
2
3
4
5
6
7
8
9
10
11
| const arr = [1, 2, 3];
// 浅拷贝
const copy = Array.from(arr);
// 类数组转数组
const arrayLike = { 0: 'a', 1: 'b', length: 2 };
const arr = Array.from(arrayLike); // ['a', 'b']
// 带 map 函数
const doubled = Array.from(arr, x => x * 2);
|
4.4 性能对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // 性能测试
const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 };
const N = 1000000;
console.time('Object.assign');
for (let i = 0; i < N; i++) {
Object.assign({}, obj);
}
console.timeEnd('Object.assign');
console.time('Spread');
for (let i = 0; i < N; i++) {
const copy = { ...obj };
}
console.timeEnd('Spread');
// 结果(Chrome):
// Object.assign: ~150ms
// Spread: ~100ms
|
结论:展开运算符性能略优于 Object.assign。
五、实战案例
5.1 React 状态更新
1
2
3
4
5
6
7
8
9
10
11
| // React 中使用浅拷贝更新状态
const [user, setUser] = useState({ name: 'Alice', age: 20 });
// 更新单个属性
setUser({ ...user, age: 21 });
// 更新嵌套属性(需要深拷贝或手动处理)
setUser({
...user,
info: { ...user.info, city: 'Shanghai' }
});
|
5.2 Redux Reducer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Redux reducer 使用浅拷贝保持不可变性
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_USER':
return {
...state,
user: {
...state.user,
name: action.payload
}
};
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload]
};
default:
return state;
}
}
|
5.3 数组操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // 添加元素
const newArr = [...arr, newItem];
// 删除元素
const newArr = arr.filter(item => item.id !== id);
// 或
const newArr = [...arr.slice(0, index), ...arr.slice(index + 1)];
// 更新元素
const newArr = arr.map(item =>
item.id === id ? { ...item, updated: true } : item
);
// 插入元素
const newArr = [...arr.slice(0, index), newItem, ...arr.slice(index)];
|
5.4 函数参数默认值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 合并配置对象
function createConfig(userConfig = {}) {
const defaultConfig = {
timeout: 5000,
retries: 3,
headers: {}
};
return {
...defaultConfig,
...userConfig,
headers: {
...defaultConfig.headers,
...userConfig.headers
}
};
}
|
六、底层原理
6.1 属性描述符
Object.assign 和展开运算符都会复制可枚举的自有属性:
1
2
3
4
5
6
7
8
9
10
11
12
13
| const obj = {};
// 定义不可枚举属性
Object.defineProperty(obj, 'hidden', {
value: 'secret',
enumerable: false
});
// 定义可枚举属性
obj.visible = 'public';
const copy = { ...obj };
console.log(copy); // { visible: 'public' } - hidden 未被复制
|
6.2 Getter/Setter 处理
1
2
3
4
5
6
7
8
9
10
| const obj = {
get value() {
console.log('getter called');
return 42;
}
};
const copy = { ...obj };
// getter 被执行,copy.value 是 42,不再是 getter
console.log(copy.value); // 42(无 getter 日志)
|
6.3 原型链
1
2
3
4
5
6
7
8
| // 展开运算符不会复制原型链上的属性
const parent = { a: 1 };
const child = Object.create(parent);
child.b = 2;
const copy = { ...child };
console.log(copy); // { b: 2 } - a 在原型链上,未复制
console.log(copy.a); // undefined
|
七、高频面试题解析
Q1:Object.assign 和展开运算符的区别?
答案:
| 区别点 | Object.assign | 展开运算符 |
|---|
| 语法 | 函数调用 | 语法糖 |
| 返回值 | 修改后的 target | 新对象 |
| 性能 | 略慢 | 略快 |
| 可读性 | 较差 | 更好 |
1
2
3
4
5
6
7
8
9
| // Object.assign 会修改 target
const target = { a: 1 };
const result = Object.assign(target, { b: 2 });
console.log(target === result); // true
// 展开运算符不会修改原对象
const obj = { a: 1 };
const copy = { ...obj, b: 2 };
console.log(obj); // { a: 1 }
|
Q2:如何实现一个浅拷贝函数?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| function shallowCopy(source) {
if (source === null || typeof source !== 'object') {
return source;
}
// 数组
if (Array.isArray(source)) {
return source.slice();
}
// 对象
const copy = {};
for (const key in source) {
if (source.hasOwnProperty(key)) {
copy[key] = source[key];
}
}
return copy;
}
|
Q3:浅拷贝会复制 Symbol 属性吗?
答案:Object.assign 会,展开运算符也会(ES2018+)。
1
2
3
4
5
6
7
8
| const sym = Symbol('key');
const obj = { [sym]: 'value' };
const copy1 = Object.assign({}, obj);
console.log(copy1[sym]); // 'value'
const copy2 = { ...obj };
console.log(copy2[sym]); // 'value'
|
Q4:为什么 React 推荐使用浅拷贝?
答案:
- 不可变性:浅拷贝创建新对象,不修改原对象
- 性能:浅拷贝比深拷贝快
- 引用比较:React 通过引用比较判断状态变化
- 浅比较:React.memo、useMemo 使用浅比较
八、总结与扩展
核心要点
- Object.assign:会修改 target,适合合并多个对象
- 展开运算符:语法简洁,性能更好,推荐使用
- 数组方法:slice、concat、Array.from 都可实现浅拷贝
- 嵌套对象:浅拷贝后仍是引用,修改会影响原对象
方法选择指南
| 场景 | 推荐方法 |
|---|
| 对象浅拷贝 | { ...obj } |
| 数组浅拷贝 | [...arr] 或 arr.slice() |
| 合并对象 | { ...obj1, ...obj2 } |
| 合并数组 | [...arr1, ...arr2] |
| 配置合并 | Object.assign({}, defaults, options) |
扩展阅读
- structuredClone:浏览器原生深拷贝 API
- Immer:不可变数据操作库
- Immutable.js:持久化数据结构
- Lodash.clone:支持自定义浅拷贝
浅拷贝是前端开发的基础操作,理解其原理和限制,才能正确处理复杂数据结构。