做上传功能,需求是"只允许图片"。同事写了 filename.split('.').pop().toLowerCase() === 'jpg',自测通过就上线了。三天后两种事故先后出现:Windows 用户上传的 photo.JPG 被拒(白名单里只有 jpg,比较前忘了归一大小写);安全扫描发现传一个叫 .gitignore 的文件,后缀被认成了 gitignore——按这个逻辑,白名单校验形同虚设。
"从文件名取后缀"大概是业务代码里被写错次数最多的十行逻辑。这篇文章把这一件事一次写对:一个可靠的解析函数,加上它必须通过的全部边界用例。
一、先看边界:一个 lastIndexOf('.') 为什么是对的
最常见的错误写法是 split('.').pop()。拿几个真实文件名过一遍就知道它错在哪:
| archive.tar.gz | gz | gz,碰巧对 |
| .gitignore | gitignore | 无后缀(点开头是隐藏文件) |
| file. | "" | 空后缀,不算 |
| LICENSE | LICENSE | 无后缀(文件名本体被当成了后缀) |
| . | "" | 无后缀 |
| .. | "" | 无后缀 |
split('.').pop() 在无点、点开头、点结尾三种情况下全军覆没。正确做法是先找最后一个点的下标,然后一刀切掉三类非法情况:
const dot = name.lastIndexOf('.');
if (dot <= 0) return ''; // dot === -1 无点;dot === 0 点开头(.gitignore、.profile)
const ext = name.slice(dot + 1);
dot <= 0 一个判断挡住两种:-1 是没有点,0 是点开头。剩下的点结尾(file.、..)会切出空串,调用方把空串当"无后缀"处理即可。多点文件名 a.b.c.D 取到的是最后一个点后的 D——单后缀口径下这是对的,复合后缀(tar.gz)后面单独说。
二、大小写:解析要归一,改名要分平台
photo.JPG 取出的后缀是 JPG。和白名单比较前必须先 toLowerCase() 归一——这不是风格问题:Windows 和 macOS 的文件系统对大小写不敏感,Linux 敏感,同一个 photo.JPG 在 Linux 服务器上和 photo.jpg 是两个文件。所以"归一"有两层:校验时比较归一,部署时文件名归一(把整批后缀统一成小写,避免 Linux 上同一文件两个名字的诡异状态)。
顺带一提首字母大写模式的实现,它是"全串小写 + 首字母大写"两步:ext.charAt(0).toUpperCase() + ext.substring(1).toLowerCase(),JS 会变成 Js——如果只大写首字母不管其余,JS 会变成 JS,hTmL 会变成 HTmL,越统一越乱。
三、路径不是文件名:先取 basename 再找点
这是最阴的一类坑。C:\\path\\to\\file 没有点,解析出无后缀,没问题。但换成:
C:\\Program Files\\my.app\\run
lastIndexOf('.') 找到的是 my.app 里的那个点,切出来的"后缀"是 app\\run——实测这种输入会一路错下去,连大小写转换都会把 run 一起改掉。浏览器里 file.name 天然是纯文件名,所以前端上传校验很少踩;但从日志、URL、命令行参数拿到的都是带路径的字符串。规则是:先取 basename(按 / 或 \\ 切最后一段),再去 URL 的 query 和 hash,然后才找点。
const base = String(name).split(/[\\\\/]/).pop().split('?')[0].split('#')[0];
四、复合后缀与"口径"问题
archive.tar.gz 按单后缀口径取到 gz。如果你的需求是识别压缩包,得显式处理复合后缀:判断文件名是否以 .tar.gz、.tar.bz2 这类已知组合结尾。而 index.html.bak 取到 bak 是对的——备份文件的本体是 index.html,但它的类型就是 bak。
这里没有对错,只有口径:上传校验看的是"真实类型"(复合后缀要展开),展示图标看的是"最后一个点"。需求里先定口径,再写代码,别让一个函数同时服务两个口径。
五、完整实现与验收用例
把前面的规则合起来:
function fileExt(name) {
const base = String(name).split(/[\\\\/]/).pop().split('?')[0].split('#')[0];
const dot = base.lastIndexOf('.');
if (dot <= 0) return '';
return base.slice(dot + 1);
}
验收用例(每一行都是实测值,建议直接抄进单元测试):
| photo.JPG | JPG |
| LICENSE | "" |
| .gitignore | "" |
| file. | "" |
| . / .. | "" |
| archive.tar.gz | gz |
| a.b.c.D | D |
| C:\\path\\to\\file | "" |
| C:\\Program Files\\my.app\\run | "" |
| URL /dl/a.tar.gz?ver=2 | gz |
还有一个从名单场景来的细节:粘贴出来的文件名经常带行尾空格,解析前要先 trim 两端空白(中间空格保留)——note.txt␠ 不裁的话后缀会变成 txt␠,白名单比较直接失败。
批量场景不需要写脚本:把混合了 .JPG、.PNG、.Jpeg 的名单丢进后缀大小写转换,三种模式只动后缀、文件名原样保留,点开头的隐藏文件、无后缀、空后缀的行会自动跳过——它对 lastIndexOf('.') <= 0 的处理就是上面这套边界逻辑,转换前还有前后对照表可以逐行核对。不过进代码的解析还是用 fileExt 加那组用例,工具适合的是批量名单的一次性清理。


