用 Oxfmt 统一 TypeScript 项目的代码格式

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
OxfmtTypeScript代码格式化工程化

我以前对代码格式化工具没有太强的感觉。编辑器保存时整理一下,ESLint 再管一部分,代码照样能写。

真正让我觉得该认真处理这件事,是一个 TypeScript API 项目开始堆积路由、Schema 和 OpenAPI 描述以后。为了少占几行,我会顺手把多个对象和回调挤在一起。改的时候还看得懂,隔几天再回来,一段路由里哪里是验证器、哪里是中间件、响应结构在哪结束,已经要靠括号计数。

我想要的其实很像 gofmt:不讨论每一处该怎么换行,给项目一个命令,让结果稳定下来。

Oxfmt 官方文档把它定位为独立格式化器,不负责替代 TypeScript、ESLint 或测试。第一次跑完以后,最直接的变化不是“代码更漂亮”,而是我不用再手工维护排版了。

先把命令放进项目

Oxfmt Quickstart列出了 npm、pnpm、Yarn 和 Bun 的安装方式。使用 npm 的项目可以将它安装为开发依赖:

npm add -D oxfmt

然后在各自的 package.json 里增加两个命令:

{
  "scripts": {
    "format": "oxfmt .",
    "format:check": "oxfmt --check ."
  }
}

npm run format 会直接改写文件,npm run format:check 只检查,不写入。后者适合 CI,也适合提交前确认没有漏掉刚改的文件。

Bun 本身没有对应的源码格式化命令,但可以按照 bunx 文档运行 npm CLI。临时试用时可以这样执行:

bunx --bun oxfmt --check .

正式项目还是应该把 Oxfmt 放进 devDependencies 并提交 lockfile。否则不同时间下载到不同版本,格式化结果发生变化时很难追查。

配置范围不要顺手变成风格迁移

如果仓库里的代码原本就使用同一种风格,在根目录保留一份配置最简单。问题通常出现在已有项目:不同 package 可能来自不同阶段,分号、尾随逗号和 TSX 的使用情况并不一致。

这时直接增加根配置,第一次格式化就可能夹带大范围风格迁移。原本只想统一缩进和换行,结果 diff 里全是分号与逗号,真正的代码改动反而难以审查。

Oxfmt 配置说明指出,它会从待格式化文件所在目录向上查找配置,离文件最近的配置优先。可以先判断哪些目录确实共享同一套规则:风格一致就共用根配置,差异已经存在则在对应目录放置更近的配置。配置文件的位置应该反映规则的适用范围,而不是机械地追求全仓库只有一份。

Oxfmt 配置项参考列出了可用字段。下面是一份适合 TSX 项目的配置:

{
  "$schema": "./node_modules/oxfmt/configuration_schema.json",
  "printWidth": 140,
  "tabWidth": 2,
  "useTabs": false,
  "semi": false,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "trailingComma": "none",
  "endOfLine": "lf",
  "insertFinalNewline": true,
  "sortImports": false,
  "sortPackageJson": false,
  "ignorePatterns": [
    "dist/**",
    ".next/**",
    ".open-next/**",
    ".wrangler/**",
    "node_modules/**",
    "src/generated/**"
  ]
}

如果项目原本使用分号,只需把 semi 改为 true;如果原本保留尾随逗号,再把 trailingComma 改成 all。格式化工具的任务是终止排版争论,不是趁机重写所有团队习惯。

我也关闭了 sortImportssortPackageJson。排序可能有用,但它已经超出单纯的空白与换行调整。尤其是副作用 import,顺序变化有机会改变运行结果。刚接入时先让格式化器只做格式化,审查范围会清楚很多。

singleQuote 不会控制 JSX 属性

这是我第一次配置时马上踩到的坑。

我已经写了:

"singleQuote": true

普通 TypeScript 字符串和 import 确实变成了单引号,但 TSX 里的属性还是双引号:

<View className="page-placeholder">

原因是 JSX 属性由另一个选项控制。要统一成单引号,还要显式设置:

"jsxSingleQuote": true

再次格式化后才会得到:

<View className='page-placeholder'>

JSON 不在这个规则里。JSON 语法要求键名和字符串使用双引号,.oxfmtrc.json 自己也不可能改成单引号。

生成文件不要跟着格式化

第一次在服务端目录执行 oxfmt . 时,源码变得清楚了,Drizzle 的迁移快照也一起变了。数组被压成单行,数据完全相同,diff 却多出了几百行。

这种变化没有维护价值。更麻烦的是,下次由生成器重建快照时,它可能再按自己的格式写回来。两个工具轮流改同一份生成文件,提交记录里会一直出现噪声。

我后来把生成目录当成接入格式化器时必须检查的一项:

{
  "ignorePatterns": [
    "dist/**",
    "deploy_versions/**",
    "src/generated/**",
    "drizzle/meta/**",
    ".next/**",
    ".open-next/**",
    ".wrangler/**",
    "next-env.d.ts",
    "cloudflare-env.d.ts",
    "tsconfig.tsbuildinfo"
  ]
}

具体列表要跟着项目走。常见的例子包括 dist、框架构建目录、RPC 生成的类型声明和数据库迁移工具生成的快照。它们的来源不同,但处理原则一样:源头不在这里,就不要让格式化器接管它。

可以先用下面的命令查看哪些文件会发生变化:

npx oxfmt --list-different .

先看列表,再运行全量格式化,比格式化以后面对一大片陌生 diff 省事。

