模板渲染与 SEO
模板渲染与 SEO
2010 年,Google 宣布开始支持爬取 JavaScript 渲染的页面。这个消息让 Web 开发圈欢欣鼓舞,SPA(单页应用)时代正式到来。
2023 年,那个时代的账单来了。
一位搜索引擎优化工程师在 Twitter 上发帖:他的客户把一个服务端渲染的 React 应用迁移到了纯客户端渲染的 SPA,流量在 6 个月内下降了 40%。Google 的 JavaScript 渲染队列有时会延迟数天,某些页面甚至永远不会被索引。
服务端渲染(SSR)正在复兴。Next.js、Nuxt.js、SvelteKit……所有主流前端框架都在加入 SSR 支持,但它们本质上是在模拟 Go/Python/PHP 已经做了 20 年的事情:在服务器上把 HTML 直接生成好,发给浏览器。
而 Go 的 html/template 包,从第一天起就是为这个用途设计的。
Level 1 · 你需要知道的
SSR vs CSR:SEO 的根本差异
CSR(客户端渲染)的 SEO 问题并不是 "Google 不支持 JavaScript"——Google 支持,但有几个根本限制:
-
两阶段爬取:Googlebot 先抓取 HTML,把需要 JavaScript 渲染的页面放入渲染队列,延迟可能从几秒到几天不等。在此期间,页面内容对 Google 不可见。
-
渲染预算:Google 对每个网站的渲染资源是有限制的。大型网站的某些页面可能根本不会被渲染,永远停留在抓取但未渲染的状态。
-
第三方 SEO 工具:Bing、百度、社交媒体平台(Twitter/Facebook/微信)的爬虫通常不执行 JavaScript,它们看到的就是空壳 HTML。
-
页面速度:CSR 页面在 JavaScript 下载和执行完成之前内容不可见,FCP(首次内容绘制)时间长,影响 Core Web Vitals 评分,间接影响排名。
SSR 的 SEO 优势:
- HTML 包含完整内容,爬虫第一次访问就能获取所有文本
- FCP 时间短(服务器直接返回完整 HTML,浏览器立即渲染)
- 天然支持社交媒体预览(Open Graph 标签在 HTML 里)
- 可以精确控制每个页面的
<title>和<meta description>
Go 的 SSR 优势:
相比 Node.js 的 SSR(Next.js),Go 的 SSR 有一个关键优势:没有 JavaScript 运行时开销。html/template 的渲染速度极快(微秒级),服务器可以在 CPU 密集的渲染任务上保持极低延迟。在 Node.js 的 SSR 场景下,V8 的事件循环和 React 的虚拟 DOM diff 计算反而是瓶颈。
html/template 的安全模型:自动转义
Go 的 html/template 包和 text/template 的核心差异是:自动上下文感知转义(Context-Aware Escaping)。
// text/template:原始输出,不安全
// html/template:自动转义,安全
import "html/template"
tmpl := template.Must(template.New("test").Parse(`
<div class="{{.Class}}">{{.Content}}</div>
<script>var name = "{{.Name}}";</script>
`))
data := struct {
Class string
Content string
Name string
}{
Class: `evil" onclick="alert(1)`,
Content: `<script>alert('xss')</script>`,
Name: `"; alert(1); //`,
}
html/template 的输出:
<div class="evil" onclick="alert(1)">
<script>alert('xss')</script>
</div>
<script>var name = "\"; alert(1); //";</script>
注意:同样的变量在不同上下文(HTML 属性、HTML 文本、JavaScript 字符串)使用了不同的转义规则,这不是简单的全局替换,而是上下文感知的转义。
html/template 认识 6 种上下文:HTML 正文、HTML 属性值、HTML 属性名、URL、CSS、JavaScript。每种上下文的转义规则不同。这使得 html/template 对 XSS 注入几乎免疫。
意外使用原始 HTML:有时你确实需要输出未转义的 HTML(例如富文本内容经过了 sanitizer 处理)。Go 提供了 template.HTML 类型:
// 危险:只在确认内容安全时使用
safeHTML := template.HTML("<strong>Bold text</strong>")
永远不要把用户输入直接转为 template.HTML——这会绕过所有 XSS 保护。
Level 2 · 它是怎么工作的
模板继承:define、block、template
Go 的模板系统通过三个指令实现继承和组合:
{{define "name"}}...{{end}}:定义一个命名模板块(可被其他模板调用)。
{{template "name" .}}:调用(嵌入)一个命名模板块,. 是传递的数据。
{{block "name" .}}...{{end}}:等价于 {{define "name"}}...{{end}}{{template "name" .}},即定义一个有默认值的块,子模板可以覆盖它。
实际工程中的模板结构:
templates/
├── layouts/
│ └── base.html # 基础布局(HTML 骨架)
├── partials/
│ ├── header.html # 页头(导航)
│ ├── footer.html # 页脚
│ └── meta.html # SEO 元标签
└── pages/
├── index.html # 首页
├── about.html # 关于页
└── post.html # 文章页
layouts/base.html:
{{define "base"}}
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{template "meta" .}}
<title>{{block "title" .}}Default Title{{end}}</title>
<link rel="stylesheet" href="/static/main.css">
</head>
<body>
{{template "header" .}}
<main>
{{block "content" .}}{{end}}
</main>
{{template "footer" .}}
<script src="/static/app.js" defer></script>
</body>
</html>
{{end}}
pages/post.html(继承 base 布局并覆盖块):
{{template "base" .}}
{{define "title"}}{{.Post.Title}} - YiteAI Blog{{end}}
{{define "content"}}
<article class="post">
<h1>{{.Post.Title}}</h1>
<time datetime="{{.Post.PublishedAt.Format "2006-01-02"}}">
{{.Post.PublishedAt.Format "January 2, 2006"}}
</time>
<div class="post-body">
{{.Post.HTMLContent}}
</div>
</article>
{{end}}
自定义模板函数
Go 模板的内建函数有限,实际工程需要注册自定义函数:
package template
import (
"fmt"
"html/template"
"strings"
"time"
)
func TemplateFuncMap() template.FuncMap {
return template.FuncMap{
// 日期格式化
"formatDate": func(t time.Time, layout string) string {
return t.Format(layout)
},
"timeAgo": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
},
// 字符串操作
"truncate": func(s string, n int) string {
if len([]rune(s)) <= n {
return s
}
return string([]rune(s)[:n]) + "..."
},
"lower": strings.ToLower,
"upper": strings.ToUpper,
"replace": strings.ReplaceAll,
// HTML 安全输出(谨慎使用)
"safeHTML": func(s string) template.HTML {
return template.HTML(s)
},
// URL 构建
"absURL": func(path string) string {
return "https://example.com" + path
},
// 序列生成(用于分页)
"seq": func(n int) []int {
result := make([]int, n)
for i := range result {
result[i] = i + 1
}
return result
},
// 判断
"eq": func(a, b interface{}) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
"ne": func(a, b interface{}) bool { return fmt.Sprint(a) != fmt.Sprint(b) },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
}
}
模板缓存
Go 的 html/template 是线程安全的,解析后的模板可以并发渲染。生产环境应在启动时解析所有模板并缓存:
package renderer
import (
"html/template"
"io/fs"
"path/filepath"
"sync"
)
type TemplateCache struct {
mu sync.RWMutex
templates map[string]*template.Template
funcMap template.FuncMap
baseDir string
}
func NewTemplateCache(baseDir string, funcMap template.FuncMap) *TemplateCache {
return &TemplateCache{
templates: make(map[string]*template.Template),
funcMap: funcMap,
baseDir: baseDir,
}
}
// Load 解析所有模板并构建缓存
func (c *TemplateCache) Load() error {
// 找到所有 page 模板
pages, err := filepath.Glob(filepath.Join(c.baseDir, "pages", "*.html"))
if err != nil {
return err
}
for _, page := range pages {
name := filepath.Base(page)
// 每个 page 模板都需要配合 layouts 和 partials 一起解析
files := []string{page}
// 添加所有 layout 文件
layouts, _ := filepath.Glob(filepath.Join(c.baseDir, "layouts", "*.html"))
files = append(files, layouts...)
// 添加所有 partial 文件
partials, _ := filepath.Glob(filepath.Join(c.baseDir, "partials", "*.html"))
files = append(files, partials...)
// 使用 FuncMap 解析模板集
tmpl, err := template.New(name).Funcs(c.funcMap).ParseFiles(files...)
if err != nil {
return fmt.Errorf("parsing template %s: %w", name, err)
}
c.mu.Lock()
c.templates[name] = tmpl
c.mu.Unlock()
}
return nil
}
func (c *TemplateCache) Render(w io.Writer, name string, data interface{}) error {
c.mu.RLock()
tmpl, ok := c.templates[name]
c.mu.RUnlock()
if !ok {
return fmt.Errorf("template %s not found", name)
}
// 使用 bytes.Buffer 先渲染到缓冲区
// 如果渲染出错,不会把半成品 HTML 发给客户端
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
return fmt.Errorf("executing template %s: %w", name, err)
}
_, err := io.Copy(w, &buf)
return err
}
Level 3 · 代码实战
构建多语言网站
多语言网站(i18n)需要在路由层和模板层都做处理:
package main
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// 支持的语言
var supportedLangs = map[string]bool{
"en": true,
"zh": true,
"ja": true,
}
// 语言提取中间件
func LangMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 优先级:URL 前缀 > Cookie > Accept-Language header > 默认
lang := extractLangFromPath(c)
if lang == "" {
lang = extractLangFromCookie(c)
}
if lang == "" {
lang = extractLangFromHeader(c)
}
if lang == "" {
lang = "en" // 默认语言
}
c.Set("lang", lang)
c.Next()
}
}
func extractLangFromPath(c *gin.Context) string {
// 路径格式:/zh/about, /en/blog/...
parts := strings.SplitN(c.Request.URL.Path, "/", 3)
if len(parts) >= 2 && supportedLangs[parts[1]] {
return parts[1]
}
return ""
}
func extractLangFromCookie(c *gin.Context) string {
lang, err := c.Cookie("lang")
if err != nil || !supportedLangs[lang] {
return ""
}
return lang
}
func extractLangFromHeader(c *gin.Context) string {
// 解析 Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
header := c.GetHeader("Accept-Language")
if header == "" {
return ""
}
// 取第一个语言标签
parts := strings.Split(header, ",")
if len(parts) == 0 {
return ""
}
tag := strings.Split(strings.TrimSpace(parts[0]), ";")[0]
// zh-CN → zh
lang := strings.Split(tag, "-")[0]
if supportedLangs[lang] {
return lang
}
return ""
}
// 多语言路由配置
func setupI18nRouter(cache *renderer.TemplateCache, tr *i18n.Translator) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery(), LangMiddleware())
// 无语言前缀的路由(重定向到默认语言)
r.GET("/", func(c *gin.Context) {
lang := c.GetString("lang")
c.Redirect(http.StatusFound, "/"+lang+"/")
})
// 带语言前缀的路由
langGroup := r.Group("/:lang")
langGroup.Use(func(c *gin.Context) {
lang := c.Param("lang")
if !supportedLangs[lang] {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Set("lang", lang)
c.Next()
})
{
langGroup.GET("/", indexHandler(cache, tr))
langGroup.GET("/about", aboutHandler(cache, tr))
langGroup.GET("/blog", blogListHandler(cache, tr))
langGroup.GET("/blog/:slug", blogPostHandler(cache, tr))
}
return r
}
func indexHandler(cache *renderer.TemplateCache, tr *i18n.Translator) gin.HandlerFunc {
return func(c *gin.Context) {
lang := c.GetString("lang")
data := gin.H{
"Lang": lang,
"T": tr.For(lang), // 翻译函数
"HreflangURLs": buildHreflangURLs(c.Request.URL.Path, lang),
"CanonicalURL": buildCanonicalURL(c),
}
if err := cache.Render(c.Writer, "index.html", data); err != nil {
c.Status(http.StatusInternalServerError)
}
}
}
// 构建 hreflang URL 列表
func buildHreflangURLs(path string, currentLang string) []HreflangURL {
// 将当前路径的语言前缀替换为其他语言
// /zh/about → /en/about, /ja/about
result := make([]HreflangURL, 0, len(supportedLangs)+1)
for lang := range supportedLangs {
url := switchLangInPath(path, currentLang, lang)
result = append(result, HreflangURL{
Lang: lang,
URL: "https://example.com" + url,
})
}
// x-default 指向英文版
result = append(result, HreflangURL{
Lang: "x-default",
URL: "https://example.com" + switchLangInPath(path, currentLang, "en"),
})
return result
}
type HreflangURL struct {
Lang string
URL string
}
在 partials/meta.html 中注入 hreflang 标签:
{{define "meta"}}
{{- range .HreflangURLs}}
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
{{- end}}
<link rel="canonical" href="{{.CanonicalURL}}">
<meta name="description" content="{{.Description}}">
{{end}}
Sitemap.xml 生成
Sitemap 告诉搜索引擎你的网站有哪些页面,以及它们的优先级和更新频率:
package sitemap
import (
"encoding/xml"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// XML 结构定义(遵循 sitemap.org 标准)
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []URL `xml:"url"`
}
type URL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority float32 `xml:"priority,omitempty"`
}
// SitemapIndex 用于大型网站(超过 50000 条 URL 时分片)
type SitemapIndex struct {
XMLName xml.Name `xml:"sitemapindex"`
XMLNS string `xml:"xmlns,attr"`
Sitemaps []SitemapEntry `xml:"sitemap"`
}
type SitemapEntry struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
}
func SitemapHandler(db *Database) gin.HandlerFunc {
return func(c *gin.Context) {
baseURL := "https://example.com"
urlset := URLSet{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
}
// 静态页面
staticPages := []struct {
path string
priority float32
freq string
}{
{"/", 1.0, "daily"},
{"/about", 0.8, "monthly"},
{"/blog", 0.9, "daily"},
{"/tools", 0.9, "weekly"},
}
for _, lang := range []string{"en", "zh", "ja"} {
for _, page := range staticPages {
urlset.URLs = append(urlset.URLs, URL{
Loc: baseURL + "/" + lang + page.path,
ChangeFreq: page.freq,
Priority: page.priority,
})
}
}
// 动态内容(从数据库获取)
posts, err := db.GetAllPublishedPosts()
if err == nil {
for _, post := range posts {
for _, lang := range []string{"en", "zh"} {
if post.HasTranslation(lang) {
urlset.URLs = append(urlset.URLs, URL{
Loc: baseURL + "/" + lang + "/blog/" + post.Slug,
LastMod: post.UpdatedAt.Format("2006-01-02"),
ChangeFreq: "weekly",
Priority: 0.7,
})
}
}
}
}
// 输出 XML
c.Header("Content-Type", "application/xml; charset=utf-8")
c.Writer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
encoder := xml.NewEncoder(c.Writer)
encoder.Indent("", " ")
if err := encoder.Encode(urlset); err != nil {
c.Status(http.StatusInternalServerError)
}
}
}
JSON-LD 结构化数据
JSON-LD 是 Google 推荐的结构化数据格式,用于告诉搜索引擎页面内容的语义(这是一篇文章、一本书、一个产品……)。它直接影响搜索结果的富摘要(Rich Results)显示:
package jsonld
import (
"encoding/json"
"html/template"
"time"
)
// Article 结构(遵循 schema.org 标准)
type Article struct {
Context string `json:"@context"`
Type string `json:"@type"`
Headline string `json:"headline"`
Description string `json:"description"`
Author Person `json:"author"`
Publisher Org `json:"publisher"`
DatePublished time.Time `json:"datePublished"`
DateModified time.Time `json:"dateModified"`
MainEntityOfPage string `json:"mainEntityOfPage"`
Image ImageObj `json:"image"`
}
type Person struct {
Type string `json:"@type"`
Name string `json:"name"`
}
type Org struct {
Type string `json:"@type"`
Name string `json:"name"`
Logo LogoObj `json:"logo"`
}
type LogoObj struct {
Type string `json:"@type"`
URL string `json:"url"`
}
type ImageObj struct {
Type string `json:"@type"`
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
// Book 结构
type Book struct {
Context string `json:"@context"`
Type string `json:"@type"`
Name string `json:"name"`
Author Person `json:"author"`
Description string `json:"description"`
URL string `json:"url"`
ISBN string `json:"isbn,omitempty"`
InLanguage string `json:"inLanguage"`
NumberOfPages int `json:"numberOfPages,omitempty"`
}
// ToScript 将结构转换为可嵌入模板的 <script> 标签内容
func ToScript(v interface{}) (template.HTML, error) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
// 注意:这里的 template.HTML 是安全的,因为 json.Marshal 会转义 < > & 字符
return template.HTML(`<script type="application/ld+json">` + "\n" +
string(data) + "\n" + `</script>`), nil
}
// BuildArticleSchema 为博客文章构建结构化数据
func BuildArticleSchema(post *Post, baseURL string) Article {
return Article{
Context: "https://schema.org",
Type: "Article",
Headline: post.Title,
Description: post.Description,
Author: Person{
Type: "Person",
Name: post.AuthorName,
},
Publisher: Org{
Type: "Organization",
Name: "YiteAI",
Logo: LogoObj{
Type: "ImageObject",
URL: baseURL + "/static/logo.png",
},
},
DatePublished: post.PublishedAt,
DateModified: post.UpdatedAt,
MainEntityOfPage: baseURL + "/blog/" + post.Slug,
Image: ImageObj{
Type: "ImageObject",
URL: baseURL + post.CoverImage,
Width: 1200,
Height: 630,
},
}
}
// 在 handler 中使用
func blogPostHandler(cache *renderer.TemplateCache) gin.HandlerFunc {
return func(c *gin.Context) {
post := getPost(c.Param("slug"))
lang := c.GetString("lang")
articleSchema, _ := jsonld.ToScript(
jsonld.BuildArticleSchema(post, "https://example.com"))
// Open Graph 标签
ogTags := OpenGraphTags{
Title: post.Title,
Description: post.Description,
Image: "https://example.com" + post.CoverImage,
URL: "https://example.com/" + lang + "/blog/" + post.Slug,
Type: "article",
SiteName: "YiteAI",
}
data := gin.H{
"Post": post,
"Lang": lang,
"JSONLDScript": articleSchema,
"OGTags": ogTags,
"CanonicalURL": "https://example.com/" + lang + "/blog/" + post.Slug,
}
cache.Render(c.Writer, "post.html", data)
}
}
在 partials/meta.html 中使用:
{{define "meta"}}
{{- if .OGTags}}
<meta property="og:title" content="{{.OGTags.Title}}">
<meta property="og:description" content="{{.OGTags.Description}}">
<meta property="og:image" content="{{.OGTags.Image}}">
<meta property="og:url" content="{{.OGTags.URL}}">
<meta property="og:type" content="{{.OGTags.Type}}">
<meta property="og:site_name" content="{{.OGTags.SiteName}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.OGTags.Title}}">
<meta name="twitter:description" content="{{.OGTags.Description}}">
<meta name="twitter:image" content="{{.OGTags.Image}}">
{{- end}}
{{- if .JSONLDScript}}
{{.JSONLDScript}}
{{- end}}
{{end}}
动态生成 robots.txt
robots.txt 通常是静态文件,但在某些场景下需要动态生成(例如,不同域名有不同规则,或者根据环境变量控制爬取行为):
func RobotsTxtHandler(sitemapURL string, env string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Type", "text/plain; charset=utf-8")
if env == "production" {
// 生产环境:允许爬取
fmt.Fprintf(c.Writer, `User-agent: *
Allow: /
# 禁止爬取 API 端点(节省爬取预算,API 没有 SEO 价值)
Disallow: /api/
Disallow: /admin/
# 禁止爬取查询参数页面(避免重复内容)
Disallow: /*?*
Sitemap: %s
`, sitemapURL)
} else {
// 非生产环境(staging/preview):禁止所有爬取
fmt.Fprintf(c.Writer, `User-agent: *
Disallow: /
`)
}
}
}
Level 4 · 深水区
开发环境模板热重载
生产环境应在启动时缓存所有模板,但开发时频繁重启很痛苦。可以在开发模式下实现热重载:
package renderer
import (
"os"
"sync"
"html/template"
)
type HotReloadRenderer struct {
mu sync.RWMutex
cache map[string]*template.Template
baseDir string
funcMap template.FuncMap
devMode bool // 开发模式下每次渲染都重新加载
}
func (r *HotReloadRenderer) Render(w io.Writer, name string, data interface{}) error {
if r.devMode {
// 开发模式:每次都重新解析模板(会慢,但修改即生效)
return r.renderFresh(w, name, data)
}
// 生产模式:使用缓存
r.mu.RLock()
tmpl, ok := r.cache[name]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("template %s not found", name)
}
return tmpl.ExecuteTemplate(w, "base", data)
}
func (r *HotReloadRenderer) renderFresh(w io.Writer, name string, data interface{}) error {
// 每次都重新从磁盘读取
pages, _ := filepath.Glob(filepath.Join(r.baseDir, "pages", name))
layouts, _ := filepath.Glob(filepath.Join(r.baseDir, "layouts", "*.html"))
partials, _ := filepath.Glob(filepath.Join(r.baseDir, "partials", "*.html"))
files := append(pages, layouts...)
files = append(files, partials...)
tmpl, err := template.New(name).Funcs(r.funcMap).ParseFiles(files...)
if err != nil {
return err
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
return err
}
_, err = io.Copy(w, &buf)
return err
}
// 通过环境变量控制模式
func NewRenderer(baseDir string, funcMap template.FuncMap) *HotReloadRenderer {
devMode := os.Getenv("GIN_MODE") != "release"
r := &HotReloadRenderer{
cache: make(map[string]*template.Template),
baseDir: baseDir,
funcMap: funcMap,
devMode: devMode,
}
if !devMode {
r.Load() // 生产模式下预加载
}
return r
}
页面级渲染缓存
对于内容不经常变化的页面(首页、关于页),可以在 Go 层缓存渲染后的 HTML,避免每次请求都执行模板渲染:
package middleware
import (
"bytes"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type PageCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
body []byte
contentType string
statusCode int
cachedAt time.Time
ttl time.Duration
}
func (c *cacheEntry) isExpired() bool {
return time.Since(c.cachedAt) > c.ttl
}
var globalPageCache = &PageCache{
entries: make(map[string]*cacheEntry),
}
// PageCacheMiddleware 缓存整个页面的渲染结果
func PageCacheMiddleware(ttl time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// 只缓存 GET 请求
if c.Request.Method != http.MethodGet {
c.Next()
return
}
// 已登录用户不使用缓存(内容可能个性化)
if c.GetString("user_id") != "" {
c.Next()
return
}
cacheKey := c.Request.URL.RequestURI()
// 检查缓存
globalPageCache.mu.RLock()
entry, exists := globalPageCache.entries[cacheKey]
globalPageCache.mu.RUnlock()
if exists && !entry.isExpired() {
c.Header("X-Cache", "HIT")
c.Data(entry.statusCode, entry.contentType, entry.body)
return
}
// 缓存未命中,执行渲染并捕获输出
c.Header("X-Cache", "MISS")
// 用 ResponseRecorder 捕获 handler 的输出
w := &responseRecorder{ResponseWriter: c.Writer, body: &bytes.Buffer{}}
c.Writer = w
c.Next()
c.Writer = w.ResponseWriter // 恢复
// 只缓存 200 响应
if w.status == http.StatusOK {
globalPageCache.mu.Lock()
globalPageCache.entries[cacheKey] = &cacheEntry{
body: w.body.Bytes(),
contentType: w.Header().Get("Content-Type"),
statusCode: w.status,
cachedAt: time.Now(),
ttl: ttl,
}
globalPageCache.mu.Unlock()
}
}
}
Core Web Vitals 优化:Go 层的贡献
Core Web Vitals(LCP、FID/INP、CLS)是 Google 的排名因素之一。Go 服务端可以在以下方面直接贡献:
1. 减少 TTFB(Time to First Byte)
// 使用 http.ResponseWriter 的分块传输(Chunked Transfer)
// 在 <head> 渲染完成后立即发送,不等待整个页面渲染完毕
func streamingRenderHandler(c *gin.Context) {
// 关键资源的 Early Hints(HTTP 103)
// 让浏览器在服务器处理请求期间就开始下载 CSS/字体
c.Header("Link", `</static/main.css>; rel=preload; as=style, </static/fonts.woff2>; rel=preload; as=font`)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
// 不支持 flush,正常渲染
normalRender(c)
return
}
// 立即发送 <head> 部分(含 CSS 链接)
c.Writer.WriteHeader(http.StatusOK)
c.Writer.Write([]byte(`<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/main.css">
</head><body>`))
flusher.Flush() // 立即推送到客户端,浏览器开始下载 CSS
// 继续渲染 <body>(可能需要数据库查询)
bodyContent := renderBodyContent(c)
c.Writer.Write([]byte(bodyContent))
c.Writer.Write([]byte(`</body></html>`))
}
2. 图片响应头优化
func imageHandler(c *gin.Context) {
img := loadImage(c.Param("id"))
// 设置适当的缓存头(影响重复访问的性能)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
// 支持条件请求(304 Not Modified)
etag := fmt.Sprintf(`"%x"`, md5.Sum(img.Data))
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
return
}
// 告知浏览器图片尺寸(减少布局偏移 CLS)
c.Header("X-Image-Width", strconv.Itoa(img.Width))
c.Header("X-Image-Height", strconv.Itoa(img.Height))
c.Data(http.StatusOK, img.ContentType, img.Data)
}
3. Canonical URL 管理
Canonical URL 防止重复内容问题(同一内容在多个 URL 下访问):
func canonicalURL(c *gin.Context) string {
// 规则:
// 1. 始终使用 HTTPS
// 2. 始终使用 www(或不使用,保持一致)
// 3. 末尾不加斜杠(首页除外)
// 4. 去掉追踪参数(utm_source 等)
path := c.Request.URL.Path
// 去掉末尾斜杠(首页除外)
if path != "/" && strings.HasSuffix(path, "/") {
path = strings.TrimRight(path, "/")
}
return "https://www.example.com" + path
}
// 在中间件中统一处理 canonical redirect
func CanonicalMiddleware(baseURL string) gin.HandlerFunc {
return func(c *gin.Context) {
host := c.Request.Host
// 如果是非 www 访问(且不是 localhost),重定向到 www
if host == "example.com" {
target := "https://www." + host + c.Request.RequestURI
c.Redirect(http.StatusMovedPermanently, target)
return
}
// 如果是 HTTP 访问,重定向到 HTTPS
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
target := "https://" + host + c.Request.RequestURI
c.Redirect(http.StatusMovedPermanently, target)
return
}
c.Next()
}
}
核心要点回顾:
- SSR 的 SEO 优势是根本性的:内容在第一次响应中即可被爬虫获取,不需要等待 JavaScript 执行。
html/template的上下文感知自动转义使 XSS 几乎不可能发生——前提是不使用template.HTML绕过它。- 模板在启动时解析并缓存,运行时并发安全。开发模式可以禁用缓存实现热重载。
- hreflang 标签告诉搜索引擎同一内容的不同语言版本,防止各语言版本互相竞争排名。
- JSON-LD 结构化数据是获得 Google 富摘要(Rich Results)的必要条件,直接影响点击率。
- Core Web Vitals 优化不只是前端的事,Go 服务端通过 TTFB 优化、缓存头、Early Hints 也能做出重要贡献。