ES6+ 核心特性

让 JavaScript 代码更简洁、更强大

🎯 学习目标

学完本章,你将能够:

  • 能够使用模板字面量和标签模板处理字符串
  • 能够使用对象和数组的解构赋值快速提取数据
  • 能够使用 Set 和 Map 管理集合和键值对数据
  • 能够使用可选链 (?.) 和空值合并 (??) 安全地访问嵌套属性
  • 能够区分 WeakMap/WeakSet 与普通 Map/Set 的使用场景

你接手了一个老项目

打开代码,满屏都是这样的写法:

// 字符串拼接——引号和加号搅成一锅粥
var msg = "Hello, " + user.name + "! 您的余额是 " + user.balance + " 元";

// var 变量提升——深夜调试两小时,发现是 hoisting 导致的 bug
for (var i = 0; i < buttons.length; i++) {
    buttons[i].onclick = function() {
        console.log("按钮" + i); // 永远打印最后一个值!
    };
}

// 用 for 循环手动做数组转换——又长又不直观
var names = [];
for (var j = 0; j < users.length; j++) {
    names.push(users[j].name);
}

// 多层嵌套取值——一层没判断就全线崩溃
var city = user && user.address && user.address.city || "未知";

ES6(ECMAScript 2015)是 JavaScript 历史上最重要的一次更新。模板字面量消灭拼接噩梦,let/const 堵住变量提升的坑,解构一行搞定提取,可选链让深层访问不再提心吊胆。本章覆盖日常开发中使用频率最高的 ES6+ 特性,掌握它们,上面那些代码都会成为历史。

Part 1: 模板与对象

字符串拼接和对象定义的现代化写法

"Hello, " + name + "! 您的余额是 " + balance + " 元"——这种字符串拼接写久了,自己都看不清哪是变量、哪是字符串。ES6 的模板字面量用反引号和 ${} 彻底解决了这个问题。对象字面量也变得更简洁:属性可以简写,方法可以省略 function,属性名还能用表达式动态生成。

模板字面量

告别字符串拼接的噩梦

模板字面量用反引号 `` 包裹字符串,支持在字符串中嵌入变量和表达式。

打个比方:以前拼字符串像"剪贴报"——把碎片粘在一起。现在像"填空题"——直接在模板里填值。

// 基本用法
let name = "张三";
let greeting = `Hello, ${name}!`;

// 表达式嵌入
let a = 5, b = 10;
console.log(`${a} + ${b} = ${a + b}`); // "5 + 10 = 15"

// 多行字符串——再也不用写 \n 了
let html = `
  <div>
    <h1>${name}</h1>
  </div>
`;

// 嵌套模板
let isMember = true;
let msg = `Welcome ${isMember ? `back, ${name}` : "guest"}!`;

标签模板

自定义模板的处理逻辑

标签模板是模板字面量的高级用法:在反引号前面放一个函数名,这个函数会接收到模板的所有字符串片段和嵌入值,你可以自由组合输出。

打个比方:普通模板字面量是"填空题直接交卷",标签模板是"填空后先交给老师审阅再输出"——你在函数里决定最终格式。

// 基本结构:strings 是字符串片段数组,values 是所有 ${} 的值
function highlight(strings, ...values) {
    return strings.reduce((result, str, i) => {
        let val = values[i] ? `<mark>${values[i]}</mark>` : '';
        return result + str + val;
    }, '');
}
let name = "张三";
let output = highlight`Hello ${name}, age ${25}`;
// "Hello <mark>张三</mark>, age <mark>25</mark>"

标签模板实战

为什么需要标签模板?

标签模板不是炫技,它在三个场景中不可替代:

