导航
导航
文章目录󰁋
  1. 一、Git
    1. 1.1 一些Git规则
    2. 1.2 Git 工作流
    3. 1.3 如何写好 Commit Message
  2. 二、文档
  3. 三、环境
    1. 3.1 一致的开发环境
    2. 3.2 依赖一致性
  4. 四、依赖
  5. 五、测试
  6. 六、结构布局与命名
  7. 七、代码风格
    1. 7.1 若干个代码风格指导
    2. 7.2 强制的代码风格标准
  8. 八、日志
  9. 九、API
    1. 9.1 API 设计
    2. 9.2 API 安全
    3. 9.3 API 文档
  10. 十、证书
  11. 总结
  12. 参考

JavaScript工程项目最佳实践完整清单

来源于互联网

接手过一个没人管规范的项目,master 上直接提交,分支名叫 fix1fix2test-new.env 带着数据库密码躺在仓库里,package.json 里三个日期库并存。改一个小需求要先花两天搞清楚现在跑的到底是哪套代码。

规范这东西平时看着都是废话,真正的价值在于「新人进来第一周能不能自己把环境跑起来」和「半年后你还能不能看懂当时为什么这么写」。这篇是一份从 Git 到 API 设计的完整清单,逐条讲了为什么这么定,并且把其中已经过时的条目单独标出来说明现在的做法。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Git 分支策略和交互式变基的完整工作流
  • Commit message 该怎么写,以及现在主流的 Conventional Commits
  • 环境变量的隔离与启动前校验
  • 依赖治理的四个动作,从审计到升级
  • 测试文件放哪儿、怎么命名、什么样的代码才可测
  • 按功能而不是按角色组织目录
  • 代码风格标准化的落地手段,从 ESLint 到 Git 钩子
  • 日志在生产环境该怎么打
  • RESTful API 的资源设计、状态码约定和安全实践

这份清单里有相当一部分条目写于 2018 年,有些已经不适用了。我会在对应位置注明,原始建议保留,方便你对照。

一、Git

1.1 一些Git规则

这里有一套规则要牢记。

在功能分支中执行开发工作

所有的工作都是在专用的分支而不是在主分支上隔离完成的。它允许你提交多个 pull request 而不会导致混乱,可以持续迭代提交,而不会使得那些很可能还不稳定而且还未完成的代码污染 master 分支。

develop 独立出分支

这样可以保持 master 分支中的代码稳定性,不会导致构建问题,并且几乎可以直接用于发布。

永远也不要把分支直接推送到 develop 或者 master,请使用合并请求(Pull Request)

通过这种方式,它可以通知整个团队某个功能的开发已经完成。这样开发伙伴就可以更容易对代码进行 code review,同时还可以互相讨论所提交的需求功能。

在推送所开发的功能并且发起合并请求前,请更新本地的 develop 分支并且完成交互式变基操作(interactive rebase)

rebase 操作会把被请求合并的分支(masterdevelop)的历史作为基底,并将你本地进行的提交应用于所有历史提交的最顶端,而不会去创建额外的合并提交(假设没有冲突的话),从而可以保持一个漂亮而干净的历史提交记录。

这条我一开始是反对的,因为 rebase 会改写提交历史,感觉不安全。用了一段时间之后改主意了。关键在于「只 rebase 还没被别人依赖的本地分支」,这个前提下它没有任何风险,换来的是 git log --oneline 出来是一条干净的直线,出问题二分定位快得多。真正危险的是 rebase 已经被别人 pull 下去的公共分支,那确实不能干。

请确保在变基并发起合并请求之前解决完潜在的冲突

合并分支后删除本地和远程功能分支

如果不删除需求分支,大量僵尸分支的存在会导致分支列表的混乱。而且该操作还能确保有且仅有一次合并到 masterdevelop。只有当这个功能还在开发中时,对应的功能分支才存在。

在进行合并请求之前,请确保功能分支可以成功构建,并已经通过了所有的测试(包括代码规则检查)

因为你即将把代码提交到这个稳定的分支。如果功能分支测试未通过,那目标分支的构建有很大的概率也会失败。此外,确保在进行合并请求之前应用代码规则检查,因为它有助于代码的可读性,并减少格式化的代码与实际业务代码更改混在一起导致的混乱问题。

这一条现在基本不用靠人自觉了,GitHub Actions 或者 GitLab CI 里配一个必须通过的 check,加上分支保护规则,构建不绿就点不了 merge 按钮。

使用一个合适的 .gitignore 文件

好的模板已经囊括了不应该和代码一起推送至远程仓库的系统文件列表。另外它还排除了大多数编辑器的设置文件夹和文件,以及最常见的依赖目录。

保护你的 develop 和 master 分支

这样可以保护生产分支免受意外情况和不可回退的变更。

具体到平台上,至少要开这几项:禁止直接 push、要求 PR 至少一人 approve、要求 CI 通过、禁止 force push。前两条防人为失误,后两条防灾难。

1.2 Git 工作流

基于以上原因,我们把功能分支工作流、交互式变基的使用方法,结合一些 Gitflow 中的基础(比如命名和使用一个 develop 分支)一起使用。主要步骤如下。

针对一个新项目,在项目目录初始化。如果是已有项目的后续功能开发,这一步请忽略。

cd <项目目录>
git init

检出(Checkout)一个新的功能或故障修复(feature/bug-fix)分支。

git checkout -b <分支名称>

新增代码变更。git commit -a 会独立启动一个编辑器用来编辑说明信息,这样的好处是可以专注于写这些注释说明。

git add
git commit -a

切换至功能分支,通过交互式变基从 develop 分支中获取最新的代码提交,以更新你的功能分支。可以使用 --autosquash 将所有提交压缩到单个提交,没有人会愿意看到 develop 分支中的单个功能开发就占据这么多提交历史。

git checkout <branchname>
git rebase -i --autosquash develop

如果没有冲突请跳过此步骤。如果有冲突,就需要解决它们并且继续变基操作。

git add <file1> <file2> ...
git rebase --continue

推送功能分支。变基操作会改变提交历史,所以必须用 -f 强制推送到远程功能分支。

git push -f

