Read OSS

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_REACTIVEEACH_INDEX_REACTIVEEACH_IS_ANIMATEDEACH_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 有三种可能的状态:

  1. 正常 — 子树渲染成功
  2. 等待中 — 正在处理异步工作(suspense)
  3. 失败 — 捕获到错误,渲染 failed snippet

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 节点

hydratingtrue 时,from_html() 等模板函数会完全跳过克隆,直接返回已有的 hydrate_nodehydrate_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/clientsvelte/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 等开发模式特性、过渡与动画运行时,以及测试基础设施。