Files
cs-note/hhs/GIN/10-template-rendering.md
T
2026-05-24 11:42:38 +08:00

11 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
模板
HTML
2026-04-28 00:05

HTML 模板渲染

概述

Gin 内建了对 Go 标准库 html/template 的封装,支持简单模板加载、多模板引擎配置、以及通过 embed.FS 将模板打包进单一二进制。虽然现代前后端分离架构中较少直接使用服务端渲染,但在管理后台、邮件模板等场景中仍然实用。

思考题:c.HTML 和 http.ServeFile 直接返回 .html 文件有什么区别?

正文

1. 基本模板渲染

Gin 通过 gin.H(即 map[string]any)将数据传给 Go 标准库 html/template。Gin 内部调用 template.ParseFiles() 加载模板,并以**文件名(不带路径)**作为模板名建立映射:

func main() {
    r := gin.Default()

    // 加载 templates/ 目录下所有 .html 文件
    r.LoadHTMLGlob("templates/*")

    r.GET("/index", func(c *gin.Context) {
        // 渲染 templates/index.html,传入模板数据
        // Gin 模板的 key 使用大驼峰(类似结构体字段),Go 的 html/template 按反射访问
        c.HTML(http.StatusOK, "index", gin.H{
            "Title": "Home Page",
            "User":  "wonder",
            "Items": []string{"item1", "item2"},
        })
    })

    r.GET("/news", func(c *gin.Context) {
        c.HTML(http.StatusOK, "news.html", gin.H{
            "Title": "News",
            "Items": []string{"item1", "item2"},
        })
    })

    r.Run(":8080")
}

模板文件 templates/index.html:

<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>
    <h1>Welcome, {{.User}}</h1>
    <!-- 遍历切片 -->
    {{range .Items}}
    <li>{{.}}</li>
    {{end}}
</body>
</html>

[!TIP] Gin 模板的 key 命名约定 Gin 模板通过 Go 反射访问字段,因此 gin.H 中的 key 应使用大驼峰(如 "Title"),与 HTML 模板中的 {{.Title}} 对应。如果写成小写 key {"title": ...},模板中使用 {{.Title}} 将无法找到该字段。

[!INFO] 思考题答案 c.HTML vs http.ServeFile:本质区别是"渲染"还是"返回"。c.HTML 先将数据注入模板执行一次模板引擎渲染(可以做条件判断、循环遍历等),最终输出完整 HTML;http.ServeFile 原样发送文件内容,无法动态插入数据。如果要返回静态页面(如前端 SPA 入口),用 ServeFile / StaticFile 更高效。

2. LoadHTMLGlob vs LoadHTMLFiles

方法 用途 示例
LoadHTMLGlob(pattern) 按 glob 模式加载一批模板 "templates/**/*.html"
LoadHTMLFiles(paths...) 指定具体的文件列表 "templates/base.html", "templates/index.html"

Gin 加载模板后会在内部建立文件名 → *template.Template 的映射关系,因此 c.HTML() 第二个参数只需匹配文件名即可。

// Glob — 适合模板较多、结构简单的场景
r.LoadHTMLGlob("templates/**/*")  // 包括子目录

// Files — 适合明确知道有哪些模板的场景
r.LoadHTMLFiles(
    "templates/base.html",
    "templates/index.html",
    "templates/error.html",
)

[!WARNING] 常见陷阱 LoadHTMLGlob("templates/**/*") 会用每个文件的基名作为模板名。如果 templates/sub/page.html 也被加载,模板名就是 sub/page.html——渲染时需写 c.HTML(200, "sub/page.html", data)。建议始终用绝对路径模式(如 "./templates/*.html")避免意外匹配到无关文件。

3. 模板函数(FuncMap)

Go 模板不像 Jinja2 那样内置丰富的过滤器,因此提供了 SetFuncMap 接口来注册自定义函数。这些函数可以在模板中以 管道符 | 链式调用:

func main() {
    r := gin.Default()

    r.SetFuncMap(template.FuncMap{
        "formatDate": func(t time.Time) string {
            return t.Format("2006-01-02")
        },
        "truncate": func(s string, n int) string {
            if len(s) <= n {
                return s
            }
            return s[:n] + "..."
        },
        "upper": strings.ToUpper,
    })

    r.LoadHTMLGlob("templates/*")

    r.GET("/article", func(c *gin.Context) {
        c.HTML(200, "article", gin.H{
            "Title": "Gin Templating Guide",
            "Body":  "这是一篇很长的文章...",
            "Date":  time.Now(),
        })
    })

    r.Run(":8080")
}

模板中使用:

<h1>{{.Title | upper}}</h1>
<p>{{.Body | truncate 50}}</p>
<small>发布于 {{.Date | formatDate}}</small>

[!WARNING] SetFuncMap 必须先于 LoadHTMLGlob 调用 Gin 将 SetFuncMap 的设置缓存在内部,因此必须在 LoadHTMLGlob() / LoadHTMLFiles() 之前设置。否则注册的函数不会生效,模板中出现自定义管道符时会报 "undefined function" 错误。

4. 模板继承(Base Template)

Go 原生模板不支持继承,但可以通过 define + block + template 模拟页面布局系统:base.html 定义骨架和可替换区块(block),子模板用 define 覆盖对应区块。

[!INFO] block vs define 的区别

  • {{block "name" .}}...{{end}} — 用于 base.html 中定义默认内容,子模板可以选择性覆盖
  • {{define "name"}}...{{end}} — 用于 子模板 中实现自己的版本来覆盖 base 中的默认内容
  • 两者配合才能实现"继承"效果
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>{{block "title" .}}Default{{end}}</title>
</head>
<body>
    <nav>...</nav>
    {{block "content" .}}{{end}}
    <footer>...</footer>