这里要单独提醒一句。如果其他人与你在该分支上进行协同开发,请使用破坏性没那么强的 --force-with-lease 参数。它会先检查远程分支的状态跟你上次 fetch 到的是否一致,不一致就拒绝推送,也就是说别人在你之后推过东西,你不会把他的提交直接抹掉。裸 -f 是不做这个检查的,覆盖了就找不回来。我的建议是把 --force-with-lease 设成别名,从此不再手打 -f

提交一个合并请求(Pull Request),由负责代码审查的同事接受、合并和关闭。

完成开发之后记得删除本地分支。

git branch -d <分支>

远程已经删掉的分支,本地还留着一堆引用,用这行一次清干净。

git fetch -p && for branch in `git branch -vv | grep ': gone]' | awk '{print $1}'`; do git branch -D $branch; done

1.3 如何写好 Commit Message

坚持遵循关于提交的标准指南,会让与他人合作使用 Git 时更容易。这里有一些经验法则。

用新的空行将标题和主体两者隔开

Git 会把提交消息的第一行识别为摘要。如果你用 git shortlog 而不是 git log,会看到一个很长的提交消息列表,只包含提交的 id 以及摘要,不会包含主体部分。

将标题行限制为 50 个字符,并将主体中一行超过 72 个字符的部分折行显示

提交应尽可能简洁明了,而不是写一堆冗余的描述。这两个数字不是随便定的,50 是为了在 GitHub 列表页和 git log --oneline 里不被截断,72 是留出 git log 缩进后仍能在 80 列终端里完整显示。

标题首字母大写

不要用句号结束标题

使用主体部分去解释「是什么」和「为什么」,而不是「怎么做」

怎么做的部分 diff 里已经写了,代码本身就是最准确的答案。真正会丢的信息是当时的判断,为什么选这个方案、放弃了哪个、有什么约束。半年后回来看,你需要的是这个。

顺着上面聊,这套「标题首字母大写、不加句号」的规则来自英文 git 社区的传统。现在国内团队更常用的是 Conventional Commits 那一套,feat: / fix: / docs: / refactor: / chore: 打头,后面跟一句中文描述。它的好处是机器可读,可以直接由工具生成 CHANGELOG 和推断语义化版本号。两套规范不冲突,Conventional Commits 只是给标题多加了一个类型前缀。

配合 commitlint 加 husky 在提交时校验格式,不合规直接拦下来,比在 code review 里提醒人改要省事得多。

二、文档

文档这块的条目看着最像废话,但它是新人上手成本的唯一决定因素。

  • 可以使用这个模板作为 README 的一个参考
  • 对于具有多个存储库的项目,请在各自的 README 文件中提供它们的链接
  • 随项目的进展,持续地更新 README
  • 给代码添加详细的注释,这样就可以清楚每个主要部分的含义
  • 不要把注释作为坏代码的借口,保持代码干净整洁
  • 也不要把那些清晰的代码作为不写注释的借口
  • 当代码更新,也请确保注释的同步更新

README 里我认为必须有的只有三块,怎么把它跑起来、目录结构里每个顶层文件夹装什么、部署走什么流程。其他的都可以后补,这三块缺了新人就得来问你。

注释这块补一条自己的判断。注释应该解释「为什么」,不是「在干什么」。// 循环遍历用户列表 这种注释是纯噪音,代码自己就写着 users.forEach。真正值钱的注释长这样,「这里必须用 setTimeout 延迟一帧,否则 Safari 上拿到的 offsetHeight 是 0」。这类信息不写下来,下一个人会把它当无用代码删掉,然后 bug 复现。

三、环境

如果需要,请分别定义 development、test 和 production 三个环境

不同的环境可能需要不同的数据、token、API、端口等。你可能需要一个隔离的 development 环境,它调用 mock 的 API,mock 会返回可预测的数据,使自动和手动测试变得更加容易。或者你可能只想在 production 环境中才启用 Google Analytics。

依据不同的环境变量加载部署的相关配置,不要将这些配置作为常量写进代码库

你会有令牌、密码和其他有价值的信息。这些配置应正确地从应用程序内部分离开来,这样代码库就可以随时独立发布,不会包含这些敏感配置信息。

具体做法是用 .env 文件来存储环境变量,并将其添加到 .gitignore 中排除掉不被提交。另外再提交一个 .env.example 作为开发人员的参考配置。对于生产环境,应该依旧以标准化的方式设置环境变量。

.env.example 这个文件很多人会省掉,别省。它的作用是「有哪些变量需要配」这份清单本身进版本库,而值不进。新人 clone 下来 cp .env.example .env 填值就能跑,不用来问你少了什么。

这里有个坑要注意,密钥一旦提交过就等于泄露了,改回来也没用,Git 历史里还在。真发生了只有一条路,去服务商那边把这个 key 撤销重签。我这个踩过,当时以为 git rm --cached 加一次提交就完事了,后来在别人 fork 的仓库里还能翻出来。

建议在应用程序启动之前校验一下环境变量

它可能会把其他人从几小时的故障排查中解救出来。少一个环境变量,程序不会当场崩,它会一路跑到某个深处的 fetch(undefined + '/api') 才报错,而那个报错跟根因看起来毫无关系。启动时校验的意义就是把这类问题从「运行时随机爆炸」变成「启动时明确告诉你少了 PORT」。

const joi = require('joi')

const envVarsSchema = joi.object({
NODE_ENV: joi.string()
.valid(['development', 'production', 'test', 'provision'])
.required(),
PORT: joi.number()
.required(),
LOGGER_LEVEL: joi.string()
.valid(['error', 'warn', 'info', 'verbose', 'debug', 'silly'])
.default('info'),
LOGGER_ENABLED: joi.boolean()
.truthy('TRUE')
.truthy('true')
.falsy('FALSE')
.falsy('false')
.default(true)
}).unknown()
.required()

const { error, value: envVars } = joi.validate(process.env, envVarsSchema)
if (error) {
throw new Error(`Config validation error: ${error.message}`)
}

const config = {
env: envVars.NODE_ENV,
isTest: envVars.NODE_ENV === 'test',
isDevelopment: envVars.NODE_ENV === 'development',
logger: {
level: envVars.LOGGER_LEVEL,
enabled: envVars.LOGGER_ENABLED
},
server: {
port: envVars.PORT
}
// ...
}

module.exports = config;

