Nginx MIME 类型与 charset 实战:default_type 兜底陷阱、CSS/JS 被拒加载与字体不生效六类排查

一个很典型的现象:本地开发时图片、字体、视频一切正常,迁到线上服务器后,CSS 加载了却完全不生效,页面裸奔;或者某个字体文件被浏览器拒绝加载,控制台只留一行「Refused to load the stylesheet」;又或者下载的 zip 被浏览器当成 HTML 打开,一堆乱码。绝大多数人第一反应是去查 CDN、查缓存、查权限,其实问题往往只有一行:Nginx 没有把这个扩展名的文件类型告诉浏览器。

本文把 Nginx 的 MIME 类型机制、default_type 的真实角色、charset 指令到底管什么、以及线上最常踩的六类「类型/编码」故障走一遍。读完之后你应该能在一分钟内判断一个 404/403/乱码问题到底属不属于 MIME 的锅。

为什么浏览器需要 MIME 类型

浏览器拿到一个 HTTP 响应,它并不依赖文件扩展名来决定怎么处理内容,真正决定的是响应头里的 Content-Type。这是 HTTP 规范的核心设计之一:扩展名只是服务器侧的约定,URL 里写 .css 不代表内容就是 CSS。

于是出现了一个强制规则:对于 CSS 和 JavaScript,现代浏览器执行严格 MIME 检查(strict MIME type checking)。如果 Content-Type 是 text/plain 或 application/octet-stream,浏览器会直接拒绝应用这份样式或脚本,并在控制台报错:

Refused to apply style from 'https://example.com/static/app.css'
because its MIME type ('text/plain') is not a supported stylesheet
MIME type, and strict MIME checking is enabled.

注意这条错误的措辞:不是「加载失败」,而是「类型不支持」。这就是 MIME 问题的指纹。如果是真 404,报错会是 404 Not Found;如果是权限问题,会是 403。看到 "not a supported stylesheet MIME type",就别再折腾权限和路径了。

Nginx 的类型映射从哪来

Nginx 的 MIME 映射全部来自 types 块,通常写在一个独立文件里,由主配置 include mime.types; 引入。这个文件是 nginx 安装包里自带的,内容形如:

types {
    text/html                             html htm shtml;
    text/css                              css;
    application/javascript                js;
    application/json                      json;
    image/png                             png;
    image/webp                            webp;
    image/avif                            avif;
    font/woff2                            woff2;
    application/pdf                       pdf;
}

这里有个容易忽略的细节:types 块不属于 http、server、location 任何一层,它是一个独立的一级上下文,只能出现在 http 块中(且同一个 http 块内只能有一个)。也就是说,你不能在某个 location 里重新定义类型映射表,你只能在整个 http 级别定义一次。

另外一个更隐蔽的限制:types 块里的映射不能加变量,也不能用通配符正则匹配文件名。它只做「从扩展名到 MIME」的静态查表,大小写不敏感。所以你没法写 ~\.(mp4|m4v)$ 这类正则键,那不是它的语法。

查一下你的服务器实际认不认某个扩展名:

# 看当前生效的类型表里有没有 avif
nginx -T 2>/dev/null | grep -nE "\bavif\b"

# 直接问 Nginx:这个路径会被映射成什么类型
# (types 块不方便单独查询,最稳的办法是发一个真实请求看响应头)
curl -sI https://example.com/static/demo.avif | grep -i content-type

nginx -T 会把 include 进来的所有配置展开打印,是排查「配置文件里明明写了却没生效」的第一把工具。很多人的困惑在于:他在 conf.d/site.conf 里加了类型,但那个文件根本没被 include 进 http 块,所以加多少都没用。

default_type:兜底类型,而不是「默认网站类型」

最常见的误解是以为 default_type 决定「HTML 页面用什么类型」。不是。default_type 的角色是兜底:当请求的文件扩展名在 types 表里查不到时,用它作为 Content-Type。Nginx 出厂默认值是:

default_type application/octet-stream;

这个默认值是有意为之:未知内容一律按二进制下载,最安全,不会让浏览器去执行一个来路不明的文件。但它在实践中有两个副作用。

副作用一:新扩展名全部变成「下载」

服务器上的 Nginx 可能是三年前装的,mime.types 里没有 avif、woff2(老版本)、webmanifest、wasm。于是你放上去的 AVIF 图片,浏览器收到 application/octet-stream,行为变成「下载文件」而不是「显示图片」。这就是「图片在 Chrome 里好好的,在 Safari 里点了就下载」的经典成因之一。

