功能说明
单页应用通常由前端路由接管地址,服务器需要把 /users/42 这样的路径交给 index.html,由前端脚本渲染;但 /assets/app.js 这类真实资源缺失时必须返回 404,不能悄悄回退成 HTML,否则浏览器会把 HTML 当脚本解析并报错。本例在 http.FileServer 外增加一层判断:请求路径经过清理后,如果对应文件存在且是目录,或路径没有扩展名且文件不存在,就把内部路径改写为根路径,交给文件服务器返回入口页。
静态响应统一加上 nosniff,避免浏览器猜测内容类型;缓存时间设为五分钟,兼顾更新速度与重复访问。
关键代码
SPAHandler 接收 http.FileSystem,便于测试时传入内存文件系统。path.Clean 消除多余的斜杠和上级目录片段,降低路径混淆风险;http.Dir 本身也会拒绝逃逸根目录的路径。root.Open 用来探测资源是否存在,拿到文件后读取信息并立即关闭,避免描述符泄漏。目录请求回退到入口页,避免目录列表泄露文件结构。文件缺失且路径带扩展名时保持原路径,让文件服务器返回 404。main 从环境变量读取端口,默认使用 dist 目录。
使用注意
回退只应发生在没有扩展名的前端路由上,API 前缀也应在中间件之前单独挂载,否则接口的 404 会被替换成页面。http.Dir 不允许符号链接逃逸,但仍应确保根目录权限最小化。缓存策略对入口页和带哈希的资源应不同,本例统一五分钟只是演示。生产环境建议由反向代理或 CDN 提供静态文件,应用服务器只处理动态请求。gzip 与 Range 请求由标准文件服务器处理,自定义包装时不要破坏这些接口。
验证方式
准备一个包含 index.html 与 assets/app.js 的 dist 目录,启动服务。访问 /users/42 返回 index.html 内容;访问 /assets/app.js 返回脚本;访问 /assets/missing.js 返回 404,而不是入口页。响应头应包含 X-Content-Type-Options: nosniff。可把 PORT 设为其他值,确认监听端口变化。
代码示例
package main
import (
"fmt"
"net/http"
"os"
"path"
)
func SPAHandler(root http.FileSystem) http.Handler {
files := http.FileServer(root)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cleaned := path.Clean("/" + r.URL.Path)
if cleaned != "/" {
file, err := root.Open(cleaned)
if err == nil {
info, statErr := file.Stat()
_ = file.Close()
if statErr != nil || info.IsDir() {
r.URL.Path = "/"
}
} else if path.Ext(cleaned) == "" {
r.URL.Path = "/"
}
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "public, max-age=300")
files.ServeHTTP(w, r)
})
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Println("serving dist on port", port)
_ = http.ListenAndServe(":"+port, SPAHandler(http.Dir("dist")))
}