</body>
</html>

<!-- templates/index.html — 引用 base -->
{{define "title"}}Home{{end}}
{{define "content"}}
    <h1>Welcome</h1>
    <p>{{.Message}}</p>
{{end}}

渲染时传入组合后的模板:

r.LoadHTMLFiles(
    "templates/base.html",
    "templates/index.html",
)
// LoadHTMLFiles 将多个文件加载到同一个 *template.Template 中,
// 因此 index.html 中的 define 可以找到 base.html 中 block 的定义并合并输出。

[!TIP] 更复杂的场景 → 多模板引擎 如果项目需要按模块隔离不同的模板集(如后台管理一套模板、前台展示一套模板),可以使用第三方库 gin-contrib/multitemplate,它允许在同一 Router 下注册多个独立的 *template.Template 对象。详见本章「进阶用法」部分。

5. 将模板打包进单一二进制(Go 1.16+)

使用 embed.FS 把模板文件嵌入 Go 编译产物,适合 Docker 单镜像部署:

import _ "embed"
import "html/template"

//go:embed templates/*.html
var templateFS embed.FS

func main() {
    r := gin.Default()

    // Go 1.23+:ParseFS 直接返回 *template.Template,配合 Must 处理错误
    t := template.Must(template.New("").ParseFS(templateFS, "templates/*.html"))
    r.SetHTMLTemplate(t)

    r.Run(":8080")
}

[!INFO] 原理 SetHTMLTemplate 替换 Gin 内部的默认模板对象(*template.Template),之后所有 c.HTML() 调用都走这个已嵌入的模板集。如果只需加载单个新模板而非全部替换,也可以用 t.AddParseTree("name", tree) 追加。

[!NOTE] 项目目录结构

cmd/
├── server/main.go       ← embed 入口
templates/               ← 模板文件,不会被 gitignore
├── base.html
├── index.html
└── error.html
internal/
└── handlers/

embed 指令位于 main.go 所在目录下执行,templates/ 是相对于 main.go 的路径。部署时只需一个二进制文件,不再需要挂载 Volume 或复制模板文件到容器中。

6. 模板安全注意事项

[!INFO] 安全原则 永远不要手动拼接入用户可控的 HTML 内容。Go 的 html/template 包根据上下文自动选择转义策略,使用默认字符串类型即可获得最安全的输出。只有在明确需要渲染富文本时才考虑绕过转义。

flowchart LR
    A["用户输入"] --> B["存入 gin.H 数据"]
    B --> C["传入 c.HTML 渲染"]
    C --> D{是否 HTML 转义?}
    D -->|"是 ✅"| E["自动转义,安全"]
    D -->|"否 ❌"| F["XSS 漏洞"]

    style E fill:#e8f5e9
    style F fill:#ffebee

Go 的 html/template 包自动对上下文相关内容进行转义:

  • 在 HTML body 中 → 转义 <>&"'
  • 在 attribute 中 → 转义引号和 <>&
  • 在 JS 上下文中 → 转义 ' 和 <\/

唯一例外: 使用 template.HTML / template.JS 类型包装的内容不会转义——这意味着你主动告诉模板"这段内容是安全的"。滥用会导致 XSS:

// ❌ 危险:用户输入未过滤就标为 safe
c.HTML(200, "page", gin.H{
    "content": template.HTML(userInput),  // 可能被注入 <script>
})

// ✅ 安全:让模板自己决定如何转义
c.HTML(200, "page", gin.H{
    "content": userInput,  // html/template 自动转义
})

[!WARNING] XSS 防御清单

  1. 绝不用 template.HTML() 包装用户输入 — 如果业务需要富文本,先用 bluemonday / goquery 等库做白名单过滤
  2. 设置正确的 Content-Type — c.HTML() 默认发送 text/html; charset=utf-8,不要手动改为 text/plain 后直接输出用户数据
  3. CSP 头 — 在中间件中设置 Content-Security-Policy: default-src 'self' 作为纵深防御的最后一道防线

7. 多模板引擎(gin-contrib/multitemplate)

当项目中存在多个隔离的模板集(如后台管理一套、前台展示一套),Gin 内置方案会将所有文件合并到同一个模板对象中。此时可以使用第三方库 gin-contrib/multitemplate 实现真正的多模板引擎:

import "github.com/gin-contrib/multitemplate"

func main() {
    r := gin.Default()

    // 创建工厂:按需构建独立的 Template 集合
    tmpls := multitemplate.NewTemplates()

    // Admin 模板集:base.html + admin/index.html + admin/dashboard.html
    tmpls.AddTemplate("admin", template.Must(template.ParseGlob(
        "templates/admin/*.html",
    )))

    // Public 模板集:base.html + index.html + about.html
    tmpls.AddTemplate("public", template.Must(template.ParseGlob(
        "templates/public/*.html",
    )))

    // 注册为 Gin 的 HTML Render
    r.HTMLRender = tmpls

    r.GET("/admin/dashboard", func(c *gin.Context) {
        c.HTML(200, "admin/index", gin.H{"Title": "Dashboard"})
    })

    r.GET("/", func(c *gin.Context) {
        c.HTML(200, "public/index", gin.H{"Title": "Home Page"})
    })

    r.Run(":8080")
}

[!TIP] 何时需要多模板引擎?

  • ✅ 项目有独立的后台/前台模板目录,且命名可能冲突(如都有 index.html)
  • ✅ 不同模块需要不同的 FuncMap 或全局变量
  • ❌ 单套模板系统即可满足时,直接用 LoadHTMLFiles + define/block 更简单

关联笔记