首页
/ Svelte `{each}` 块完整指南:列表遍历、键控更新、解构与空态渲染

Svelte `{each}` 块完整指南:列表遍历、键控更新、解构与空态渲染

2026-09-06 09:11:20作者:秋泉律Samson

本篇指南基于 Svelte 官方文档中 {#each ...} 块的模板语法说明展开,覆盖基本遍历、索引访问、带 key 的键控列表、解构/Rest 模式、无项渲染(重复 N 次)与 {:else} 空态六大核心用法,并结合 Svelte 仓库源码剖析 each 块从编译到运行时协调(diff)的实现链路。读完本文,你既能直接复制可运行的列表写法,也能理解 key 为何能让列表"智能移动"而非整段重建,以及编译器为 each 块生成的底层代码形态。

基本语法与适用的集合类型

each 块的基本形式:

{#each expression as name}...{/each}

带索引的形式:

{#each expression as name, index}...{/each}

被遍历的值可以是以下几类:

  • 数组(array);
  • 数组类对象(array-like object,即任何带有 length 属性的对象);
  • 可迭代对象(iterable),如 MapSet

从实现上可以印证这一点:编译器内部会将这些值统一转换为数组(文档说明内部使用 Array.from 完成转换)。在仓库中,服务端运行时也确实提供了这样的归一化工具 ensure_array_like,服务端编译出的 each 块会调用 $.ensure_array_like(collection) 对表达式求值并归一化(见 服务端 EachBlock 转换器)。

一个典型的购物清单示例:

<h1>Shopping list</h1>
<ul>
	{#each items as item}
		<li>{item.name} x {item.qty}</li>
	{/each}
</ul>

空值语义:如果表达式求值为 nullundefined,each 块会按空数组处理——也就是说不会渲染任何列表项,同时会触发 {:else} 分支(如果存在),这与直接传 [] 的行为一致。

带索引遍历

each 块可以指定一个索引变量,语义等价于 array.map(...) 回调的第二个参数:

{#each items as item, i}
	<li>{i + 1}: {item.name} x {item.qty}</li>
{/each}

键控 each 块(Keyed each blocks)

语法:

{#each expression as name (key)}...{/each}
{#each expression as name, index (key)}...{/each}

当提供 key 表达式——它必须能唯一标识列表中的每一项——Svelte 会在数据变化时利用 key 智能地更新列表:插入、移动、删除对应的项目,而不是在末尾增删项目、再更新中间的状态。这正是文档强调的核心收益:列表的视觉状态(焦点、输入框内容、组件内部状态等)跟随 key 对应的条目移动,而不是留在原来的 DOM 位置上

官方建议:key 可以是任意对象,但推荐使用字符串和数字,因为这样即使条目对象本身被替换(对象引用变化),身份(identity)也能保持不变。

{#each items as item (item.id)}
	<li>{item.name} x {item.qty}</li>
{/each}

<!-- 或者附带索引 -->
{#each items as item, i (item.id)}
	<li>{i + 1}: {item.name} x {item.qty}</li>
{/each}

解构与 Rest 模式

each 块中可自由使用解构(destructuring)和 rest 模式:

{#each items as { id, name, qty }, i (id)}
	<li>{i + 1}: {name} x {qty}</li>
{/each}

{#each objects as { id, ...rest }}
	<li><span>{id}</span><MyComponent {...rest} /></li>
{/each}

{#each items as [id, ...rest]}
	<li><span>{id}</span><MyComponent values={rest} /></li>
{/each}

从客户端编译器的实现看,当上下文是标识符(普通变量)时走一条简单路径;而当上下文是对象/数组解构模式时,客户端 EachBlock 转换器 会通过 extract_paths 展开解构路径,为每个绑定项生成独立的派生信号($.derived / $.derived_safe_equal,带默认值的解构项会走 derived_safe_equal 确保默认值只求值一次),并为每一项注册 read/assign/mutate 行为——这意味着你可以直接在模板里改写解构出来的字段,赋值会写回到原集合对应位置。

无项的 each 块:重复渲染 N 次

如果你只是想把某段内容渲染固定 n 次,可以省略 as 部分:

{#each expression}...{/each}
{#each expression, index}...{/each}

官方文档给出的完整示例——用两层无项 each 渲染一个 8x8 国际象棋棋盘:

<div class="chess-board">
	{#each { length: 8 }, rank}
		{#each { length: 8 }, file}
			<div class:black={(rank + file) % 2 === 1}></div>
		{/each}
	{/each}
</div>

<style>
	.chess-board {
		display: grid;
		grid-template-columns: repeat(8, 1fr);
		grid-template-rows: repeat(8, 1fr);
		border: 1px solid black;
		aspect-ratio: 1;

		.black {
			background: black;
		}
	}
</style>

这里外层 {#each { length: 8 }, rank} 利用了"数组类对象"的能力:一个只有 length 属性的普通对象即可充当可遍历集合,第二个参数 rank 拿到的是 0 到 7 的索引,配合奇偶判断 (rank + file) % 2 === 1 着色黑格。

{:else} 空态分支

{#each expression as name}...{:else}...{/each}

each 块可以带 {:else} 子句:当列表为空(含 null/undefined 的情形)时渲染该分支。

{#each todos as todo}
	<p>{todo.text}</p>
{:else}
	<p>No tasks today!</p>
{/each}

这一点在服务端编译结果中体现得很直白:服务端 EachBlock 转换器 在有 fallback 时会生成 if (array.length !== 0) { ...for 循环... } else { fallback } 的结构;没有 fallback 时则直接输出一个按 length 遍历的 for 循环:

// 服务端编译产物形态(简化自源码)
const each_array = $.ensure_array_like(expression);
for (let i = 0, $$length = each_array.length; i < $$length; i++) {
	let item = each_array[i];
	// ...body
}

源码纵深:客户端 each 块的编译与运行时

编译阶段:为每个 each 块计算 flags

客户端 EachBlock 转换器 是理解客户端行为的入口。它会为每个 each 块计算一组位标志(定义在 constants 中):

  • EACH_INDEX_REACTIVE:键控 each 块带索引时设置。因为条目会移动,索引本身也变成了"响应式"的,读取索引需要经过信号取值;
  • EACH_ITEM_REACTIVE:当列表表达式引用了外部状态(如 store 订阅、跨作用域依赖)时设置,条目需要包在信号里以便追踪;
  • EACH_ITEM_IMMUTABLE:runes 模式下且无 store 时设置;
  • EACH_IS_ANIMATED:当键控 each 的子元素带有 animate: 指令时设置——注释里说明,由于 animate: 只能出现在"键控 each 块的唯一子元素"上,所以编译器能在编译期确定该 each 块是否被动画化,从而在协调前后测量动画元素的位置;
  • EACH_IS_CONTROLLED:当该 each 块是父元素的唯一子节点(controlled 形态)时设置,允许运行时走更快的清理路径。

关于 key,编译器会生成一个 key 函数:无 key 时默认使用 index 函数(即直接用下标作为 key);有 key 时则编译为 pattern => keyExpression 形式的箭头函数(见 客户端 EachBlock 转换器 L295-L307)。最终每个 each 块被编译为一次 $.each(flags, thunk, key_function, render_fn, fallback?) 调用。

另外值得注意的细节:如果表达式依赖的是 legacy store 订阅,编译器会在条目变更时生成 $.invalidate_store 调用来反向刷新 store(见 客户端 EachBlock 转换器 L104-L111)。

运行阶段:协调、暂停与快速路径

运行时实现位于 each.js。它的核心机制包括:

  • 条目即 effect:每个列表项对应一个独立的 effect(子块),列表更新时按 key 比对,新增项创建 effect、删除项销毁 effect、移动项则重排;
  • pause_effects 批量暂停:删除多个条目时(each.js L66 起),编译器/运行时会先把所有待删 effect 暂停(pause),等待其中若有 out 过渡动画完成后再统一销毁——这就是列表删除时过渡动画能正常播放的底层机制;
  • controlled 快速路径:当 each 块是父元素的唯一子节点(EACH_IS_CONTROLLED)、全部条目被删除且没有过渡时,可以直接清空父元素内容并重置锚点,省去逐个销毁的开销;
  • 压力测试背书:文件头部注释明确要求对该文件的任何实质改动都必须通过 each 块压力测试验证,该测试同时存在于仓库中的 each-stress-test,覆盖了大规模列表增删改移的极端场景。

编译产物的对照验证

如果你想在仓库内验证"文档语法 → 实际生成代码"的对应关系,可以直接查看:

实践建议小结

  1. 能取稳定 id 就用 key{#each items as item (item.id)} 让状态跟随数据条目移动;仅当列表内容绝对静态或纯展示时才省略 key(此时编译器默认按下标做 key);
  2. key 优先选字符串/数字:条目对象整体被替换时身份仍可保持;
  3. 解构要配 key{#each items as { id, name, qty }, i (id)} 是"既省事又不丢状态"的推荐组合;
  4. 用无项 each 渲染固定重复内容{#each { length: n }, i} 是生成 N 份占位(棋盘、星评、表格行)的惯用写法;
  5. 列表可能为空就写 {:else}:编译器会把它编译成空态分支,语义清晰且零成本。

适用前提说明:以上行为描述基于当前仓库的 Svelte 5(runes 时代)实现;legacy 模式(非 runes)下,对列表项的重赋值会触发 invalidate 式的响应式刷新(客户端转换器中有对应的 TODO 6.0 注释表明该行为是 legacy 模式专属),具体差异可参考仓库中的 legacy 测试样本 与迁移指南 v5-migration-guide

登录后查看全文
热门项目推荐
相关项目推荐