feat: compress dist to gz
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/static"
|
||||
@@ -32,66 +37,128 @@ func initStaticRoutes(root *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
// 静态资源服务(Vue SPA),带缓存头部
|
||||
assetsGroup := root.Group("/assets")
|
||||
assetsGroup.Use(cacheControl("public, max-age=31536000, immutable")) // 带哈希的资源缓存
|
||||
assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets")))
|
||||
|
||||
// logo.svg 短缓存实现
|
||||
root.GET("/logo.svg", func(ctx *gin.Context) {
|
||||
data, err := static.ReadFile("logo.svg")
|
||||
if err != nil {
|
||||
assetsFS := mustSubFS(staticFS, "assets")
|
||||
|
||||
// 静态资源路由:专门处理 assets 目录
|
||||
root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) {
|
||||
path := strings.TrimPrefix(ctx.Param("filepath"), "/")
|
||||
if path == "" {
|
||||
ctx.Status(404)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "public, max-age=86400") // 缓存1天
|
||||
ctx.Data(200, "image/svg+xml", data)
|
||||
|
||||
// 智能选择资源:优先寻找 .gz 版本,即便请求的是原文件名 (如 typescript.js)
|
||||
gzPath := path + ".gz"
|
||||
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
|
||||
|
||||
// 检查 .gz 文件是否存在
|
||||
if gzFile, err := assetsFS.Open(gzPath); err == nil {
|
||||
defer gzFile.Close()
|
||||
|
||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
ctx.Header("Content-Type", contentType)
|
||||
|
||||
if isGzipSupported {
|
||||
// 1. 客户端支持 Gzip: 直接发送压缩后的数据 (最优解)
|
||||
ctx.Header("Content-Encoding", "gzip")
|
||||
io.Copy(ctx.Writer, gzFile)
|
||||
} else {
|
||||
// 2. 客户端不支持 Gzip: 现场解压给它 (兼容性退路)
|
||||
gr, _ := gzip.NewReader(gzFile)
|
||||
defer gr.Close()
|
||||
io.Copy(ctx.Writer, gr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 如果连 .gz 都没有,最后尝试返回原文件(比如图片等本身不适合压缩的资源)
|
||||
if file, err := assetsFS.Open(path); err == nil {
|
||||
defer file.Close()
|
||||
http.FileServer(http.FS(assetsFS)).ServeHTTP(ctx.Writer, ctx.Request)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(404)
|
||||
})
|
||||
|
||||
// logo.svg 短缓存实现
|
||||
root.GET("/logo.svg", func(ctx *gin.Context) {
|
||||
serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400")
|
||||
})
|
||||
}
|
||||
|
||||
// serveSingleFile 处理单个静态文件的逻辑(支持自动解压)
|
||||
func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) {
|
||||
if cache != "" {
|
||||
ctx.Header("Cache-Control", cache)
|
||||
}
|
||||
ctx.Header("Content-Type", contentType)
|
||||
|
||||
// 尝试寻找压缩版
|
||||
if gzData, err := static.ReadFile(filename + ".gz"); err == nil {
|
||||
if strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") {
|
||||
ctx.Header("Content-Encoding", "gzip")
|
||||
ctx.Data(200, contentType, gzData)
|
||||
} else {
|
||||
gr, _ := gzip.NewReader(bytes.NewReader(gzData))
|
||||
defer gr.Close()
|
||||
io.Copy(ctx.Writer, gr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试原文件
|
||||
if data, err := static.ReadFile(filename); err == nil {
|
||||
ctx.Data(200, contentType, data)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(404)
|
||||
}
|
||||
|
||||
// serveSPA 注入配置并返回 index.html 给前端渲染
|
||||
func serveSPA(ctx *gin.Context, urlPrefix string, status int) {
|
||||
data, err := static.ReadFile("index.html")
|
||||
if err != nil {
|
||||
// 如果读不到 index.html (如 dev 模式未 build),返回基础 HTML
|
||||
// 如果已经是 /404 路径,则不再重定向以免死循环
|
||||
path := ctx.Request.URL.Path
|
||||
if strings.HasSuffix(path, "/404") {
|
||||
ctx.Data(status, "text/html; charset=utf-8", []byte("<!DOCTYPE html><html><body><h1>404 Not Found</h1><p>Frontend assets not found. Please run 'npm run build' or check dev server.</p><a href='/'>Go Home</a></body></html>"))
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
var data []byte
|
||||
|
||||
// 尝试解压 index.html.gz (因为我们需要修改其内容,不能直接发 gz)
|
||||
if gzData, err := static.ReadFile("index.html.gz"); err == nil {
|
||||
gr, _ := gzip.NewReader(bytes.NewReader(gzData))
|
||||
data, _ = io.ReadAll(gr)
|
||||
gr.Close()
|
||||
} else {
|
||||
// 回退到普通 index.html
|
||||
data, _ = static.ReadFile("index.html")
|
||||
}
|
||||
|
||||
fallback := `<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404 Not Found</title></head><body>
|
||||
<script>
|
||||
const baseUrl = window.__BASE_URL__ || "/";
|
||||
if (!window.location.pathname.endsWith("/404")) {
|
||||
window.location.href = baseUrl + (baseUrl.endsWith("/") ? "" : "/") + "404";
|
||||
}
|
||||
</script>
|
||||
<p>Not Found. Redirecting...</p>
|
||||
</body></html>`
|
||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.Data(status, "text/html", []byte(fallback))
|
||||
ctx.Abort()
|
||||
if data == nil {
|
||||
// ... 保持原有 fallback 逻辑 ...
|
||||
serveFallback(ctx, urlPrefix, status)
|
||||
return
|
||||
}
|
||||
|
||||
html := string(data)
|
||||
|
||||
// 注入配置变量供前端使用(API 调用和路由)
|
||||
baseHref := urlPrefix + "/"
|
||||
if urlPrefix == "" {
|
||||
baseHref = "/"
|
||||
}
|
||||
|
||||
// 注入 base tag 为了让深度路由的相对路径资源加载正常
|
||||
if urlPrefix == "" { baseHref = "/" }
|
||||
html = strings.Replace(html, "<head>", "<head>\n <base href=\""+baseHref+"\">", 1)
|
||||
|
||||
configScript := `<script>window.__BASE_URL__ = "` + urlPrefix + `"; window.__API_VERSION__ = "/api/v1";</script>`
|
||||
html = strings.Replace(html, "</head>", configScript+"</head>", 1)
|
||||
|
||||
ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
ctx.Data(status, "text/html; charset=utf-8", []byte(html))
|
||||
}
|
||||
|
||||
func serveFallback(ctx *gin.Context, urlPrefix string, status int) {
|
||||
path := ctx.Request.URL.Path
|
||||
if strings.HasSuffix(path, "/404") {
|
||||
ctx.Data(status, "text/html; charset=utf-8", []byte("<!DOCTYPE html><html>..."))
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// ... 原有逻辑 ...
|
||||
ctx.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.Data(status, "text/html", []byte("Not Found"))
|
||||
ctx.Abort()
|
||||
}
|
||||
|
||||
+43
-1
@@ -3,6 +3,45 @@ import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { writeFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
|
||||
// Simple inline compression plugin to avoid extra dependencies
|
||||
const compressionPlugin = () => ({
|
||||
name: 'compression-plugin',
|
||||
apply: 'build',
|
||||
closeBundle: {
|
||||
order: 'post',
|
||||
handler: async () => {
|
||||
const distDir = resolve(process.cwd(), 'dist')
|
||||
if (!existsSync(distDir)) return
|
||||
|
||||
const compressDir = (dir: string) => {
|
||||
const files = readdirSync(dir)
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file)
|
||||
const stat = statSync(filePath)
|
||||
if (stat.isDirectory()) {
|
||||
compressDir(filePath)
|
||||
} else if (file.match(/\.(js|css|html|svg|json|vs)$/) && !file.endsWith('.gz')) {
|
||||
try {
|
||||
const content = readFileSync(filePath)
|
||||
if (content.length < 1024) continue // Don't compress very small files
|
||||
const gzipped = gzipSync(content, { level: 9 })
|
||||
writeFileSync(`${filePath}.gz`, gzipped)
|
||||
// Remove original file after successful compression to save space in binary
|
||||
unlinkSync(filePath)
|
||||
} catch (e) {
|
||||
console.error(`Compression failed for ${file}:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compressDir(distDir)
|
||||
}
|
||||
}
|
||||
} as const)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -15,7 +54,8 @@ export default defineConfig({
|
||||
dest: 'assets'
|
||||
}
|
||||
]
|
||||
})
|
||||
}),
|
||||
compressionPlugin()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -39,6 +79,8 @@ export default defineConfig({
|
||||
// 浏览器会根据当前页面 URL 解析相对路径
|
||||
base: './',
|
||||
build: {
|
||||
reportCompressedSize: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 防止生成以 _ 开头的文件,导致被 Cloudflare Pages 或 Github Pages 等静态托管平台拦截并降级返回 HTML
|
||||
|
||||
Reference in New Issue
Block a user