// 场景1:安全转义 HTML——防 XSS 攻击
function safeHTML(strings, ...values) {
    let escape = s => String(s).replace(/&/g,'&amp;').replace(//g,'&gt;');
    return strings.reduce((res, str, i) =>
        res + str + (i < values.length ? escape(values[i]) : ''), '');
}
let userInput = "<script>alert('xss')</script>";
safeHTML`<p>评论:${userInput}</p>`;
// "<p>评论:&lt;script&gt;alert('xss')&lt;/script&gt;</p>"

// 场景2:SQL 参数化查询——防注入
function sql(strings, ...values) {
    // 把 ${} 占位替换成 $1、$2...,真实值放进 params,与 SQL 文本分离
    const text = strings.reduce((acc, s, i) =>
        i < values.length ? acc + s + '$' + (i + 1) : acc + s, '');
    return { text, params: values };
}
let userId = "1; DROP TABLE users;";
sql`SELECT * FROM users WHERE id = ${userId}`;
// { text: "SELECT * FROM users WHERE id = $1", params: ["1; DROP TABLE users;"] }
// 参数和 SQL 分离,杜绝注入

// 原始字符串:不转义反斜杠
console.log(String.raw`C:\Users\name`); // 输出 C:\Users\name(保留原始反斜杠,不转义)

场景3 是国际化(i18n):根据语言环境替换模板中的词汇和日期格式。标签函数可以根据 locale 决定值的输出形式,比手动拼接更安全、更易维护。

对象字面量增强

少写代码,多办事

let x = 1, y = 2;

// 属性简写:变量名和属性名相同时
let obj = { x, y }; // 等同于 { x: x, y: y }

// 方法简写:省略 function 关键字
let calculator = {
    add(a, b) { return a + b; },
    sub(a, b) { return a - b; },
};

// 计算属性名:用表达式作为属性名
let prop = "name";
let prefix = "user";
let user = {
    [prop]: "张三",
    [`${prefix}Id`]: 1001,
    [`get${prop.charAt(0).toUpperCase()}${prop.slice(1)}`]() {
        return this[prop];
    }
};
// { name: "张三", userId: 1001, getName: [Function] }

Part 2: 解构赋值

从数组和对象中"拆包"提取值

从 API 拿到一个用户对象,只需要 name 和 email 两个字段。老写法要两行赋值,解构赋值一行搞定:let { name, email } = user。数组也能解构,连交换变量都变得优雅。这一部分覆盖解构的所有常见模式,包括默认值、重命名、嵌套解构和函数参数解构。

对象解构

按名字从对象中提取值

解构赋值是一种快速从数组或对象中提取值并赋给变量的语法。

打个比方:解构就像"拆快递"——包裹(对象)里有很多东西,你只取自己需要的。

// 基本用法
let { name, age, gender = "未知" } = { name: "张三", age: 25 };

// 重命名:提取后换个变量名
let { name: userName, age: userAge } = { name: "张三", age: 25 };

// 嵌套解构
let { address: { city, zip } } = {
    address: { city: "北京", zip: "100000" }
};

// 函数参数解构 + 默认值
function draw({ x = 0, y = 0, color = "black" } = {}) {
    console.log(x, y, color);
}
draw({ x: 100, color: "red" }); // 100 0 "red"
draw(); // 0 0 "black"

数组解构

按位置从数组中提取值

// 基本用法
let [a, b, c] = [1, 2, 3];

// 跳过某个位置
let [first, , third] = [1, 2, 3];

// 剩余元素
let [head, ...rest] = [1, 2, 3, 4]; // head=1, rest=[2,3,4]

// 默认值
let [x = 0, y = 0] = [1]; // x=1, y=0

// 交换变量——再也不需要临时变量了!
let m = 1, n = 2;
[m, n] = [n, m];

// 混合解构
let { items: [item1, item2], count } = {
    items: ["苹果", "香蕉"],
    count: 2
};

// 在 for...of 中使用解构
let entries = [["a", 1], ["b", 2]];
for (let [key, value] of entries) {
    console.log(key, value);
}

Part 3: 展开运算符

一个 ... 搞定合并、复制、收集

合并两个数组要写 concat,复制对象要写 Object.assign,收集函数参数要用 arguments——ES5 的写法又多又杂。展开运算符 ... 一个符号统一了这三个场景。它既能"摊平"数组和对象,也能"收集"剩余参数。用法简单直觉,是现代 JS 中出镜率最高的新语法之一。

数组展开

把数组"摊平"成单个元素

// 合并数组
let a = [1, 2, 3];
let b = [4, 5, 6];
let merged = [...a, ...b]; // [1,2,3,4,5,6]

// 浅拷贝数组
let copy = [...a];

// 替代 push + concat
let arr = [1, 2];
arr.push(...[3, 4]); // [1,2,3,4]

// 函数调用展开
let nums = [5, 2, 8, 1, 9];
console.log(Math.max(...nums)); // 9

// 字符串转字符数组
let chars = [..."hello"]; // ["h","e","l","l","o"]

// Set 转数组(数组去重)
let unique = [...new Set([1, 1, 2, 2, 3])]; // [1,2,3]

对象展开与收集

合并配置、收集参数,一气呵成

// 对象展开(后者覆盖前者)
let defaults = { theme: "dark", lang: "zh" };
let userPrefs = { lang: "en", fontSize: 14 };
let config = { ...defaults, ...userPrefs };
// { theme: "dark", lang: "en", fontSize: 14 }

// 剩余参数:收集所有传入的参数
function sum(...numbers) {
    return numbers.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3, 4); // 10

// 条件展开
let includeDebug = true;
let features = [
    "core",
    ...(includeDebug ? ["debug"] : []),
    "prod"
]; // ["core", "debug", "prod"]

Part 4: Set 与 Map

更灵活的数据集合

数组去重要写 filter + indexOf,对象只能用字符串做键——遇到这些限制时怎么办?Set 是自动去重的值集合,Map 是键可以是任意类型的字典。它们是数组之外的两种重要数据结构,在缓存、标记、去重等场景中比数组好用得多。

Set 集合

自动去重的值集合

Set 是一组唯一值的集合,同一个值只能出现一次。

打个比方:Set 像"会员名单"——名字只能登记一次,重复登记自动忽略。

let set = new Set();
set.add(1);
set.add(2);
set.add(2); // 重复,不添加
console.log(set.size);  // 2
console.log(set.has(2)); // true
set.delete(1);

// 数组去重一行搞定
let unique = [...new Set([1, 1, 2, 2, 3])]; // [1,2,3]

// 遍历 Set
for (let item of set) { console.log(item); }

集合运算

let a = new Set([1, 2, 3]);
let b = new Set([2, 3, 4]);

let union = new Set([...a, ...b]);              // 并集 {1,2,3,4}
let intersect = new Set([...a].filter(x => b.has(x))); // 交集 {2,3}
let difference = new Set([...a].filter(x => !b.has(x))); // 差集 {1}

Map 映射

键可以是任意类型的字典

Map 是键值对集合,和普通对象最大的区别是:键可以是任意类型(对象、函数等)。

打个比方:Object 像"名片夹"——只能用名字查找;Map 像"储物柜"——钥匙可以是任何东西(卡片、指纹、手环)。

let map = new Map();
map.set("name", "张三");
map.set(42, "the answer");
map.set({ id: 1 }, "object key");

console.log(map.get("name")); // "张三"
console.log(map.has(42));     // true
console.log(map.size);        // 3

// 初始化
let m = new Map([["key1", "val1"], ["key2", "val2"]]);

// 遍历
for (let [key, value] of map) { console.log(key, value); }

// 对象与 Map 互转
let obj = { a: 1, b: 2 };
let map2 = new Map(Object.entries(obj));
let obj2 = Object.fromEntries(map2);

Map 还是 Object?

何时用 Map,何时用 Object?

很多同学疑惑:Map 能做的事 Object 也能做,为什么还要学 Map?关键看你的场景:

对比维度ObjectMap
键的类型字符串 / Symbol任意类型(对象、函数、数字等)
键的顺序整数键优先排序按插入顺序
大小获取需手动计算 Object.keys().length直接 .size
遍历性能键少时快频繁增删时更快
序列化直接 JSON.stringify需手动转换
原型链有(可能被污染)无(更安全)

选 Object:定义数据结构(用户、配置项)、需要 JSON 序列化、键是固定字符串。

选 Map:频繁增删键值对、键不是字符串(如 DOM 元素做键)、需要保证遍历顺序、运行时才知道有哪些键。

Part 5: Symbol

创建独一无二标识符的原始类型

给对象加个 id 属性,万一别人也加了同名属性怎么办?Symbol 就是用来解决命名冲突的:每次调用 Symbol() 都生成一个绝对唯一的值。此外还有 WeakMap 和 WeakSet——它们不阻止垃圾回收,适合给对象"暗中"附加数据。

WeakMap/WeakSet

不阻止垃圾回收的集合

WeakMap/WeakSet 是"弱引用"版本的 Map/Set。它们对键(或元素)持有弱引用——当一个对象不再被其他地方引用时,即使它还在 WeakMap 里,也会被垃圾回收自动清理。

打个比方:普通 Map 像"保险箱"——东西放进去就锁住了,没人要也不会扔。WeakMap 像"租的储物柜"——租户走了(对象被回收),柜子自动清空。

// WeakMap:键必须是对象,键被回收时条目自动消失
let cache = new WeakMap();
let obj = { data: "重要数据" };
cache.set(obj, "处理结果");
console.log(cache.get(obj)); // "处理结果"
obj = null; // 原对象被回收,cache 中的条目自动消失

Weak 实战场景

什么时候该用 Weak?

// 场景1:DOM 元素关联数据——元素删除后数据自动清理
let elementData = new WeakMap();
function bindData(el, data) {
    elementData.set(el, data);
}
// 当 el 从 DOM 中移除,elementData 中的条目自动消失
// 不会造成内存泄漏!

// 场景2:缓存计算结果——对象不用时缓存自动清除
let computeCache = new WeakMap();
function expensiveCompute(obj) {
    if (computeCache.has(obj)) return computeCache.get(obj);
    let result = /* 耗时计算 */ obj.value * 2;
    computeCache.set(obj, result);
    return result;
}

// 场景3:存储私有数据——外部无法访问
let privateData = new WeakMap();
class User {
    constructor(name, secret) {
        privateData.set(this, { secret });
        this.name = name;
    }
    getSecret() { return privateData.get(this).secret; }
}

// WeakSet:做"已处理"标记
let processed = new WeakSet();
function processOnce(task) {
    if (processed.has(task)) return; // 跳过已处理的
    processed.add(task);
    // 处理逻辑...
}

WeakMap/WeakSet 不可遍历、没有 size 属性、没有 keys()/values() 方法。这是设计如此——因为条目随时可能被垃圾回收清理,遍历结果不可预测。在框架(Vue、React)和库中广泛用于缓存和私有数据存储。

Symbol 类型

每次调用都生成唯一标识

Symbol 是 ES6 引入的第七种原始类型。每个 Symbol() 调用都返回一个独一无二的值,即使描述相同。

let s1 = Symbol("description");
let s2 = Symbol("description");
console.log(s1 === s2); // false —— 每个都唯一

// 用作属性键,避免命名冲突
let id = Symbol("id");
let user = { name: "张三", [id]: 1001 };
console.log(user[id]); // 1001

// Symbol 属性不出现在 for...in 中
for (let key in user) { console.log(key); } // 只有 "name"

// 全局 Symbol 注册表
let g1 = Symbol.for("app.id");
let g2 = Symbol.for("app.id");
console.log(g1 === g2); // true

Symbol 的核心用途:为对象添加"隐藏"属性,避免与别人的代码产生属性名冲突。很多内置行为(如 Symbol.iterator)也通过 Symbol 定义。

Part 6: 安全访问

不再为 null 和 undefined 提心吊胆

user.address.city——如果 address 是 null,直接报错崩溃。以前只能一层层 if 判断,代码又长又丑。可选链 ?. 和空值合并 ?? 是两个小运算符,却彻底改变了访问深层属性和设置默认值的方式。几个字符就能解决以前十几行代码的问题。

可选链 ?.

安全访问嵌套属性

可选链(Optional Chaining)让你安全地访问深层属性。如果中间某层是 null 或 undefined,直接返回 undefined 而不报错。

打个比方:可选链像"快递柜逐层找包裹"——某一层是空的,就返回"没找到",而不是把整个柜子砸了。

let user = { address: { city: "北京" } };

// 以前:需要一层层判断
if (user && user.phone && user.phone.number) { ... }

// 现在:一行搞定
console.log(user?.address?.city);    // "北京"
console.log(user?.phone?.number);    // undefined(不报错)

// 可选方法调用
console.log(user.sayHi?.());  // undefined

// 可选数组访问
let arr = null;
console.log(arr?.[0]); // undefined

空值合并 ??

只在 null/undefined 时兜底

空值合并运算符(??)只在值为 null 或 undefined 时返回右侧的默认值。和 || 不同,0、""、false 都被视为有效值。

// ?? vs || 的关键区别
console.log(0 || "default");    // "default"(0 是 falsy)
console.log(0 ?? "default");    // 0(0 不是 null/undefined)
console.log("" || "default");   // "default"
console.log("" ?? "default");   // ""(空字符串是有效值)
console.log(false ?? "default"); // false

// 实际应用:配置默认值
const config = { timeout: 0, retries: null };
const finalConfig = {
    timeout: config.timeout ?? 5000,  // 0(保留了 0)
    retries: config.retries ?? 3,     // 3(null 用默认值)
};

// 链式使用
function getDisplayName(user) {
    return user?.nickname ?? user?.name ?? "匿名用户";
}

综合练习

练习三:数据清洗器

任务:用 ES6+ 特性实现一个数据清洗函数。接收一个原始用户对象数组,返回清洗后的结果。

let rawUsers = [
    { name: "张三", email: "zhang@test.com", tags: ["js", "ts"] },
    { name: "",     email: null,             tags: null },
    { name: "李四", email: "li@test.com",    tags: ["vue"] }
];

cleanUsers(rawUsers);
// [
//   { name: "张三", email: "zhang@test.com", tags: ["js","ts"] },
//   { name: "李四", email: "li@test.com",    tags: ["vue"] }
// ]

要求:过滤掉 name 为空的;email 缺失时用 ??"未知" 兜底;tags 为空时默认 []。用解构赋值 + 展开运算符 + 可选链。

参考答案

function cleanUsers(users) {
    return users
        .filter(u => u.name)
        .map(({ name, email, tags }) => ({
            name,
            email: email ?? "未知",
            tags: tags ?? []
        }));
}

练习四:购物车

用 Map + Set 实现购物车

任务:实现一个购物车类,支持添加商品、去重、按分类筛选、统计。

class ShoppingCart {
    // 用 Map 存商品 { id => { name, price, category } }
    // 用 Set 记录已有的分类

    addItem(item)  { /* 添加商品,重复 id 覆盖 */ }
    getItemsByCategory(cat) { /* 返回某分类的所有商品 */ }
    getCategories()   { /* 返回所有分类名 */ }
    getTotal()        { /* 计算总价 */ }
    removeItem(id)    { /* 按 id 删除 */ }
}

// 测试用例
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: "JS权威指南", price: 99, category: "书籍" });
cart.addItem({ id: 2, name: "机械键盘", price: 299, category: "外设" });
cart.addItem({ id: 3, name: "ES6入门", price: 69, category: "书籍" });
cart.addItem({ id: 2, name: "机械键盘Pro", price: 399, category: "外设" }); // 覆盖

