跳到主要内容

2025年每个JavaScript开发人员都应该知道的一些特性

· 阅读需 26 分钟
Random Image
图片与正文无关

哈喽,各位奋斗在一线的前端小伙伴们!JavaScript 这门语言一直在高速进化,新特性层出不穷。这也意味着,我们过去的一些编码习惯可能已经悄悄过时,甚至在效率上打了折扣。今天,我们就来盘点一些(不论新旧)但很多开发者可能还没留意到的、非常实用的 JS 特性,一起给自己的技能库充充电!🔋

1. Iterator Helpers (迭代器助手):告别低效的数组链式操作

你有没有写过类似这样的代码:arr.slice(10, 20).filter(el => el < 10).map(el => el + 5)?这种在同一个数组上连续进行多次转换的操作,看起来很酷,但其实效率不高。为啥?因为每一次 slice, filter, map 操作,都会创建一个全新的中间数组。想象一下,如果 arr 是一个包含几十万甚至上百万元素的巨型数组,那内存开销和性能损耗可就相当可观了!😱

为了解决这个问题,JavaScript 最近引入了一系列 迭代器助手(Iterator Helpers) 方法。它们的作用和我们熟悉的数组转换方法(map, filter 等)类似,但关键区别在于:它们不会每次都创建临时数组,而是创建新的迭代器,这些迭代器基于前一个迭代器进行操作,直到最后才可能生成结果。这是一种更“懒惰”(Lazy Evaluation)且内存友好的方式。

来看看这些得力助手们:

  1. Iterator.prototype.drop(n): 跳过迭代器开头的 n 个元素,返回一个新的迭代器助手对象。有点像数组的 Array.prototype.slice(n)
  2. Iterator.prototype.take(n): 最多获取迭代器开头的 n 个元素,返回一个新的迭代器助手对象。类似于数组的 Array.prototype.slice(0, n)
  3. Iterator.prototype.some(callback): 和 Array.prototype.some() 类似,测试迭代器中是否至少有一个元素满足 callback 函数的测试。
  4. Iterator.prototype.every(callback): 和 Array.prototype.every() 类似,测试迭代器中是否所有元素都满足 callback 函数的测试。
  5. Iterator.prototype.filter(callback): 和 Array.prototype.filter() 类似,返回一个包含通过 callback 测试的元素的新迭代器。
  6. Iterator.prototype.find(callback): 和 Array.prototype.find() 类似,返回迭代器中第一个满足 callback 测试的元素。
  7. Iterator.prototype.flatMap(callback): 和 Array.prototype.flatMap() 类似,先映射每个元素,然后将结果展平一层,返回一个新迭代器。
  8. Iterator.prototype.forEach(callback): 和 Array.prototype.forEach() 类似,为迭代器中的每个元素执行一次 callback 函数。
  9. Iterator.prototype.map(callback): 和 Array.prototype.map() 类似,返回一个包含经 callback 转换后的元素的新迭代器。
  10. Iterator.prototype.reduce(callback, initialValue): 和 Array.prototype.reduce() 类似,对迭代器中的每个元素执行 "reducer" 函数,将其累积成单个值。
  11. Iterator.prototype.toArray(): 这是终结操作之一,它遍历整个迭代器链,并将最终产生的所有值收集到一个新的数组中。

如何创建迭代器?

  • 很多内置对象(如 Array, Map, Set, NodeList 等)都有 values() 方法可以获取迭代器。
  • 可以使用静态方法 Iterator.from() 将一个可迭代对象(比如一个实现了 Symbol.iterator 的对象)转换成迭代器助手对象。

回到开头的例子,用迭代器助手优化后就是这样:

// 假设 arr 是一个大数组
const result = arr
.values() // 1. 获取数组迭代器
.drop(10) // 2. 跳过前10个 (返回新迭代器)
.take(10) // 3. 最多取10个 (返回新迭代器)
.filter((el) => el < 10) // 4. 过滤 (返回新迭代器)
.map((el) => el + 5) // 5. 映射 (返回新迭代器)
.toArray(); // 6. 最后生成数组

// 整个过程只在最后 toArray() 时创建了一个新数组,中间步骤几乎没有额外内存分配!

2. Array.prototype.at():优雅地访问数组末尾元素

想获取数组的第 n 个元素,我们通常用 arr[n]。但如果想获取倒数第一个、倒数第二个元素呢?以前我们得写 arr[arr.length - 1]arr[arr.length - 2],代码显得有点啰嗦笨拙。

