GoHTML

一个强大且易用的 Go HTML 模板引擎,基于标准库 html/template,支持模板继承、热重载和丰富的内置函数。
特性
- 🎯 模板继承 - 使用
{{ extends "parent.gohtml" }} 实现模板继承
- 🔥 热重载 - 开发环境支持模板热重载,无需重启
- 📦 embed.FS 支持 - 生产环境可将模板编译进二进制
- 🧩 丰富的内置函数 - 字符串处理、日期格式化、逻辑判断等 20+ 函数
- 🌐 HTTP 中间件 - 内置 HTTP 中间件,轻松集成到 Web 应用
- 🎨 全局数据注入 - 支持在 context 中注入全局模板数据
- 🔒 类型安全 - 基于标准库
html/template,自动转义防止 XSS
- 0️⃣ 零依赖 - 仅依赖 Go 标准库
安装
go get github.com/hupeh/gohtml
快速开始
基础示例
创建模板文件 templates/index.gohtml:
<!DOCTYPE html>
<html>
<head>
<title>{{ .Title }}</title>
</head>
<body>
<h1>{{ .Message }}</h1>
<p>当前时间: {{ now | datetimeFormat }}</p>
</body>
</html>
Go 代码:
package main
import (
"fmt"
"github.com/hupeh/gohtml"
)
func main() {
// 创建引擎
engine := gohtml.New()
// 从目录加载模板
err := engine.JoinDir("./templates")
if err != nil {
panic(err)
}
// 渲染模板
result, err := engine.Render("index", map[string]any{
"Title": "欢迎",
"Message": "Hello, GoHTML!",
})
if err != nil {
panic(err)
}
fmt.Println(string(result))
}
模板继承
父模板 templates/layout.gohtml:
<!DOCTYPE html>
<html>
<head>
<title>{{ block "title" . }}默认标题{{ end }}</title>
<style>
{{ block "style" . }}{{ end }}
</style>
</head>
<body>
<header>
<h1>我的网站</h1>
</header>
<main>
{{ block "content" . }}{{ end }}
</main>
<footer>
<p>© 2024 我的网站</p>
</footer>
</body>
</html>
子模板 templates/home.gohtml:
{{ extends "layout.gohtml" }}
{{ define "title" }}首页 - 我的网站{{ end }}
{{ define "style" }}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
}
{{ end }}
{{ define "content" }}
<h2>欢迎来到首页</h2>
<p>用户: {{ .Username }}</p>
<p>今天是 {{ now | dateFormat }}</p>
{{ end }}
Go 代码:
engine := gohtml.New()
engine.JoinDir("./templates")
result, _ := engine.Render("home", map[string]any{
"Username": "张三",
})
使用 embed.FS (生产环境)
package main
import (
"embed"
"github.com/hupeh/gohtml"
)
//go:embed templates/*.gohtml
var templateFS embed.FS
func main() {
engine := gohtml.New()
// 从嵌入的文件系统加载模板
err := engine.JoinFS(templateFS)
if err != nil {
panic(err)
}
result, _ := engine.Render("index", map[string]any{
"Title": "生产环境",
})
}
HTTP 中间件集成
package main
import (
"net/http"
"github.com/hupeh/gohtml"
)
func main() {
engine := gohtml.New()
engine.JoinDir("./templates")
mux := http.NewServeMux()
// 使用中间件注入引擎和全局数据
handler := gohtml.Middleware(engine, map[string]any{
"SiteName": "我的网站",
"Version": "1.0.0",
})(mux)
// 路由处理 - 方式 1:使用 Render 函数(推荐)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Render 函数会自动从 context 获取引擎和全局数据
result, err := gohtml.Render(r.Context(), "index", map[string]any{
"Title": "首页",
"Content": "欢迎访问",
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(result)
})
// 路由处理 - 方式 2:手动获取引擎
mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
eng, _ := gohtml.FromContext(r.Context())
data := gohtml.GetData(r.Context())
data["Title"] = "关于我们"
result, _ := eng.Render("about", data)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(result)
})
http.ListenAndServe(":8080", handler)
}
内置函数完整列表
GoHTML 提供了丰富的内置函数,包括 Go 标准库的函数和自定义扩展函数。
📋 函数来源说明
- 🔵 标准库 - 来自 Go
html/template 标准库,行为保持不变
- 🟢 覆盖 - 覆盖了标准库的同名函数,行为已修改
- 🟣 扩展 - GoHTML 新增的自定义函数
⚠️ 重要说明:自动转义与类型转换函数
GoHTML 基于 html/template,默认会自动转义所有输出以防止 XSS 攻击。
<!-- 自动转义示例 -->
{{ .Content }}
<!-- 如果 Content = "<script>alert('xss')</script>" -->
<!-- 输出: <script>alert('xss')</script> -->
<!-- 浏览器显示为纯文本,不执行脚本 -->
当你需要渲染可信的 HTML 内容时(如富文本编辑器的输出),使用类型转换函数跳过转义:
<!-- 跳过转义示例 -->
{{ html .Content }}
<!-- 如果 Content = "<h2>标题</h2><p>段落</p>" -->
<!-- 输出: <h2>标题</h2><p>段落</p> -->
<!-- 浏览器正确渲染 HTML -->
安全提示:
- ✅ 对用户输入的内容:使用
{{ .UserInput }}(自动转义)
- ✅ 对可信的 HTML 内容:使用
{{ html .TrustedContent }}(跳过转义)
- ⚠️ 永远不要对未经验证的用户输入使用
html 函数,这会导致 XSS 漏洞
类型转换函数
用于标记可信内容,跳过自动转义:
| 函数 |
来源 |
说明 |
示例 |
html |
🟢 覆盖 |
标记为安全的 HTML(标准库是转义,这里是跳过转义) |
{{ html "<b>粗体</b>" }} |
css |
🟣 扩展 |
标记为安全的 CSS |
{{ css "color: red;" }} |
js |
🟢 覆盖 |
标记为安全的 JavaScript(标准库是转义,这里是跳过转义) |
{{ js "alert('hi')" }} |
url |
🟣 扩展 |
标记为安全的 URL |
{{ url "https://example.com" }} |
attr |
🟣 扩展 |
标记为安全的 HTML 属性 |
{{ attr "data-id='123'" }} |
字符串处理函数
| 函数 |
来源 |
说明 |
示例 |
upper |
🟣 扩展 |
转换为大写 |
{{ "hello" | upper }} → HELLO |
lower |
🟣 扩展 |
转换为小写 |
{{ "WORLD" | lower }} → world |
trim |
🟣 扩展 |
去除两端空白 |
{{ " text " | trim }} → text |
trimPrefix |
🟣 扩展 |
去除前缀 |
{{ trimPrefix "Hello" "He" }} → llo |
trimSuffix |
🟣 扩展 |
去除后缀 |
{{ trimSuffix "Hello" "lo" }} → Hel |
replace |
🟣 扩展 |
替换字符串 |
{{ replace "foo bar" "bar" "baz" -1 }} |
split |
🟣 扩展 |
分割字符串 |
{{ split "a,b,c" "," }} → [a b c] |
join |
🟣 扩展 |
连接字符串数组 |
{{ join .array ", " }} |
contains |
🟣 扩展 |
检查是否包含 |
{{ contains "hello" "ll" }} → true |
hasPrefix |
🟣 扩展 |
检查前缀 |
{{ hasPrefix "hello" "he" }} → true |
hasSuffix |
🟣 扩展 |
检查后缀 |
{{ hasSuffix "hello" "lo" }} → true |
print |
🔵 标准库 |
格式化输出(同 fmt.Sprint) |
{{ print "a" "b" }} → ab |
printf |
🔵 标准库 |
格式化输出(同 fmt.Sprintf) |
{{ printf "%s-%d" "id" 123 }} → id-123 |
println |
🔵 标准库 |
格式化输出带换行(同 fmt.Sprintln) |
{{ println "hello" }} |
len |
🔵 标准库 |
返回长度 |
{{ len .Array }}, {{ len .String }} |
index |
🔵 标准库 |
访问数组/切片/映射元素 |
{{ index .Array 0 }}, {{ index .Map "key" }} |
slice |
🔵 标准库 |
切片操作 |
{{ slice .Array 1 3 }} |
日期时间函数
| 函数 |
来源 |
说明 |
示例 |
now |
🟣 扩展 |
获取当前时间 |
{{ now }} |
formatTime |
🟣 扩展 |
自定义格式化 |
{{ formatTime .time "2006-01-02" }} |
dateFormat |
🟣 扩展 |
格式化为日期 |
{{ now | dateFormat }} → 2024-01-13 |
datetimeFormat |
🟣 扩展 |
格式化为日期时间 |
{{ now | datetimeFormat }} → 2024-01-13 15:04:05 |
rfc3339 |
🟣 扩展 |
RFC3339 格式化 |
{{ now | rfc3339 }} → 2024-01-13T15:04:05Z07:00 |
逻辑与比较函数
| 函数 |
来源 |
说明 |
示例 |
not |
🔵 标准库 |
逻辑非 |
{{ if not .IsEmpty }}有内容{{ end }} |
and |
🔵 标准库 |
逻辑与 |
{{ if and .A .B }}都为真{{ end }} |
or |
🔵 标准库 |
逻辑或 |
{{ if or .A .B }}至少一个为真{{ end }} |
eq |
🔵 标准库 |
等于 (==) |
{{ if eq .Status "active" }}激活{{ end }} |
ne |
🔵 标准库 |
不等于 (!=) |
{{ if ne .Count 0 }}非零{{ end }} |
lt |
🔵 标准库 |
小于 (<) |
{{ if lt .Age 18 }}未成年{{ end }} |
le |
🔵 标准库 |
小于等于 (<=) |
{{ if le .Score 60 }}不及格{{ end }} |
gt |
🔵 标准库 |
大于 (>) |
{{ if gt .Price 100 }}贵{{ end }} |
ge |
🔵 标准库 |
大于等于 (>=) |
{{ if ge .Age 18 }}成年{{ end }} |
default |
🟣 扩展 |
提供默认值 |
{{ default "未知" .Name }} |
其他函数
| 函数 |
来源 |
说明 |
示例 |
urlquery |
🔵 标准库 |
URL 查询参数转义 |
{{ urlquery "a b" }} → a+b |
call |
🔵 标准库 |
调用函数 |
{{ call .Method .Arg }} |
完整示例
博客应用示例
布局模板 templates/layout.gohtml:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ block "title" . }}{{ .SiteName }}{{ end }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; line-height: 1.6; }
header { background: #333; color: #fff; padding: 1rem; }
nav a { color: #fff; margin-right: 1rem; text-decoration: none; }
main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
footer { background: #f4f4f4; padding: 1rem; text-align: center; margin-top: 2rem; }
{{ block "style" . }}{{ end }}
</style>
</head>
<body>
<header>
<h1>{{ .SiteName }}</h1>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
<a href="/contact">联系</a>
</nav>
</header>
<main>
{{ block "content" . }}{{ end }}
</main>
<footer>
<p>© {{ now | formatTime "2006" }} {{ .SiteName }} | 版本 {{ .Version }}</p>
</footer>
</body>
</html>
文章列表 templates/posts.gohtml:
{{ extends "layout.gohtml" }}
{{ define "title" }}文章列表 - {{ .SiteName }}{{ end }}
{{ define "content" }}
<h2>最新文章</h2>
{{ if .Posts }}
{{ range .Posts }}
<article style="border-bottom: 1px solid #eee; padding: 1rem 0;">
<h3><a href="/posts/{{ .ID }}">{{ .Title }}</a></h3>
<p>{{ .Summary }}</p>
<small>
作者: {{ .Author | upper }} |
发布时间: {{ .PublishedAt | datetimeFormat }}
</small>
</article>
{{ end }}
{{ else }}
<p>暂无文章</p>
{{ end }}
{{ end }}
文章详情 templates/post.gohtml:
{{ extends "layout.gohtml" }}
{{ define "title" }}{{ .Post.Title }} - {{ .SiteName }}{{ end }}
{{ define "content" }}
<article>
<h2>{{ .Post.Title }}</h2>
<p>
<small>
作者: {{ .Post.Author }} |
发布: {{ .Post.PublishedAt | datetimeFormat }} |
标签: {{ join .Post.Tags ", " }}
</small>
</p>
<div style="margin-top: 2rem;">
{{ html .Post.Content }}
</div>
{{ if .Post.UpdatedAt }}
<p style="margin-top: 2rem; color: #666;">
最后更新: {{ .Post.UpdatedAt | datetimeFormat }}
</p>
{{ end }}
</article>
<section style="margin-top: 3rem;">
<h3>相关文章</h3>
<ul>
{{ range .RelatedPosts }}
<li><a href="/posts/{{ .ID }}">{{ .Title }}</a></li>
{{ else }}
<li>暂无相关文章</li>
{{ end }}
</ul>
</section>
{{ end }}
API 文档
Engine
type Engine struct {
// 私有字段
}
// 创建新的引擎实例
func New() *Engine
// 从 fs.FS 加载模板(支持 embed.FS)
func (e *Engine) JoinFS(fsys fs.FS) error
// 从目录加载模板(支持热重载)
func (e *Engine) JoinDir(dir string) error
// 渲染模板,返回字节数组
func (e *Engine) Render(name string, data any) ([]byte, error)
Template
type Template struct {
// 私有字段
}
// 创建新的模板实例
func NewTemplate() *Template
// 设置模板分隔符
func (x *Template) Delims(left, right string) *Template
// 注册自定义函数
func (x *Template) Funcs(funcMap template.FuncMap) *Template
// 查找模板
func (x *Template) Lookup(name string) *template.Template
// 执行模板
func (x *Template) ExecuteTemplate(wr io.Writer, name string, data any) error
// 从目录加载模板
func (x *Template) ParseDir(root string, extensions []string) error
// 从 fs.FS 加载模板
func (x *Template) ParseFS(root fs.FS, extensions []string) error
Context 函数
// 将引擎存入 context
func WithEngine(ctx context.Context, engine *Engine) context.Context
// 从 context 获取引擎
func FromContext(ctx context.Context) (*Engine, bool)
// 存入单个数据
func WithDatum(ctx context.Context, key string, value any) context.Context
// 存入多个数据
func WithData(ctx context.Context, dataMap map[string]any) context.Context
// 获取所有数据
func GetData(ctx context.Context) map[string]any
// 从 context 中获取引擎并渲染模板(自动合并全局数据)
func Render(ctx context.Context, name string, data map[string]any) ([]byte, error)
HTTP 中间件
// 创建 HTTP 中间件
func Middleware(engine *Engine, globals ...map[string]any) func(next http.Handler) http.Handler
高级用法
自定义函数
engine := gohtml.New()
// 注册自定义函数(需在加载模板前)
tmpl := gohtml.NewTemplate()
tmpl.Funcs(template.FuncMap{
"currency": func(amount float64) string {
return fmt.Sprintf("¥%.2f", amount)
},
"truncate": func(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length] + "..."
},
})
// 使用自定义模板
engine.t = tmpl
engine.JoinDir("./templates")
在模板中使用:
<p>价格: {{ currency 99.99 }}</p>
<p>摘要: {{ truncate .Content 100 }}</p>
自定义分隔符
tmpl := gohtml.NewTemplate()
tmpl.Delims("[[", "]]") // 使用 [[ ]] 替代 {{ }}
嵌套模板
<!-- components/button.gohtml -->
{{ define "button" }}
<button class="{{ .Class }}" type="{{ default "button" .Type }}">
{{ .Text }}
</button>
{{ end }}
<!-- page.gohtml -->
{{ template "button" dict "Class" "primary" "Text" "提交" "Type" "submit" }}
性能优化
生产环境最佳实践
- 使用 embed.FS: 将模板编译进二进制,避免磁盘 I/O
- 模板缓存: Engine 会自动缓存已解析的模板
- 避免频繁重载: 生产环境不要使用
JoinDir,优先使用 JoinFS
//go:build !dev
package main
import (
"embed"
"github.com/hupeh/gohtml"
)
//go:embed templates/*.gohtml
var templateFS embed.FS
func newEngine() *gohtml.Engine {
engine := gohtml.New()
engine.JoinFS(templateFS)
return engine
}
开发环境热重载
//go:build dev
package main
import "github.com/hupeh/gohtml"
func newEngine() *gohtml.Engine {
engine := gohtml.New()
engine.JoinDir("./templates")
return engine
}
构建命令:
# 开发环境
go build -tags dev
# 生产环境
go build
测试
运行测试:
go test ./...
运行带覆盖率的测试:
go test -cover ./...
许可证
MIT License - 详见 LICENSE
贡献
欢迎提交 Issue 和 Pull Request!
作者
hupeh
致谢
本项目基于 Go 标准库 html/template 构建,灵感来自 Django 和 Jinja2 模板引擎。