这段代码用的是 joi 来声明环境变量的形状,跑在应用入口的最前面,缺了必填项直接 throw 让进程起不来。思路今天依然成立,但抄这段之前要注意两件事。

一是 joi.validate(value, schema) 这个调用形式在后来的 joi 版本里被移除了,改成 schema.validate(value)。二是 .valid(['a', 'b']) 传数组的写法也变了,现在是展开参数 .valid('a', 'b')。照着这段抄进新项目会直接报错,看一眼当前版本文档再写。

现在做同一件事,前端项目里更常见的是 zod,写法更简洁而且能直接推导出 TypeScript 类型,校验和类型定义只写一遍。Node 服务里 envalid 也很常用,它专门就是干这个的。核心思想没变,都是「启动前把配置校验一遍,不合规就别启动」。

3.1 一致的开发环境

在 package.json 里的 engines 中设置 node 版本

让其他人可以清晰地知道这个项目用的什么 node 版本。注意 engines 默认只是警告,npm 里配合 .npmrc 里的 engine-strict=true 才会真的拦住安装。

另外,使用 nvm 并在项目根目录下创建一个 .nvmrc 文件,不要忘了在文档中标注

任何使用 nvm 的人都可以用 nvm use 来切换到合适的 node 版本。配上 shell 的自动切换钩子,cd 进目录就自动切好,基本不用想这件事。

最好设置一个检查 nodenpm 版本的 preinstall 脚本

某些依赖项可能会在新版本的 npm 中安装失败。这一条的价值在多人协作时特别明显,同一个 lock 文件被不同大版本的包管理器处理过,会产出不一样的依赖树,然后你们俩本地跑出来的结果不同。

如果可以的话最好使用 Docker 镜像

它可以在整个工作流程中为你提供一致的环境,而且不用花太多的时间来解决依赖或配置。这一条在 2018 年算建议,现在基本算标配了,尤其是有原生模块依赖(node-gyp 那一类)的项目,本地 macOS 编过的 .node 文件在 Linux 上根本用不了,不上容器迟早出事。

使用本地模块,而不是使用全局安装的模块

你不能指望同事在自己的全局环境都装了相应的模块,本地模块可以方便你分享工具链。全局安装还有个更麻烦的问题,版本对不上而且没有记录,别人复现不了你的构建结果。

3.2 依赖一致性

确保团队成员获得与你完全相同的依赖

因为你希望代码在任何开发环境中运行都能像预期的一样。原文当时给的方案是「在 npm@5 或者更高版本中使用 package-lock.json」,还专门写了两段来讨论「我们没有 npm@5」和「我不太喜欢 Yarn」怎么办:

  • 没有 npm@5 的话,可以使用 yarn,并确保在 README.md 中标注了使用 yarn。锁文件和 package.json 在每次依赖关系更新后应该具有相同的版本
  • 不喜欢 Yarn 的话,对于旧版本的 npm,在安装新的依赖关系时使用 --save --save-exact,并在发布之前创建 npm-shrinkwrap.json

顺带修个笔误,原文写的是 -save --save-exactsave 前面少了一个横线。

这一整段今天可以直接跳过了。npm 早就默认生成 package-lock.json,yarn 和 pnpm 各有自己的 lock 文件,npm-shrinkwrap.json 只在发布 CLI 工具时还有点用。现在这条规则可以简化成三句话:

  • 选一个包管理器,写进 README,全团队统一,别混用
  • lock 文件必须提交到仓库
  • CI 里用 npm ci / yarn install --frozen-lockfile / pnpm install --frozen-lockfile,让 lock 和 package.json 不一致时直接失败,而不是悄悄改 lock

最后那条是 2018 年这份清单里没有但今天最重要的一条。CI 里如果用的是普通 install,它会自己解析并改写 lock 文件,那 lock 就白锁了。

pnpm 现在还多给了一层保障,它用符号链接加内容寻址存储,默认不允许访问没有在 package.json 里声明的依赖,能把「幽灵依赖」这类问题在本地就暴露出来。

四、依赖

依赖治理可以拆成四个动作,审计、评估、更新、清理。

持续跟踪当前的可用依赖包,举个例子 npm ls --depth=0

查看这些软件包是否未使用或者与项目无关,用 depcheck

你可能会在代码中包含未使用的库,这会增大生产包的大小。请搜索出这些未使用的依赖关系并去掉它们。

depcheck 会同时报出两类问题,装了但没用的,和用了但没在 package.json 里声明的。第二类更危险,它现在能跑起来纯粹是因为某个别的包顺带把它装进了 node_modules,哪天那个包升级换了依赖,你的代码就凭空报模块找不到。

在使用依赖之前,请检查它的下载统计信息,看看是否被社区大量使用,用 npm-stat

更多的使用量很大程度上代表更多的贡献者,通常也代表更好的维护,这些能确保错误能够被快速地发现并修复。

在使用依赖之前,请检查它是否具有良好而成熟的版本发布频率与足够多的维护者,例如 npm view async

如果维护者没有足够快地合并修补程序,那么贡献者也会变得不积极。

我自己评估一个包会多看两项,npm view <pkg> 出来的最后发布时间,以及仓库里 issue 的回复情况。一个两年没发版、issue 堆了三百个没人回的包,下载量再高也不能进生产依赖。另外还要看它自己的依赖数量,一个功能很小但拖进来二十个传递依赖的包,等于给你的供应链凭空开了二十个口子。

如果需要使用那些不太熟悉的依赖包,请在使用之前与团队进行充分讨论

始终确保应用程序在最新版本的依赖包上能正常运行,用 npm outdated

依赖关系更新有时包含破坏性更改。当显示需要更新时,请始终先查看其发行说明。并逐一地更新依赖项,如果出现任何问题,可以让故障排除更容易。可以使用类似 npm-check-updates 的工具。

「逐一更新」这四个字是这段里最值钱的。一次升十个包然后测试全红,你根本不知道是哪个引起的,最后只能整体回退。一次升一个,出问题立刻知道原因。

这块现在有更省事的做法,配 Renovate 或者 Dependabot,让它自动开 PR,一个包一个 PR,CI 跑绿了直接 merge。安全漏洞用 npm audit 定期扫,不过它的误报不少,报出来的 high 有一半在你的使用场景下根本触发不了,别看到红就慌。

五、测试

如果需要,请构建一个 test 环境

