DOM 渲染:模板、控制流块与 Hydration
前置知识
- ›第 2 篇:编译器流水线(理解编译产物)
- ›第 3 篇:响应式引擎(signals、effects、批量调度器)
- ›DOM API(cloneNode、importNode、注释节点、树遍历)
- ›服务端渲染与 hydration 的基本概念
DOM 渲染:模板、控制流块与 Hydration
编译器负责生成 JavaScript,响应式引擎负责追踪变更,最后一块拼图就是 Svelte 如何真正操作 DOM。Svelte 的方式颇具特色:预先创建 HTML 模板,高效克隆,再通过响应式 effect 绑定到具体节点上进行更新。本文将从模板实例化出发,一路追踪到 hydration 的完整过程。
模板实例化:from_html() 与 from_tree()
在第 2 篇中我们看到,编译器会从静态 HTML 生成模板字符串。在运行时,from_html() 负责将这些字符串转换为可克隆的 DOM 节点:
export function from_html(content, flags) {
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
var node;
return () => {
if (hydrating) {
assign_nodes(hydrate_node, null);
return hydrate_node;
}
if (node === undefined) {
node = create_fragment_from_html(content);
if (!is_fragment) node = get_first_child(node);
}
var clone = use_import_node || is_firefox
? document.importNode(node, true)
: node.cloneNode(true);
// ...
};
}
该函数返回一个闭包。首次调用时,它通过临时元素的 innerHTML 将 HTML 字符串解析为 DOM 树;后续调用则直接用 cloneNode(true) 克隆这棵树。这是经典的模板克隆优化思路:只解析一次,克隆多次。
flowchart TD
First["首次调用"] --> Parse["create_fragment_from_html(content)<br/>innerHTML 解析"]
Parse --> Store["存储为 'node'"]
Subsequent["后续调用"] --> Clone["node.cloneNode(true)"]
Clone --> DOM["插入 DOM"]
Hydrating["hydration 期间"] --> Reuse["直接返回 hydrate_node<br/>跳过克隆"]
注意 Firefox 的特殊处理:由于浏览器 bug,这里使用 document.importNode 而非 cloneNode。编译器通过 TEMPLATE_USE_IMPORT_NODE 标志来告知运行时何时需要启用该兼容逻辑。
template.js#L40-L45 中的 assign_nodes() 函数将克隆出的 DOM 节点与当前 effect 关联起来,生成 { start, end } 锚点,供块更新时的 DOM 操作使用。
提示: 模板字符串中的
<!>前缀会创建一个注释节点,作为稳定的锚点。当你在编译产物中看到<!>时,它就是运行时用来追踪动态内容起始位置的标记。
挂载与 Hydrate 组件
公共 API 提供了两种将组件接入 DOM 的方式:用于全新渲染的 mount(),以及用于接管服务端渲染 HTML 的 hydrate()。两者最终都会委托给 _mount()。
hydrate() 函数尤为有趣。它在目标元素中搜索表示服务端渲染内容起始位置的 <!--[--> 注释标记。找到后,将 hydrating 设为 true 并开始遍历已有的 DOM。一旦出现问题——结构不匹配、节点缺失——它会捕获 HYDRATION_ERROR,打印警告,清空目标元素,并回退到客户端的 mount():
export function hydrate(component, options) {
try {
var anchor = get_first_child(target);
while (anchor && anchor.nodeType !== COMMENT_NODE || anchor.data !== HYDRATION_START) {
anchor = get_next_sibling(anchor);
}
set_hydrating(true);
set_hydrate_node(anchor);
const instance = _mount(component, { ...options, anchor });
set_hydrating(false);
return instance;
} catch (error) {
// Fallback to client-side rendering
clear_text_content(target);
set_hydrating(false);
return mount(component, options);
}
}
第 163 行的 _mount() 函数通过 boundary() 将组件包裹在一个 component_root effect 中,并以锚节点和 props 调用编译后的组件函数。该组件函数——也就是编译器的输出——负责建立所有响应式状态和 DOM effect。
sequenceDiagram
participant App as 应用
participant Mount as mount() / hydrate()
participant Root as component_root
participant Component as 编译后的组件
participant Runtime as $.state, $.if 等
participant DOM as 浏览器 DOM
App->>Mount: mount(Component, { target })
Mount->>Root: component_root(() => ...)
Root->>Component: Component(anchor, props)
Component->>Runtime: $.state(), $.from_html(), $.if()
Runtime->>DOM: cloneNode, appendChild, effects
控制流块:{#if}、{#each}、{#key}
控制流块是响应式引擎与 DOM 相交汇的地方。每种块类型在 dom/blocks/ 目录下都有对应的运行时实现。
if_block() 函数会创建一个 BranchManager 和一个块 effect。条件变化时,update_branch() 会以键(分支索引)和渲染函数为参数被调用。BranchManager 负责处理过渡:暂停旧分支(触发 outro 动效)、创建新分支,并管理 DOM 插入点:
export function if_block(node, fn, elseif = false) {
var branches = new BranchManager(node);
var flags = elseif ? EFFECT_TRANSPARENT : 0;
block(() => {
fn((child_fn, key) => {
update_branch(key, child_fn);
});
}, flags);
}
each 块则复杂得多,它支持两种对账策略:
- 带 key — 每个列表项有唯一 key。列表变化时,运行时按 key 匹配新旧项,重排 DOM 节点,只创建或销毁发生变化的部分。
- 不带 key — 按索引匹配。实现更简单,但无法在重排时保留状态。
{#each} 块中的每个列表项都有独立的分支 effect,以及用于列表项值和索引的响应式数据源。EACH_ITEM_REACTIVE、EACH_INDEX_REACTIVE、EACH_IS_ANIMATED 和 EACH_IS_CONTROLLED 是编译器根据模板用法设置的位掩码标志。
flowchart TD
Each["$.each(node, flags, items_fn, render_fn)"]
Each --> Block["block effect<br/>(列表变化时重新执行)"]
Block --> Reconcile{"带 key?"}
Reconcile -->|是| Keyed["按 key 匹配,<br/>重排 DOM 节点,<br/>按需创建/销毁"]
Reconcile -->|否| Unkeyed["按索引匹配,<br/>原地更新,<br/>在末尾增删"]
Keyed --> Items["每个列表项:<br/>分支 effect + 响应式数据源"]
Unkeyed --> Items
Boundary:错误处理与异步 Suspense
boundary 块是 Svelte 5 较新的特性之一。它包裹组件树的某个区域,捕获渲染过程中抛出的错误,并提供降级 UI:
// Props shape:
{
onerror?: (error: unknown, reset: () => void) => void;
failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
pending?: (anchor: Node) => void;
}
一个 boundary 有三种可能的状态:
- 正常 — 子树渲染成功
- 等待中 — 正在处理异步工作(suspense)
- 失败 — 捕获到错误,渲染
failedsnippet
boundary effect 使用 BOUNDARY_EFFECT | EFFECT_TRANSPARENT | EFFECT_PRESERVED 标志。其中 EFFECT_PRESERVED 防止 boundary 自身在 effect 树优化过程中被裁剪掉。发生错误时,boundary 会销毁子树,用错误信息和 reset 回调渲染 failed snippet,并阻止错误继续向上传播。
stateDiagram-v2
[*] --> Normal: 初始渲染
Normal --> Failed: 捕获到错误
Normal --> Pending: 开始异步工作
Pending --> Normal: 异步工作完成
Pending --> Failed: 异步工作失败
Failed --> Normal: 调用 reset()
Hydration 协议
Hydration 是将客户端响应式能力附加到服务端渲染 HTML 上的过程。Svelte 的协议依赖 SSR 阶段注入的注释标记来实现。
Hydration 状态由 hydration.js 管理,两个关键变量负责追踪进度:
hydrating— 布尔标志,表示当前处于 hydration 模式hydrate_node— 游标,指向当前正在被 hydrate 的 DOM 节点
当 hydrating 为 true 时,from_html() 等模板函数会完全跳过克隆,直接返回已有的 hydrate_node。hydrate_next() 函数将游标推进到下一个兄弟节点,reset() 函数则在处理完一个块后校验是否存在意外的剩余兄弟节点。
对于控制流块,服务端会注入指令标记:[0 表示"渲染了第 0 个分支",[-1 表示"渲染了 else 分支"。客户端通过读取这些标记来判断服务端选择了哪个分支。如果出现不匹配(例如 {#if browser} 在服务端和客户端渲染结果不同),运行时会丢弃服务端内容,对该块回退到客户端渲染。
sequenceDiagram
participant SSR as 服务端 HTML
participant Hydrate as hydrate()
participant Cursor as hydrate_node
participant Block as if_block
SSR->>Hydrate: <!--[--> content <!--]-->
Hydrate->>Cursor: 设置为 <!--[--> 之后的第一个子节点
Cursor->>Block: hydrate_node = <!--[0-->
Block->>Block: read_hydration_instruction() → "[0"
Block->>Block: key 匹配?创建分支,遍历子节点
Block->>Cursor: 推进到 <!--]--> 之后
提示: 如果你在调用栈中看到
HYDRATION_ERROR,说明客户端检测到与服务端渲染 HTML 的结构不一致。检查你的组件是否在服务端和客户端会产生不同 markup 的地方使用了typeof window !== 'undefined'之类的浏览器环境判断。
SSR:Renderer 类
在服务端,Renderer 类以字符串树的形式累积 HTML。它与客户端运行时有着本质区别——没有 signals,没有 effects,没有 DOM,只有字符串拼接:
export class Renderer {
#out = []; // string | Renderer items
type; // 'head' | 'body'
promise; // for async rendering
push(content) { /* append string to #out */ }
child(fn) { /* 创建子 Renderer,调用 fn(renderer) */ }
head(fn) { /* 创建 type: 'head' 的子 Renderer */ }
// ...
}
Renderer 之所以是树形结构,是因为内容可能需要输出到页面的不同部分。<svelte:head> 中的内容会进入 type: 'head' 的子 Renderer,常规内容则保留在 type: 'body' 中。渲染完成后,整棵树会被"收集"——递归拼接成最终的 { head: string, body: string } 结果。
服务端运行时的 render() 函数创建一个 Renderer 实例,并调用编译后的服务端组件,由组件将 HTML 字符串推入其中:
export function render(component, options = {}) {
return Renderer.render(component, options);
}
服务端版本的 element() 位于 index.js#L40-L61,它直接将开闭标签作为字符串推入,并为动态元素注入 hydration 标记(<!---->)。
客户端与服务端:同一组件的两条路径
同一个 .svelte 组件会根据构建目标产出不同的编译结果。正如第 1 篇所述,编译器在 transform 阶段就会分叉。以下是一个简单组件的对比:
客户端产物(简化版):
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<h1><!></h1>`);
export default function Component($$anchor) {
let count = $.state(0);
var h1 = root();
var text = $.child(h1);
$.template_effect(() => $.set_text(text, $.get(count)));
$.append($$anchor, h1);
}
服务端产物(简化版):
import * as $ from 'svelte/internal/server';
export default function Component($$renderer, $$props) {
let count = 0; // no reactivity needed
$$renderer.push(`<h1>${$.escape(count)}</h1>`);
}
服务端版本要简洁得多:没有 signals,没有 effects,没有 DOM 操作,值就是普通的 JavaScript 变量,Renderer 负责累积 HTML 字符串。onMount 等生命周期钩子是空操作(正如第 1 篇所述,它们在 index-server.js 中被存根化处理)。
这种双轨机制通过 package.json 中的条件导出系统来维护。svelte/internal/client 和 svelte/internal/server 是完全独立的模块树,仅通过 svelte/internal/shared 共享属性渲染和 HTML 转义等工具函数。
flowchart TD
Component[".svelte 组件"]
Component -->|"generate: 'client'"| Client["客户端 JS<br/>$.state(), $.from_html(),<br/>$.template_effect()"]
Component -->|"generate: 'server'"| Server["服务端 JS<br/>renderer.push(),<br/>$.escape()"]
Client --> ClientRuntime["svelte/internal/client<br/>Signals、Effects、DOM"]
Server --> ServerRuntime["svelte/internal/server<br/>Renderer、字符串拼接"]
下一步
至此,我们已经梳理了从 .svelte 源码到运行时 DOM 的完整流水线。最后一篇文章将探讨完善开发者体验的配套系统:公共响应式工具类、Svelte 4 迁移工具、所有权追踪和 HMR 等开发模式特性、过渡与动画运行时,以及测试基础设施。