Cairn
← 返回博客

别把所有东西塞进模板:Go 服务端渲染的前端重构

别把所有东西塞进模板:Go 服务端渲染的前端重构

项目早期的 HTML 很诚实:每个页面都有完整的 DOCTYPEhead、导航栏、页脚、内联 CSS 和一大段 JavaScript。

它能运行。

它也非常擅长让一次导航栏修改变成全站搜索替换。

这次重构没有换前端框架,而是把 SSR 项目最基本的边界重新整理清楚。

一、原来的问题

典型页面:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/static/css/output.css">
  <style>
    /* 当前页面的全部样式 */
  </style>
</head>
<body>
  <!-- 复制过来的导航栏 -->
  <!-- 页面内容 -->
  <!-- 复制过来的页脚 -->
  <script>
    // 当前页面的全部交互
  </script>
</body>
</html>

主要问题:

  1. 公共结构重复。
  2. 内联样式无法复用。
  3. 内联脚本难以维护。
  4. 页面依赖不明确。
  5. 改动容易漏页面。

不使用 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.jsdiffview.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 项目的前端不需要复杂,但必须有边界。模板、样式和脚本各自待在正确的位置后,页面代码会安静很多。

安静的代码有个好处:下一次打开它时,不会先问自己“这到底是谁写的”。尤其当答案很可能是“上周的我”。

评论

暂无评论,来说点什么吧。

请先登录后再发表评论

去登录