开发人员需要思考大量日常工作,这已不是什么秘密。他们的部分心思总是花在各种日常事务上,例如 "我应该把这个函数放在哪个模块中?"、"我应该如何给这个变量命名?"或 "这个变量应该做什么?"。尽管这些问题看似简单琐碎,却一直在消耗着人们的脑力资源。然而,如果有办法通过自动化一些常规任务来简化这一过程,从而将注意力集中到更重要的事情上,那么利用这些方法无疑是有意义的。
在这种情况下,命名约定发挥着至关重要的作用。掌握知识并明智地使用这些约定有助于增强代码的可读性、简化理解并减轻开发人员的认知负担。然而,并非所有开发人员都知道,有时甚至会忘记如何正确应用这些约定,从而无意识地将注意力转移到看似更重要的事情上。这反过来又会使代码的可读性和理解复杂化,使表面上 "更关键 "的任务变得比实际上更复杂。
下面是我有机会参与的一个真实项目的截图。记下每个导入项。尝试根据名称猜测我们可以从每个导入项中获得什么项目。
在本文中,我们将探讨其中的大部分,了解或提醒自己在为编写的结构体选择名称时最好遵循哪些原则。这将帮助您和今后使用您代码的任何人(无论是您的团队还是您自己)避免臆测,并提高代码的可读性。
JavaScript 命名约定
本文将包括一系列命名建议。我不希望你将它们视为唯一的真理。首先,事实并非完全如此,因为大多数建议都附有免责声明,指出存在许多例外情况,暗示约定本身并不涵盖所有情况。其次,虽然 JavaScript 社区接受了大多数建议,但出于各种原因,并不是每个人都会遵循这些建议,而且有些建议会根据特定团队的需要进行部分或大幅修改。
这篇文章的主旨是,每个项目都应建立自己的命名惯例,无论这些惯例是与广泛采用的惯例相一致,还是与你的团队独有的惯例相一致。最重要的是制定并遵守约定。
一般规则
互联网上有大量关于最佳命名实践的文章。众所周知的惯例强调,名称应简短,更重要的是应易于理解。名称应该具体,符合使用环境,并能一目了然地传达代码的含义。实际上,这些规则不仅适用于 JavaScript,也适用于任何编程语言。在这些一般规则中,我想重点谈谈约定中的一些具体细节,它们广为人知,但经常被遗忘或没有得到完全遵守。
统一性
在 JavaScript 项目中,有许多约定俗成的惯例,但最值得遵守的是在单个项目中保持一致的编码风格。参与不同项目的人员比任何人都清楚,不同项目之间的编写风格会有很大差异。
应用程序的不同部分由不同团队开发或在开发过程中使用不同技术的情况经常出现。命名约定可能因所选编程语言或开发团队的偏好而不同。尽管存在这些差异,应用程序的不同部分仍需要相互交互,例如前台和后台之间的数据交换。例如,从后端请求的数据的命名风格可能与前端代码中使用的风格不同。
我们无权评判使用不同的命名方式是好是坏,因为可能会有各种原因导致这种选择。不过,明确混合两种或多种命名方式可能会带来挑战。对于新加入项目的开发人员来说,理解正在发生的事情并决定使用哪种命名方式可能会变得相当具有挑战性。
// Bad
const hasAccess = checkHasUserAccess(session.current_user) && checkIsPageVisible(session.current_page)
// Good
const hasAccess = checkHasUserAccess(session.currentUser) && checkIsPageVisible(session.currentPage)
// Also good, if such a naming case is preferred
const has_access = check_has_user_access(session.current_user) && check_is_page_visible(session.current_page)
解决这一问题的方法有多种,包括使用重命名与析构、导入重命名、使用映射函数(如 Array.prototype.map()),以及使用适配器设计模式等更高级的方法。
关键是要采用一致的方法,不仅在命名规则上,而且在完成任务的方式上。在可能的情况下,优先选择单一方法来完成特定操作。避免强迫自己和其他开发人员花费时间和额外的精神资源来决定使用哪种方法。考虑将此类任务(尤其是与编码风格相关的任务)委托给 ESLint 等工具。
仅限英文
尽管在 JavaScript 中可以使用非拉丁字母,但不建议这样做。虽然不会出现任何编程错误,但强烈不建议使用英语以外的任何语言。即使是为自己编写代码,每段代码都有 "长寿 "的能力,也许有一天您需要与其他开发人员共享这些代码。对于其他开发人员,尤其是来自不同国家的开发人员来说,很难理解代码中发生了什么。
在强调只使用英语时,人们往往只想到键盘布局。然而,我们不应忘记缩写和名称简称的普遍使用。缩写和简写只应用于公认的单词,如 idx
、 err
、 evt
、 xhr
、 src
及其他社会上历史上公认的单词。在其他情况下,我们强烈反对使用缩略词和词的简写形式,因为它们往往会导致混淆,而且破译它们会耗费大量时间。
// Bad
const 新規ユーザーのウェルカムメッセージ = 'こんにちは'
const usrInf = { fName: 'John', lName: 'Doe' }
const isAdult = a >= 18
// Good
const newUserWelcomeMessage = 'こんにちは'
const userInfo = { firstName: 'John', lastName: 'Doe' }
const isAdult = userAge >= 18
此外,建议在代码编辑器中启用拼写检查程序,它将突出显示单词中的语法错误。在许多编辑器中,它都是默认启用的,对于某些编辑器,您可能需要安装一个扩展,如 VS Code 的 Code Spell Checker。如今的拼写检查程序已经足够智能,大多数常用缩写和单词简写都不会被标记为错误。
不仅是 camelCase
许多资源都强调,在使用 JavaScript 编写时,只能使用 camelCase
符号。然而,这并不完全准确。该建议更多的是要遵守语言本身使用的符号。虽然 JavaScript 语言中有很大一部分是使用 camelCase
编写的,但并不普遍。下面是几个例子:
parseInt('18') // camelCase
new RegExp('[A-Z]', 'i') // PascalCase
Number.MAX_SAFE_INTEGER // PascalCase + UPPER_SNAKE_CASE
正如我们所看到的,JavaScript 不仅使用了 camelCase
符号,还使用了其他一些符号。JavaScript 中的所有类和构造函数都是使用 PascalCase
符号编写的。在声明自定义类和构造函数时,通常会遵循与语言本身相同的命名约定。这同样适用于表示固定值的常量。无论是内置的 JavaScript 常量还是开发人员创建的常量,都习惯使用 UPPER_SNAKE_CASE
命名。
有些编程语言在编写应用程序接口(API)时,会使用各种不同的情况,而没有明确的分配,相比之下,JavaScript 的应用程序接口(API)就不存在这个问题。因此,我们习惯于遵守语言本身的约定。
遵循readme.md
假设您正在使用 axios 这样的网络请求库,而该库的 README 建议使用标准名称 axios
进行导入。
import httpRequester from 'npm:axios'
// Somewhere very far at the bottom of the file
httpRequester('https://example.com')
将名称从 axios
更改为 httpRequester
可能会导致混乱和错误,因为其他开发人员在开发该项目时,如果期望使用标准名称 axios
可能会遇到问题。因此,请务必遵守库 README 中的建议,以确保兼容性和代码理解。
乍一看, axios
库的示例似乎并无大碍,但在使用深度集成到项目中的更复杂的库和框架时,也会出现类似的重命名情况。采用有意义重命名做法的团队需要创建自己的文档,因为新团队成员无法在官方文档中找到足够的信息来理解技术使用风格的变化。
通常情况下,将有意义的重命名转换为更抽象的名称,会与依赖反转原则结合使用。不过,理解这里的细微差别很重要。您和您的团队必须清楚地了解您在做什么以及为什么这样做,因为在采用这种做法时需要保持平衡。
无数据类型
在变量名中包含数据类型可能很诱人,但屈从于这种诱惑往往会给变量名增加不必要的语义负担。例如,使用 arr
作为包含数组的变量名,可能会在其他地方使用同名变量时产生冲突。此外,名称 arr
并不能传达有意义的信息。使名称更具体(如 userArr
)是一种改进,但并不能解决所有问题。
// Bad
const userNameMinLengthConst = 3
const userObj = getUser()
const getUniqueUserNames = (arr) => Array.from(new Set(arr))
// Good
const USER_NAME_MIN_LENGTH_COUNT = 3
const user = getUser()
const getUniqueUserNames = (names) => Array.from(new Set(names))
对于每种语言结构体,无论是内置的还是自定义的数据结构,都有一种常规的方法来表示值的预期数据类型。这反映在结构体的名称中,该名称应与已确立的社区约定和团队内部约定保持一致。建议优先考虑社区约定,因为解释广为接受的东西通常比引入完全独特的东西更容易。如果是社区约定俗成的东西,你可以让别人参考互联网上的文章,而对于团队特定的约定俗成的东西,你可能需要自己撰写这篇文章。
具体规则
在本节中,我们将讨论数据类型和数据结构的具体命名规则。虽然 JavaScript 的内置数据类型和结构看起来很有限,但它却提供了广泛的功能。这些功能包括各种方法,而在其他编程语言中,这些方法通常被划分为不同的内置类型或数据结构。
例如,在 JavaScript 中,一个对象可以作为 enum
、 map
( dictionary
)、 graph
等。JavaScript 中的 number
数据类型包括处理整数和浮点数。根据具体要求,JavaScript 数据类型和结构可以灵活调整使用,这为 JavaScript 提供了多样性和强大的功能。
在这种情况下,命名约定的重要性就显而易见了。它们不仅能提高代码的可读性,还能防止在与代码库的其他部分交互时出现混淆和错误。良好的命名规范可确保代码的清晰度,并有助于其他开发人员理解数据结构及其用途,从而提高团队内部的协作效率。
布尔值
布尔值的名称应以肯定前缀开头,即前缀应回答 "是 "的问题。虽然有几个肯定词语(should、can、will 等)可以回答 "是",但建议首选两个最常用的词语 - is
和 has
。虽然使用其他肯定词语不会被视为错误,但应将其作为例外情况处理,如果可能,最好避免使用。
这一约定还有一个经常被忽视的重要补充,即肯定前缀不应包含否定。这是因为否定操作符 ( !
) 最常用于布尔值。因此,一个名为 isNotAllowed
的值如果应用了否定操作符 !isNotAllowed
,就会产生相当大的误导。不信?那就试着快速找出 !!isNotAllowed
(双重否定)等于什么。即使你能很快做到,想象一下,在实际代码中,所有这些否定反转都散布在整个文件中。跟踪这些逻辑,尤其是当这些逻辑很多的时候,可能会相当具有挑战性。在这种情况下,最好改变布尔变量的评估逻辑,在其名称中使用肯定肯定。
// Bad
const userLogin = user !== null
const user = {
friend: false,
}
const isNotUserRemoved = userActionState !== ActionState.REMOVED
if (isNotUserRemoved) { /* some logic */ }
// Good
const isUserAuthorized = user !== null
const hasUser = user !== null
const user = {
hasFriend: false,
}
const isUserRemoved = userActionState === ActionState.REMOVED
if (!isUserRemoved) { /* some logic */ }
除了在布尔名称中使用肯定前缀这一约定之外,我们还偏离了 W3C 规范的建议,因为它建议不要在布尔名称中使用这些前缀。这与前面提到的优先选择语言本身使用的惯例的观点相矛盾。不过,凡事都有例外,这就是其中之一。这是正常的,因为规范作者的思维方式可能与社区的观点不同。
JavaScript 是很久以前创建的,在创建之初,作者决定不在布尔名称中使用肯定前缀。现在,即使与社区的意见相悖,他们也会尽力继续遵循自己的约定。即使作者想在规范中引入新的命名约定,他们也做不到,至少做不到连贯一致。旧代码不能重新命名,因为 JavaScript 必须保持向后兼容。使用新方法开始编写新代码也不是一个好主意,因为做同一件事会有两种方法,这也是不可取的。
例外是正常的。世界上没有任何事物是完美无缺的。即使是规范本身也有例外 - node.isContentEditable
或 evt.isTrusted
。关键是在遵守先前约定的同时,尽量减少例外情况。
函数和方法
函数/方法的名称应为动词,并与其执行的操作相对应。
// Bad
const userNaming = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const user = {
name: 'John',
action() {
console.log('Hey')
},
}
const userPermissions = (userId) => permissionRepository.getByUserId(userId)
// Good
const getCapitalizedUserName = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const user = {
name: 'John',
sayHello() {
console.log('Hey')
},
}
const getUserPermissions = (userId) => permissionRepository.getByUserId(userId)
虽然函数/方法的命名约定乍看之下似乎很简单,但它们的命名却有最多的例外和其他约定。
- 遵循 JavaScript 的写作风格(
Number.isNaN()
、Array.isArray()
、salary.toFixed()
、构造函数等)。 - 事件处理函数的命名约定(
onBtnClick
、onImgMouseOver
等) - 库函数,例如 Redux 库中的还原器函数(
const users = (state, action) => { /* some logic */ }
)、React 中的功能组件(const UserDashboard = () => { /* component */ }
),以及库和框架提出的许多其他例外和约定。
您可以通过直接使用这些函数和方法来了解它们。不过,对于大多数函数和方法而言,其名称应为动词并与其执行的操作相对应这一主要约定保持不变。
集合和迭代器
命名规则如下--如果使用迭代器或索引集合(例如, Array
, NodeList
, FileList
等),名称应为复数名词。否则,如果使用键集合( Set
, Map
),并且我们只对可以通过键获得的值(通常使用 Array.from()
或扩展语法)感兴趣,那么命名约定将保持不变--名称应为复数名词。但是,如果在键集合中键对我们也很重要,那么这类集合就应该使用单数名词命名,并在名称末尾添加一个前缀,表示一组东西。例如, Collection
, List
, Group
等。
一个重要的补充是,这一约定也应适用于用户使用迭代协议创建的索引或键值集合。
// Bad
const userRoleArr = Object.values(UserRole)
const usersAccordionList = document.querySelectorAll('.users-accordion__list-item',)
const groupPermissions = new Map(Object.entries(Object.groupBy(permissions, (permission) => permission.group)))
// Good
const userRoles = Object.values(UserRole)
const usersAccordionItemNodes = document.querySelectorAll('.users-accordion__list-item',)
const permissionsByGroupList = new Map(Object.entries(Object.groupBy(permissions, (permission) => permission.group)))
类
在使用类时,必须遵守几个约定。下面是一个列表:
- 使用
PascalCase
作为类名,采用与 JavaScript 类和构造函数相同的大小写样式。 - 类名应为单数名词。
- 所有类成员的名称不应包括类名称。
// Bad
class userService { /* class members */ }
class Permissions { /* class members */ }
class UserAuthPopup {
isPopupOpen = false
openPopup() {
this.isPopupOpen = true
}
}
// Good
class UserService { /* class members */ }
class PermissionService { /* class mmebers */ }
class UserAuthPopup {
isOpen = false
open() {
this.isOpen = true
}
}
如果试图在其成员名称中使用类名,则很可能是该类的逻辑负载过重,或者负责的事情超过了它应该负责的范围。在这种情况下,往往会出现违反 "单一责任原则 "的情况。为避免这种情况,建议创建更多的类,并使用继承或依赖注入来限制每个类的责任。
常量
用于描述程序执行前已知的值,这些值在程序执行过程中不应发生变化。
常量是组织程序代码的一种重要且被广泛采用的方法。关于命名约定,开发人员之间有一个重要的共识:常量的名称应使用 UPPER_SNAKE_CASE
符号来书写。
const userFailingLoginAttempts = 3
const USER_ROLE = calculateUserRole(user)
let userDefaultAuthMethod = 'mfa'
// Good
const USER_FAILING_LOGIN_ATTEMPTS_COUNT = 3
const userRole = calculateUserRole(user)
let USER_DEFAULT_AUTH_METHOD = 'mfa'
虽然在 JavaScript 中声明变量和常量有多种方法,但只使用 const
关键字似乎是合乎逻辑的,因为这样可以防止在程序执行过程中对其进行更改,但有些团队却不愿意为选择变量声明的关键字而操心。他们可能会使用 ESLint 等自动化工具来强制程序代码中的变量声明只使用 let
关键字。
同样,每个团队都不尽相同,每个团队都可能有自己的惯例。但是,即使是禁止使用看似更合适的结构和关键词,也不能说明应该如何做或如何命名。命名总是比使用任何关键词更有意义。这就是为什么命名是代码编写的一个重要方面。
枚举
也称为枚举。在 JavaScript 中,这种数据结构用于枚举一组固定值。
许多其他编程语言都有单独的枚举数据类型,而 JavaScript 却没有这种数据类型(至少目前还没有)。相反,要在 JavaScript 中模拟枚举,可以使用普通对象,但要遵守特定的命名约定。下面列出了这些命名约定:
- 枚举名称应以大写字母开头。
- 枚举名称应使用单数名词。
- 枚举的键值应大写。
// Bad
const userRole = {
admin: 'admin',
guest: 'guest',
}
const USER_API_PATHS = {
root: '/',
users$Id: '/users/:id',
}
const userValidationRules = {
MIN_PASSWORD_LENGTH: 8,
MAX_NAME_LENGTH: 20,
VALID_REFERRER_DOMAINS: ['google.com'],
}
// Good
const UserRole = {
ADMIN: 'admin',
GUEST: 'guest',
}
const UserApiPath = {
ROOT: '/',
USERS_$ID: '/users/:id',
}
const UserValidationRule = {
MIN_PASSWORD_LENGTH: 8,
MAX_NAME_LENGTH: 20,
VALID_REFERRER_DOMAINS: ['google.com'],
}
由于 TypeScript 已成为 JavaScript 开发的重要组成部分,因此值得一提的是,TypeScript 使用 enum
关键字来表示枚举。如果您决定在您的团队中使用 TypeScript 的枚举,那么习惯上也要遵循相同的命名约定。
映射(Maps)
也称为 dictionary
数据结构。这种数据结构用于将一个值映射到另一个值。
在任何编程语言中,地图都是一种非常有用且经常使用的数据结构。对于 JavaScript 世界中的地图,有一个特定的命名约定。名称应遵循 aToB
的模式,其中 a
是从映射表中检索值的键,其后是介词 To
表示两个事物的映射,然后是 B
表示 a
的映射值。
// Bad
const userRolesReadable = {
[UserRole.ADMIN]: 'Administrator',
[UserRole.GUEST]: 'Guest',
}
const REDIRECTING = {
'/groups': '/admin-login',
'/profile': '/login',
}
const UserPermissions = {
[UserRole.ADMIN]: [Permission.MANAGE_USERS, Permission.MANAGE_GROUPS],
[UserRole.GUEST]: [Permission.EDIT_PROFILE],
}
// Good
const userRoleToReadable = {
[UserRole.ADMIN]: 'Administrator',
[UserRole.GUEST]: 'Guest',
}
const pagePathToRedirectPath = {
'/groups': '/admin-login',
'/profile': '/login',
}
const userRoleToPermissions = {
[UserRole.ADMIN]: [Permission.MANAGE_USERS, Permission.MANAGE_GROUPS],
[UserRole.GUEST]: [Permission.EDIT_PROFILE],
}
JavaScript 中有一个内置的 Map
类。它与普通对象的主要区别在于可以使用任何数据类型(甚至是对象)作为键。通常情况下,使用本机 Map
类创建映射作为数据结构是多余的。但是,如果需要本地 Map
提供的功能,则可以使用它来创建映射数据结构。在大多数情况下,使用普通对象就足够了。
类型和接口
如前所述,TypeScript 已成为当今 JavaScript 开发中不可或缺的一部分。一般来说,在大多数情况下,类型和接口是可以互换的。不过,由于关于选择哪一种类型的讨论已经够多了,在本文中,我们将特别关注命名问题。对于类型和接口,存在以下命名约定:
- 类型和接口的名称应以
PascalCase
符号书写。 - 名称应尽可能直截了当地描述类型或接口的用途:
const user: User = getUserById(id)
/const users: User[] = getUsers()
。 - 如果开发团队决定在一个代码库中同时使用类型和接口,建议为接口添加一个前缀来区分它们。最常用的前缀是
I
和Contract
。
// Bad
type TUser = {
firstName: string
lastName: string
}
interface user {
firstName: string
lastName: string
}
interface userServiceInterface {
findByEmail: (email: string) => User
}
// Good
type User = {
firstName: string
lastName: string
}
interface User {
firstName: string
lastName: string
}
interface UserServiceContract {
findByEmail: (email: string) => User
}
interface IUserService {
findByEmail: (email: string) => User
}
重要的是要认识到,尽管本文对 JavaScript 命名约定进行了广泛的概述,但并不能涵盖所有可能的情况。即使在本文介绍的要点中,也应考虑到许多例外情况。命名过程虽然是最基本的,但也意味着应用的灵活性,要考虑到每个项目的独特功能和要求。
结论
在开发过程中,我们经常钻研复杂的技术细节,却忘记了细节往往蕴含着力量。多年的开发经验告诉我们,关注细节(如命名)对于创建高效、可读性强的代码至关重要。在开发人员的日常工作中,简单明了、无需思考如何命名一个值以及如何快速理解该值所包含的内容,这些都会给开发人员带来难以置信的轻松感。
如果一个开发人员的变量没有清晰的命名,他还能认为自己很强大吗?清晰的命名不仅能让其他开发人员更容易理解代码,也能让程序员自己更容易理解代码,从而提高开发效率,减少错误。
经常出现的情况是,搜索不良命名会导致发现不良代码。这只能说明命名规范是代码质量的一个指标。正确的命名反映了对细节的关注,反过来也说明了开发过程中的细心。
下面是在应用本文讨论的约定时,从介绍部分导入值的情况。现在看起来清晰多了,不是吗?我相信,现在你理解每个导入值的含义所花费的精力会少很多。
总之,必须记住,命名标准固然重要,但并非绝对。每个项目都有其特殊性,因此确定自己的约定至关重要。最重要的是,它们应该存在,并且您应该遵守它们,以确保代码的一致性并提高集体的工作效率。此外,如果可能的话,尽量将代码风格问题委托给 ESLint 等工具来简化和改进开发流程。