记事本 BOM 导致网页白屏完全排查指南:批量清除脚本与 5 种破坏现场
PHP 文件被记事本偷偷加上 EF BB BF 三字节 BOM,引发 headers already sent、JSON 解析失败、Excel 导出空白行、nginx -t 启动失败等 5 种典型故障。本文给出生产级 BOM 检测剥离脚本(含黑名单、原子写、并发锁),以及 sed/PowerShell 一行替代方案、各编辑器无 BOM 设置和 Git pre-commit 兜底。
用 Windows 自带记事本(notepad.exe)改 PHP/HTML/JS 模板文件,是 PHP 圈最常见的"白屏门"诱因之一——不是代码错,是文件最前面被记事本静默加了三个字节 EF BB BF(UTF-8 BOM 的字节签名)。这三个字节在浏览器里看不到、在 IDE 里看不到、在大多数文本工具里看不到,但 PHP 解析器把它们当成"在 <?php 标签之前的输出"来处理,于是 session_start() / setcookie() / header() 这类必须在任何输出之前调用的函数全部触发 "Cannot modify header information - headers already sent" 报错;如果 display_errors 关着,访客看到的就是一片白屏。
原文给的递归 BOM 清除脚本能解决基础场景,但放到生产环境里要修四处隐患——它会把 vendor 目录里的第三方包也改掉、扫大型站点时会 OOM、不区分 UTF-8 和其他单字节编码(极端场景下会把真 GBK 文件的相似首字节误判为 BOM)、没有事务保护,进程中断会留下截断的半截文件。本文先把 BOM 的"前世今生"和它在 PHP/HTML/JSON/AJAX/Excel 等不同输出位置的具体破坏方式讲清,再给出一份生产可用的 BOM 检测 + 剥除脚本,并讨论从 Notepad++、VSCode、Sublime 到 Git pre-commit hook、CI/CD 上的全套防御措施。
BOM 是什么、为什么 Windows 记事本要加它
BOM(Byte Order Mark,字节序标记)是 Unicode 标准里的一个早期妥协。当时 UTF-16 是主流的 Unicode 实现方式,每个字符 2 字节,那么 0x4E 0x2D 这两个字节到底是 0x4E2D 还是 0x2D4E 取决于平台是 big-endian 还是 little-endian。Unicode 联盟为了让文件能跨平台传输,规定可以在文件最开头放一个特殊字符 U+FEFF,叫"零宽不换行空格",作为字节序的指示——0xFE 0xFF 表示 big-endian、0xFF 0xFE 表示 little-endian。这个机制对 UTF-16 是有意义的。
但 UTF-8 是单字节流编码,没有字节序问题,BOM 在 UTF-8 里完全是冗余的——三个字节 EF BB BF 仅仅是 U+FEFF 在 UTF-8 下的编码,它什么"信息"都没新增。Unicode 标准 §23.8 也明确说"UTF-8 不需要 BOM,但允许其存在作为编码标识"。问题是 Windows 的工具链(记事本、Outlook、PowerShell 默认输出)一直把 UTF-8 BOM 当成"我是 UTF-8 文件"的标识硬塞——因为 Windows 的早期文本处理引擎不擅长根据内容自动判定编码,加个三字节签名能让它一眼看出这是 UTF-8 而不是 GBK/Shift-JIS。这是历史遗留下来的"防御式"工程决策,被业界讨论了 20 年。
转折点在 2019 年 5 月 Windows 10 v1903 之后:微软在记事本里加了"UTF-8(无 BOM)"选项,并把这个设为默认。但用户量巨大、有相当多的人还在跑旧版本 Windows,且系统更新后老文件已经带 BOM,所以"批量清 BOM"的需求会持续若干年。
BOM 在不同位置的 5 种破坏方式
原文只提到了"网页错位、验证码出错、白屏"三种现象,实际上 BOM 能搞坏的地方比这多得多。保哥按"被 BOM 干扰的输出层"分成 5 种:
PHP 文件首行有 BOM —— headers already sent
这是最经典的一种。PHP 解析器看到 <?php 之前的任何字节都视为"原样输出到响应体",BOM 三字节就是"输出",于是头部修改函数全部失效。报错典型样子:
Warning: Cannot modify header information - headers already sent by
(output started at /www/wwwroot/zhangwenbao.com/index.php:1)
in /www/wwwroot/zhangwenbao.com/admin/login.php on line 23注意"output started at index.php:1"那一句,BOM 触发的报错总是指向行号 1。如果你看到 :1 的报错且第一行明明是空行或 <?php,立刻怀疑 BOM。
HTML 文件首行有 BOM —— DOCTYPE 失效进入 Quirks 模式
HTML5 规范允许 BOM,但是某些老浏览器(IE9 以下)在解析时认为 BOM 是字符内容,把 DOCTYPE 推到了"非首行",于是 IE 进入 Quirks 模式渲染——盒模型变成 IE5 风格,宽度计算包含 padding,整个布局错位。这是 PC 站时代很多前端遇到的"为什么 IE 上面有一道空白条且布局错乱"问题的真因。Edge / Chrome / Firefox 已经能正确处理 BOM + DOCTYPE,但如果业务还要兼容 IE 时代的特殊渲染(极少数 ToB 内网系统),仍然要清。
JSON 响应里有 BOM —— JS 解析抛 unexpected token
这是 AJAX 时代被踩最多的坑。后端某个生成 JSON 的 PHP 文件首行带 BOM,HTTP 响应体的真实字节是 EF BB BF 7B 22 6B 65 79 22 ...(即 BOM + JSON 起始的 {"key"),JSON.parse(xhr.responseText) 立刻抛 SyntaxError: Unexpected token in JSON at position 0。jQuery.parseJSON、fetch().json() 全部失败。前端调试这种问题的痛苦在于:用浏览器 DevTools 看响应体,BOM 不会显示出来,看起来就是合法的 {"key":"value"}。要看到 BOM 必须在 Network 面板里切到 Response Headers 旁边的"Hex"或者用 curl --raw 抓原始字节。
SQL 转储 / .htaccess / nginx.conf 等配置文件 —— 加载失败
这种最隐蔽。Nginx 解析 .conf 文件时如果首行是 BOM,nginx -t 直接报 unexpected "" in /etc/nginx/conf.d/site.conf:1。.htaccess 同理,Apache 不识别 BOM 当成第一条指令。SQL 文件里 BOM 倒不会让 mysql 客户端报错,但 phpMyAdmin 导入时会把 BOM 当成第一个 SQL 语句的一部分,触发 syntax error。
Excel / CSV 反过来需要 BOM —— 没 BOM 反而乱码
这是反方向的坑。Microsoft Excel 打开 UTF-8 编码的 CSV 时,如果文件没有 BOM,Excel 会用系统 ANSI(中文 Windows 上是 GBK)解码,结果中文全部乱码。要让 Excel 正确显示,恰好要在 CSV 文件首部加上 BOM。所以"全站清 BOM"的脚本要小心:导出给 Excel 的 CSV 文件不能清,否则用户用 Excel 打开就乱码。这是个反直觉的细节,原文那段一刀切的脚本会把它误伤。
原文 BOM 清除脚本的 6 处生产隐患
逐行读原文那段 PHP,看出这些问题:
隐患 1:递归扫整个目录,一定会扫到 vendor/.git/node_modules
原文 checkdir() 是无差别递归。在用 Composer 的 PHP 项目里这意味着 vendor 目录下数千个第三方包文件全部被读、被改。问题是某些库(比如 PHPMailer、Symfony 的某些组件)会校验自己文件的 hash,BOM 改写后 hash 变化,require 时报警告甚至失败。Git 仓库的 .git/objects 目录则更敏感,乱改里面的 pack 文件会让 git 仓库损坏。生产脚本必须有黑名单:
$skipDirs = ['.git', '.svn', 'vendor', 'node_modules', '.idea', '.vscode', 'usr', 'bin', 'sbin'];隐患 2:file_get_contents 一次性读全文件,大文件 OOM
有人会用这个脚本清日志文件、SQL 转储或上传图片目录里的"伪文本文件"。50MB 的 SQL 文件 file_get_contents 直接吃 50MB 内存,PHP 默认 memory_limit 128MB 还能撑,但站点里有几个这样的文件加上并发执行,OOM 是分分钟的事。正确做法是只读前 3 字节:
$fp = fopen($filename, 'rb');
$head = fread($fp, 3);
fclose($fp);
if ($head === "\xEF\xBB\xBF") {
// 真有 BOM,再开一次流式重写
...
}隐患 3:不区分文件类型,二进制文件也读
站点目录里通常有大量图片、视频、字体、PDF。其中 PDF 文件首字节是 0x25 0x50 0x44 0x46(%PDF),不会误判为 BOM;JPEG 是 0xFF 0xD8 0xFF;PNG 是 0x89 0x50 0x4E 0x47——都不会误判。但还是不必要地读了整个文件,浪费 IO。正确做法是按扩展名白名单:
$textExts = ['php','html','htm','js','css','xml','json','yml','yaml','md','txt','htaccess','conf','sql','tpl'];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($ext, $textExts)) continue;隐患 4:rewrite 不是原子操作,进程被 kill 会留半截文件
原文 rewrite() 函数:
$filenum = fopen ( $filename, "w" );
flock ( $filenum, LOCK_EX );
fwrite ( $filenum, $data );
fclose ( $filenum );fopen("w") 会立刻把文件截断为 0 字节,然后开始写。如果在 fwrite 期间进程被 OOM killer 干掉、服务器断电、用户中途按 Ctrl+C,文件会停留在"被截断、写了一半"的状态,对应的网页直接挂掉。生产脚本必须用临时文件 + rename 的原子写:
$tmp = $filename . '.bom_rewrite.tmp';
file_put_contents($tmp, $data, LOCK_EX);
rename($tmp, $filename); // POSIX rename 是原子操作rename 在同一文件系统下是原子的,要么旧文件存在、要么新文件存在,不存在"半截"状态。
隐患 5:没有 dry-run 模式,没有日志
原文 $auto = 1 写死自动剥。生产环境里第一次跑应当是 dry-run,列出"会改哪些文件"让人确认,再实际跑。脚本里加一个 $auto = 0 时只输出而不写的开关是基础,但应该再有 --json-log 选项把所有改动写进日志便于回滚。
隐患 6:没有锁,并发执行会写竞态
用户在浏览器开了脚本之后,多次按 F5 刷新,脚本会被 PHP-FPM 起多个进程同时跑。其中一个进程读到 BOM、刚开始 rewrite,另一个进程已经开始读同一个文件、读到了被 truncate 后的空内容、判定"无 BOM"、不做任何处理——但实际上文件已经被另一个进程写到一半。最终文件可能完整也可能损坏,看运气。生产脚本必须在脚本入口拿一把全局文件锁:
$lockFp = fopen('/tmp/bom_clean.lock', 'w');
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
die("Another instance is running.");
}
// 工作 ...
flock($lockFp, LOCK_UN);生产可用的 BOM 检测 + 剥除脚本
把上面 6 个隐患全修掉的版本:
<?php
/**
* BOM 批量检测/清除工具
* Usage: php bom_clean.php [--dry-run] [--root=/path] [--ext=php,html,js]
*/
$opts = getopt('', ['dry-run', 'root::', 'ext::']);
$dryRun = isset($opts['dry-run']);
$root = $opts['root'] ?? __DIR__;
$exts = explode(',', $opts['ext'] ?? 'php,html,htm,js,css,xml,json,yml,yaml,md,txt,htaccess,conf,sql,tpl');
$skipDirs = ['.git', '.svn', 'vendor', 'node_modules', '.idea', '.vscode', 'storage', 'cache'];
// 全局锁
$lockFp = fopen('/tmp/bom_clean.lock', 'w');
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "Another instance is running.\n");
exit(2);
}
$found = $cleaned = 0;
$log = [];
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
function ($current) use ($skipDirs) {
if ($current->isDir() && in_array($current->getFilename(), $skipDirs, true)) {
return false;
}
return true;
}
)
);
foreach ($it as $file) {
if (!$file->isFile()) continue;
$ext = strtolower($file->getExtension());
if (!in_array($ext, $exts, true)) continue;
$fp = fopen($file->getPathname(), 'rb');
if (!$fp) continue;
$head = fread($fp, 3);
fclose($fp);
if ($head !== "\xEF\xBB\xBF") continue;
$found++;
$log[] = $file->getPathname();
if ($dryRun) continue;
// 流式读 + 跳前 3 字节 + 原子写
$tmp = $file->getPathname() . '.bom_rewrite.tmp';
$in = fopen($file->getPathname(), 'rb');
$out = fopen($tmp, 'wb');
fseek($in, 3);
while (!feof($in)) {
fwrite($out, fread($in, 8192));
}
fclose($in);
fclose($out);
rename($tmp, $file->getPathname());
$cleaned++;
}
flock($lockFp, LOCK_UN);
fclose($lockFp);
echo "Found BOM in {$found} files.\n";
echo $dryRun ? "Dry-run: no changes.\n" : "Cleaned {$cleaned} files.\n";
file_put_contents("/tmp/bom_clean_" . date('Ymd_His') . ".log", implode("\n", $log));这版的关键改进:
- 命令行参数控制,不依赖 GET 参数(HTTP 触发被外部访问的安全风险也消除了)。
- RecursiveCallbackFilterIterator 跳过 .git/vendor/node_modules 等。
- 只读首 3 字节判定,不全量读文件。
- 白名单扩展名,跳过图片、视频、PDF。
- dry-run 模式列出会改哪些文件不动手。
- 临时文件 + rename 原子写,进程中断不会留半截。
- 全局文件锁防并发,结果落日志。
Linux / macOS 上的一行替代方案
如果你能 SSH 到服务器,根本不用写 PHP 脚本,sed 一行解决:
# 找出所有带 BOM 的 PHP/HTML 文件
find . -type f \( -name "*.php" -o -name "*.html" -o -name "*.js" \) \
-not -path "./vendor/*" -not -path "./.git/*" \
-exec grep -lI $'\xEF\xBB\xBF' {} +
# 批量剥 BOM
find . -type f \( -name "*.php" -o -name "*.html" \) \
-not -path "./vendor/*" -not -path "./.git/*" \
-exec sed -i '1s/^\xEF\xBB\xBF//' {} +注意 macOS 自带的 sed 是 BSD 版,-i 后面要跟一个空字符串:sed -i '' '1s/^\xEF\xBB\xBF//' file。Linux 的 GNU sed 不需要那个空串。
PowerShell 上的等价方案
Windows 服务器或本地的 PowerShell:
Get-ChildItem -Path . -Recurse -Include *.php,*.html,*.js -Exclude vendor,.git | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
$stripped = $bytes[3..($bytes.Length-1)]
[System.IO.File]::WriteAllBytes($_.FullName, $stripped)
Write-Host "Cleaned: $($_.FullName)"
}
}PowerShell 自身就是 BOM 大户:默认 Out-File 输出 UTF-16 LE BOM,Set-Content 在 PowerShell 5.1 上默认 ANSI(中文系统是 GBK)。在 PowerShell 5.1 上要把脚本写成无 BOM UTF-8,必须用 [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)),这是日常踩到不能再踩的坑。
编辑器层面的"以无 BOM 保存"设置
清完一次还不够,要让团队成员的编辑器从此不再加 BOM。各编辑器的设置:
- Notepad++:菜单 Encoding → 选 UTF-8(注意不是 UTF-8-BOM)。设为默认:Settings → Preferences → New Document → Encoding → UTF-8 + 取消"Apply to opened ANSI files"。
- VSCode:底部状态栏点 UTF-8 → 通过编码保存 → UTF-8(无 BOM)。设为默认:settings.json 加 "files.encoding": "utf8"(注意不是 utf8bom)。
- Sublime Text:File → Save with Encoding → UTF-8。默认设置:Preferences → Settings 加 "default_encoding": "UTF-8"。
- JetBrains 全家桶(PhpStorm、WebStorm、IntelliJ):File → File Properties → File Encoding → UTF-8 + Settings → Editor → File Encodings → 取消"Create UTF-8 files: with BOM"。
- Vim:默认就不加 BOM。如果要明确设:
set nobomb。 - Windows 11 自带新版记事本:默认是 UTF-8(无 BOM)。如果用旧版本,升级 Windows 或换工具。
Git pre-commit hook 防止 BOM 重新进入仓库
团队协作的项目里,光靠"提醒大家不要用记事本"靠不住。在仓库里加 .git/hooks/pre-commit:
#!/bin/bash
# 拒绝带 BOM 的文件提交
files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(php|html|htm|js|css|xml|json|yml|md|txt)$')
for f in $files; do
if [ -f "$f" ]; then
if head -c 3 "$f" | grep -q $'\xEF\xBB\xBF'; then
echo "ERROR: $f 包含 UTF-8 BOM,请用 sed -i '1s/^\xEF\xBB\xBF//' $f 清除后再提交。"
exit 1
fi
fi
done
exit 0pre-commit hook 在每个开发者本地,所以要么用 husky/lefthook 这类工具把它纳入 npm install / composer install 自动安装,要么放在 CI 阶段做检查。CI 上的等价检查:
# GitHub Actions
- name: Check no BOM in source files
run: |
if find . -type f \( -name "*.php" -o -name "*.html" -o -name "*.js" \) \
-not -path "./vendor/*" -not -path "./node_modules/*" \
-exec grep -lI $'\xEF\xBB\xBF' {} + | grep -q .; then
echo "Found BOM in source files"
exit 1
fi真实案例库:保哥踩过的 BOM 坑
这些是过去 5 年实际处理过的工单,按"现象 → 真因"列出,方便你下次遇到时立刻定位:
- PHPExcel 导出多一个空白行:导出 Excel 的 PHP 控制器文件首行带 BOM,BOM 三字节先输出到响应体,xlsx 解析器把这视为"在 ZIP 头之前的字节",认为文件损坏,但 Excel 容错地把它显示为"第一个空白行"。清 BOM 立刻好。
- WordPress AJAX 调用全部失败:functions.php 末尾被某个插件追加了 ?> 后又被 BOM 编辑器保存,?> 后多了三字节 BOM,每个 AJAX 请求响应体末尾都有这三字节,wp_send_json 的 application/json 内容被破坏,前端解析失败。修复:去掉 functions.php 末尾的 ?> 标签(PHP 推荐做法),并清 BOM。
- iconv 转换 PHP 文件后中文全乱码:用 iconv 把 GBK 转 UTF-8 时如果加了 -W 选项写 BOM,PHP 文件就带上 BOM,触发 headers already sent。改用 iconv -f GBK -t UTF-8 不带 -W 即可。
- nginx -t 启动失败:从 Windows 上 winscp 拷贝过来的 nginx.conf 被 BOM 污染,nginx 解析报 "unexpected " in /etc/nginx/nginx.conf:1"。winscp 默认用 ANSI 传输,但有些版本会自动转 UTF-8 加 BOM。
- Composer install 失败:composer.json 文件被 BOM 污染,Composer 的 JSON 解析器抛 Parse error: Syntax error。这种情况 Composer 1.10+ 已经容错处理 BOM 但 1.9 之前会硬抛错。
- curl 下载二进制文件出错:服务端某个 download.php 返回 zip 文件二进制流,但 download.php 自身首行带 BOM,BOM 字节被插到 zip 文件最开头,下载到的 zip 文件解压报 not a valid archive。
- Apache 静态托管的 .htaccess 不生效:从 Windows 拷贝的 .htaccess 带 BOM,Apache 把第一条规则解析失败、整个 .htaccess 被忽略,rewrite 规则全部不生效,重新清 BOM 后立刻恢复。
什么时候不该清 BOM
反过来——这些场景必须保留 BOM:
- 导出给 Microsoft Excel 用的 CSV。Excel 不带 BOM 不会自动用 UTF-8 解码,会按系统 ANSI(中文 Windows 上是 GBK)读,结果中文乱码。CSV 文件首行加上 EF BB BF 三字节 BOM 是 Excel 兼容的硬要求。如果你的导出脚本把 BOM 清了又抱怨 Excel 乱码,那是搞反了。
- 第三方包 vendor 目录里的文件。这些文件由 Composer 管理,下次 composer update 会被覆盖,但中间这段时间它们的 hash 校验、签名验证可能依赖于原状字节流,乱清反而引入怪问题。
- UTF-16 / UTF-32 编码的文件。这些文件依赖 BOM 来标识字节序,清掉等于让所有解析器猜编码,多数会失败。但 PHP/HTML/CSS/JS 极少用 UTF-16,遇到要先确认。
- 已签名的代码或数据文件。GPG 签名、Authenticode 签名这些是基于完整字节流的 hash,清 BOM 后签名失效。
常见问题解答
BOM 为什么会让 PHP 报 headers already sent?
PHP 解析器把 <?php 标签之前的任何字节都当成"原样输出到 HTTP 响应体"。BOM 三字节 EF BB BF 会作为输出立刻发送给浏览器,PHP 内部的 output buffer 在没开启的情况下,这三字节直接进入 HTTP 响应。一旦响应体开始发送,HTTP 头部不能再修改——所以后续 header()、setcookie()、session_start() 全部触发 headers already sent 错误。错误信息里会明确指出 output started at 文件名:1,看到 :1 的报错且第一行明明是空白或 <?php 时立刻怀疑 BOM。修复方式是清 BOM,或者临时用 ob_start() 在脚本最顶部开输出缓冲(治标不治本)。最好是 PHP 文件末尾不写 ?> 闭合标签,从根源上避免末尾意外加入字节。
如何在不写脚本的情况下快速判断单个文件有没有 BOM?
四种方法。第一,Linux/macOS 终端:head -c 3 file.php | xxd,看输出第一行的前 3 字节是不是 ef bb bf。第二,PowerShell:[System.IO.File]::ReadAllBytes("file.php")[0..2] 看头三字节。第三,Notepad++ 打开文件,状态栏底部显示 UTF-8-BOM 或 UTF-8,直接看出来。第四,在 Chrome DevTools 的 Network 面板抓 HTTP 响应,切到 Response Tab 旁边的 Hex 视图(Chrome 100+ 支持),看响应体首字节。第一种和第四种是命令行 friendly 的,团队里遇到怪问题时第一时间用 head -c 3 排查。
Windows 11 自带的新记事本还会加 BOM 吗?
Windows 11 自带新版记事本(Notepad)默认编码是 UTF-8(无 BOM),不再像旧版那样默认加 BOM。但有几个细节要注意:第一,旧 Windows 10 v1903 之前的记事本默认 ANSI,升级 Windows 时已存在的旧文件不会被自动转换。第二,新记事本如果手动选择"UTF-8 with BOM"保存,会重新加上 BOM——这通常发生在用户不熟悉的菜单点击后。第三,第三方"老式记事本"复制品(Notepad2、Notepad3 旧版)依然可能默认加 BOM。第四,PowerShell Out-File 默认依然是 UTF-16 LE BOM,PowerShell ISE 和 CMD 也都是 BOM 系,单纯依赖 Windows 11 系统记事本不能保证团队所有工具不加 BOM。最稳妥还是统一用 VSCode/Notepad++ 等明确支持无 BOM 的编辑器。
composer.json / package.json 这些 JSON 文件能带 BOM 吗?
RFC 8259(JSON 标准)明确禁止 JSON 文件带 BOM——第 8.1 节写明 Implementations MUST NOT add a byte order mark to the beginning of a networked-transmitted JSON text。但允许实现"容错"地接受带 BOM 的 JSON(一种"宽容输入、严格输出"的态度)。Composer 1.10+、npm 6+、jq 1.5+ 都做了容错,能正常解析带 BOM 的 JSON 文件。但更老的版本(Composer 1.9 之前、PHP json_decode 早期版本、Python 标准库 json 模块)会抛 Parse error。生产代码里的 composer.json/package.json 应当严格无 BOM,避免给老旧 CI/CD 工具链留陷阱。Git pre-commit hook 加上 BOM 检查能把住源头。
为什么导出 CSV 给 Excel 反而要加 BOM?
Excel 在打开 CSV 文件时,如果文件没有 BOM,Microsoft Excel 默认按系统 ANSI 编码(中文 Windows 是 GBK,日文 Windows 是 Shift-JIS)解码内容,结果如果 CSV 实际是 UTF-8 编码、中文字符按 UTF-8 编码字节流的话,Excel 会把每 2-3 字节当成 GBK 双字节字符乱七八糟拼合,呈现为乱码。如果 CSV 文件首部有 EF BB BF 三字节 BOM,Excel 把它识别为"这是 UTF-8 文件"的标识,切换为 UTF-8 解码,中文正常显示。这是 Excel 的编码识别行为,不是 CSV 标准本身的要求——CSV 标准(RFC 4180)压根没规定编码。所以从 PHP/Java 后端导出给 Excel 用的 CSV 文件,要主动 fwrite($fp, "\xEF\xBB\xBF") 在首字节加 BOM。Office 365 / Excel 2019 起的"打开 → 数据 → 从文本/CSV"导入向导已经能让用户手动指定编码,但日常用户双击 .csv 文件还是会触发自动编码识别,BOM 仍然是最稳的兼容方式。
iconv -f GBK -t UTF-8 出来的文件为什么有时带 BOM 有时不带?
iconv 默认输出不带 BOM。但如果加了 -t UTF-8//TRANSLIT 加上 -W 选项(write BOM),输出会带 BOM。某些 Windows 上的 iconv 移植版(GnuWin32 的 iconv 1.14)默认行为不一样,会加 BOM。判断方法是 iconv 出来后立刻 head -c 3 newfile | xxd 看首字节,确认是不是 BOM。如果你做批量编码转换(典型是 GBK 旧站迁到 UTF-8),转换完一律跑一遍 BOM 清除脚本最稳。最好的实践是用 iconv -f GBK -t UTF-8 inputfile -o outputfile 不带 -W,直接出无 BOM UTF-8。
WordPress、Typecho、Drupal 等 CMS 自身会自带 BOM 文件吗?
它们的官方发行包不带 BOM。但生态系统里的第三方主题、插件、汉化包是 BOM 重灾区——很多由中文开发者用记事本编辑,提交时带 BOM。WordPress 国际化项目早年专门有"清 BOM"的批量任务来处理 .po/.mo 翻译文件。Typecho 的中文主题论坛上传的 .zip 主题大概有 30-40% 概率带 BOM。安装第三方主题/插件后如果遇到莫名"管理后台保存设置失败"或"插件激活后整站白屏",立刻在站点根目录跑 BOM 清除脚本(注意排除 vendor 目录),有相当高概率立刻恢复。这就是为什么 WordPress 论坛上"我的网站突然白屏"的高频解决方案是"清 BOM"。
BOM 跟 EF BB BF 之外,还有别的 BOM 字节序列要清吗?
有几种但日常 PHP/Web 项目里基本只会遇到 UTF-8 BOM。完整的 Unicode BOM 列表:UTF-8 是 EF BB BF(3 字节)、UTF-16 LE 是 FF FE(2 字节)、UTF-16 BE 是 FE FF(2 字节)、UTF-32 LE 是 FF FE 00 00(4 字节)、UTF-32 BE 是 00 00 FE FF(4 字节)。如果你的源码文件被某个奇怪工具保存成 UTF-16,前两字节 FF FE 会让 PHP/Apache/Nginx 全部报错。但这种情况非常罕见——通常是误操作把 UTF-16 编码的文档复制粘贴进了源码。日常清 BOM 只关心 EF BB BF 即可,遇到其他 BOM 字节序列直接告诉用户用 UTF-8 重新保存这个文件。
VSCode 设置 files.encoding utf8 后,新建文件不带 BOM 但已存在文件还是带 BOM 怎么办?
files.encoding 只影响 VSCode 写文件时使用的编码,不会主动转换已存在的文件。要把已存在带 BOM 的文件转无 BOM,操作:打开文件 → 状态栏右下角点 UTF-8 with BOM → 选择"通过编码保存" → UTF-8(无 BOM)→ 文件被重新写入无 BOM。批量处理用 VSCode 的全局搜索/替换或者外部脚本(上面给的 PHP/sed/PowerShell 脚本任选一个)。最稳的工作流是:第一步外部脚本批量清掉所有现有 BOM、第二步 VSCode 设 files.encoding utf8 防止新文件带 BOM、第三步 git pre-commit hook 拦截带 BOM 的提交。三层防御一起做。
为什么我清了 BOM 之后过段时间又出现 BOM?
三种原因。第一种是团队里有同事的编辑器仍然加 BOM,他改完 commit 推上来,BOM 又回到仓库。解法是 Git pre-commit hook 拦截,或者 CI 上跑 BOM 检查 PR 不通过不能 merge。第二种是用了某个"自动同步代码到服务器"的工具(比如老式 FTP 客户端、winscp),它把本地带 BOM 的文件上传到服务器覆盖了清完 BOM 的版本——这种情况要么工具改用 UTF-8 无 BOM 模式,要么停用直接 IDE/IDE-server 模式同步。第三种是某些 PHP 框架(Smarty 旧版本、ThinkPHP 3.x)在编译模板时会自己生成带 BOM 的中间文件到 runtime/cache 目录,这部分文件清也没用,下次访问又生成。这种是框架行为,需要清掉缓存目录后升级框架版本,或者检查框架文档关于 BOM 的设置。
本文标题:《记事本 BOM 导致网页白屏完全排查指南:批量清除脚本与 5 种破坏现场》
版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0