虽然有时在 production 模式下端到端测试可能看起来已经足够了,但有一些例外。比如你可能不想在生产环境下启用数据分析功能,只用测试数据来污染某人的仪表板。另一个例子是,你的 API 可能在 production 中才有速率限制,并在请求达到一定量级后会阻止你的测试请求。

将测试文件放在使用 *.test.js*.spec.js 命名约定的测试模块,比如 moduleName.spec.js

你肯定不想进入一个层次很深的文件夹结构来查找里面的单元测试。测试文件贴着实现文件放,还有个额外好处,删功能的时候一眼看到旁边有个 .spec.js,会顺手一起删掉,不会留下一堆测着已经不存在的模块的僵尸测试。

将其他测试文件放入独立的测试文件夹中以避免混淆

一些测试文件与任何特定的文件实现没有特别的关系。把它放在最有可能被其他开发人员找到的文件夹里,也就是 __tests__ 文件夹。这个名字被大多数 JavaScript 测试框架所接受。

原文这里写的是 __test__,少了个 s。Jest 默认匹配的目录名是 __tests__(复数),写成单数不会被自动识别,得手动改 testMatch 配置。这个坑不算大但很浪费时间,因为测试跑了 0 个用例也是「通过」,你不盯着数量根本发现不了。

编写可测试代码,避免副作用,提取副作用,编写纯函数

你想要将业务逻辑拆分为单独的测试单元,必须尽量减少不可预测性和非确定性过程对代码可靠性的影响。

纯函数是一种总是为相同的输入返回相同输出的函数。不纯的函数可能会有副作用,或者取决于来自外部的条件来决定产生对应的输出值,这使得它不那么可预测。

这条是整节里最重要的一条,可惜也是最容易被当成口号跳过的。它的可操作版本是这样:把「算什么」和「做什么」分开。一个函数里既算了折扣又发了请求又改了 DOM,你要测它就得 mock 三样东西;把折扣计算抽成纯函数,测它只需要传参数看返回值。真正难测的代码,问题从来不在测试写不出来,而在代码本身把太多东西搅在了一起。

使用静态类型检查器

有时你可能需要一个静态类型检查器,它为代码带来一定程度的可靠性。

原文当时的语境里,静态类型检查主要指 Flow(后面代码风格那节还专门提了 FlowType 的 ESLint 规则)。这块变化最大,现在 JS 生态的静态类型基本上就是 TypeScript 一家,Flow 只在少数 Meta 系的老项目里还能见到。新项目要选,没什么犹豫空间。

先在本地 develop 分支运行测试,待测试通过后再发起 pull request

你不想成为一个导致生产分支构建失败的人吧。在 rebase 之后运行测试,然后再把改动的功能分支推送到远程仓库。

这一条现在建议直接交给工具,用 husky 加 lint-staged 在 pre-push 钩子里跑一遍相关测试,而不是靠自觉。

记录你的测试,包括在 README 文件中的相关说明部分

这是为其他开发者、DevOps、QA 或者其他能和你一起协作的人留下的便捷笔记。至少要写清楚三件事,怎么跑全量测试、怎么只跑单个文件、怎么看覆盖率报告。

六、结构布局与命名

请围绕产品功能、页面、组件来组织文件,而不是围绕角色。此外,请把测试文件放在它们对应实现的旁边

  • 不规范
.
├── controllers
| ├── product.js
| └── user.js
├── models
| ├── product.js
| └── user.js
  • 规范
.
├── product
| ├── index.js
| ├── product.js
| └── product.test.js
├── user
| ├── index.js
| ├── user.js
| └── user.test.js

比起一个冗长的列表文件,创建一个单一职责封装的小模块,并在其中包括测试文件,会更容易浏览,也更一目了然。

这两种结构的差别在改需求的时候才体现出来。改「用户」这个功能,按角色分的目录要你在 controllersmodelsservicestests 四个文件夹里来回跳;按功能分的只要打开 user/ 这一个文件夹,要改的东西全在里面。项目小的时候两者差别不大,文件一多,第一种结构会让每个文件夹都变成一个几十项的长列表。

将其他测试文件放在单独的测试文件夹中以避免混淆

这样可以节约团队中其他开发人员或 DevOps 的时间。

使用 ./config 文件夹,不要为不同的环境制作不同的配置文件

当你为不同的目的(数据库、API 等)分解不同的配置文件时,把它们放在一个容易识别的文件夹(如 config)里才是有意义的。请记住不要为不同的环境制作不同的配置文件,这不是具有扩展性的做法,会导致随着更多部署环境被创建出来,新的环境名称也不断被创建,非常混乱。配置文件中使用的值应通过环境变量提供。

这条我特别认同。一旦开了 config.dev.js / config.test.js / config.staging.js 这个头,接下来一定会出现 config.staging2.jsconfig.prod.hotfix.js,然后没人说得清线上到底读的哪个。正确的形态是配置结构只有一份,值全部来自环境变量。

将脚本文件放在 ./scripts 文件夹中,包括 bash 脚本和 node 脚本

很可能最终会出现很多脚本文件,比如生产构建、开发构建、数据库填充、数据库同步等。

将构建输出结果放在 ./build 文件夹中,把 build/ 添加到 .gitignore 中忽略此文件夹

命名成你最喜欢的就行,dist 看起来也蛮酷的,但请确保与团队保持一致。放在该文件夹下的东西应该是已经生成(打包、编译、转换)或者被移到这里的。你产生什么编译结果,你的队友也可以生成同样的结果,所以没有必要把这些结果提交到远程仓库中,除非你故意希望提交上去。

文件名和目录名请使用 PascalCase 或 camelCase 风格,组件请使用 PascalCase 风格

CheckBox/index.js 应该代表 CheckBox 组件,也可以写成 CheckBox.js,但是不能写成冗长的 CheckBox/CheckBox.jscheckbox/CheckBox.js

理想情况下,目录名称应该和 index.js 的默认导出名称相匹配

这样你就可以通过简单地导入其父文件夹直接使用预期的组件或模块。

命名风格这块要补一个 2018 年之后才凸显出来的问题。macOS 和 Windows 的默认文件系统对大小写不敏感,Linux 敏感。所以你本地把 CheckBox.js 改名成 Checkbox.js,git 可能根本不认为有变化,推上去 CI 在 Linux 上构建就报模块找不到。我排查过一次这个,本地怎么都复现不出来。

