Gin框架中的HTML多模板渲染和生成静态HTML Gin HTML rendering and generate static HTML

72 阅读5分钟

单文件渲染

Gin 默认允许只使用一个 html 模板。c.HTML(),可以指定一个HTML文件。这个待渲染的模板中,通过{{ template "header.html" .}}来引用公用部分 适合页面数量较少的场景(3~5个)

# 文件结构如下,无需在模板文件中定义define,也不支持子文件夹
templates
├── about.html
├── footer.html
├── head.html
├── header.html
└── index.html
<!-- 公用部分header.html -->
<header>
  This is the header
</header>
<!-- 引用公用部分 index.html -->
{{ template "head.html" .}}
{{ template "header.html" .}}
{{ template "aside.html" .}}
<main> <h1>content:{{ .title}}</h1></main>
{{ template "footer.html" .}}
</body>
</html>

多模板渲染

github.com/gin-contrib… 可以使用 go 1.6 block template 等功能。

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"weued/renderer"
)

func main() {
	fmt.Println("Hello World!")
	r := gin.Default()
	r.HTMLRender = renderer.LoadTemplates("view")
	r.GET("/", func(c *gin.Context) {
		c.HTML(200, "index.html", gin.H{
			"title": "Home Page",
		})
	})
	r.Run(":9099")
}
package renderer

import (
	"github.com/gin-contrib/multitemplate"
	"path/filepath"
)

func LoadTemplates(templateDir string) multitemplate.Renderer {
	r := multitemplate.NewRenderer()

	layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
	if err != nil {
		panic(err)
	}
	partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
	if err != nil {
		panic(err)
	}
	pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
	if err != nil {
		panic(err)
	}

	funcMap := FuncMap()

	for _, page := range pages {
		// 合并 layout + partials + page
		files := append([]string{}, layouts...)
		files = append(files, partials...)
		files = append(files, page)

		name := filepath.Base(page)
		r.AddFromFilesFuncs(name, funcMap, files...)
	}

	return r
}
package renderer

import (
	"fmt"
	"html/template"
	"regexp"
	"strings"
	"time"
)

// 正则匹配 URL
var (
	urlRegex = regexp.MustCompile(`(https?://[^\s"'<>]+)`)
	tagRegex = regexp.MustCompile(`<(\/?[^>]+)>`)
)

// FuncMap 提供模板可用的函数
func FuncMap() template.FuncMap {
	return template.FuncMap{
		"formatDate": func(t any) string {
			// 支持 time.Time 或 string
			switch v := t.(type) {
			case string:
				if len(v) >= 10 {
					return v[:10] // 截取 YYYY-MM-DD
				}
				return v
			case time.Time:
				return v.Format("2006-01-02")
			default:
				return ""
			}
		},
		"formatDateTime": func(t time.Time) string {
			return t.Format("2006-01-02 15:04")
		},
		"nl2br": func(text string) template.HTML {
			// 替换 \r\n 或 \n 为 <br>
			s := strings.ReplaceAll(text, "\r\n", "<br>")
			s = strings.ReplaceAll(s, "\n", "<br>")
			return template.HTML(s) // 用 template.HTML 标记为安全 HTML
		},
		"linkify": func(text template.HTML) template.HTML {
			s := string(text)

			// 暂存所有HTML标签
			var tags []string
			s2 := tagRegex.ReplaceAllStringFunc(s, func(tag string) string {
				tags = append(tags, tag)
				return fmt.Sprintf("{{TAG%d}}", len(tags)-1)
			})

			// 替换文本中的url为<a>标签
			s2 = urlRegex.ReplaceAllStringFunc(s2, func(url string) string {
				return `<a href="` + url + `" target="_blank">` + url + `</a>`
			})

			// 还原HTML标签
			for i, tag := range tags {
				s2 = strings.ReplaceAll(s2, fmt.Sprintf("{{TAG%d}}", i), tag)
			}

			return template.HTML(s2)
		},
	}
}
view
├── layouts
│   └── base.html
├── pages
│   └── index.html
└── partials
    └── footer.html
<!-- base.html-->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, minimum-scale=1.0,user-scalable=no" />
    <title>title</title>
  </head>

  <body>
    {{ template "content" . }}
    <button class="btn btn-primary">base</button>
  </body>
</html>
{{ define "content" }}
<h1 class="text-slate-500">Hello,world!</h1>
<button class="btn btn-primary">content</button>
{{ template "footer" . }} {{ end }}
{{define "footer"}}
<div class="footer">
  <div class="content">
    <p>
      &copy; 2025
      <a href="https://weued.com">weued.com</a>. All rights reserved.
    </p>
  </div>
</div>
{{end}}

单文件生成静态HTML

// 生成静态 HTML 文件
func GenerateStaticHTML(file string,outputPath string,outputFile string,data any) error {
	// 加载模板
	tmpl, err := template.ParseFiles(file)
	if err != nil {
		return err
	}



	// 渲染到 buffer
	var buf bytes.Buffer
	err = tmpl.Execute(&buf, data)
	if err != nil {
		return err
	}

	// 写入静态文件
	os.MkdirAll(outputPath, 0755)
	return os.WriteFile(filepath.Join(outputPath, outputFile), buf.Bytes(), 0644)
}

多模板生成静态HTML

package main