现在有了 Array.prototype.at(),事情变得简单多了!

  • 它接受一个整数作为参数,表示要访问的元素的索引。
  • 最酷的是:它支持负数索引! -1 表示最后一个元素,-2 表示倒数第二个,以此类推。
const colors = ["red", "green", "blue"];

console.log(colors.at(1)); // 'green' (正数索引,和 colors[1] 一样)
console.log(colors.at(-1)); // 'blue' (倒数第一个)
console.log(colors.at(-2)); // 'green' (倒数第二个)
console.log(colors.at(5)); // undefined (索引越界)
console.log(colors.at(-5)); // undefined (索引越界)

// 告别繁琐的 arr[arr.length - n]
console.log(colors[colors.length - 1]); // 'blue' (旧方法)
console.log(colors.at(-1)); // 'blue' (新方法,更简洁!)

这个方法不仅适用于数组,也适用于字符串 (String.prototype.at()) 和 TypedArrays。代码更简洁,可读性也更高了!

3. Promise.withResolvers():轻松掌控 Promise 的状态

在某些复杂的异步场景下,我们可能需要先创建一个 Promise,然后在稍后的某个时刻、从外部控制它的状态(resolve 或 reject)。以前,我们通常这样做:

let resolveCallback, rejectCallback;

const promise = new Promise((resolve, reject) => {
// 把 resolve 和 reject 函数暴露出来
resolveCallback = resolve;
rejectCallback = reject;
});

// ... 在代码的其他地方 ...
function someAsyncOperation() {
// 异步操作成功
if (success) {
resolveCallback("操作成功的数据");
} else {
// 异步操作失败
rejectCallback(new Error("操作失败"));
}
}

// 使用 promise
promise.then((data) => console.log(data)).catch((err) => console.error(err));

是不是感觉有点绕?代码不够紧凑。好消息是,现在有了 Promise.withResolvers() 这个静态方法,可以更优雅地实现同样的效果:

// 一行代码搞定!
const { promise, resolve, reject } = Promise.withResolvers();

// ... 在代码的其他地方 ...
function someAsyncOperation() {
if (success) {
resolve("操作成功的数据"); // 直接使用解构出来的 resolve
} else {
reject(new Error("操作失败")); // 直接使用解构出来的 reject
}
}

// 使用 promise
promise.then((data) => console.log(data)).catch((err) => console.error(err));

Promise.withResolvers() 直接返回一个包含 promiseresolve 函数和 reject 函数的对象,代码瞬间清爽了很多!这个特性对于实现一些需要外部信号来完成的异步流程(比如等待某个事件触发)特别有用。

4. String.prototype.replace() / replaceAll() 的回调函数:强大的动态替换

replace()replaceAll() 用来替换字符串中的子串,这我们都知道。但你知道吗?它们的第二个参数不仅可以是替换字符串,还可以是一个回调函数!这可就厉害了,它赋予了我们进行动态、上下文相关替换的能力。

这个回调函数会接收到匹配到的子串(以及其他信息,如捕获组、索引位置等),然后函数的返回值将作为替换内容

来看个例子,给句子中所有出现的 "NUMBER" 动态编号:

let counter = 0;
const text =
"Found NUMBER item, then another NUMBER item, and finally the last NUMBER.";

const result = text.replaceAll("NUMBER", (match) => {
// match 就是匹配到的 "NUMBER"
counter++;
return `${match}=${counter}`; // 返回动态生成的替换内容
});

console.log(result);
// 输出: "Found NUMBER=1 item, then another NUMBER=2 item, and finally the last NUMBER=3."

这个特性非常强大且高效:

  • 动态替换:可以根据匹配到的内容、上下文、甚至外部状态来决定替换为什么。
  • 一次遍历replaceAll 配合回调,只需要一次遍历就能完成所有动态替换,相比多次调用 replace 性能更好,内存占用也更低。
  • 复杂逻辑:可以在回调函数里执行更复杂的逻辑,比如查表、计算等。

想了解回调函数的详细参数,可以查阅 MDN 文档

5. 变量交换:更优雅的 ES6 方式

交换两个变量的值,经典的“三杯水”方法是使用一个临时变量:

let a = 1,
b = 2;
console.log(a, b); // 1, 2

const temp = a;
a = b;
b = temp;

console.log(a, b); // 2, 1

虽然可行,但不够简洁。在 ES6 中,我们可以利用数组解构赋值 (Destructuring Assignment) 来实现更优雅的变量交换:

let a = 1,
b = 2;
console.log(a, b); // 1, 2

[a, b] = [b, a]; // 一行代码,搞定!

