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

313 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [后端, Go, Gin, 模板, HTML]
create time: 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()` 加载模板,并以**文件名(不带路径)**作为模板名建立映射:
```go
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`:
```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()` 第二个参数只需匹配文件名即可。
```go
// 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` 接口来注册自定义函数。这些函数可以在模板中以 **管道符 `|`** 链式调用:
```go
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")
}
```
模板中使用:
```html
<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 中的默认内容
> - 两者配合才能实现"继承"效果
```html
<!-- 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}}
```
渲染时传入组合后的模板:
```go
r.LoadHTMLFiles(
"templates/base.html",
"templates/index.html",
)
// LoadHTMLFiles 将多个文件加载到同一个 *template.Template 中,
// 因此 index.html 中的 define 可以找到 base.html 中 block 的定义并合并输出。
```
> [!TIP] 更复杂的场景 → 多模板引擎
> 如果项目需要按模块隔离不同的模板集(如后台管理一套模板、前台展示一套模板),可以使用第三方库 [gin-contrib/multitemplate](https://github.com/gin-contrib/multitemplate),它允许在同一 Router 下注册多个独立的 `*template.Template` 对象。详见本章「进阶用法」部分。
### 5. 将模板打包进单一二进制(Go 1.16+)
使用 `embed.FS` 把模板文件嵌入 Go 编译产物,适合 Docker 单镜像部署:
```go
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` 包根据**上下文自动选择转义策略**,使用默认字符串类型即可获得最安全的输出。只有在明确需要渲染富文本时才考虑绕过转义。
```mermaid
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:
```go
// ❌ 危险:用户输入未过滤就标为 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`](https://github.com/gin-contrib/multitemplate) 实现真正的多模板引擎:
```go
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` 更简单
## 关联笔记
- [[GIN/9-response-rendering]] — 除了 HTML,Gin 还支持 JSON/XML 等多种渲染
- [[GIN/11-static-files]] — 静态资源(CSS/JS/图片)通过 Static 路由提供