在 Go 的模板引擎中,range 循环是渲染列表时的高频动作。不过一旦进入 range,当前上下文中的点(.)就会切换为当前迭代到的元素,外层结构体里的字段就没法再用 .Field 直接读取。很多模板代码因此写得很别扭,甚至出现取到空值、渲染异常的情况。要解决这个问题,首先得弄清楚 text/template 和 html/template 中变量作用域与根对象引用 $ 的关系。

一、range 循环中的上下文切换与变量作用域
Go 模板执行时会维护一个当前数据上下文,也就是常说的点。普通输出 {{ .Name }} 时,点代表传入模板的整个数据对象。但 range 动作开始后,点会被重新绑定为本次迭代得到的元素。举例来说,如果传入的数据包含 Items 切片,那么 {{ range .Items }} 内部的 . 就是切片中的单个元素,而不是原来的根对象。
这种切换是模板引擎为了简化循环体书写而设计的:在循环里直接写 {{ .Name }} 就能输出每个元素的 Name 字段,不必再写 {{ .Item.Name }}。但副作用是,一旦你还需要循环外部的标题、配置或父级对象,直接使用点就会失败,因为它已经指向别处。很多人在这里会误以为 . 仍然保持外层含义,结果渲染出空字符串或直接报错。
除了点,模板变量也有自己的作用域规则。用 {{ $x := . }} 声明的变量,其作用域从声明处一直延伸到声明它的控制结构结束为止。如果变量声明在模板顶层,那么它的作用域是整个模板,即使在 range 内部也可以按名称访问。但要注意,如果 range 内部用 := 重新声明了同名变量,这个新变量只在内层有效,外层变量就被遮蔽了。
下面的模板片段展示了一个典型错误:循环内试图用 .Title 访问外层页面的标题,但当前点已经变成单个 Item,Item 上并没有 Title 字段,因此输出为空。
{{ range .Items }}
<span>{{ .Name }} - {{ .Title }}</span>
{{ end }}
二、访问外部变量的三种可靠写法
既然点会被 range 覆盖,那么访问外层数据的核心思路就是提前把需要的上下文保存到变量中,或者使用根对象引用。下面分别说明。
第一种,也是最常见的方式:进入 range 之前,将当前上下文保存到一个命名变量。比如 {{ $parent := . }},之后在循环体内通过 $parent.Title 读取外层标题。这样做的好处是无论嵌套多少层 range,只要保存了对应层级的上下文,就能在任意内层循环中取到。
{{ $page := . }}
{{ range .Items }}
<div class="item">
<strong>{{ .Name }}</strong>
<span>所属页面:{{ $page.Title }}</span>
</div>
{{ end }}
第二种:当需要访问的数据刚好来自模板根对象时,可以直接使用 $。在 Go 模板中,$ 始终指向传入模板的根数据,不随 range 或 with 的上下文变化而改变。所以如果循环外部字段就是根对象的字段,{{ $.SiteName }} 这样的写法就能直接取到。
{{ range .Articles }}
<h3>{{ .Title }}</h3>
<p>来自站点:{{ $.SiteName }}</p>
{{ end }}
第三种:当出现嵌套 range 时,内层循环需要同时访问外层循环的变量和更外层的根数据,就需要分别保存。外层循环通常用 $item 保存当前元素,内层循环再用另一个变量保存内层元素。这样名称不会冲突,读取时逻辑清晰。
{{ range .Categories }}
{{ $category := . }}
<h2>{{ $category.Name }}</h2>
{{ range $category.Products }}
<div>
<span>{{ .Name }}</span>
<span>分类:{{ $category.Name }}</span>
<span>根标题:{{ $.Title }}</span>
</div>
{{ end }}
{{ end }}
三、遮蔽效应与排查:别再猜,直接打印当前点
变量遮蔽在 Go 模板中非常容易制造隐蔽 bug。例如你在模板顶层写了 {{ $v := .Value }},然后在 range 内又写了 {{ $v := . }},此时 range 内的 $v 指向当前迭代元素,而不是顶层的 Value。外层变量仍然存在,但在该作用域内无法通过同名访问,这就是遮蔽。很多人会以为修改变量会对循环外部生效,实际上在模板里变量只读,更不能跨作用域修改。
另一个常见误区是把 $ 当成“外部变量”的万能引用。实际上 $ 只会指向根数据,如果你在 range 之前定义了一个非根字段的变量,然后在循环里写 {{ $.myVar }},这通常取不到,因为 $ 访问的是根对象的字段,而不是模板变量。正确的做法是直接用变量名 {{ $myVar }},不要加点和美元符号。
排查这类问题时,有一个简单有效的办法:在 range 内部输出当前上下文。比如写 {{ printf "%#v" . }},它会打印当前点的类型和值。也可以通过 {{ printf "%T" . }} 查看类型。只要看到当前点已经不是预期的结构体,就能迅速定位作用域问题。
{{ range .Items }}
<pre>当前上下文:{{ printf "%#v" . }}</pre>
{{ end }}
四、完整示例:在 html/template 中渲染带父级信息的列表
下面这个例子把前面的技巧串起来,展示一个电商页面片段的渲染。数据结构包含页面标题、店铺信息和商品切片,模板需要在每个商品条目里同时显示商品名、所属分类以及页面根标题。
代码使用 html/template 而非 text/template,因为 html/template 会在输出 HTML 时做上下文转义,防止注入。变量保存和 $ 的用法在两个包中完全一致。
package main
import (
"html/template"
"os"
)
type Product struct {
Name string
Price float64
}
type Category struct {
Name string
Products []Product
}
type Page struct {
Title string
SiteName string
Categories []Category
}
func main() {
tpl := `<!DOCTYPE html>
<html>
<head><title>{{ .Title }}</title></head>
<body>
<h1>{{ .Title }}</h1>
<p>站点:{{ .SiteName }}</p>
{{ range .Categories }}
{{ $category := . }}
<section>
<h2>{{ $category.Name }}</h2>
{{ range $category.Products }}
<div class="product">
<span>{{ .Name }}</span>
<span>价格:{{ .Price }}</span>
<span>分类:{{ $category.Name }}</span>
<span>根标题:{{ $.Title }}</span>
</div>
{{ end }}
</section>
{{ end }}
</body>
</html>`
t := template.Must(template.New("page").Parse(tpl))
data := Page{
Title: "今日特价",
SiteName: "示例商城",
Categories: []Category{
{
Name: "数码",
Products: []Product{
{Name: "机械键盘", Price: 299},
{Name: "无线鼠标", Price: 99},
},
},
{
Name: "办公",
Products: []Product{
{Name: "人体工学椅", Price: 1599},
},
},
},
}
if err := t.Execute(os.Stdout, data); err != nil {
panic(err)
}
}
执行这段代码后,输出中的每个商品 div 内部都会包含商品名、价格、所属分类以及根标题。例如数码分类下的机械键盘条目大致如下:
<div class="product">
<span>机械键盘</span>
<span>价格:299</span>
<span>分类:数码</span>
<span>根标题:今日特价</span>
</div>
可以看到,$category.Name 来自外部 range 保存的变量,$.Title 来自根对象,内层循环的 .Name 和 .Price 来自当前产品。这三类数据同时出现在一个循环体内,互不干扰,这正是合理运用作用域变量后的效果。
理解 Go 模板中 range 的作用域切换,是写出可维护模板的关键。进入循环前保存上下文、用 $ 访问根对象、为嵌套循环分层命名变量,这三招可以覆盖绝大多数列表渲染场景。遇到取不到值的情况,优先检查当前点被切换到了哪里、变量是否被遮蔽,而不是怀疑模板引擎出了问题。把这些规则内化之后,Go 模板也能写出清晰且不易出错的渲染逻辑。