别把所有东西塞进模板:Go 服务端渲染的前端重构
别把所有东西塞进模板:Go 服务端渲染的前端重构
项目早期的 HTML 很诚实:每个页面都有完整的 DOCTYPE、head、导航栏、页脚、内联 CSS 和一大段 JavaScript。
它能运行。
它也非常擅长让一次导航栏修改变成全站搜索替换。
这次重构没有换前端框架,而是把 SSR 项目最基本的边界重新整理清楚。
一、原来的问题
典型页面:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/css/output.css">
<style>
/* 当前页面的全部样式 */
</style>
</head>
<body>
<!-- 复制过来的导航栏 -->
<!-- 页面内容 -->
<!-- 复制过来的页脚 -->
<script>
// 当前页面的全部交互
</script>
</body>
</html>
主要问题:
- 公共结构重复。
- 内联样式无法复用。
- 内联脚本难以维护。
- 页面依赖不明确。
- 改动容易漏页面。
不使用 React,并不意味着 HTML 可以随意生长。服务器渲染同样需要前端结构。
二、抽出统一页面骨架
基础模板:
{{define "page-start"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/output.css">
{{range .Scripts}}
<script src="{{.}}" defer></script>
{{end}}
</head>
<body>
{{end}}
站点公共结构:
{{define "site-start"}}
{{template "page-start" .}}
{{template "header" .}}
{{end}}
{{define "site-end"}}
{{template "footer" .}}
</body>
</html>
{{end}}
页面只保留自己负责的部分:
{{define "tools"}}
{{template "site-start" .}}
<main>
<!-- 工具列表 -->
</main>
{{template "site-end" .}}
{{end}}
三、用明确的数据结构代替 map
早期 Handler 常写:
map[string]interface{}{
"Active": "blog",
"User": user,
}
字符串键没有编译期检查,拼错后要等模板执行才知道。
公共页面数据:
type PageData struct {
Title string
Active string
Page string
User *auth.User
Scripts []string
BodyClass string
}
具体页面嵌入它:
type postListPage struct {
web.PageData
Posts []blog.Post
}
模板数据现在有了类型,Handler 也更容易看出页面依赖什么。
四、统一 Renderer
所有 Handler 直接调用 ExecuteTemplate 会重复:
- 设置 Content-Type
- 处理执行错误
- 设置状态码
- 记录日志
统一封装:
func (r *Renderer) RenderStatus(
w http.ResponseWriter,
status int,
name string,
data any,
) {
var buf bytes.Buffer
if err := r.tpl.ExecuteTemplate(&buf, name, data); err != nil {
r.logger.Error("render template", "template", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
先渲染到 buffer,再写响应。否则模板执行到一半报错时,浏览器会收到半张页面和一个已经来不及修改的 200 状态。
五、JavaScript 按业务归档
整理后的目录:
web/static/js/
├── admin/
│ └── upload.js
├── blog/
│ └── comments.js
├── tools/
│ ├── diff.js
│ └── json.js
└── vendor/
├── difflib.js
├── htmx-2.0.4.min.js
└── json-formatter.js
含义很直观:
vendor不修改第三方源码。tools只服务工具页面。blog处理博客交互。admin处理管理功能。
之前遗留但已不再引用的 diffview.js、diffview.css 直接删除。静态目录不是博物馆,不需要为每个历史方案留展位。
六、页面脚本显式注入
不是所有页面都加载全部 JavaScript。
Handler 为页面指定脚本:
PageData{
Scripts: []string{
"/static/js/vendor/difflib.js",
"/static/js/tools/diff.js",
},
}
模板统一输出:
{{range .Scripts}}
<script src="{{.}}" defer></script>
{{end}}
这样从 Handler 就能看出页面依赖,博客列表也不会加载文本对比算法。
七、CSS 从页面搬回样式文件
页面专属样式集中在 input.css:
.diff-editors {
display: flex;
gap: 12px;
min-height: 260px;
}
@media (max-width: 768px) {
.diff-editors {
flex-direction: column;
}
}
Tailwind 负责常见布局,复杂工具组件使用语义化 class。全部强行写成几十个 utility class 并不会自动更规范;当一组样式形成稳定组件时,给它一个名字更容易维护。
八、UI 重构时具体改了什么
页面视觉使用一套克制规则:
- 内容最大宽度固定
- 浅灰页面背景
- 白色内容表面
- 蓝色强调色
- 卡片圆角不超过 8px
- 阴影只做轻微悬停反馈
- 文章正文使用 typography
欢迎页只保留网站名、说明和入口;文章页优先保证长文本和代码块可读;工具页优先保证输入区、按钮和结果区稳定。
没有加入渐变球、悬浮大卡片和三段式营销 Hero。因为这是一个要反复使用的网站,不是一次性产品发布页。
九、重构后的检查方法
全局扫描内联反模式:
rg '<style>|<script>|onclick=|onchange=|style="' web/templates
检查静态引用:
rg '/static/js/' internal web/templates
重新编译 CSS:
npm run build:css
最后逐页检查:
- 公共导航是否一致
- 当前菜单高亮是否正确
- 页面只加载必要脚本
- 移动端是否溢出
- 长标题是否挤压按钮
- 错误状态是否仍有完整布局
SSR 项目的前端不需要复杂,但必须有边界。模板、样式和脚本各自待在正确的位置后,页面代码会安静很多。
安静的代码有个好处:下一次打开它时,不会先问自己“这到底是谁写的”。尤其当答案很可能是“上周的我”。
评论
暂无评论,来说点什么吧。
请先登录后再发表评论
去登录