修法有两种。第一种是补类型表,在 http 块里追加一个 types 块——但注意前面说过同一个 http 块只能有一个 types 块,实际上 Nginx 允许多个 types 块并存(会合并),不过为了可读性,更常见的做法是维护一个自己的 mime.types.local 一起 include:

# /etc/nginx/mime.types.local
types {
    image/avif        avif;
    font/woff2        woff2;
    application/wasm  wasm;
    application/manifest+json  webmanifest;
    text/markdown     md markdown;
}
# http 块
include /etc/nginx/mime.types;
include /etc/nginx/mime.types.local;

第二种是在 location 里用 types { } 局部覆盖,适合「只有一个目录放特殊文件」的场景。同样注意它只能在 http 上下文里出现,location 里的写法是:

location /downloads/ {
    types {
        application/zip  zip;
        application/x-7z-compressed  7z;
    }
}

副作用二:误设 default_type 把整个站点搞坏

另一个高频事故是有人在 server 块里写了 default_type text/html;,理由是「我这个站全是 HTML」。这看起来无害,实际上把兜底行为改成了「未知内容当 HTML 渲染」。后果是:任何 .env、备份文件、日志文件、未识别扩展名的源码,只要被直接访问到,浏览器都会当 HTML 渲染,虽然不会执行(因为不是 JS),但内容会明文显示,属于信息泄露的放大器。

如果确实需要把「无扩展名的 URL」当 HTML 处理(比如某些伪静态或 API 返回 HTML),正确做法是针对具体 location 单独设置,而不是全局改兜底:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

# 只有当目录内确实全部是无扩展名 HTML 时才这么做
location /docs/ {
    default_type text/html;
}

charset:只管编码,不管类型

需要把 charset 和 MIME 明确区分开。charset 指令做两件事:给响应头追加 ; charset=utf-8,以及按需做字符集转换。它不改变主类型。

charset utf-8;
# 或者只对特定类型追加编码声明
charset_types text/html text/css application/javascript application/json;

这里有个非常经典的坑:Nginx 默认的 charset_types 只包含 text/html。也就是说,你写了 charset utf-8;,HTML 页面会带上 charset=utf-8,但你返回的 JSON 接口、纯文本 API、CSS 文件都不会带。如果一个 JSON 接口被前端按 GBK 解析,中文会乱码;如果浏览器对 text/plain 没拿到编码声明,会用系统默认编码猜,中文环境常常猜错。

所以对个人站来说,推荐的最小配置是:

charset utf-8;
charset_types text/html text/css text/plain application/json application/javascript;

还有一个更隐蔽的问题:charset 指令在响应头里写的是 Content-Type: text/html; charset=utf-8,但如果上游(PHP-FPM、Node、后端应用)自己已经设置了带 charset 的 Content-Type,Nginx 可能会加重复,或者因为 source_charset 与实际不符而触发不必要的转换。排查方法永远是看原始响应头:

curl -sI https://example.com/api/list | grep -i content-type
# 期望: Content-Type: application/json; charset=utf-8
# 若出现 application/json; charset=utf-8; charset=utf-8 说明重复追加

线上六类高频故障与定位命令

下面这张表是我在实际运维中反复遇到的场景,按「症状 → 根因 → 一行定位」整理,可以直接当检查清单用。

症状根因定位命令
CSS/JS 加载但控制台报 strict MIME 错误扩展名未在 types 表,落到 octet-streamcurl -sI .../app.css | grep -i content-type
AVIF/WEBP 图片被下载而非显示MIME 表缺少 image/avifnginx -T | grep -c avif
字体未生效,控制台报 CORS 或类型拒绝woff2 类型缺失或跨域头缺失curl -sI .../f.woff2 -H 'Origin: https://a.com'
下载的 zip 在浏览器里打开成乱码被当成 text/html 或 text/plain 渲染curl -sI ...x.zip | grep -i content-type
JSON 接口中文乱码charset_types 未包含 jsoncurl -sI .../api | grep -i charset
改完配置不生效文件未被 include / 未 reloadnginx -t && nginx -T | grep 关键字

关于字体这一类要单独补一句。现代浏览器对字体资源执行 CORS 检查,即使 MIME 类型正确,如果字体放在独立域名(或 CDN 域名)而访问页面的源不同,就必须返回 Access-Control-Allow-Origin。这时控制台报的是 CORS 错误,看起来像 MIME 问题,其实不是。两件事要分开处理:类型对了但没有 CORS 头,仍然加载失败。