console.log(a, b); // 2, 1

是不是瞬间感觉代码逼格高了不少?这利用了等号右侧先创建一个临时数组 [b, a](即 [2, 1]),然后左侧通过解构赋值,将这个临时数组的第一个元素赋给 a,第二个元素赋给 b。简洁、直观、高效!

6. structuredClone():更靠谱的深拷贝选择

深拷贝(Deep Copy)对象是前端开发中常见的需求。很多人习惯使用 JSON.stringify()JSON.parse() 的组合拳来实现:const clonedObj = JSON.parse(JSON.stringify(originalObj))。这种方法简单粗暴,但在很多情况下并不可靠,甚至会出错!

JSON.stringify() / JSON.parse() 深拷贝的“坑”:

  1. 丢失或转换特定值类型
    • undefinedFunctionSymbol 类型的值,在 JSON.stringify 时会被忽略(如果在对象中)或转换为 null(如果在数组中)。
    • NaNInfinity-Infinity 会被转换为 null
    • Date 对象会被转换为 ISO 格式的字符串,parse 后不会自动变回 Date 对象。
    • RegExp 正则表达式对象会被转换为空对象 {}
    • MapSet 会被转换为空对象 {}
    • 不支持 BigInt 类型,会直接抛出 TypeError
  2. 无法处理循环引用:如果对象内部存在循环引用(例如 obj.self = obj),JSON.stringify() 会抛出 TypeError
    const obj = {};
    obj.selfReference = obj;
    // console.log(JSON.stringify(obj)); // Uncaught TypeError: Converting circular structure to JSON
  3. 性能问题:对于非常大的或嵌套很深的对象,先序列化为庞大的 JSON 字符串,再解析回来,性能开销和内存占用都比较大。

推荐使用 structuredClone()

幸运的是,现代浏览器和 Node.js 环境都内置了 structuredClone() API。它专门用于执行深拷贝,并且能够正确处理许多 JSON 方法无法处理的情况。

structuredClone() 的优势:

  • 支持更多类型:能正确克隆 Date, RegExp, Map, Set, Blob, File, ImageData, ArrayBuffer, TypedArray 等多种类型。
  • 自动处理循环引用:无需担心循环引用问题,structuredClone() 会正确地在克隆对象中重建这些引用关系。
  • 性能通常更好:底层使用结构化克隆算法(与 Web Workers postMessage、IndexedDB 存储对象时使用的算法相同),通常比 JSON 序列化/反序列化更快,内存效率也更高。
const original = {
date: new Date(),
regex: /hello/g,
map: new Map([["a", 1]]),
set: new Set([1, 2]),
undef: undefined,
num: 123,
// BigInt 需要注意,虽然 structuredClone 支持,但 JSON 不支持
// bigNum: 123n, // 如果要用 JSON 序列化,这行会报错
};
original.circular = original; // 添加循环引用

// 使用 structuredClone
const cloned = structuredClone(original);

console.log(cloned !== original); // true (是不同的对象引用)
console.log(cloned.date instanceof Date); // true (仍然是 Date 对象)
console.log(cloned.regex instanceof RegExp); // true (仍然是 RegExp 对象)
console.log(cloned.map instanceof Map); // true (仍然是 Map 对象)
console.log(cloned.set instanceof Set); // true (仍然是 Set 对象)
console.log(cloned.undef); // undefined (undefined 被保留了)
console.log(cloned.circular === cloned); // true (循环引用也正确克隆了)

// 对比 JSON 方法 (会丢失信息或改变类型)
// const jsonCloned = JSON.parse(JSON.stringify(original)); // 如果包含 BigInt 会报错
// console.log(jsonCloned.date); // 变成字符串了
// console.log(jsonCloned.regex); // 变成 {}
// console.log(jsonCloned.map); // 变成 {}
// console.log(jsonCloned.set); // 变成 {}
// console.log(jsonCloned.undef); // undefined 丢失了 (属性直接没了)
// console.log(JSON.stringify(original)); // 如果有循环引用,这步就报错了

注意structuredClone() 也不是万能的,它无法克隆函数 (Function)、错误对象 (Error) 和 DOM 节点。但对于绝大多数数据对象的深拷贝场景,structuredClone() 是目前最优、最可靠的选择。

7. Tagged Templates (标签模板):给模板字符串加个“处理器”