console.log(cart.getTotal());           // 567
console.log(cart.getCategories());      // ["书籍", "外设"]
console.log(cart.getItemsByCategory("书籍").length); // 2

要求:内部用 Map 存商品(id 做 key),用 Set 维护分类列表。添加时自动去重(相同 id 覆盖)。

参考答案

class ShoppingCart {
    #items = new Map();     // id => item
    #categories = new Set();

    addItem(item) {
        this.#items.set(item.id, item);
        this.#categories.add(item.category);
    }
    removeItem(id) {
        let item = this.#items.get(id);
        if (!item) return;
        this.#items.delete(id);
        // 检查该分类下是否还有商品
        let remaining = [...this.#items.values()]
            .some(i => i.category === item.category);
        if (!remaining) this.#categories.delete(item.category);
    }
    getItemsByCategory(cat) {
        return [...this.#items.values()]
            .filter(i => i.category === cat);
    }
    getCategories() { return [...this.#categories]; }
    getTotal() {
        return [...this.#items.values()]
            .reduce((sum, i) => sum + i.price, 0);
    }
}

常见坑与排错

ES6 新特性虽好,但有些"坑"需要提前知道。

问题一:解构时变量已声明会报错

原因:{ a, b } = obj 被解析为代码块而非解构。

修复:用括号包裹 ({ a, b } = obj)。

问题二:展开运算符是浅拷贝

原因:{ ...obj } 只复制第一层,嵌套对象仍然是引用。

修复:深拷贝用 structuredClone() 或 JSON 序列化。

问题三:Set 中 NaN 等于 NaN

说明:在 Set 内部,NaN 等于 NaN(与 === 不同)。这其实是正确行为,不需要修复。

问题四:?? 和 && / || 不能混用

原因:a ?? b || c 会报语法错误,因为优先级不明确。

修复:加括号明确优先级:(a ?? b) || c。

问题五:Map 用对象做键,必须是同一个引用

原因:map.get({ id: 1 }) 每次创建新对象,引用不同,取不到值。

修复:用变量保存引用,或改用原始值(字符串/数字)做键。

问题六:WeakMap 遍历会报错

原因:WeakMap/WeakSet 没有迭代器,不支持 for...of、forEach、size。

修复:如果需要遍历,说明你不该用 WeakMap——改用普通 Map。

动手练一练

练习一

任务:用解构和展开运算符实现对象合并,并提取特定字段。

// 要求:
// 1. 合并 defaultConfig 和 userConfig
// 2. 从合并结果中解构出 theme 和 lang
// 3. 其余属性收集到 rest 变量中
const defaultConfig = { theme: "light", lang: "zh", pageSize: 10 };
const userConfig = { theme: "dark", sidebar: true };
// 你的代码:

参考答案

const merged = { ...defaultConfig, ...userConfig };
// { theme: "dark", lang: "zh", pageSize: 10, sidebar: true }

const { theme, lang, ...rest } = merged;
console.log(theme); // "dark"
console.log(rest);  // { pageSize: 10, sidebar: true }

练习二

任务:用可选链和空值合并安全访问用户信息。

// 要求:安全获取用户的城市名
// 如果没有城市信息,返回 "未知城市"
// 如果城市为空字符串,返回空字符串(不是"未知城市")
const users = [
  { profile: { address: { city: "北京" } } },
  { profile: { address: null } },
  { profile: null },
  { profile: { address: { city: "" } } }
];
// 你的代码:

参考答案

function getCity(user) {
    return user?.profile?.address?.city ?? "未知城市";
}

users.forEach(u => console.log(getCity(u)));
// "北京"  "未知城市"  "未知城市"  ""

📝 要点总结

本章核心知识点

模板字面量

反引号 + ${} 实现字符串插值

解构赋值

从对象和数组中快速提取值

Set 和 Map

集合去重和任意类型键的映射

可选链与空值合并

?. 和 ?? 安全访问嵌套属性

WeakMap/WeakSet

弱引用,适合存储元数据

下一章

你已经掌握了 ES6+ 最核心的特性。下一章我们进入 异步编程核心——学习 Promise、async/await,让你的代码能处理耗时任务而不"卡死"。

1 / X