正因为这个坑,现在不少团队(包括 Next.js 的 App Router 约定)对文件名一律用 kebab-case 小写,只有组件的导出名用 PascalCase。这样文件名里根本没有大写字母,大小写敏感问题从源头消失。原文这条建议本身没错,只是要知道它有这个前提。

七、代码风格

7.1 若干个代码风格指导

对新项目请使用 Stage2 和更高版本的 JavaScript 语法。对于老项目,保持与老的语法一致,除非你打算把老项目也一起现代化

这完全取决于你的选择。我们使用转译器来使用新的语法糖。Stage2 更有可能最终成为规范的一部分,而且只需经过小版本的迭代就会成为规范。

这条要更新一下。Babel 后来把 @babel/preset-stage-x 这类按提案阶段打包的预设整个移除了,理由是它让开发者在不了解提案风险的情况下批量启用了一堆可能被推翻的语法。现在的做法是用 @babel/preset-env 按目标浏览器自动决定转译范围,需要某个还没定稿的提案就单独装那一个插件。

顺带说,当年被当作「Stage 2 新语法」讨论的那些东西,比如类的私有字段、可选链、空值合并,现在都早已进入正式标准并被浏览器原生支持了。今天这条规则的实际含义变成了「按目标环境定 browserslist,其余交给工具」。

在构建过程中包含代码风格检查

在构建时中断下一步操作是一种强制执行代码风格检查的方法,能强制你认真对待代码。请确保在客户端和服务器端代码都执行代码检查。

使用 ESLint 去强制执行代码检查

ESLint 支持大量规则、可配置,也能添加自定义规则。

针对 JavaScript 可以使用 Airbnb JavaScript Style Guide,请依据你的项目和团队选择使用所需的代码风格

当使用 FlowType 的时候,可以使用 ESLint 的 Flow 样式检查规则

Flow 引入了很少的语法,而这些语法仍然需要遵循代码风格并进行检查。前面说过,这块现在基本被 TypeScript 取代了,对应的是 typescript-eslint

使用 .eslintignore 将某些文件或文件夹从代码风格检查中排除

当你需要从风格检查中排除几个文件时,就不需要用 eslint-disable 注释来污染代码了。

ESLint 后来换了配置体系,扁平配置(eslint.config.js)里已经没有 .eslintignore 这个文件了,忽略规则改成在配置对象里写 ignores 字段。功能是一样的,只是位置变了。老项目的 .eslintrc.eslintignore 那套还能用,但迁移是趋势。

在 Pull Request 之前,请删除任何 eslint 的禁用注释

在处理某段代码时临时禁用风格检查是正常现象,这样可以先专注业务逻辑。请记住把那些 eslint-disable 注释删除并遵循风格规则。

这条我的态度稍微软一点。有些 eslint-disable 是合理且应该保留的,前提是必须写上原因,// eslint-disable-next-line no-console -- 这里是启动日志,需要输出到 stdout。真正要禁止的是不写原因的裸 disable,那个跟没有一样,下一个人不敢删也不知道为什么在。ESLint 也提供了 reportUnusedDisableDirectives 来把已经不需要的 disable 注释报出来。

根据任务的大小使用 //TODO: 注释或者建一个工单

这样你就可以提醒自己和他人有这样一个小任务需要处理(如重构一个函数或更新一个注释)。对于较大的任务,可以使用由 lint 规则(no-warning-comments)强制要求完成的 //TODO(#3456),其中 #3456 是工单号,方便查找且防止相似的注释堆积导致混乱。

带工单号这个细节很关键。不带号的 TODO 会在代码库里活到项目结束,我见过 2016 年的 TODO 还在。带了号至少有个地方能查它到底还要不要做。

随着代码的变化,始终保持注释的相关性,删除那些注释掉的代码块

代码应该尽可能可读,你应该摆脱任何分散注意力的东西。如果你在重构一个函数,就不要注释那些旧代码,直接删掉。

被注释掉的代码是版本控制发明之前的产物。有 git 在,删掉的东西随时能翻回来,留在文件里只会让每个读到它的人多花三十秒判断「这是不是还要用」。

避免不相关和搞笑的注释、日志或命名

请使用有意义、容易搜索的命名,避免缩写。函数使用长描述性命名,命名应该是一个动词或动词短语,能清楚传达意图

「容易搜索」这个标准比「简洁」重要得多。getUserByIdgetUsr 长,但半年后你要找所有按 id 取用户的地方,全局搜前者一次就干净了。缩写最大的问题不是难懂,是搜不到,usruseru 三种写法混在一个库里,你永远搜不全。

依据《代码整洁之道》的 step-down 规则组织源文件中的函数声明。高抽象级别的函数在上,低抽象级别函数在下

这样阅读代码时遇到还没出现的函数,仍然是从上往下的顺序,不会被打断去往前翻,函数的抽象层次也依次递减。

这条在 JS 里能成立,是因为 function 声明有提升,写在下面也能被上面调用。如果你用的是 const fn = () => {},就没有提升,被调用的必须写在前面,step-down 顺序反而实现不了。这是个很少有人提但实际会遇到的约束。

7.2 强制的代码风格标准

让编辑器提示你代码风格方面的错误,把 eslint-plugin-prettiereslint-config-prettier 和你目前的 ESLint 配置搭配使用

这两个包的分工要分清,混用是常见错误来源。eslint-config-prettier 是把 ESLint 里所有和格式化相关的规则关掉,避免两个工具互相打架,这个是必须的。eslint-plugin-prettier 是把 Prettier 当成一条 ESLint 规则来跑,让格式问题以 ESLint 报错的形式出现。

后者现在 Prettier 官方自己不太推荐了,理由是把格式化塞进 lint 会让 ESLint 明显变慢,而且格式问题以「错误」形式呈现也不太合适。更清爽的做法是两者分开跑,Prettier 只管格式(prettier --check),ESLint 只管代码质量,中间用 eslint-config-prettier 划清边界。

考虑使用 Git 钩子

Git 钩子能大幅提升开发者的生产力。在做出改变、提交、推送的过程中充分检验代码,就不用担心推上去的代码会导致构建失败。

将 Git 的 pre-commit 钩子与 Prettier 结合使用

