一个强大且易用的 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": "张三",
})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": "生产环境",
})
}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 }}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)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
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 中间件
func Middleware(engine *Engine, globals ...map[string]any) func(next http.Handler) http.Handlerengine := 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!
本项目基于 Go 标准库 html/template 构建,灵感来自 Django 和 Jinja2 模板引擎。