完整的一组验证脚本

与其每次手打 curl,不如写一个小脚本,把站点上「最容易出类型问题」的几类资源一次性体检完。下面这段会按扩展名逐个发 HEAD 请求,把实际 Content-Type 打出来,凡是不符合预期的会用 FAIL 标出:

#!/bin/bash
# check_mime.sh 使用: bash check_mime.sh https://example.com
BASE="${1:?usage: check_mime.sh https://example.com}"
declare -A EXP=(
  [css]="text/css"
  [js]="application/javascript"
  [json]="application/json"
  [woff2]="font/woff2"
  [avif]="image/avif"
  [webp]="image/webp"
  [svg]="image/svg+xml"
  [pdf]="application/pdf"
)
for ext in "${!EXP[@]}"; do
  url="$BASE/probe.$ext"
  ct=$(curl -sI --max-time 8 "$url" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-type"{print $2; exit}')
  if [[ "$ct" == *"${EXP[$ext]}"* ]]; then
    echo "OK   .$ext -> $ct"
  else
    echo "FAIL .$ext -> ${ct:-<none>}  (expect ${EXP[$ext]})"
  fi
done

注意 /probe.css 这类文件可能并不存在,返回 404。404 时 Nginx 仍然会给出 Content-Type: text/html(因为错误页是 HTML),这会让脚本误报。所以正确用法是把 probe.$ext 换成你站点上真实存在的资源路径,或者先往站点根目录放几个空的探针文件(内容无所谓),体检完再删掉。

另一个更省事的思路是直接看 Nginx 的 error.log 里有没有类型相关的线索。Nginx 本身不会因为类型不对而报错——它只是如实告诉浏览器这是什么。所以类型问题永远只在客户端表现,服务端日志是干净的。这点很关键:如果服务端日志无异常而前端行为异常,MIME/charset 就应该排在怀疑列表前三位。

一个真实的排查故事

去年帮一个朋友的站点处理「手机端样式全丢、电脑端正常、换浏览器也一样」的问题。站点是 Nginx + PHP,静态资源走子域名 static.example.com,后面挂了一层 CDN。

第一轮:直接访问静态资源 URL,用浏览器打开,能看到 CSS 源码,说明文件存在、权限正确、CDN 也没问题。第二轮:看响应头,Content-Type: text/plain。问题定位了——子域名这台 server 块的配置是从老站复制过来的,include mime.types; 写在主域名的 server 外面而漏在了子域名那份 include 文件之外,于是这个 virtual host 根本没有类型表,所有静态文件都落到 default_type 的 text/plain(他们当时把它设成了 text/plain 而不是默认的 octet-stream)。

为什么电脑端「看起来正常」?因为老版本浏览器和某些缓存会让 CSS 以宽松模式应用。用户是在刷新缓存后才发现问题的,这也是这类故障容易被误判为「偶发」的原因:缓存把一个稳定存在的配置错误伪装成了不稳定现象。

修复只做了一件事:确保 include mime.types; 在最外层的 http 块里,对所有 server 生效。教训是——MIME 类型是 http 级配置,任何「部分站点正常、部分站点异常」的类型问题,都应该先怀疑 include 的位置。

上线前的最小检查清单

把上面内容收敛成可执行的动作,建议挂在发布流程里:

  • 确认 http 块内有且仅有一次 include mime.types;,并且位置早于所有 server 块。
  • default_type 保持出厂值 application/octet-stream,除非有明确理由在具体 location 覆盖。
  • 对站点用到的所有静态扩展名做一次 curl -sI 抽查,重点看 css/js/woff2/avif/webp/json。
  • charset utf-8; 配合 charset_types 显式列上 json/plain/css/js。
  • 每次改配置后 nginx -t 通过再 reload,并用 nginx -T 验证目标行确实在最终配置里。
  • 把 MIME 检查脚本挂到部署后步骤,让「新扩展名上线没配类型」这类问题在五分钟内暴露,而不是等用户来问。

MIME 和 charset 这类配置的特点是不出错时完全隐形,一出错就是「整站样式消失」这种级别的观感事故。花十分钟把类型表和兜底类型理顺,比事后花两小时怀疑 CDN 要划算得多。真正的排查顺序应该是:先看响应头,再看服务端日志,最后才去怀疑缓存和网络——因为响应头是唯一一个能同时告诉你「服务器认为这是什么」和「浏览器会怎么处理」的证据。

Last modification:October 17th, 2026 at 09:24 pm

Leave a Comment