虽然 Prettier 自身已经非常强大,但每次把它作为一个独立的 npm 任务去格式化整个项目并不高效。这正是 lint-staged(还有 husky)可以解决的地方。

lint-staged 的关键价值在「staged」这个词上,它只处理你这次真正改动的文件。全量跑 Prettier 的项目里,一次提交可能顺手把二十个不相干的文件格式化了,diff 一下变成几千行,review 的人根本没法看。

有一点要提醒,Git 钩子是可以被 --no-verify 跳过的,所以它只是提升体验的手段,不是保证。真正的关卡必须放在 CI 上,本地钩子负责「让你少提交错误」,CI 负责「错误绝对进不了主干」。

八、日志

避免在生产环境中使用客户端的控制台日志

你可以在构建过程中把它们去掉,但请确保代码风格检查里对控制台日志有警告提示。

原因不只是「不专业」。console.log 打出去的对象在浏览器里是被持有引用的,DevTools 一直开着的话这些对象没法被回收;日志里还很容易带出 token、手机号这类不该出现在用户控制台里的东西。构建时去掉可以用 Terser 的 drop_console,或者按环境包一层自己的 logger。

产出生产环境的可读日志记录,使用专门的日志库(比如 winston 或者 node-bunyan)

它通过添加着色、时间戳,输出到控制台或者文件,甚至是按天轮转日志文件,来减少故障排查时那些令人不愉快的事情。

现在这块的主流选择变了一些,pino 用得很多,因为它输出的是结构化 JSON 而且开销极低。结构化这一点在有日志采集系统的场景下差别很大,JSON 日志可以直接被检索和聚合,纯文本日志只能靠正则去捞。

补一条原文没写但很重要的,日志要带请求上下文。一个请求在服务里跑过五个函数,五条日志分散在几万行里,没有一个共同的 requestId 你根本没法把它们串起来。做法是在请求入口生成一个 id,通过 AsyncLocalStorage 或者显式传参贯穿整条链路。这个在排查线上问题时的效果,比日志打得多要有用得多。

九、API

9.1 API 设计

我们试图开发出结构稳健的 RESTful 接口,让团队成员和客户可以简单而一致地使用它们。缺乏一致性和简单性会大大增加集成和维护的成本,这就是 API 设计这部分会包含在这份文档中的原因。

我们主要遵循资源导向的设计方式,它有三个主要要素,资源、集合和 URL

  • 资源具有数据、嵌套和一些操作方法
  • 一组资源称为一个集合
  • URL 标识资源或集合的线上位置

这是针对开发人员(你的主要 API 使用者)非常著名的设计方式。除了可读性和易用性之外,它还允许我们在无需了解 API 细节的情况下编写通用库和一些连接器。

命名上的约定:

  • 使用 kebab-case(短横线分割)的 URL
  • 在查询字符串或资源字段中使用 camelCase 模式
  • 在 URL 中使用多个 kebab-case 作为资源名称
  • 总是使用复数名词来命名指向一个集合的 url,比如 /users

这样可读性会更好,并可以保持 URL 的一致性。为什么 URL 用短横线而查询参数用驼峰?因为 URL 路径在很多场景下会被大小写归一化处理,而查询参数最终要变成 JS 对象的键,驼峰能直接解构。这个分界不是审美问题,是两边各自贴合自己的下游。

在源代码中,把复数形式转换为带列表后缀的变量和属性

复数形式的 URL 非常好,但在源代码里使用复数变量名却很容易出错。userusers 差一个字母,看错一次就是一个 bug,写成 userList 就区分得开了。

坚持这样一个概念,始终以集合名起始并以标识符结束

/students/245743
/airports/kjfk

避免这样的网址

GET /blogs/:blogId/posts/:postId/summary

这不是在指向资源,而是在指向属性。你完全可以把属性作为参数传递,以减少响应体积。

URL 里面请尽量少用动词

如果你为每个资源操作使用一个动词,很快就会维护一个很大的 URL 列表,而且没有一致的使用模式,这会让使用者难以学习。此外,动词还有别的用途。

为非资源型请求使用动词。这种情况下 API 并不需要返回任何资源,而是执行一个操作并返回执行结果,这些不属于 CRUD 操作

/translate?text=Hallo

因为对于 CRUD 我们在资源或集合 URL 上使用 HTTP 自带的方法。这里说的动词实际上是指 Controller 型接口,通常不会开发太多。

实践里这类接口比想象的多,/orders/123/cancel/exports/run/sessions/refresh 都是。硬把它们套成 RESTful 反而更别扭,比如把「取消订单」写成 PATCH /orders/123 传一个 status: cancelled,调用方看不出这个状态变更有什么副作用。我的判断是,纯粹的数据增删改查走 REST,带业务流程和副作用的操作老老实实用动词,写清楚就行。

请求体或响应类型如果是 JSON,请遵循 camelCase 规范命名 JSON 属性来保持一致性

这是一份 JavaScript 项目指南,生成和解析 JSON 的语言都假定为 JavaScript。如果后端是 Python 或 Go,团队里习惯 snake_case,那就在一处统一转换,别让两种风格在同一个响应里混着出现。

如何使用 HTTP 方法来操作 CRUD 功能

方法 语义
GET 查询资源的表示
POST 创建新的资源或者子资源
PUT 整体替换一个已存在的资源
PATCH 局部更新现有资源,只更新提供的字段
DELETE 删除一个已存在的资源

PUTPATCH 的差别常被忽略。PUT 语义上是整体替换,你没传的字段应该被清空;PATCH 才是「只改我传的」。很多后端把 PUT 实现成了局部更新,能跑,但对接的人按语义写代码就会踩坑。

还有一点,PUTDELETE 应该是幂等的,同一个请求发十次和发一次结果一样。这在前端加重试逻辑的时候非常关键,POST 重试可能创建出十条数据,PUT 重试是安全的。

对于嵌套资源,请在 URL 中把它们的关系表现出来。例如用 id 把员工与公司联系起来

这是一种自然的方式,方便对资源的认知。

  • GET /schools/2/students 应该从学校 2 得到所有学生的名单
  • GET /schools/2/students/31 应该得到学生 31 的详细信息,且此学生属于学校 2
  • DELETE /schools/2/students/31 应删除属于学校 2 的学生 31
  • PUT /schools/2/students/31 应该更新学生 31 的信息,仅在资源 URL 上使用 PUT,不要用在集合上
  • POST /schools 应该创建一所新学校,并返回创建的新学校的细节,在集合 URL 上使用 POST

