这四个特性在日常开发中出场率极高,但很多人只是"会用",没真正理解背后的设计意图。今天一次性梳理清楚。
前言
ES6(ECMAScript 2015)是 JavaScript 历史上最大的一次更新,后面每年都有新特性陆续放出,我们统称 ES6+。
今天复习四个出场率极高的特性:解构赋值、展开运算符、Symbol、Proxy。它们不是什么花哨的语法糖,而是能真正改变你写代码方式的东西。
我尽量用最少的代码把每个特性讲透,顺便聊聊实际开发中哪些场景最好用。
一、解构赋值:优雅地"拆包"
1.1 本质是什么?
解构赋值就是从数组或对象中提取值,赋给变量。
以前我们取值是这样的:
javascript
let obj = { name: 'Alice', age: 25, city: 'Beijing' };
let name = obj.name;
let age = obj.age;
let city = obj.city;
有了解构,一行搞定:
javascript
let { name, age, city } = obj;
就这么简单。但别小看它,解构的玩法非常多。
1.2 对象解构
基本用法
javascript
let { name, age } = { name: 'Alice', age: 25 };
console.log(name); // 'Alice'
console.log(age); // 25
重命名
实际开发中,后端返回的字段名经常跟前端不一致,这时候重命名就派上用场了:
javascript
let { name: userName, age: userAge } = { name: 'Alice', age: 25 };
console.log(userName); // 'Alice'
console.log(userAge); // 25
// 注意:name 和 age 在这里不存在,变量名是 userName 和 userAge
默认值
javascript
let { name, role = 'user' } = { name: 'Bob' };
console.log(role); // 'user' ------ 没有role属性,使用默认值
这个在函数参数中特别好用,后面会讲到。
嵌套解构
javascript
let user = {
name: 'Alice',
address: {
city: 'Beijing',
district: 'Haidian'
}
};
let { address: { city, district } } = user;
console.log(city); // 'Beijing'
console.log(district); // 'Haidian'
注意 :address 这一层只是用来定位的,并没有创建 address 变量。如果你也想拿到 address,得这样写:
javascript
let { address, address: { city } } = user;
1.3 数组解构
数组解构是按位置取值,不是按属性名:
javascript
let [a, b, c] = [1, 2, 3];
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3
跳过某些元素
javascript
let [first, , third] = [1, 2, 3];
console.log(first); // 1
console.log(third); // 3
交换变量
这个用法我经常用,比声明临时变量优雅多了:
javascript
let x = 1, y = 2;
[x, y] = [y, x];
console.log(x); // 2
console.log(y); // 1
配合 rest 参数
javascript
let [head, ...tail] = [1, 2, 3, 4, 5];
console.log(head); // 1
console.log(tail); // [2, 3, 4, 5]
1.4 函数参数解构
这个在实际开发中出场率极高,强烈建议掌握:
javascript
// 以前这么写
function createUser(options) {
let name = options.name || 'unknown';
let age = options.age || 0;
let role = options.role || 'user';
}
// 现在这么写
function createUser({ name = 'unknown', age = 0, role = 'user' } = {}) {
console.log(name, age, role);
}
createUser({ name: 'Alice', age: 25 });
// 'Alice' 25 'user'
注意参数后面的 = {},这是为了防止调用时不传参数导致解构报错。
1.5 常见坑
对象解构对基本类型无效
javascript
let { length } = null; // TypeError!
let { length } = undefined; // TypeError!
解构 null 或 undefined 会报错,因为它们不能被当作对象来解构。实际开发中可以给个默认值:
javascript
let { length } = null || {}; // 安全
已声明的变量解构需要加括号
javascript
let x;
{x} = { x: 1}; // SyntaxError!
// 加括号就行
let x;
({ x } = { x: 1 });
console.log(x); // 1
这是因为 {} 在行首会被JS引擎当作代码块,不是对象字面量。
二、展开运算符:一个点搞定很多事
2.1 本质是什么?
展开运算符(...)把一个可迭代对象"展开"成一个个独立的元素。
javascript
let arr1 = [1, 2, 3];
let arr2 = [...arr1, 4, 5];
console.log(arr2); // [1, 2, 3, 4, 5]
就这么简单。但它能做的事情远不止于此。
2.2 数组相关
合并数组
javascript
let a = [1, 2];
let b = [3, 4];
let c = [...a, ...b]; // [1, 2, 3, 4]
比 concat() 直观多了。
数组拷贝(浅拷贝)
javascript
let original = [1, 2, 3];
let copy = [...original];
copy.push(4);
console.log(original); // [1, 2, 3] ------ 不受影响
注意 :这是浅拷贝。如果数组里有对象,拷贝的只是引用:
javascript
let original = [{ name: 'Alice' }];
let copy = [...original];
copy[0].name = 'Bob';
console.log(original[0].name); // 'Bob' ------ 被改了!
把类数组转成真正的数组
javascript
function foo() {
let args = [...arguments]; // 把arguments转成数组
console.log(args); // [1, 2, 3]
}
foo(1, 2, 3);
// NodeList转数组
let nodes = [...document.querySelectorAll('div')];
字符串转数组
javascript
let chars = [...'hello'];
console.log(chars); // ['h', 'e', 'l', 'l', 'o']
这个比 split('') 好在它能正确处理 Unicode 字符:
javascript
[...'😄'].length; // 1 ✅
'😄'.split('').length; // 2 ❌(把emoji拆成了两个字符)
2.3 对象相关
合并对象
javascript
let defaults = { theme: 'light', lang: 'zh', pageSize: 10 };
let userConfig = { theme: 'dark', fontSize: 14 };
let config = { ...defaults, ...userConfig };
console.log(config);
// { theme: 'dark', lang: 'zh', pageSize: 10, fontSize: 14 }
后面的属性会覆盖前面的,这个在合并配置项时特别好用。
对象浅拷贝
javascript
let original = { name: 'Alice', address: { city: 'Beijing' } };
let copy = { ...original };
copy.name = 'Bob';
console.log(original.name); // 'Alice' ------ 不受影响
copy.address.city = 'Shanghai';
console.log(original.address.city); // 'Shanghai' ------ 浅拷贝的坑!
剔除某些属性
javascript
let { password, ...safeUser } = { name: 'Alice', age: 25, password: '123456' };
console.log(safeUser); // { name: 'Alice', age: 25 }
这个技巧在把用户数据传给前端时特别实用,避免敏感信息泄露。
2.4 展开运算符 vs 剩余参数
它们用的都是 ...,但场景不同:
| 场景 | 名称 | 作用 |
|---|---|---|
[...arr] |
展开运算符(Spread) | 把数组/对象"拆开" |
function(...args) |
剩余参数(Rest) | 把散落的元素"收集"成数组 |
javascript
// 展开:拆开
let arr = [1, 2, 3];
console.log(...arr); // 1 2 3
// 剩余:收集
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3); // 6
一个"拆",一个"收",方向相反。
2.5 实际开发中的高频用法
javascript
// 1. React/Vue 中合并 props
<Component {...defaultProps} {...customProps} />
// 2. Redux 中合并 state
return { ...state, loading: true };
// 3. 删除数组某个元素(不可变方式)
let items = [1, 2, 3, 4];
let filtered = items.filter(item => item !== 3); // [1, 2, 4]
// 4. 更新数组某个元素(不可变方式)
let list = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
let updated = list.map(item =>
item.id === 1 ? { ...item, name: 'AA' } : item
);
三、Symbol:JavaScript 的"隐藏属性"
3.1 本质是什么?
Symbol 是 ES6 引入的一种新的原始数据类型,表示独一无二的值。
javascript
let s1 = Symbol();
let s2 = Symbol();
console.log(s1 === s2); // false ------ 每个Symbol都是唯一的
即使描述相同,也不相等:
javascript
let s1 = Symbol('foo');
let s2 = Symbol('foo');
console.log(s1 === s2); // false
描述只是用来调试的,不影响唯一性。
3.2 为什么需要 Symbol?
你可能会问,已经有了 string 做属性名,为什么还要搞个 Symbol?
核心原因:Symbol 属性不会被常规方式遍历到。
javascript
let obj = {};
let key = Symbol('secret');
obj[key] = '隐藏的值';
obj.name = 'Alice';
Object.keys(obj); // ['name'] ------ 拿不到Symbol属性
for (let k in obj) { /* 只会输出 name */ }
JSON.stringify(obj); // '{"name":"Alice"}' ------ Symbol属性被忽略了
这就像给对象加了一个"暗格",只有拿到对应的 Symbol key 才能访问。
3.3 全局 Symbol
有时候你可能想在不同的地方使用"同一个" Symbol,可以用 Symbol.for():
javascript
let s1 = Symbol.for('shared');
let s2 = Symbol.for('shared');
console.log(s1 === s2); // true ------ 从全局注册表中取的
Symbol.for() 会在全局注册表中查找,有就返回已有的,没有就创建新的。
javascript
// Symbol.keyFor() 可以反向查找
let key = Symbol.keyFor(s1);
console.log(key); // 'shared'
3.4 内置 Symbol(Well-known Symbols)
JavaScript 内置了一些 Symbol,用来定制对象的行为。这是 Symbol 最强大的用法。
Symbol.iterator ------ 自定义迭代行为
javascript
let range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
let last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { done: true };
}
};
}
};
// 现在可以用 for...of 遍历了
for (let num of range) {
console.log(num); // 1, 2, 3, 4, 5
}
// 也可以用展开运算符
console.log([...range]); // [1, 2, 3, 4, 5]
实现了 Symbol.iterator,你的对象就"可迭代"了,能被 for...of、展开运算符、解构等使用。
Symbol.toPrimitive ------ 自定义类型转换
javascript
let money = {
amount: 100,
currency: 'CNY',
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
if (hint === 'string') return `${this.amount}${this.currency}`;
return `${this.amount}${this.currency}`;
}
};
console.log(Number(money)); // 100
console.log(String(money)); // "100CNY"
console.log(money + ''); // "100CNY"
其他常用内置 Symbol
| Symbol | 用途 |
|---|---|
Symbol.toStringTag |
自定义 Object.prototype.toString.call() 的返回值 |
Symbol.hasInstance |
自定义 instanceof 的行为 |
Symbol.toPrimitive |
自定义类型转换 |
Symbol.iterator |
自定义迭代行为 |
javascript
// 自定义 toStringTag
class MyClass {
get [Symbol.toStringTag]() {
return 'MyClass';
}
}
Object.prototype.toString.call(new MyClass()); // "[object MyClass]"
3.5 实际应用场景
场景一:防止属性名冲突
在开发库或框架时,给用户对象添加属性,但又不想跟用户自己的属性冲突:
javascript
// 库内部使用
const INTERNAL_KEY = Symbol('internal');
function process(obj) {
obj[INTERNAL_KEY] = { processed: true };
// 用户遍历对象时不会看到这个属性
}
场景二:定义常量
javascript
const STATUS = {
PENDING: Symbol('pending'),
FULFILLED: Symbol('fulfilled'),
REJECTED: Symbol('rejected')
};
// 比用字符串更安全,不用担心值重复
if (status === STATUS.PENDING) { /* ... */ }
场景三:枚举
javascript
const Direction = {
UP: Symbol('UP'),
DOWN: Symbol('DOWN'),
LEFT: Symbol('LEFT'),
RIGHT: Symbol('RIGHT')
};
四、Proxy:JavaScript 的"拦截器"
4.1 本质是什么?
Proxy 可以拦截并自定义对象的基本操作(读取、赋值、函数调用等)。
javascript
let obj = { name: 'Alice', age: 25 };
let proxy = new Proxy(obj, {
get(target, key) {
console.log(`读取了 ${key} 属性`);
return target[key];
},
set(target, key, value) {
console.log(`设置了 ${key} = ${value}`);
target[key] = value;
return true;
}
});
proxy.name; // 控制台:读取了 name 属性 → 'Alice'
proxy.age = 26; // 控制台:设置了 age = 26
Proxy 就像给对象套了一层"壳",所有操作都要经过这层壳。
4.2 常用拦截器(Trap)
Proxy 支持的拦截器非常多,这里列出最常用的几个:
| 拦截器 | 触发时机 |
|---|---|
get |
读取属性 |
set |
设置属性 |
has |
in 操作符 |
deleteProperty |
delete 操作 |
apply |
函数调用 |
construct |
new 操作 |
getPrototypeOf |
Object.getPrototypeOf() |
ownKeys |
Object.keys() 等遍历操作 |
4.3 实战场景
场景一:数据校验
javascript
function createValidator(target, rules) {
return new Proxy(target, {
set(obj, key, value) {
if (rules[key] && !rules[key](value)) {
throw new TypeError(`${key} 的值不合法: ${value}`);
}
obj[key] = value;
return true;
}
});
}
let user = createValidator({}, {
name: v => typeof v === 'string' && v.length > 0,
age: v => typeof v === 'number' && v > 0 && v < 150
});
user.name = 'Alice'; // ✅
user.age = 25; // ✅
user.age = -1; // TypeError: age 的值不合法: -1
这个在表单处理中特别实用。
场景二:实现响应式(简化版 Vue3 原理)
javascript
function reactive(target, callback) {
return new Proxy(target, {
set(obj, key, value) {
let oldValue = obj[key];
obj[key] = value;
if (oldValue !== value) {
callback(key, value, oldValue);
}
return true;
}
});
}
let state = reactive({ count: 0 }, (key, value, oldValue) => {
console.log(`${key} 从 ${oldValue} 变成了 ${value}`);
// 这里可以触发视图更新
});
state.count = 1; // "count 从 0 变成了 1"
state.count = 2; // "count 从 1 变成了 2"
Vue3 的响应式系统就是基于 Proxy 实现的(当然比这个复杂得多)。
场景三:优雅地处理属性不存在的情况
javascript
let data = { name: 'Alice' };
let proxy = new Proxy(data, {
get(target, key) {
if (key in target) {
return target[key];
}
console.warn(`属性 "${key}" 不存在`);
return undefined;
}
});
proxy.name; // 'Alice'
proxy.age; // 控制台:属性 "age" 不存在 → undefined
场景四:函数调用拦截(apply)
javascript
function sum(a, b) {
return a + b;
}
let proxy = new Proxy(sum, {
apply(target, thisArg, args) {
console.log(`调用了 sum(${args.join(', ')})`);
let result = target.apply(thisArg, args);
console.log(`结果: ${result}`);
return result;
}
});
proxy(1, 2);
// "调用了 sum(1, 2)"
// "结果: 3"
场景五:只读对象
javascript
function readonly(obj) {
return new Proxy(obj, {
set() {
throw new TypeError('这个对象是只读的');
},
deleteProperty() {
throw new TypeError('不能删除属性');
}
});
}
let config = readonly({ theme: 'dark', lang: 'zh' });
config.theme = 'light'; // TypeError: 这个对象是只读的
4.4 Proxy vs Object.defineProperty
面试经常问这两个的区别:
| 对比项 | Proxy | Object.defineProperty |
|---|---|---|
| 拦截范围 | 13种操作,全面 | 只有 get/set |
| 数组支持 | ✅ 原生支持 | ❌ 需要重写数组方法 |
| 动态新增属性 | ✅ 自动拦截 | ❌ 需要手动 Observe |
| 嵌套对象 | 递归代理即可 | 需要深度遍历 |
| 性能 | 整体代理,懒处理 | 初始化时就要遍历所有属性 |
这也是 Vue3 从 Object.defineProperty 切换到 Proxy 的原因。
4.5 注意事项
Proxy 的 this 指向问题
javascript
let target = {
m() {
console.log(this === proxy); // true
}
};
let proxy = new Proxy(target, {});
proxy.m(); // this 指向 proxy,不是 target!
如果你在 get 拦截器里返回方法,要注意 this 的指向。可以用 Reflect.get() 来保持正确的行为。
Proxy 不能代理基本类型
javascript
new Proxy(1, {}); // TypeError
Proxy 只能代理对象。如果需要代理基本类型,可以包装成对象。
五、总结速查表
解构赋值
| 用法 | 示例 |
|---|---|
| 对象解构 | let { name, age } = obj |
| 重命名 | let { name: userName } = obj |
| 默认值 | let { role = 'user' } = obj |
| 嵌套解构 | let { a: { b } } = obj |
| 数组解构 | let [a, b] = arr |
| 交换变量 | [x, y] = [y, x] |
| 函数参数 | function fn({ a, b } = {}) |
展开运算符
| 用法 | 示例 |
|---|---|
| 合并数组 | [...a, ...b] |
| 合并对象 | { ...defaults, ...config } |
| 浅拷贝 | [...arr] / { ...obj } |
| 剔除属性 | let { pwd, ...rest } = obj |
| 类数组转数组 | [...arguments] / [...nodeList] |
Symbol
| 用法 | 说明 |
|---|---|
Symbol() |
创建唯一值 |
Symbol.for() |
全局注册 Symbol |
Symbol.iterator |
自定义迭代行为 |
Symbol.toPrimitive |
自定义类型转换 |
Symbol.toStringTag |
自定义 toString 标签 |
Proxy
| 拦截器 | 用途 |
|---|---|
get |
属性读取拦截 |
set |
属性设置拦截 |
apply |
函数调用拦截 |
has |
in 操作拦截 |
deleteProperty |
delete 操作拦截 |
写在最后
这四个特性在日常开发中出场率极高,建议每个都动手写几遍,比看十遍文章都有用。
特别是 Proxy,理解了它就理解了 Vue3 响应式的核心原理,面试的时候也能讲出深度。
有问题欢迎评论区交流