最近评审一个前端PR的时候,我被一个现象搞得挺崩溃:同事明明只改了登录接口的一个请求参数,结果提交上来,diff里显示500多行全部红绿交替——整个文件从头到尾每一行都算“改动”。点开详情对比,代码逻辑一个字没变,唯一的差别就是每行结尾多了一个不可见的^M字符。
在Windows下做前端开发,这个问题早晚会遇到:你保存文件时用的是CRLF行尾,而仓库里其他人都是LF,Git按字节对比,每一行都不同,自然就“全文件重写”了。我后来花了整整一天,把Prettier的行尾配置、Git的attributes机制、编辑器的默认行为全部捋了一遍,才彻底把这个坑填平。这篇文章就把完整方案写出来,希望能帮你少走弯路。
1. 那个让我失眠的“500行diff”到底怎么来的
1.1 一行真实改动为什么变成了全文件重写
先还原一下事故现场。某个开发者环境是Windows,他的编译器/编辑器保存文件时默认用了CRLF(回车+换行,两个控制字符)。改完代码,他只动了第30行,但保存的时候整个文件所有行的行尾都被写成了CRLF。
Git在对比文件差异的时候,是逐字节对比的。if (a) {后面是\r\n还是\n,在Git眼里就是两个不同的字符串。所以哪怕逻辑完全一样,只要行尾不同,Git就判定“这一行被修改了”。一两行还好,整个前端项目的文件动辄几百上千行,一下子diff全炸。
这种diff的危害不只是评审难看,它还会污染git blame的历史记录,以后你想查某一行是谁在什么时候写的,会看到一堆无意义的全文件改动记录。更麻烦的是,如果这种CRLF文件被提交进仓库,接下来每个人拉代码都会受到影响。
1.2 CRLF和LF是字节问题,不是“打字习惯”问题
我发现很多前端开发者对行尾符的理解停留在“Windows和Linux换行不一样”这种模糊概念上,这里说清楚一点:
- CR是
回车,ASCII码13,用转义符表示是\r,作用是让光标回到行首。 - LF是
换行,ASCII码10,用转义符表示是\n,作用是让光标下移一行。
老式打印机和电传打字机时代,换行确实需要两个动作:先回车再换行。所以Windows沿用这套逻辑,文本文件里每行结尾都是\r\n两个字节。Unix/Linux设计的时候就简化了,一个\n就代表换行,后来macOS也统一成LF。
所以一个文件如果每行都以CRLF结尾,和LF版本相比,每行会多出1个字节。一个5000行的文件,CRLF版本多出约5000字节。更重要的是,当你的构建系统、Node.js脚本、Shell脚本跑在Linux容器或者CI机器上时,很多场景对CRLF非常不友好,轻则是脚本执行报一个奇怪的错,重则是整个构建流程直接挂掉。
1.3 别指望Git的autocrlf自动帮你摆平一切
很多团队遇到这个问题,第一反应是让所有人配置core.autocrlf。
这个配置有三个值:
| 值 | 提交时 | 检出时 | 适用场景 |
|---|---|---|---|
| true | CRLF转LF | LF转CRLF | Windows单平台项目 |
| input | CRLF转LF | 不转换 | Linux/macOS |
| false | 不转换 | 不转换 | 仓库里存什么就是什么 |
看着好像把Windows开发者的core.autocrlf设为true就行了,但问题在于:这个配置是每个开发者本地自己设置的,你根本管不住别人。有人设了true,有人设了false,还有人压根不知道这个配置,仓库里就会混进各种行尾。最危险的是false,Windows下提交什么就是什么,CRLF直接进仓库,之后全队遭殃。
即使全队都设成true,在Windows上检出时LF还是会先转成CRLF,这意味着Prettier在本地看到的文件还是CRLF。如果Prettier配置没写死,它可能按CRLF重新格式化,又产生一次全文件改动。所以,正确的方式不能靠个人自觉,必须在仓库层面做硬性约束。
2. Prettier的endOfLine:为什么auto是最危险的选择
2.1 endOfLine的四档选项分别是什么行为
Prettier是行尾统一的主力工具,它有一个专门的配置项endOfLine。很多项目出问题,往往就出在这个选项上。
| 选项值 | 行为 | 建议 |
|---|---|---|
| "lf" | 格式化后统一使用LF | 推荐,前端项目首选 |
| "crlf" | 统一使用CRLF | 除非有特殊要求,否则别选 |
| "cr" | 统一使用CR | 老Mac时代的产物,基本可忽略 |
| "auto" | 以文件现有行尾为准 | 协作场景下不要用 |
“auto”看起来人畜无害,好像很智能,会“尊重文件的现状”。但它的真实实现逻辑是:看这个文件第一个换行符是什么,然后整个文件统一成这个风格。如果某个文件已经被Windows编辑器保存成CRLF,Prettier会认为“现状就是CRLF”,然后帮你把整个文件都固定成CRLF,全程不报错、不提示,非常安静,所以也最容易被忽视。
我见过好几个项目,.prettierrc里写着"endOfLine": "auto",写这个配置的人本意是“不要乱动别人的行尾格式”,结果这个配置恰恰是持续产出CRLF文件的元凶。
2.2 Prettier 2.0换默认值的坑:老配置害人最深
还有一个历史遗留问题值得单独说:Prettier 2.0之前,endOfLine默认值就是auto,2.0之后默认值改成了lf。如果你们项目是从Prettier 1.x时代一路升上来的,老版本的.prettierrc里很可能遗留了显式的"endOfLine": "auto"。
配置文件里的显式值优先级高于默认值,所以哪怕你升级到了3.x,写着auto的旧配置依然会在每次格式化时把CRLF文件保留下来。我建议所有从旧版本升级来的项目,都去检查一下.prettierrc,看到auto就改成lf,或者直接删掉这个字段走默认值也行。
2.3 一份可以直接复制的.prettierrc配置
这里给一份我目前前端项目里实际在用的配置,你可以直接抄走:
{ "endOfLine": "lf", "tabWidth": 2, "semi": true, "singleQuote": true, "trailingComma": "all", "printWidth": 100, "arrowParens": "always" }关键就一句话:"endOfLine": "lf"必须显式写出来,不要靠运气。
除了格式化,我还建议你在CI里加一步校验:
npx prettier --check .prettier --check和--write不一样,它只检查不修改,发现有文件不符合配置就直接让命令以非零状态退出,CI就会失败。这一步放到流水线里,相当于给行尾原则上了个保险,谁提交了CRLF文件,流水线就会立刻告诉他“你犯规了”。
3. 解决另一半问题:.gitattributes让Git别再折腾行尾
3.1 text=auto和eol=lf的角色分工
Prettier只能管“格式化过的文件”,但如果有人漏了格式化,或者某一天某个编辑器直接保存了CRLF文件,CRLF还是可能进仓库。要堵住这个漏洞,必须在Git层面加约束,这就是.gitattributes存在的意义。
先搞清楚Git在背后做了什么。你执行git add的时候,Git有一个“clean”流程,把工作区的内容转换成仓库内存储的格式;执行git checkout的时候,又有一个“smudge”流程,把仓库内格式再转换回工作区格式。行尾转换就是在这两个环节里发生的。
Git判断行尾规则的优先级是:.gitattributes中的规则 > 本地core.autocrlf> 本地core.eol。也就是说,只要你在仓库里写清楚规则,就能覆盖掉所有开发者本地的“个人习惯”。
那.gitattributes里两个重要属性是什么分工?
text=auto:让Git自动判断文件是文本还是二进制。文本文件在提交进仓库时,行尾会被规范化为LF;二进制文件则完全不动。eol=lf:强制指定文件在工作区里的行尾风格是LF。
关键点来了:如果只写*.js text=auto,Windows开发者在检出文件时,因为有core.autocrlf=true,本地看到的文件仍然可能被转成CRLF。要彻底锁定,必须加上eol=lf,写成*.js text eol=lf,这样不管开发者本地是什么配置,工作区里都是LF。
3.2 一套适合前端项目的.gitattributes写法
下面这套配置我基本是直接复用的,你可以根据项目情况增删文件类型:
* text=auto *.js text eol=lf *.jsx text eol=lf *.ts text eol=lf *.tsx text eol=lf *.css text eol=lf *.scss text eol=lf *.less text eol=lf *.html text eol=lf *.json text eol=lf *.md text eol=lf *.yml text eol=lf *.yaml text eol=lf *.sh text eol=lf *.bat text eol=crlf *.cmd text eol=crlf最后两行是故意保留crlf的,因为Windows批处理脚本用CRLF最稳妥,这和前端主代码用LF并不冲突。另外,*.png、*.jpg这类二进制文件不需要专门写排除,默认的text=auto会自动识别;万一有二进制文件被误判成文本,可以单独加一行*.png binary强制排除。
3.3 配置之后的老文件怎么处理
很多人以为加了.gitattributes,仓库里已有的CRLF文件就会自动变干净——不会。Git只会在下一次git add写对象库时应用新规则,已经存在的老文件不会自动重生。
这时需要手动触发一次重新规范化:
git add --renormalize .这个命令在Git 2.16以上可用,作用是让所有已跟踪文件按当前的.gitattributes规则重新走一遍“clean”流程。执行完之后,git status会显示一大批文件发生变化,这正是我们想要的,说明存量CRLF正在被一次性清洗成LF。
如果你的Git版本太老,没有--renormalize,可以用一个更暴力的方案:
git rm --cached -r . git add .这个方案的缺点是会清空暂存区再重新加入,操作更折腾,我建议能用--renormalize就尽量用。
实际操作的时候,我建议分两步提交:先把.gitattributes本身提交一次,再执行git add --renormalize .提交第二次。这样代码评审的时候,第一次提交是“新增规则”,第二次提交是“按规则清洗存量文件”,逻辑清晰,review也轻松。
4. 编辑器和Lint同步站队:VS Code与ESLint别拖后腿
4.1 VS Code的“自动恢复CRLF”陷阱
很多人在Windows上装了VS Code,打开文件会发现右下角显示“CRLF”,然后手动点一下切成“LF”。问题是,下次再打开另一个文件,又变成CRLF了,每次都要手动改,非常烦人。
VS Code有一个配置项files.eol,它控制“新建文件”时默认使用的行尾序列。Windows上如果不设置,默认就是CRLF。所以强烈建议在项目根目录建一个.vscode/settings.json,写清楚:
{ "files.eol": "\n", "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } }把.vscode目录一起提交进Git,这样全队打开项目,默认行为就是统一的。
但这里要提醒一个坑:files.eol只管“新文件”,不会自动转换已经以CRLF打开的旧文件。如果某个同事的窗口里已经打开了一个CRLF文件,哪怕你配置改了,他按Ctrl+S保存时,文件大概率还是以CRLF写回去。最省事的方式是保存时自动触发Prettier格式化,Prettier会按endOfLine: lf把整个文件重写成LF。这也是为什么editor.formatOnSave和Prettier插件的组合,比单纯设置files.eol更可靠。
4.2 ESLint的linebreak-style怎么和Prettier配合
ESLint也可以做一道检查,规则叫linebreak-style,专门检查行尾是否统一。配置方法:
{ "rules": { "linebreak-style": ["error", "unix"] } }["error", "unix"]要求文件必须使用LF,如果有CRLF直接报错;["error", "windows"]则反过来要求CRLF。
不过我的建议是:先完成全仓行尾统一之后,再开这条规则。如果你在CRLF存量文件还没清理干净的时候开,整个仓库会飘满红点,新成员一进来就被劝退了。等git add --renormalize和prettier --write做完,再开这个规则,它就是一道很好的防线。
4.3 从编辑到提交,完整工具链一览
把前面所有工具串起来,一个行尾完全可控的前端工程应该是这样的:
.prettierrc:显式endOfLine: "lf",格式化时统一行尾。.editorconfig:IDE层面的补充(可选)。Prettier会自动读取.editorconfig中的end_of_line等配置,但.prettierrc的优先级更高,所以真正的标准仍以.prettierrc为准。.gitattributes:仓库层面硬性约束,提交和检出都强制LF。.vscode/settings.json:让编辑器默认行为向LF靠拢。- ESLint的
linebreak-style:提交前拦截CRLF文件。 - CI里跑
npx prettier --check .:最后一道兜底。
这里要说清楚,.editorconfig不是必须的,它需要编辑器装对应插件才生效,而且Prettier读它的时候也只是把它当配置来源之一。我一般建议团队最核心的依赖是.prettierrc+.gitattributes两件套,其他都是锦上添花。
5. 存量项目全量改造实录:一次搞定不返工
5.1 改造前先查现状:git ls-files --eol怎么看
接手一个老前端项目时,第一步永远是摸清现状。Git提供一个命令能看到每个文件的行尾状态:
git ls-files --eol | head -n 20输出大概长这样:
i/lf w/lf attr/text eol=lf src/index.js i/crlf w/crlf attr/ src/legacy.js i/lf w/crlf attr/text eol=lf src/mixed.js每一行有三列信息:
| 列 | 含义 |
|---|---|
| i/ | 索引(暂存区)里的行尾 |
| w/ | 工作区(本地磁盘)里的行尾 |
| attr/ | .gitattributes规则对该文件的要求 |
看到i/crlf w/crlf,说明这个文件的CRLF已经进到Git索引里了,存量污染,需要清洗。看到i/lf w/crlf,说明仓库里虽然存的是LF,但Windows开发者的工作区被autocrlf转成了CRLF,这种情况虽然不影响仓库内规范,但会影响Prettier在本地看到的内容,也需要处理。
5.2 三步完成全仓行尾统一
有了现状,接下来按顺序操作:
第一步:添加.gitattributes并单独提交。
git add .gitattributes git commit -m "chore: add gitattributes for line ending normalization"第二步:重新规范化所有已跟踪文件。
git add --renormalize . git commit -m "chore: normalize line endings to LF"这一步产生的diff会很大,请做好心理准备,但这正说明存量CRLF被清理了。如果项目里有些文件改动特别大,建议你以为便于review,按目录分批提交,比如git add --renormalize src、git add --renormalize tests,拆成几个提交。
第三步:用Prettier批量格式化兜底。
npx prettier --write .这一步会把所有Prettier支持的文件都按.prettierrc重写一遍,顺带把工作区里残留的行尾修正为LF。这一步只针对代码文件,比git add --renormalize覆盖面更广一点,因为它会统一缩进、引号、分号等格式。如果你们项目之前格式没这么规整,运行完会改很多文件,建议也要单独提交。
如果你不想让Prettier改动代码格式,只想纯换行尾,可以用命令行工具手动替换。Git Bash环境里用sed:
find src -type f \( -name "*.js" -o -name "*.ts" -o -name "*.json" \) ! -path "*/node_modules/*" -exec sed -i 's/\r$//' {} +Windows PowerShell环境里可以这样:
Get-ChildItem -Path src -Recurse -Include *.js,*.ts,*.json | ForEach-Object { $text = [System.IO.File]::ReadAllText($_.FullName) if ($text.Contains("`r`n")) { [System.IO.File]::WriteAllText($_.FullName, $text -replace "`r`n", "`n") } }这两个命令都只改行尾,不动代码格式。改完以后用git status确认变更范围,再正常提交。
5.3 改造后怎样防止卷土重来
改造完成只是开始,防止复发才是重点。
最有效的一招是在CI里加入格式检查。以GitHub Actions之类的流水线为例,加一个job:
- name: Check Prettier run: npx prettier --check .每次PR跑一遍,只要有CRLF文件混进来,流水线就会红。
本地也可以加一道防护。如果项目用了husky + lint-staged,可以在lint-staged配置里加prettier --check,只检查暂存区的文件,速度很快,不会拖慢提交。
{ "lint-staged": { "*.{js,ts,jsx,tsx,json,css,md}": [ "prettier --check" ] } }这么一套下来,新成员再怎么折腾,只要走到提交这一步就会被卡住。
6. 事后复盘:这些坑,项目组第二周又踩了一遍
6.1 只配工具不重开窗口,改了等于白改
这是我们改造完之后翻过的一个车。全仓清洗干净、规则也加好了,第二天又有同事提交了一个CRLF文件,而且只有他那个文件“顽固”地变回了CRLF。
排查下来发现,他本地VS Code窗口里一直开着旧文件。虽然git pull之后磁盘上的文件已经被新规则换成LF了,但他的编辑器还停留在修改前的内存状态,没有重新读取文件内容。结果他改了两行代码,一键保存,整个文件又按CRLF写回去了。
所以,行尾改造的当天,一定要通知所有成员:先关闭所有项目窗口,再重新打开仓库。这个不起眼的细节,能省下一整周的扯皮。
6.2 别忽视.sh脚本和CI里的意外报错
前端项目里多少都会有几个Shell脚本:部署脚本、build脚本、hook脚本。如果它们被保存成CRLF,在Linux的CI或服务器上执行,经常会报一个莫名其妙的错:
$'\r': command not found原因就是CRLF里的\r被Shell当成命令的一部分了。这个问题在网上被问烂了,而根源往往就是Windows编辑器默认行尾。.gitattributes里显式写*.sh text eol=lf,目的就是为了保这一手。
如果你的公司CI仍然偶尔出现这个问题,还有一个非常粗暴但有效的处理,就是流水线里clone完仓库之后先执行一句:
git config core.autocrlf false这能保证流水线上所有文件按仓库内实际存储的形式落地,不受任何本地CRLF规则影响。
6.3 三分钟自查清单
写到这里,把我现在验收前端工程时的检查清单也分享出来,你可以直接当成工具用:
- 检查
.prettierrc里是否显式写了"endOfLine": "lf",有没有残留"auto"。 - 检查仓库根目录有没有
.gitattributes,里面有没有覆盖.js/.ts/.json/.sh等关键类型的eol=lf。 - 检查
.vscode/settings.json是否提交到仓库,files.eol是否为"\n"。 - 运行
git ls-files --eol | grep 'w/crlf',确认工作区没有CRLF残留。 - 运行
npx prettier --check .,确认所有文件符合格式规范。 - 检查CI流水线里有没有
prettier --check之类的步骤。
我现在接手一个前端工程,第一件事就是看这几样东西齐不齐。如果缺了,我会在开工第一周内把它补齐,绝对不会等到“500行diff”出现再救火。行尾这个东西,平时看不见摸不着,一旦出问题,它报复起来比谁都狠。