嵌套别超过两层。/a/1/b/2/c/3 这种 URL 写起来累读起来更累,而且一旦资源关系调整,所有路径都要改。超过两层的关系用查询参数表达更灵活,/students?schoolId=2&classId=3

版本使用带 v 前缀的简单序数(v1、v2),并把它放在 URL 的左侧,使其具有最高的作用范围

http://api.domain.com/v1/schools/3/students    

当你的 API 对第三方公开时,升级 API 会产生一些意料之外的影响,也可能导致使用方的服务不可用。在 URL 中做版本化可以防止这种情况。

也有另一派做法是把版本放在请求头里(Accept: application/vnd.api+json;version=1),理论上更符合 REST 的内容协商思想。我个人还是偏 URL 里带版本,理由很土,它能直接在浏览器地址栏里打开调试,也能被 CDN 按路径缓存。纯内部接口的话,版本号往往可以省掉,改动直接和前端一起发。

响应消息必须是自我描述的。一个好的错误响应可能长这样

{
"code": 1234,
"message" : "Something bad happened",
"description" : "More details"
}

或验证错误:

{
"code" : 2314,
"message" : "Validation Failed",
"errors" : [
{
"code" : 1233,
"field" : "email",
"message" : "Invalid email"
},
{
"code" : 1234,
"field" : "password",
"message" : "No password provided"
}
]
}

开发人员在排查线上问题的关键时刻,一定会用到这些精心设计的错误消息。好的错误消息设计能节约大量的排查时间。

这里有个安全上的注意点,尽可能保持安全异常消息的通用性。例如别说「密码不正确」,换成「用户名或密码无效」,以免不知不觉地告诉攻击者这个用户名确实存在、只是密码不对。这在防撞库时是必要的。

那个 code 字段是自定义业务码,跟 HTTP 状态码不是一回事,两者要分层。HTTP 状态码表达「这次通信在协议层面发生了什么」,业务码表达「业务上为什么不行」。我见过把所有错误都返回 200 然后靠 body 里的 code 区分的接口,坏处是监控系统看不到任何异常,错误率永远是 0,出事了没人报警。

只使用这 8 个状态码,并配合自定义的响应描述来表达程序是否正常、客户端错了什么或者 API 出了什么问题

  • 200 OK GET、PUT 或 POST 请求响应成功
  • 201 Created 标识一个新实例创建成功。当创建一个新的实例,请使用 POST 方法并返回 201 状态码
  • 304 Not Modified 表示资源未变更,客户端可以直接使用缓存的副本
  • 400 Bad Request 请求未被处理,因为服务器不能理解客户端要什么
  • 401 Unauthorized 请求缺少有效的凭据,应该使用所需的凭据重新发起请求
  • 403 Forbidden 服务器理解本次请求,但拒绝授权
  • 404 Not Found 表示未找到请求的资源
  • 500 Internal Server Error 请求本身是有效的,但由于某些意外情况服务器无法完成,服务器发生了故障

大多数 API 提供方只使用一小部分 HTTP 状态码。例如 Google GData API 仅使用了 10 个,Netflix 使用了 9 个,Digg 只使用了 8 个。HTTP 状态码总共有超过 70 个,大多数开发者不可能全部记住。如果你选了不常用的状态码,使用方还得跑去查文档才知道你想说什么。

304 那条原文写的是「浏览器会自动减少请求次数」,说得有点含糊。准确的行为是,客户端带着 If-None-MatchIf-Modified-Since 发请求,服务端发现资源没变就回一个 304 且不带响应体,客户端继续用本地缓存。请求次数并没有减少,减少的是传输的数据量。

这份清单我会补三个,它们在实践中出现的频率很高:

  • 204 No Content 操作成功但没有内容要返回,DELETE 用它比 200 加空 body 干净
  • 422 Unprocessable Content 请求格式没问题但业务校验没通过。用它跟「JSON 都解析不了」的 400 区分开,前端能据此决定是提示字段错误还是提示系统异常
  • 429 Too Many Requests 触发限流。配合 Retry-After 响应头告诉客户端多久之后再来

401403 也经常被混用,记法很简单,401 是「你是谁我不知道」,403 是「我知道你是谁,但你没这个权限」。前者前端应该跳登录页,后者跳登录页只会让用户困惑。

在响应中提供资源的总数

接受 limit 和 offset 参数

limit/offset 这种偏移分页在数据量大或者数据在翻页过程中变动时会有问题,同一条记录可能在第 2 页和第 3 页各出现一次,或者被完全跳过。数据量大的列表建议用游标分页(基于上一页最后一条的 id 或时间戳往后取),代价是不能随机跳页。

还应考虑资源暴露的数据量。API 消费者并不总是需要资源的完整表述,可以用一个字段查询参数,值是逗号分隔的字段列表

GET /student?fields=id,name,age,class

分页、过滤和排序功能并不需要一开始就在所有资源上支持。记录下哪些资源提供了过滤和排序

字段裁剪这个能力要留意实现成本。它会让后端的 SQL、缓存键、权限校验都变得复杂,而且很容易被用来探测不该暴露的字段。小项目里我倾向于直接定义两三个固定的视图(简版、详版),比开放任意字段组合可控得多。

9.2 API 安全

这些是一些基本的安全实践。

除非通过安全的连接(HTTPS),否则不要使用基本认证。不要在 URL 中传输验证令牌,比如 GET /users/123?token=asdf...

因为令牌、用户 ID 和密码通过网络是明文传递的(basic auth 只是 base64 编码,而 base64 是可逆的),所以基本认证方案本身是不安全的。

放在 URL 里更糟糕,除了明文的问题,URL 还会被记进 Nginx 的 access log、浏览器历史、Referer 头以及各种监控系统。也就是说这个 token 会在你完全没预料到的地方留下副本。

必须使用授权请求头在每个请求上发送令牌,比如 Authorization: Bearer xxxxxx

授权码应该是短暂的

短期 access token 加长期 refresh token 是现在的常见组合。access token 有效期短,泄露了影响窗口也小;refresh token 存 HttpOnly Cookie,前端 JS 拿不到,XSS 也偷不走。