我们对模板字符串(用反引号 ` 包裹的字符串)应该很熟悉了,它允许我们方便地嵌入表达式 ${expression}。但你可能没太注意过标签模板 (Tagged Templates)

标签模板允许你用一个函数来“解析”模板字符串。这个函数(称为“标签函数”)会接收到模板字符串的各个部分作为参数,然后你可以对它们进行处理,最后返回处理后的结果。

如何工作?

标签函数的第一个参数是一个数组,包含了模板字符串中被 ${...} 分隔开的静态文本部分。 后续的参数则是模板字符串中每个 ${...} 表达式的计算结果

这有什么用呢?它非常适合用来对模板字符串进行自动转换、校验或处理

例如:自动 HTML 转义

假设我们想创建一个标签函数 escapeHtml,它能自动对模板字符串中的插值(${...} 里的内容)进行 HTML 转义,防止 XSS 攻击:

function escapeHtml(strings, ...values) {
let output = strings[0]; // 先获取第一个静态文本片段
for (let i = 0; i < values.length; i++) {
// 对每个表达式的值进行处理 (这里是 HTML 转义)
const value = values[i];
const escapedValue = String(value) // 确保是字符串
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
output += escapedValue; // 拼接转义后的值
output += strings[i + 1]; // 拼接下一个静态文本片段
}
return output;
}

const userInput = '<script>alert("xss")</script>';
const safeHtml = escapeHtml`<p>用户输入内容:${userInput}</p>`;

console.log(safeHtml);
// 输出: "<p>用户输入内容:&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>"
// 用户输入的恶意脚本被成功转义了!

原文中的例子 (使用 innerTextinnerHTML 来转义,也是一种技巧,但不如直接替换特殊字符通用):

// 原文示例的另一种实现思路
function escapeHtmlViaDOM(strings, ...args) {
const div = document.createElement("div"); // 仅在浏览器环境有效
let output = strings[0];
for (let i = 0; i < args.length; ++i) {
div.innerText = args[i]; // 利用 innerText 的自动转义特性
output += div.innerHTML; // 读取转义后的 HTML
output += strings[i + 1];
}
div.remove(); // 清理 DOM 元素
return output;
}

const maliciousInput = "<br>";
console.log(escapeHtmlViaDOM`<p>Here is some code: ${maliciousInput}</p>`);
// 输出: "<p>Here is some code: &lt;br&gt;</p>"

标签模板非常强大,常见应用场景包括:

  • HTML/SQL 转义:如上例所示,防止注入攻击。
  • 国际化 (i18n):根据语言环境自动替换占位符。
  • 样式化组件 (Styled Components):像 React 中的 styled-components 库就重度使用了标签模板来写 CSS in JS。
  • 创建领域特定语言 (DSL):比如用于生成特定格式的查询语句。

8. WeakMap / WeakSet:不阻止垃圾回收的“弱”集合

除了我们常用的 MapSet,JavaScript 还提供了它们的“弱”版本:WeakMapWeakSet。它们与 MapSet 类似,但有几个关键区别和特性:

  1. 键必须是对象WeakMap 的键 (key) 和 WeakSet 的值 (value) 只能是对象,不能是原始类型值(如 string, number, boolean, symbol, bigint, null, undefined)。
  2. 弱引用 (Weak Reference):这是它们的核心特性。WeakMapWeakSet 对其存储的对象键(或值)是弱引用。这意味着,如果一个对象只被 WeakMapWeakSet 引用,而没有其他任何强引用指向它,那么这个对象就可以被垃圾回收机制 (Garbage Collection, GC) 回收掉。回收发生时,对应的键值对(或值)也会自动从 WeakMap/WeakSet 中移除。
  3. 不可迭代:正因为垃圾回收可能随时移除成员,WeakMapWeakSet 不支持迭代(没有 keys(), values(), entries(), forEach 方法),也无法获取 size 属性。这是为了防止在迭代过程中成员突然消失导致的不确定性。

为什么需要它们?

主要用途是将数据与对象关联起来,同时又不阻止该对象被垃圾回收。这在某些场景下非常有用,可以避免内存泄漏。

示例:

const weakSet = new WeakSet();
const weakMap = new WeakMap();

let obj1 = { id: 1 };
let obj2 = { id: 2 };

weakSet.add(obj1);
weakMap.set(obj2, "一些与 obj2 关联的数据");

console.log(weakSet.has(obj1)); // true
console.log(weakMap.get(obj2)); // '一些与 obj2 关联的数据'

// 现在,我们移除对 obj1 和 obj2 的所有其他强引用
obj1 = null;
obj2 = null;

// 在将来的某个时刻,垃圾回收机制运行时...
// obj1 和 obj2 因为不再被强引用,它们占用的内存会被回收。
// 同时,它们也会自动从 weakSet 和 weakMap 中移除。

// 注意:我们无法直接验证它们是否已被移除,因为 WeakMap/WeakSet 不可迭代。
// 但我们可以确信,它们不会阻止 obj1 和 obj2 被回收。
// console.log(weakSet.has(obj1)); // 此时 obj1 已经是 null 了,查询无意义
// console.log(weakMap.get(obj2)); // 此时 obj2 已经是 null 了,查询无意义

常见用例:

  • 缓存对象元数据:为一个对象存储一些计算成本较高的元数据。当对象不再使用并被回收时,关联的元数据也自动被清理,无需手动管理。
  • 存储私有数据:在类外部(或模拟私有成员)为类的实例关联一些数据,而不会干扰实例本身的生命周期。
  • 跟踪 DOM 节点状态:比如记录某个 DOM 元素是否已被处理,当该 DOM 元素从文档中移除并被回收时,WeakMap 中的记录也自动消失。

如果你需要在不影响对象生命周期的情况下,给对象附加一些信息,WeakMapWeakSet 就是你的不二之选。

9. Set 集合操作:轻松实现交、并、差集

Set 对象存储唯一值,本身就很有用。最近,JavaScript 又为 Set 增加了几个进行集合运算的方法,使得处理集合之间的关系变得异常简单和高效。再也不用自己写循环和判断逻辑啦!

这些方法都会返回一个新的 Set,而不会修改原始的 Set

  1. set1.difference(set2) (差集): 返回一个新 Set,包含所有在 set1 中但不在 set2 中的元素。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([3, 4, 5, 6]);
    console.log(set1.difference(set2)); // Set(2) { 1, 2 }
  2. set1.intersection(set2) (交集): 返回一个新 Set,包含所有同时存在于 set1set2 中的元素。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([3, 4, 5, 6]);
    console.log(set1.intersection(set2)); // Set(2) { 3, 4 }
  3. set1.union(set2) (并集): 返回一个新 Set,包含所有在 set1 set2 中(或两者皆有)的元素。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([3, 4, 5, 6]);
    console.log(set1.union(set2)); // Set(6) { 1, 2, 3, 4, 5, 6 }
  4. set1.symmetricDifference(set2) (对称差集): 返回一个新 Set,包含所有只存在于 set1set2 之一中,但不同时存在于两者中的元素。(即并集减去交集)。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([3, 4, 5, 6]);
    console.log(set1.symmetricDifference(set2)); // Set(4) { 1, 2, 5, 6 }
  5. set1.isDisjointFrom(set2) (是否不相交): 返回一个布尔值,判断 set1set2 是否没有共同元素。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([3, 4, 5, 6]);
    const set3 = new Set([5, 6]);
    console.log(set1.isDisjointFrom(set2)); // false (因为有 3, 4)
    console.log(set1.isDisjointFrom(set3)); // true (没有共同元素)
  6. set1.isSubsetOf(set2) (是否为子集): 返回一个布尔值,判断 set1 中的所有元素是否都存在于 set2 中。
    const set1 = new Set([1, 2]);
    const set2 = new Set([1, 2, 3, 4]);
    const set3 = new Set([3, 4]);
    console.log(set1.isSubsetOf(set2)); // true
    console.log(set3.isSubsetOf(set1)); // false
  7. set1.isSupersetOf(set2) (是否为超集): 返回一个布尔值,判断 set1 是否包含 set2 中的所有元素。
    const set1 = new Set([1, 2, 3, 4]);
    const set2 = new Set([1, 2]);
    const set3 = new Set([5, 6]);
    console.log(set1.isSupersetOf(set2)); // true
    console.log(set1.isSupersetOf(set3)); // false

这些集合操作方法极大地简化了处理数据集相关的逻辑,比如比较用户权限、筛选数据、查找差异等场景,代码更易读,性能也通常优于手动实现。


总结

JavaScript 的世界日新月异,不断有新的特性和语法糖涌现,旨在让我们写出更高效、更简洁、更健壮的代码。今天介绍的这些特性,有些是近年的新秀(如 Iterator Helpers, Set Operations),有些是虽老但可能被忽视的实用技巧(如 replace 回调, 变量解构交换)。

作为前端开发者,持续学习、拥抱变化是我们的必备素养。希望这篇解读能帮助你更好地理解和运用这些 JavaScript 特性,提升你的开发效率和代码质量!下次遇到类似场景,不妨试试这些新“武器”吧!🚀