Windows 换行符要和 Git 一起处理

我原本希望 Oxfmt 不碰现有换行符,后来发现它没有 autopreserveendOfLine 只能明确选择 lfcrlfcr

只改这一个选项还不够。在 Windows 上,编辑器、Oxfmt 和 Git 都可能接触换行符。它们各自的配置单看都说得通,放到一起却可能互相改写。

我最后统一成了 LF,并把约束分别写进 EditorConfig、Oxfmt 和 Git。EditorConfig 先这样设置:

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2

end_of_line 约束支持 EditorConfig 的编辑器,insert_final_newline 则要和 Oxfmt 的同名行为保持一致。如果这里写成 false,Oxfmt 却配置成 true,保存文件和运行格式化就可能轮流改动文件末尾。

Oxfmt 仍然显式固定这两个值:

{
  "endOfLine": "lf",
  "insertFinalNewline": true
}

Oxfmt 会读取 EditorConfig,但 .oxfmtrc.json 中已经写明的选项优先。两边保持一致不是为了让 Oxfmt 能运行,而是避免编辑器或其他工具写出另一种结果。

Git 负责的是仓库中的规范化和检出结果。按照 Git 的 .gitattributes 文档,可以在根目录增加:

* text=auto eol=lf

*.bat text eol=crlf
*.cmd text eol=crlf

eol=lf 表示这些文本文件在索引中以 LF 规范化,Git 下次检出或重写工作区文件时也使用 LF。Windows 批处理文件单独保留 CRLF。

我还在当前仓库关闭了自动转换:

git config --local core.autocrlf false

这里的 --local 很重要。Git 配置分为 system、global 和 local 等层级,仓库级配置会覆盖前两级。因此,看到全局仍然是 core.autocrlf=true,不等于当前仓库还在使用它。可以直接查看配置来自哪里:

git config --show-origin --show-scope --get-all core.autocrlf
git config --get core.autocrlf

第一条会列出各层配置,第二条返回当前仓库最终采用的值。

Git 的换行警告在说什么

加入 .gitattributes 后,第一次执行 Git 命令仍可能看到这样的提示:

warning: in the working copy of '.editorconfig', CRLF will be replaced by LF the next time Git touches it

这不是提交失败,也不表示文件已经损坏。它只是在说明:工作区里的这个文件目前是 CRLF,但该路径的 Git 属性要求 LF;Git 下次处理它时会按规则转换。

一次性出现这种提示很正常。真正的问题是它反复出现,例如编辑器每次保存都写回 CRLF,Oxfmt 每次运行又改成 LF。遇到这种情况,不要继续猜 core.autocrlf,直接检查具体文件:

git check-attr text eol -- .editorconfig
git ls-files --eol -- .editorconfig

git check-attr 显示这个路径实际命中了哪些属性。git ls-files --eol 会同时显示索引和工作区的换行状态,例如:

i/lf w/crlf attr/text=auto eol=lf .editorconfig

这里的 i/lf 是索引中的状态,w/crlf 是工作区中的状态。两者不同并不一定会让 git status 报告修改,因为 Git 比较文本文件时会先按照属性规范化内容。这也解释了一个看起来很奇怪的现象:工作区显示干净,文件的实际换行符却还没有变成 LF。

第一次统一已有文件

如果仓库以前混用了换行符,先提交业务改动,或者把它们另行妥善保存,再在干净工作区执行一次规范化:

git add --renormalize .
git status --short
git diff --cached --stat
git diff --cached --check

如果 git status 一下出现很多文件,先别急着还原,也不要只凭文件数量判断发生了大规模代码修改。查看暂存区的统计和差异,确认它们是否只是换行规范化。

git diff --cached

这一步会更新暂存区,所以不要在一堆尚未提交的业务改动中间执行。换行规范化最好单独审查、单独提交。处理完以后,EditorConfig 负责编辑器写入,Oxfmt 负责格式化结果,.gitattributes 负责 Git 中的文本策略,各自的边界就清楚了。

格式化应该排在检查之前

接入完成后,我把项目规则写成了固定顺序:

npm run format
npm run check
npm run test

先格式化再检查有一个很实际的好处:检查通过以后,工作区就是准备提交的最终代码,不会发生“测试刚通过,又运行格式化改了一遍文件”的情况。

format:check 仍然值得保留。它适合 CI 或只读审查:

npm run format:check

Oxfmt 不会证明代码正确。TypeScript 仍然负责类型,ESLint 仍然检查代码规则,测试仍然验证行为。它只是把排版从这些工作里拿走,让每个工具做自己的事。

第一次格式化要单独看

在一个已有项目里首次执行 Oxfmt,diff 很大是正常的。我遇到的一次全量格式化处理了 50 个文件,用时不到一秒;真正花时间的是审查这些差异。

我会先确认生成文件已经排除,再看 git diff --stat 和几个变化最大的文件:

git diff --stat
git diff --check
npm run check
npm run test

如果格式化和功能修改混在同一批改动里,git diff -w 可以暂时帮助观察非空白变化,但它不能代替正常审查。更稳妥的做法仍然是先完成一次纯格式化提交,以后的功能提交自然会安静下来。

这也是 Oxfmt 最让我满意的地方。工具本身安装只要一条命令,麻烦的是第一次把边界理顺:哪些风格要保留,哪些文件不归它管,编辑器和 Git 又该听谁的。处理完这些,日常使用确实只剩 npm run format