import (
    "bytes"
    "fmt"
    "html/template"
    "os"
    "path/filepath"

    "try/renderer"
)

// RenderStaticPage 将指定模板渲染成静态 HTML 文件
func RenderStaticPage(templateDir, pageFile, outputPath string, data map[string]any) error {
    // 1. 加载所有模板文件(添加 FuncMap)
    tmpl, err := template.New("").Funcs(renderer.FuncMap()).ParseGlob(filepath.Join(templateDir, "**/*.html"))
    if err != nil {
        return fmt.Errorf("failed to parse templates: %w", err)
    }

    // 2. 查找 base.html 模板
    base := tmpl.Lookup("base.html")
    if base == nil {
        return fmt.Errorf("base.html not found")
    }

    // 3. 克隆 base.html 模板
    baseTmpl, err := base.Clone()
    if err != nil {
        return fmt.Errorf("failed to clone base template: %w", err)
    }

    // 4. 查找页面模板
    pageTemplate := tmpl.Lookup(filepath.Base(pageFile))
    if pageTemplate == nil {
        return fmt.Errorf("page template %s not found", pageFile)
    }

    // 5. 将页面模板内容添加到 "content" 模板中
    _, err = baseTmpl.AddParseTree("content", pageTemplate.Tree)
    if err != nil {
        return fmt.Errorf("failed to add page template: %w", err)
    }

    // 6. 渲染模板到 buffer
    var buf bytes.Buffer
    if err := baseTmpl.Execute(&buf, data); err != nil {
        return fmt.Errorf("failed to execute template: %w", err)
    }

    // 7. 创建输出目录
    if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
        return fmt.Errorf("failed to create output directory: %w", err)
    }

    // 8. 一次性写入文件
    if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil {
        return fmt.Errorf("failed to write output file: %w", err)
    }

    return nil
}

func main() {
    data := map[string]any{
        "title":    "首页",
        "pageFile": "index.html",
    }

    err := RenderStaticPage("view", "index.html", "output/index.html", data)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    fmt.Println("静态页面生成完成: output/index.html")
}

相关方法函数签名

func New(name string) *Template

tmpl := template.New("") // 创建名为 空 的模板

  • 返回一个新的空模板对象 *Template,这个对象还没有任何内容。这个模板对象本身是空的,需要通过 .Parse().ParseFiles().ParseGlob() 来填充内容

func ParseFiles(filenames ...string) (*Template, error)

tmpl, err := template.ParseFiles("template/index.html")

  • 将文件内容解析成模板对象。每个模板的名称默认为文件的基础名(不含路径)。例如 "templates/index.html" 的模板名称是 "index.html"
  • 文件的先后次序是有区别的 —— 尤其是你在用 Go 的模板继承({{ define }} / {{ template }} 机制时。
  • 最后一个文件(最后一个参数)将成为返回的“主模板(root template)”。
    • 所有文件都会被解析并加入到同一个模板集合中;
    • ParseFiles() 返回的模板对象,是最后一个文件对应的模板。

func ParseGlob(pattern string) (*Template, error)

tmpl, err := template.ParseGlob("templates/**/*.html")

根据给定的通配符(glob pattern)匹配所有模板文件,然后依次解析并组合成一个模板集合(*template.Template)。

func Must(t *Template, err error) *Template

tmpl := template.Must(template.ParseGlob("templates/**/*.html"))

用于简化模板初始化时的错误处理

  • 如果 err != nil → 直接 panic(err)(程序崩溃并打印错误);

  • 如果 err == nil → 返回模板对象。

链式调用

tmpl := template.Must( template.New("root").ParseFiles("templates/layouts/base.html").ParseGlob("templates/pages/*.html"),
)

tmpl := template.Must(
    template.New("root").ParseGlob("templates/**/*.html"),
)

func (t *Template) Execute(wr io.Writer, data any) error

err := tmpl.Execute(writer, data) //执行主模板(第一个解析的文件) err := tmpl.ExecuteTemplate(writer, "index.html", data) //执行指定名称的模板

解析后需要使用 ExecuteExecuteTemplate 来渲染

ExecuteTemplate(w io.Writer, name string, data any) error

err = tmpl.ExecuteTemplate(os.Stdout, "base.html", data)

渲染模板集合中指定名字的模板

允许你从同一个模板集合中选择任意子模板渲染

var buf bytes.Buffer

bytes.Buffer 位于标准库 bytes 包,是一个 可变大小的字节缓冲区(buffer),常用于:

  • 临时存储字节数据
  • 构建或拼接字符串/字节切片
  • 作为 io.Readerio.Writer 使用

它的内部结构是一个可自动增长的字节切片 ([]byte),性能比每次拼接字符串更高,尤其是在大量拼接场景下。

方法功能
Write(p []byte)向 Buffer 末尾写入字节,返回写入长度和错误
WriteString(s string)向 Buffer 写入字符串
Read(p []byte)从 Buffer 读取数据到 p
Bytes()返回 Buffer 内部的字节切片(只读,不影响 Buffer)
String()返回 Buffer 内部数据的字符串形式
Reset()清空 Buffer,容量保留

func WriteFile(name string, data []byte, perm FileMode) error

写入文件的便捷函数,一次性完成创建文件、写入内容、关闭文件的操作。

  • 如果文件已存在,会被完全覆盖
  • 如果文件不存在,会自动创建