H3 静态资源服务
把本地目录作为静态资源(HTML、图片、CSS、JS)对外提供,serveStatic 一个工具就够了。它不直接读盘——你提供「怎么读」和「怎么取元数据」两个回调,剩下的交给它。
基本用法:必须 return
import { H3, serveStatic } from "h3";
const app = new H3();
app.use("/public/**", (event) => {
// 关键:必须 return,否则中间件形同虚设,请求会穿透过去
return serveStatic(event, {
getContents: (id) => {
// TODO:根据 id 读文件内容,返回 Buffer/string 或 undefined
},
getMeta: (id) => {
// TODO:根据 id 取元数据,返回 { size, mtime } 或 undefined
},
});
});
getContents 和 getMeta 两个回调职责分离,是有意为之:
getMeta只取大小与修改时间,H3 用它回HEAD请求、算etag、发304 Not Modified——不必真的读文件内容。getContents才真正读文件。
这样对 HEAD 请求和大文件都更省。
完整示例:基于 Node fs
import { stat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { H3, serve, serveStatic } from "h3";
const app = new H3();
app.use("/public/**", (event) => {
return serveStatic(event, {
indexNames: ["/index.html"], // 目录请求回落到 index.html
getContents: (id) => readFile(join("public", id)), // 读文件内容
getMeta: async (id) => {
const stats = await stat(join("public", id)).catch(() => {});
if (stats?.isFile()) {
return { size: stats.size, mtime: stats.mtimeMs }; // 供 etag / 304 使用
}
// 文件不存在返回 undefined,H3 会按 404 处理
},
});
});
serve(app); // 启动监听
size 和 mtime 被用来生成 etag;当客户端带着匹配的 If-None-Match / If-Modified-Since 来时,H3 返回 304 Not Modified,避免重复传输未改动的文件。
路径穿越防护:谁负责?
路径穿越防护是 serveStatic 与你的后端共同承担的,先说清楚分工:
serveStatic自身会拒绝非规范的pathname——出现多个前导斜杠、\、或%25嵌套转义的点段(如//private/x、/pub/%252e%252e/private/x)时直接返回 404,根本不会进到getContents。- 但它刻意把
%2f/%5c这类编码分隔符保留在传给getContents/getMeta的id里(与event.url.pathname同源、同分段结构)。所以后端绝不能先decodeURIComponent(id)再拼路径——那样会把一个透明段重新变成真正的/,绕过上面的use("/public/**")守卫。 - 后端真正要负责的,是对
id解析出的绝对路径做作用域校验:确认它落在资源根目录之内。
import { stat, readFile } from "node:fs/promises";
import { join, resolve, normalize } from "node:path";
app.use("/public/**", (event) => {
return serveStatic(event, {
getContents: (id) => {
// 关键:不要 decodeURIComponent(id)——serveStatic 保留 %2f/%5c 正是为了防穿越
const full = resolve("public", "." + normalize(id).replace(/^\/+/, "/"));
const root = resolve("public");
if (!full.startsWith(root + "/") && full !== root) {
return undefined; // 越界,当文件不存在处理
}
return readFile(full);
},
getMeta: async (id) => {
const full = resolve("public", "." + normalize(id).replace(/^\/+/, "/"));
const root = resolve("public");
if (!full.startsWith(root + "/") && full !== root) return undefined;
const stats = await stat(full).catch(() => {});
return stats?.isFile() ? { size: stats.size, mtime: stats.mtimeMs } : undefined;
},
});
});
⚠️ 直接用
join("public", id)而不校验,等于把路径穿越的口子敞开。恶意请求/public/../../etc/passwd在join之后会逃出public。务必在拼完路径后做一次「是否在根目录内」的判定。
结合 routeRules 加缓存头
静态资源通常想带长缓存。与其在每个 getMeta 里手设,不如用第 3 章讲过的路由规则,在 routeRules 里对 /public/** 统一加 cache 头,由 H3 在响应阶段自动补上——serveStatic 与路由规则是叠加生效的:
const app = new H3({
routeRules: {
// 对 /public 下所有资源统一加 1 小时强缓存
"/public/**": { cache: { maxAge: 60 * 60 } },
},
});
这样 serveStatic 负责读文件、routeRules 负责加头,职责清晰,也不用在每个请求里重复设。
💡 当静态资源体量很大、或需要边缘分发时,直接用对象存储 / CDN 比自己
serveStatic更合适——把/public/**用proxy或反向代理指到 CDN 即可,H3 只管动态部分。