本文翻译自 JETBRAINS Blog 的 JavaScript Best Practices,原作者是 David Watson
JavaScript 无疑是世界上使用最广泛的编程语言,并且对我们所依赖的最大技术之一 —— 互联网具有巨大的影响。这种能力伴随着巨大的责任,JavaScript 生态系统一直在快速发展,使得跟上最新的 JavaScript 最佳实践变得极其困难。
在这篇博文中,我们将介绍现代 JavaScript 中的几种关键最佳实践,以编写更干净、更易于维护、性能更高的代码。
项目定义的实践胜过所有其他实践#
您编写代码的项目可以有自己的严格规则。项目规则比任何最佳实践文章中的任何建议都更有意义 —— 包括这篇文章!如果您想使用特定的做法,请确保将其与项目规则和代码库同步,并确保团队中的每个人都参与其中。
使用最新的 JavaScript#
JavaScript 于 1995 年 12 月 4 日被发明。从那时起,它就一直在不断地发展。在网上,你可以找到很多过时的建议和做法。要小心并验证你想要使用的做法是否是最新的。
另外,使用最新的 JavaScript 功能时要小心谨慎。最好开始使用至少经过 Ecma TC39 Stage 3 的新 JavaScript 功能。
话虽如此,这里还是汇集了一些当前常见的 JavaScript 最佳实践:
声明变量#
您可能会遇到使用许多 var 声明的代码。这可能是故意的,但如果是旧代码,则可能是因为这是旧方法。
建议: 使用 let
和 const
而不是 var
来声明变量。
为什么这很重要: 尽管 var
仍然可用,但 let
和 const
提供块作用域,它更可预测,并减少了使用 var
(函数作用域)声明变量时可能发生的意外错误。
for (let j = 1; j < 5; j++) {
console.log(j);
}
console.log(j);
// you get 'Uncaught ReferenceError: j is not defined'
//If we did this using var:
for (var j = 1; j < 5; j++) {
console.log(j);
}
// <-- logs the numbers 1 to 4
console.log(j);
//You’d get 5 as it still exists outside the loop
类代替函数:原型#
在许多有关 JavaScript 中 OOP 的旧代码库或文章中,您可能会遇到用于模拟类的函数原型方法。例如:
function Person(name) {
this.name = name;
}
Person.prototype.getName = function () {
return this.name;
}
const p = new Person('A');
console.log(p.getName()); // 'A'
建议: 这种方法使用构造函数来控制原型链。但是,在这种情况下,使用类几乎总是更好的。
class Person {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
const p = new Person('A');
console.log(p.getName()); // 'A'
为什么重要: 使用类的主要原因是它们具有更清晰的语法。
私有类字段#
在较旧的 JavaScript 代码中,通常使用下划线 (_) 作为约定来表示类中的私有属性或方法。然而,这实际上并没有强制隐私 —— 它只是向开发人员发出信号,表明某些东西应该是私密的。
class Person {
constructor(name) {
this._name = name; // Conventionally treated as private, but not truly private
}
getName() {
return this._name;
}
}
const p = new Person('A');
console.log(p.getName()); // 'A'
console.log(p._name); // 'A' (still accessible from outside)
建议: 当您确实需要类中的私有字段时,JavaScript 现在使用 #
语法拥有真正的私有字段。这是强制实施真正隐私的官方语言功能。
箭头函数表达式#
箭头函数通常用于使回调函数或匿名函数更简洁、更易读。它们在使用 map
、filter
或 Reduce
等高阶函数时尤其有用。
const numbers = [1, 2];
// Using arrow function
numbers.map(num => num * 2);
// Instead of
numbers.map(function (num) {
return num * 2;
});
建议: 箭头函数提供了更简洁的语法,尤其是当函数体是单个表达式时。它们还会自动绑定 this
上下文,这在类方法中特别有用,因为 this
很容易丢失。
为什么重要: 箭头函数通过删除样板代码,使回调函数和内联表达式更加简洁,从而提高了可读性。此外,它们在使用类或事件处理程序时特别有价值,因为它们会自动将 this
绑定到周围的词法范围。这避免了传统函数表达式中与 this
相关的常见错误,尤其是在异步或回调密集型代码中。
空值合并(??)#
在 JavaScript 中,当变量为 undefined
或 null
时,开发人员经常使用逻辑或(||
)运算符来分配默认值。但是,当变量保存诸如 0
、false
或空字符串 (""
) 之类的值时,可能会出现意外行为,因为 ||
将它们视为假值并替换为默认值。
例如:
const value = 0;
const result = value || 10;
console.log(result); // 10 (unexpected if 0 is a valid value)
建议: 解析默认值时,使用空值合并运算符 (??
) 代替 ||
。它仅检查是否为undefined
或 null
,而其他假值(如 0
、false
、""
)保持不变。
const value = 0;
const result = value ?? 10;
console.log(result); // 0 (expected behavior)
为什么重要: 当 null
或 undefined
应触发回退时,??
运算符提供了一种更精确的方式来处理默认值。它可以防止因使用||
而导致的错误,因为 ||
可能会无意中覆盖有效的假值。使用空值合并可实现更可预测的行为,从而提高代码清晰度和可靠性。
可选链接(?.):#
处理深层嵌套的对象或数组时,通常必须在尝试访问下一级之前检查每个属性或数组元素是否存在。如果没有可选链,这需要冗长而重复的代码。
例如:
const product = {};
// Without optional chaining
const tax = (product.price && product.price.tax) ?? undefined;
建议: 可选链接运算符(?.
)通过在尝试访问属性或方法之前自动检查其是否存在来简化此过程。如果链中的任何部分为空或未定义,它将返回未定义而不是抛出错误。
const product = {};
// Using optional chaining
const tax = product?.price?.tax;
为什么重要: 可选链接减少了样板代码的数量,并使处理深度嵌套的结构变得更加容易。它可以通过优雅地处理空值或未定义值来确保您的代码更干净、更少出错,而无需进行多次检查。这使得代码更具可读性和可维护性,特别是在处理动态数据或复杂对象时。
async/await#
在较旧的 JavaScript 中,处理异步操作通常依赖于回调或链接承诺,这很快就会导致复杂且难以阅读的代码。例如,使用 .then()
进行承诺链可能会使流程更难遵循,尤其是在多个异步操作的情况下:
function fetchData() {
return fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
}
建议: 使用 async
和 await
使异步代码看起来更像常规同步代码。这提高了可读性,并使 try...catch
的错误处理更加容易。
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
为什么重要: async/await
语法消除了链接 .then()
和 .catch()
的需要,从而简化了异步操作。它使您的代码更易读、更易于维护且更易于理解,尤其是在处理多个异步调用时。使用 try...catch
错误处理也更直接,从而产生更清晰、更可预测的逻辑。
与对象键和值的交互#
在较旧的 JavaScript 代码中,与对象的键和值的交互通常涉及使用 for...in
或 Object.keys()
手动循环,然后通过括号符号或点符号访问值。这可能会导致代码冗长且不太直观。
const obj = { a: 1, b: 2, c: 3 };
// Older approach with Object.keys()
Object.keys(obj).forEach(key => {
console.log(key, obj[key]);
});
建议: 使用现代方法(例如 Object.entries()
、Object.values()
和 Object.keys()
)来处理对象键和值。这些方法简化了流程并返回有用的结构(如数组),使您的代码更简洁且更易于使用。
const obj = { a: 1, b: 2, c: 3 };
// Using Object.entries() to iterate over key-value pairs
Object.entries(obj).forEach(([key, value]) => {
console.log(key, value);
});
// Using Object.values() to work directly with values
Object.values(obj).forEach(value => {
console.log(value);
});
为什么重要: 使用现代对象方法(例如 Object.entries()
、Object.values()
和 Object.keys()
)可以生成更清晰、更易读的代码。这些方法减少了迭代对象所需的样板量并提高了代码清晰度,特别是在处理复杂或动态数据结构时。它们还支持更轻松地将对象转换为其他形式(例如数组),从而使数据操作更加灵活和高效。
检查变量的数组类型#
过去,开发人员使用各种非直接的方法来检查变量是否是数组。这些方法包括检查构造函数或使用 instanceof
等方法,但它们通常不可靠,尤其是在处理不同的执行上下文(如 iframe
)时。
const arr = [1, 2, 3];
// Older approach
console.log(arr instanceof Array); // true, but not always reliable across different contexts
建议: 使用现代的 Array.isArray()
方法,该方法提供了一种简单可靠的方法来检查变量是否为数组。此方法在不同环境和执行上下文中均能一致地工作。
const arr = [1, 2, 3];
console.log(Array.isArray(arr)); // true
为什么重要: Array.isArray()
是一种检查数组的清晰、可读且可靠的方法。它消除了对诸如 instanceof 之类的冗长或容易出错的方法的需要,确保您的代码正确处理数组检测,即使在复杂或跨环境场景中也是如此。当使用不同类型的数据结构时,这会减少错误并使行为更可预测。
Map#
在早期的 JavaScript 中,开发人员经常使用普通对象将键映射到值。然而,这种方法有局限性,尤其是当键不是字符串或符号时。普通对象只能使用字符串或符号作为键,因此如果需要将非原始对象(如数组或其他对象)映射到值,则会变得麻烦且容易出错。
const obj = {};
const key = { id: 1 };
// Trying to use a non-primitive object as a key
obj[key] = 'value';
console.log(obj); // Object automatically converts key to a string: '[object Object]: value'
建议: 当您需要映射非原始对象或需要更强大的数据结构时,请使用 Map
。与普通对象不同,Map
允许任何类型的值(原始和非原始)作为键。
const map = new Map();
const key = { id: 1 };
// Using a non-primitive object as a key in a Map
map.set(key, 'value');
console.log(map.get(key)); // 'value'
为什么重要: Map
是一种更灵活、更可预测的方式,可以将值与任何类型的键(无论是原始键还是非原始键)关联起来。它保留了键的类型和顺序,而普通对象则将键转换为字符串。这使得键值对的处理更加强大和高效,特别是在处理复杂数据或需要在更大的集合中快速查找时。
隐藏值的符号#
在 JavaScript 中,对象通常用于存储键值对。但是,当您需要向对象添加 “隐藏” 或唯一值,而又不冒与其他属性发生名称冲突的风险,或者您希望将它们对外部代码保密时,使用 Symbol 会非常有用。符号创建唯一的键,无法通过正常枚举或意外属性查找来访问。
const obj = { name: 'Alice' };
const hiddenKey = Symbol('hidden');
obj[hiddenKey] = 'Secret Value';
console.log(obj.name); // 'Alice'
console.log(obj[hiddenKey]); // 'Secret Value'
建议: 当您想要向对象添加不可枚举的隐藏属性时,请使用 Symbol
。在典型的对象操作(例如 for...in
循环或 Object.keys()
)期间无法访问符号,这使得它们非常适合不应意外暴露的内部或私有数据。
const obj = { name: 'Alice' };
const hiddenKey = Symbol('hidden');
obj[hiddenKey] = 'Secret Value';
console.log(Object.keys(obj)); // ['name'] (Symbol keys won't appear)
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(hidden)] (accessible only if specifically retrieved)
为什么重要: 符号允许您安全地向对象添加唯一和 “隐藏” 属性,而不必担心密钥冲突或将内部细节暴露给代码库的其他部分。它们在库或框架中特别有用,您可能需要存储元数据或内部状态而不影响或干扰其他属性。这确保了更好的封装并降低了意外覆盖或误用的风险。
使用额外的格式化库之前请检查 Intl API#
过去,开发人员通常依赖第三方库来完成诸如格式化日期、数字和货币等任务以适应不同的地区。虽然这些库提供了强大的功能,但它们会给您的项目增加额外的负担,并且可能会重复 JavaScript 中已经内置的功能。
// Using a library for currency formatting
const amount = 123456.78;
// formatLibrary.formatCurrency(amount, 'USD');
建议: 在使用外部库之前,请考虑使用内置的 ECMAScript 国际化 API(Intl
)。它提供了强大的现成功能,可根据区域设置格式化日期、数字、货币等。这通常可以满足您的大部分国际化和本地化需求,而无需第三方库的额外开销。
const amount = 123456.78;
// Using Intl.NumberFormat for currency formatting
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
console.log(formatter.format(amount)); // $123,456.78
为什么重要性: Intl
API 为国际化提供本机和高度优化的支持,因此无需为简单的格式化需求导入大型库。通过使用内置功能,您可以保持项目轻量级、减少依赖性,同时仍然提供全面的基于语言环境的格式化解决方案。这不仅提高了性能,而且还减少了与第三方库相关的维护负担。
常见做法#
现在,让我们看一下一些应该是最佳实践的常见做法。
如果可能的话,使用严格相等(===)#
JavaScript 中最棘手和最令人惊讶的行为之一来自松散相等运算符 (==
)。它执行类型强制,这意味着它会在比较操作数之前尝试将其转换为相同类型。它执行类型强制转换,这意味着它会在比较操作数之前尝试将其转换为相同类型。这可能会导致奇怪且意想不到的结果,正如 Brian Leroux 的演讲中著名的 “WTFJS” 案例所示:
console.log([] == ![]); // 返回 true (什么叫做惊喜?!)
在这种情况下,松散相等运算符(==
)会以意想不到的方式转换双方,从而导致不直观的结果。
建议: 尽可能使用严格相等 (===
) 而不是松散相等 (==
)。严格相等不会执行类型强制转换 - 它直接比较值和类型,从而产生更可预测和更可靠的行为。
console.log([] === ![]); // 返回 false (和预期一样)
下面是一个更典型的例子来强调差异:
// Loose equality (==) performs type coercion
console.log(0 == ''); // true
// Strict equality (===) compares both value and type
console.log(0 === ''); // false (as expected)
为什么重要: 使用严格相等(===
)有助于避免 JavaScript 中类型强制带来的意外行为。它使比较更加可预测,并降低了出现细微错误的风险,尤其是在处理数字、字符串或布尔值等不同数据类型时。默认使用 ===
是一种很好的做法,除非您有特殊理由使用松散相等性并且理解其含义。
明确处理 if 语句中的表达式:#
在 JavaScript 中,if
语句将其计算的表达式的结果隐式转换为 “真” 值或 “假” 值。这意味着 0
、""
(空字符串)、null
、undefined
和 false
等值都被视为假值,而大多数其他值(甚至像 []
或 {}
这样的值)都是真值。如果不小心,这种隐式转换有时会导致意外的结果。
例如:
const value = 0;
if (value) {
console.log('This will not run because 0 is falsy.');
}
建议: 明确说明 if 语句中的条件是一种很好的做法,尤其是当您使用的值在真 / 假评估中可能表现不正常时。这会使代码更可预测且更易于理解。
例如,不依赖于隐式类型强制:
const value = 0;
// Implicit check (may behave unexpectedly for some values)
if (value) {
console.log('This won’t run');
}
您可以使用明确的条件:
// Explicitly check for the type or value you expect
if (value !== 0) {
console.log('This will run only if value is not 0.');
}
或者,检查是否为 null
或 undefined
时:
const name = null;
if (name != null) { // Explicitly checking for null or undefined
console.log('Name is defined');
} else {
console.log('Name is null or undefined');
}
为什么重要: 通过明确定义 if
语句中的条件,可以减少因 JavaScript 自动类型强制而出现意外行为的可能性。这使得您的代码更清晰,并有助于在使用可能存在歧义的值(如 0
、false
、null
或“”
)时防止出现错误。明确说明您要检查的条件是一种很好的做法,尤其是在复杂逻辑中。
不要使用内置 Number
进行敏感计算#
JavaScript 的内置 Number
类型是基于 IEEE 754 标准的浮点数。虽然这对于大多数用途来说都很有效,但它可能会导致令人惊讶的不准确性,尤其是在十进制算术中。这不是 JavaScript 特有的问题,但当您处理敏感数据(例如财务计算)时,它可能会导致严重问题。
例如,您可能会遇到这个著名的浮点问题:
console.log(0.1 + 0.2); // 0.30000000000000004
建议:当精度至关重要时(例如在财务计算中),请避免使用标准 Number
类型进行算术运算。相反,使用专门的库,如 decimal.js
或 big.js
,它们旨在处理精确的十进制计算而不会出现浮点错误。
以下是它与 decimal.js
等库一起工作的方式:
const Decimal = require('decimal.js');
const result = new Decimal(0.1).plus(0.2);
console.log(result.toString()); // '0.3'
这些库确保计算的精确性,并且舍入误差不会影响结果,使其成为处理金钱等敏感任务的理想选择。
为什么重要: 处理财务数据等时,计算不准确会导致严重问题,即使是微小的差异也会造成严重影响。JavaScript 的浮点数学可能会产生意外的结果,虽然该语言正在不断改进,但目前最好依靠 decimal.js
或 big.js
等库来确保精度。通过使用这些库,您可以避免常见的陷阱并确保您的计算准确、可信且适合关键应用。
谨慎使用 JSON 和大整数#
JavaScript 在处理非常大的数字时有限制。JavaScript 中的最大安全整数是 9007199254740991
(也称为 Number.MAX_SAFE_INTEGER
)。大于此值的数字可能会失去精度并产生错误的结果。在使用 JavaScript 以外的 API 或系统时,这会成为一个问题,因为大数字(例如数据库 ID
字段)很容易超出 JavaScript 的安全范围。
例如,解析包含大量数字的 JSON 时:
console.log(
JSON.parse('{"id": 9007199254740999}')
);
// 输出: { id: 9007199254741000 } (精度丢失)
建议: 处理 JSON 数据中的大量数字时,为了避免出现此精度问题,可以使用 JSON.parse()
的 reviver
参数。这使您可以手动处理特定值(例如 id
字段)并以安全格式(例如字符串)保存它们。
console.log(
JSON.parse(
'{"id": 9007199254740999}',
(key, value, ctx) => {
if (key === 'id') {
return ctx.source; // Preserve the original value as a string
}
return value;
}
)
);
// Output: { id: '9007199254740999' }
使用 BigInt: JavaScript 引入了 BigInt
来安全地处理大于 Number.MAX_SAFE_INTEGER
的数字。但是,BigInt
不能直接序列化为 JSON。如果您尝试将包含 BigInt
的对象字符串化,则会收到 TypeError
:
const data = { id: 9007199254740999n };
try {
JSON.stringify(data);
} catch (e) {
console.log(e.message); // '不知道如何序列化 BigInt'
}
为了解决这个问题,请使用 **JSON.stringify () ** 中的 replacer 参数将 BigInt
值转换为字符串:
const data = { id: 9007199254740999n };
console.log(
JSON.stringify(data, (key, value) => {
if (typeof value === 'bigint') {
return value.toString() + 'n'; // Append 'n' to denote BigInt
}
return value;
})
);
// Output: {"id":"9007199254740999n"}
⚠️重要注意事项: 如果使用这些技术处理 JSON 中的大整数,请确保应用程序的客户端和服务器端都同意如何序列化和反序列化数据。例如,如果服务器以特定格式的字符串或 BigInt 形式发送 id,则客户端必须准备在反序列化期间处理该格式。
为什么重要: 在处理来自外部系统的大量数字时,JavaScript 的数字精度限制可能会导致严重的错误。通过使用 BigInt
等技术以及 JSON.parse()
和 JSON.stringify()
的 reviver/replacer
参数,您可以确保正确处理大整数,从而避免数据损坏。在精度至关重要的情况下,这一点尤其重要,例如处理大型身份证或金融交易。
使用 JSDoc 帮助代码阅读者和编辑者#
使用 JavaScript 时,函数和对象签名通常缺少文档,这使得其他开发人员(甚至是您未来的自己)更难理解参数和对象包含什么或如何使用函数。如果没有适当的文档,代码可能会产生歧义,尤其是在对象结构不清楚的情况下:
例如:
const printFullUserName = user =>
// Does user have the `middleName` or `surName`?
`${user.firstName} ${user.lastName}`;
在这种情况下,没有任何文档,无法立即清楚用户对象应该具有哪些属性。user
是否包含 middleName
?是否应该使用 surName
而不是 lastName
?
建议: 通过使用 JSDoc,您可以定义对象、函数参数和返回类型的预期结构。这使得代码阅读器更容易理解功能,也有助于代码编辑器提供更好的自动完成、类型检查和工具提示。
下面展示了如何使用 JSDoc 改进前面的示例:
/**
* @typedef {Object} User
* @property {string} firstName
* @property {string} [middleName] // Optional property
* @property {string} lastName
*/
/**
* Prints the full name of a user.
* @param {User} user - The user object containing name details.
* @return {string} - The full name of the user.
*/
const printFullUserName = user =>
`${user.firstName} ${user.middleName ? user.middleName + ' ' : ''}${user.lastName}`;
重要性: JSDoc 通过清楚地指示函数或对象中所需的值类型来提高代码的可读性和可维护性。它还通过在许多编辑器和 IDE(例如 Visual Studio Code 或 WebStorm)中启用自动完成和类型检查来增强开发人员体验。这减少了出现错误的可能性,并使新开发人员更容易加入并理解代码。
使用 JSDoc,编辑器可以提供提示、对象属性的自动完成,甚至在开发人员误用函数或提供错误的参数类型时发出警告,使您的代码更易于理解和健壮。
Use tests#
随着代码库的增长,手动验证新更改不会破坏重要功能会变得非常耗时且容易出错。自动测试有助于确保您的代码按预期运行,并允许您放心地进行更改。
在 JavaScript 生态系统中,有许多可用的测试框架,但从 Node.js 版本 20 开始,您不再需要外部框架来开始编写和运行测试。Node.js 现在包含一个内置的稳定测试运行器。
这是一个使用 Node.js 内置测试运行器的简单示例:
import { test } from 'node:test';
import { equal } from 'node:assert';
// A simple function to test
const sum = (a, b) => a + b;
// Writing a test for the sum function
test('sum', () => {
equal(sum(1, 1), 2); // Should return true if 1 + 1 equals 2
});
您可以使用以下命令运行此测试:
node --test
此内置解决方案简化了在 Node.js 环境中编写和运行测试的过程。您不再需要配置或安装 Jest 或 Mocha 等其他工具,尽管这些选项对于较大的项目仍然非常有用。
浏览器中的 E2E 测试: 对于浏览器中的端到端 (E2E) 测试,Playwright 是一款出色的工具,可让您轻松地自动化和测试浏览器内的交互。使用 Playwright,您可以测试用户流程,模拟跨多个浏览器(例如 Chrome、Firefox 和 Safari)的交互,并确保您的应用从用户的角度按照预期运行。
其他环境: 两个备选的 JavaScript 运行时 Bun 和 Deno 也提供了类似于 Node.js 的内置测试运行器,因此无需额外设置即可轻松编写和运行测试。
为什么重要: 从长远来看,编写测试可以节省时间,因为它可以尽早发现错误,并减少每次更改后进行手动测试的需要。它还能让您确信新功能或重构不会引入回归。事实上,Node.js、Bun 和 Deno 等现代运行时包含内置测试运行器,这意味着您可以以最少的设置立即开始编写测试。Playwright 等测试工具有助于确保您的应用程序在真实的浏览器环境中无缝运行,为关键的用户交互增加额外的保证。
最后的想法#
虽然这看起来似乎有很多内容需要学习。但希望它能让您深入了解一些您以前没有考虑过并希望在您的 JavaScript 项目中实现的领域。再次强调,请随意将此内容添加到书签中并在需要时随时参考。JavaScript 惯例不断变化和发展,框架也是如此。跟上最新的工具和最佳实践将不断改进和优化您的代码,但这可能很难做到。我们建议关注 ECMAScript 版本的动向,因为这通常会指向最新的 JavaScript 代码中普遍采用的新约定。TC39 通常会针对最新的 ECMAScript 版本提出建议,您也可以关注这些建议。
通过采用这些现代 JavaScript 最佳实践,您不仅可以编写可用的代码,还可以创建更清晰、更高效、更易于维护的解决方案。无论是使用 async/await
等较新的语法,避免浮点数的陷阱,还是利用强大的 Intl
API,这些实践都将帮助您保持最新状态并对代码库充满信心。随着 JavaScript 生态系统的不断发展,现在花点时间采用最佳实践将使您免于未来的麻烦并为长期成功做好准备。
今天就到这里!我们希望这篇文章对您有所帮助 —— 欢迎评论提问、讨论和分享建议。祝您编码愉快!