拒绝任何非 TLS 请求,避免不安全的数据交换

原文这条建议是「不响应任何 HTTP 请求」或者「对 HTTP 请求返回 403 Forbidden」。这个做法今天不推荐了,现在的标准做法是对 HTTP 请求返回 301 永久重定向到对应的 HTTPS 地址,同时在 HTTPS 响应上带 Strict-Transport-Security 头(HSTS)。

差别在于,直接拒绝只是挡住了这一次请求,浏览器下次还是会先发一次 HTTP;HSTS 会让浏览器在有效期内自动把该域名的所有请求改成 HTTPS,连第一次那个可被中间人劫持的 HTTP 请求都不会发出去。

考虑使用速率限制

保护 API 免受每小时数千次的机器人扫描。你应该在早期就考虑实施流控。

限流的维度要想清楚,按 IP 限会误伤同一个出口 NAT 后面的整栋写字楼,按用户限对未登录接口无效。实际做法通常是分层,未登录接口按 IP 加验证码,登录接口按用户 id,敏感操作(发短信、改密码)单独设更严的额度。

适当地设置 HTTP 响应头可以帮助锁定和保护 Web 应用程序

具体来说至少要配 Strict-Transport-SecurityX-Content-Type-Options: nosniffContent-Security-PolicyReferrer-Policy。Node 服务用 helmet 中间件可以一次性把这套默认值配上。

API 应把收到的数据转换为规范形式,或者直接拒绝并返回 400,并在响应里包含关于错误或缺失数据的详细信息

所有通过 REST API 交换的数据必须由 API 来校验

这条是整节里最重要的,也最容易被前端和后端互相推诿。前端的校验是为了体验,让用户少来回一趟;后端的校验是为了正确性和安全,因为请求可以被任意伪造。两者都要有,且后端那份不能省。

序列化 JSON

JSON 编码器的一个关键问题是阻止任意可执行代码在浏览器或服务器中执行。你必须使用合适的 JSON 序列化程序对用户输入的数据进行正确编码,以防止用户提供的、可能包含恶意代码的输入在浏览器上被执行。

验证 Content-Type,主要接受 application/json

例如接受 application/x-www-form-urlencoded 这个 MIME 类型,会允许攻击者构造一个表单并触发一次简单的 POST 请求。服务器不应该假定 Content-Type,缺少或异常的 Content-Type 请求头,应该让服务器直接以 4XX 拒绝请求。

这条防的其实是 CSRF。浏览器发跨站的表单 POST 不需要预检请求,而发 Content-Type: application/json 的跨站请求会先触发 CORS 预检,被同源策略挡住。所以「只接受 JSON」本身就是一层 CSRF 防护,虽然不能替代 token 校验。

9.3 API 文档

  • 在 README.md 模板里为 API 填写 API Reference 段落
  • 尽量使用示例代码来描述 API 的授权方式
  • 解释 URL 的结构(仅 path,不包括根 URL),包括请求方法

对于每个端点(endpoint)都要说明清楚以下几点。

如果存在 URL 参数就写出来,并按 URL 中实际使用的名称来标注

Required: id=[integer]
Optional: photo_id=[alphanumeric]

如果请求方法是 POST,请提供使用示例。上面的 URL 参数规则同样适用,区分可选和必需

成功响应对应什么状态码、返回了哪些数据。使用方需要知道回调里拿到的数据长什么样

Code: 200
Content: { id : 12 }

错误响应。大多数端点都有很多失败的可能,从未授权访问到参数错误。所有情况都应该列在这里,虽然可能会重复,但它能省下别人猜的时间。例如

{
"code": 403,
"message" : "Authentication failed",
"description" : "Invalid username or password"
}

使用 API 设计工具,有很多开源工具可用于生成良好的文档

手写文档的问题是它一定会过期,没有例外。所以现在更值得投入的方向是让文档从代码生成,OpenAPI(Swagger)规范加上从代码注解或类型定义生成 schema,再由 schema 生成文档和客户端 SDK。这样接口改了文档跟着改,对不上的话 CI 直接失败。

我自己的感受是,能自动生成的文档才有人看,因为大家都知道它是准的。手写的那份第一次不准之后就再也没人相信了。

十、证书

确保你有权使用这些资源。如果你使用了其中的软件库,请记住先查一下它是 MIT、Apache 还是 BSD,以便了解自己能拥有哪些权限。如果你打算修改它们,请仔细查看许可证细节。图像和视频的版权更容易导致法律问题。

这块最容易出事的是两类。一类是 GPL 系的传染性许可,你的商业项目引了它,理论上整个项目都要开源。另一类是图标和字体,网上随手下的图标包很多是「个人非商用免费」,上了商业产品就是侵权。现在 CI 里可以加一道 license 检查(license-checker 这类工具),把不在白名单里的许可证直接报出来,比事后被找上门要好。

总结

这份清单最有价值的部分不是那些条目本身,而是每条后面的「为什么」。规范如果没人说得清理由,它就会在第一次赶工时被丢掉。

Git 那一节的核心是「主干永远可发布」。分支保护、PR 必审、CI 必绿这三件事配齐,主干就基本坏不了,剩下的分支命名、rebase 习惯都是效率问题而不是安全问题。

环境和依赖这两节讲的是同一件事,可复现。配置结构进版本库而值不进、lock 文件必须提交、CI 用 frozen 模式安装,这三条做到了,「我本地是好的」这句话才有意义。

测试那节里真正能落地的只有一条,把「算什么」和「做什么」分开。可测试性是设计的结果,不是测试写得好不好的问题。

代码风格标准化的手段有优先级,编辑器提示(体验最好)优于 Git 钩子(可被绕过)优于 CI 拦截(最后防线)。三层都要有,但别指望靠人自觉。

API 设计里最容易做错的两处,一是把所有错误都返回 200 靠 body 区分,这会让监控彻底失效;二是只在前端做校验,后端信任客户端传来的一切。

最后说过时的那部分。这份清单写于 2018 年,npm@5 的兼容讨论、Babel 的 stage 预设、FlowType、.eslintignore 这几处今天已经换了做法。原始条目我都保留了,因为你在维护老项目时还会遇到它们,知道当年为什么这么定,才知道现在该怎么改。

参考