← 中文资源

简体中文版本

静态文件服务与 SPA 回退

从磁盘提供前端构建产物,无扩展名路由回退到 index.html,缺失的静态资源仍返回 404。

阅读英文原文

功能说明

单页应用通常由前端路由接管地址,服务器需要把 /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")))
}