【翻译】React 中可提升的 SVG defs:用 Portal 与 Context 把定义和组件放在一起

0 阅读17分钟

React 中可提升的 SVG defs:用 Portal 与 Context 把定义和组件放在一起

将 SVG 定义与使用它们的组件同位放置,借助 portal 与 context。一次略显过度设计、但很有教学意义的练习。

发表于 2026 年 3 月 29 日。原文提供目录展开控件(Show Table of Contents),此处从略。

SVG 中的定义元素

SVG 里有一些元素并不会直接绘制出来——例如 <linearGradient><marker><clipPath><filter><mask>——但它们会为图形元素——例如 <path><circle><text><image>——定义可供复用的内容。规范把这些称为「被引用元素(referenced elements)」1。它们放在 <defs> 元素里,各自带有唯一的 id 2。图形元素通过 url(#id) 引用它们。

下面是一个简单例子:在 <defs> 里定义箭头 <marker>,再由 <path> 引用:

svg-arrow-example.sanitized.png

<svg viewBox="0 0 200 80" width="150">
  <defs>
    <marker
      id="arrow"
      markerWidth="10"
      markerHeight="7"
      refX="9"
      refY="3.5"
      orient="auto"
    >
      <path d="M0,0 L0,7 L10,3.5 z" fill="tomato" />
    </marker>
  </defs>
  <path
    d="M 15,40 L 175,40"
    stroke="tomato"
    strokeWidth="2"
    markerEnd="url(#arrow)"
  />
</svg>

由此可见,定义又叠了一层:url(#id) 这类引用会横穿整段 SVG 标记。

component-boundary.svg

上图对应原文在「Notice that definitions…」一段之后给出的结构示意:展示 marker-endurl(#…) 如何穿过 <svg> / <defs> / <path> 的层级关系。

实际上浏览器相当宽容,也允许把定义类元素写在 <defs> 之外。但若你更清楚利弊,就别这么做,把它们留在 <defs> 里。

规范建议:在可能的情况下,被引用元素应定义在 ‘defs’ 元素内部。

SVG1.1 规范:定义可复用内容,以及 defs 元素

规范也允许写多个 <defs>。但我不喜欢在一堆散落的 <defs> 里翻找,所以我会把规范的建议再推进一步:被引用元素必须放在同一个 <defs> 元素里

<symbol> 不知为何也可以放在 <defs> 之外;我个人认为它仍应始终放进 <defs>

问题一:ID 冲突

url(#id) 的解析范围是整份文档,而不是单个 SVG 元素。若在同一页渲染两个 SVG,而它们都定义了 id="arrow",那么页面上每一处 url(#arrow) 都会解析成浏览器最先遇到的那条定义。这就会造成 ID 冲突。图标 SVG,或用 Figma、Illustrator 等工具导出的 SVG,都经常踩到这个坑。

Jim Nielsen 写过一篇遇到此问题的实战记录Anton Ball 也讨论过同类问题。

不用内联 SVG 来规避冲突

若你不需要内联 SVG,可以完全绕开 ID 冲突:通过 <img> 加载 SVG:

import arrowSvg from './arrow.svg';

<img src={arrowSvg} alt="" />

像 Vite 这类打包器会把 import 解析成 URL,浏览器把每个 SVG 当作独立文档加载,于是 ID 不会跨实例泄漏。没有冲突,也没有额外仪式。

代价是:<img> 里的 SVG 是隔离的。你会失去 currentColor、无法对内部元素做 CSS 选择器定位,也没有交互。若你需要这些能力,就必须用内联 SVG,本文余下内容也正是在这个前提下展开。

这类方案本质上是用「文档级隔离」来换取「零冲突」。

是否采用,关键取决于你是否需要对 SVG 内部节点做样式与交互控制。

一旦需要复用 currentColor、内部选择器或事件处理,通常就要回到内联 SVG。

问题二:组件并不「拥有」自己的定义

ID 冲突是一个问题。但我在 React 里写 SVG 时,还有一层更深的别扭:组件并不拥有它所依赖的那些定义。

把我们的例子改写成 React 组件:

const arrowId = "arrow"

function Arrow({ path, color = "#f00" }) {
    return (
        <path
            d={path}
            stroke={color}
            strokeWidth="2"
            markerEnd={`url(#${arrowId})`}
        />
    );
}

<marker> 不能放在组件内部;换句话说,<Arrow> 并不拥有它。它必须在别处、在 <defs> 里定义。我们在树状示意图里给 <Arrow> 组件画一个框。

svg-tree-problem2-arrow-box.png

上图对应原文 Problem 2 小节中、在树状图上用虚线框标出「Arrow component」之后的那一版示意,与上一节「仅结构」的图区分开。

可见 url(#arrow) 这条引用在组件边界上「戳了一个洞」:这是隐式耦合,会破坏真正的封装。

我想要什么:可提升的 defs

React 19 为 HTML 的 <head> 解决过一个类似问题:借助特殊渲染行为,像 <link><meta> 这类本应出现在 <head> 里的标签,可以写在组件树的任意位置,react-dom 会把它们提升到 <head> 并去重 3 4

react-head-hoisting.png

上图对应原文在介绍 React 19 将 <link> / <meta> 提升到 <head> 时给出的 React 树与 DOM 树对照示意。

在React团队内部,这类可以提升到 <head> 的元素被称为 「hoistables」,对应 HostHoistable 这类 fiber 类型;该特性也叫 Float

我想要的是 SVG <defs> 上的同款能力:

  1. 定义与使用它的组件同位(colocated)
  2. 只有一个 <defs> 元素;
  3. 没有重复定义;
  4. 当拥有某条定义的组件卸载时,定义也随之移除。

试试看。

useId 做「本地」<defs>

记住:SVG 规范允许在 SVG 内的任意位置放多个 <defs>。浏览器处理得很好。组件完全可以紧挨着使用它的图形,内联声明自己的 <defs>

配合 useId,可以让组件具备实例安全(instance-proof)——每个实例一个唯一 id 5——从而避免冲突。

function Arrow({ path, color = "#f00" }) {
    const id = useId();
    return (
        <>
            <defs>
                <marker
                    id={id}
                    markerWidth="10"
                    markerHeight="10"
                    refX="0"
                    refY="3"
                    orient="auto"
                >
                    <path d="M0,0 L0,6 L9,3 z" fill={color} />
                </marker>
            </defs>
            <path
                d={path}
                stroke={color}
                strokeWidth="2"
                markerEnd={`url(#${id})`}
            />
        </>
    );
}

这能工作,但每个 <Arrow> 实例都会得到自己的 <defs> 和自己的 marker。渲染 50 支箭头,就会在 DOM 里出现 50 份 marker 定义。

  • ✅ 定义与使用该定义的组件同位存放
  • ❌ 只有一个 <defs> 元素
  • ❌ 没有重复定义
  • ✅ 当拥有定义的组件卸载时,定义随之移除

算完成一半。

用 Portal 把定义「摆」到正确位置

我们希望:在组件内部编写定义,却把它们渲染进 SVG 顶部的单个 <defs> 里。

Portal 来救场!我们不去搬动组件树,而是用 Portal 在 DOM 里复用一个固定挂载点。Portal 把谁负责渲染(owner)与最终出现在哪里解耦开来 6

由 React 管理的 Portal 目标

典型的 portal 目标是已经存在的 DOM 节点——也就是非 React 管理的节点,例如 HTML 里的 <div id="modal">,或 document.body

这里不一样:<defs> 是由 React 创建的,因此必须在 React 完成渲染之后才能拿到它的引用。

这意味着 ref 要放进 state,而不是普通的 ref 对象。单纯的 useRef 在赋值时不会触发重渲染,<DefsPortal> 就永远不知道 <Defs> 何时挂载,会一直渲染 null。放进 state 后,一旦 <defs> 对应的 DOM 节点可用,React 会立刻重渲染消费者。

state setter 本身是稳定的 7,因此把它当作 ref 回调传入、ref={setDefsEl},是安全的:挂载时收到 DOM 节点,卸载时收到 null

推荐阅读: 这就是「由 React 管理的 Portal 元素」模式。更细的说明见 React ref 回调的用例

随后通过 context 共享这个 DOM 引用,这样子树里任意 <DefsPortal> 都能指向同一个 <defs> 节点。

function DefsProvider({ children }) {
    const defsElState = useState<SVGDefsElement | null>(null);
    // 等价于 const [defsEl, setDefsEl] = defsElState;

    return (
        <DefsContext.Provider value={defsElState}>
            {children}
        </DefsContext.Provider>
    )
}

export function Defs() {
    const [, setDefsEl] = useContext(DefsContext)!;

    return <defs ref={setDefsEl} />;
}

export function DefsPortal({
    id,
    children,
}) {
    const [defsEl] = useContext(DefsContext)!;

    if (!defsEl) {
        return null;
    }

    return createPortal(children, defsEl);
}

现在 <Arrow> 可以在 React 树里「拥有」它的 <marker>,而 marker 实际落在 DOM 的 <defs> 里。

const arrowMarkerId = "arrow";

function Arrow({ path, color = "#f00" }) {
    return (
        <>
            <DefsPortal id={arrowMarkerId}>
                <marker
                    id={arrowMarkerId}
                    markerWidth="10"
                    markerHeight="10"
                    refX="0" refY="3"
                    orient="auto"
                >
                    <path d="M0,0 L0,6 L9,3 z" fill={color} />
                </marker>
            </DefsPortal>
            <path
                d={path}
                stroke={color}
                strokeWidth="2"
                markerEnd={`url(#${arrowMarkerId})`}
            />
        </>
    );
}

拼在一起:

function Chart() {
    return (
        <DefsProvider>
        <svg viewBox="0 0 800 400">
            <Defs />
            <Arrow path="M 10,50 L 200,50" />
            <Arrow path="M 10,100 L 300,100" />
        </svg>
        </DefsProvider>
    );
}

再用一张示意图概括(已转存为图片):

defs-portal-architecture.png

上图对应原文在「And as a diagram」之后展示的三列树(Parent tree / Owner tree / DOM tree),说明 DefsPortaldefsEl、portal 目标等关系。

方向是对的。

然而,把子树通过 Portal 传过去时,它不会替换目标 DOM 节点里的子节点,而是追加 8。于是每个 <Arrow> 实例都会把自己的 <marker> 追加进 <defs>。三支箭头就会得到三份完全相同的 marker 定义。

浏览器并不在意,它只会取第一个匹配的 id,所以你可以停在这里。

  • ✅ 定义与使用该定义的组件同位存放
  • ✅ 只有一个 <defs> 元素
  • ❌ 没有重复定义
  • ✅ 当拥有定义的组件卸载时,定义随之移除

差不多——但我在意重复,所以不会停在这里。

一份定义,多个实例

要解决重复定义,就会暴露更深层的问题:协同(coordination)

React 的数据流在子树内是自上而下的:父节点可以协调子节点,但兄弟节点互不相识

这里我们需要能回答:「我是第一个,还是已经有人占了这个 ID?」

无论挂载多少个 <Arrow> 实例,<defs> 里应当恰好只有一份 <marker id="arrow">。当最后一个实例卸载时,marker 也应一起消失。

我们需要跟踪两种身份:

  • id:定义的公开、语义化名字,也就是 url(#arrow) 里用的那个。所有 <Arrow> 实例共用同一个。
  • instanceId每个实例私有的身份。登记簿靠它判断哪一个组件实例拥有这条定义,并负责渲染它。

简而言之:id 回答「这是什么定义?」;instanceId 回答「是谁登记了它?」。

两者都放进一个 Map

function DefsProvider({ children }) {
    const [defsEl, setDefsEl] = useState<SVGDefsElement | null>(null);
    const [registry, setRegistry] = useState<DefsRegistry>(() => new Map());

    const value = useMemo( () => ({
        defsEl, setDefsEl, registry, setRegistry }),
        [defsEl, setDefsEl, registry, setRegistry],
    );

    return
        <DefsContext.Provider value={value}>
            {children}
        </DefsContext.Provider>;
}

export function Defs() {
    const { setDefsEl } = useContext(DefsContext)!;

    return <defs ref={setDefsEl} />;
}

export function DefsPortal({ id, children }) {
    const { defsEl, registry, setRegistry } = useContext(DefsContext)!;
    const instanceId = useId();
    const tag = isValidElement(children) ? children.type : null;

    useEffect(function registerDef() {
        setRegistry((prev) => {
            if (prev.has(id)) {
                if (process.env.NODE_ENV !== 'production') {
                    const existing = prev.get(id);
                    if (existing.tag !== tag) {
                        console.warn( `DefsPortal: id "${id}" is already registered as <${existing.tag}> but this instance is <${tag}>. Two different definition types share the same id.` );
                    }
                }

                return prev;
            }
            const next = new Map(prev);
            next.set(id, { instanceId, tag });

            return next;
        });

        return function unregisterDef() {
            setRegistry((prev) => {
                if (prev.get(id)?.instanceId !== instanceId) {
                    return prev;
                }
                const next = new Map(prev);
                next.delete(id);

                return next;
            });
        };
    }, [id, instanceId, tag, setRegistry]);

    if (!defsEl) {
        return null;
    }

    const owner = registry.get(id);
    if (owner && owner.instanceId !== instanceId) {
        return null;
    }

    return createPortal(children, defsEl);
}

是啊,useEffect,有点难受。React 在 Fiber 树里已经在内部跟踪哪些组件处于挂载状态 9——那是一串链表节点,存着状态与连接。但 Fiber 属于私有实现细节 10,并没有 API 能问「此刻还有谁挂在树上?」

于是我们只能在自建登记簿里重建这份知识,用的是 React 留给我们的唯一缝隙:Effects。我们滥用 useEffect 的 setup/cleanup 去观察挂载与卸载——这确实不是它的本职。

若你经历过 hooks 之前的 React,会认出我们又回到了组件生命周期的思路上。这是我第一次想念 componentDidMountcomponentWillUnmount

现在清单上的最后一项也可以打勾了。

  • ✅ 定义与使用该定义的组件同位存放
  • ✅ 只有一个 <defs> 元素
  • ✅ 没有重复定义
  • ✅ 当拥有定义的组件卸载时,定义随之移除

StackBlitz 上有可运行示例。体验很爽;同时这个模式又让我有点不是滋味。文末 Closing 还会再谈。

注意事项

有一点要清楚:登记簿按 id 去重,先注册的实例获胜。因此所有实例上的定义必须完全一致。若你的 <Arrow>color prop 且会影响 marker,那么每一支箭头都会使用第一个实例的颜色。若定义需要随实例变化,就必须使用不同的 ID。

此模式与 SSR

DefsPortal 通过 ref 回调拿到的 DOM 节点做 portal。服务端没有 DOM,因此 defsElnull,portal 也返回 null,即服务端 HTML 里不会出现任何定义。

SVG 图形元素本身可以正常渲染:path、circle、text 都会在首屏出现。缺的是被引用的装饰:marker、渐变、clip-path、filter 等。当不存在匹配定义时,浏览器会静默忽略 url(#id) 引用。

水合完成后,ref 回调触发,defsEl 被设置,portal 再把定义渲染进 <defs>

提升到哪里:就近 <svg> 还是全局 <defs>

还有一个架构选择:DefsPortal 究竟要提升到哪里

最近的 <svg> 祖先

上文采用的就是这种方案。每个 SVG 拥有自己的 <DefsProvider><defs>。定义只作用于使用它的那个 SVG。

<DefsProvider>
  <svg viewBox="0 0 800 400">
    <Defs />
    <Arrow path="M 10,50 L 200,50" />
    <Arrow path="M 10,100 L 300,100" /></svg
></DefsProvider>

干净、自洽。每个 SVG 彼此独立,可以单独挂载/卸载而不互相影响。缺点是:若同一页有两个互不相关的 SVG 都渲染 <Arrow>,就会各有一份 marker 定义——又回到了重复,只是范围从「每实例」变成了「每 SVG」。

文档级全局 <defs>

整个页面共用一个 <defs>,所有 SVG 共享。这要求在应用根部放一个不可见的 SVG:

// 放在布局或应用根组件中
<DefsProvider>
    <svg width="0" height="0" style={{ position: 'absolute' }}>
        <Defs />
    </svg>

    {/* 应用的其余部分——任意位置的 SVG 都可使用 DefsPortal */}
    {children}
</DefsProvider>

应用里所有 <DefsPortal> 都 portal 到同一个 <defs> 节点。无论多少 SVG 使用,marker 只定义一次。这样可以从根本上消除 ID 冲突,因为每条定义在文档里只出现一次。

这个隐藏 SVG 必须在任何组件尝试 portal 进去之前就挂载好,因此它通常与应用同寿。小代价是:你的 SVG 不再完全自洽,它们依赖的定义位于自身 <svg> 之外。

若 SVG 彼此隔离,就把定义限定在最近的 SVG;若许多 SVG 共用同一套定义,或你希望整页零重复,就走全局方案。

收尾

这算不算过度设计?算。Context、portal、登记簿、用 useEffect 跟踪生命周期——一大堆机械结构,只为把一个 DOM 节点摆对地方。多数场景下,散落几份重复的 <defs> 也完全没问题

但作为 React 练习它很合适:把 useId、portal、ref 回调、context、useEffect 都用在了略偏离日常用法的姿势上,而正是在这种偏离里,你会理解这些 API 如何拼在一起。

一个「懂 SVG」的 reconciler 本可以自动对 <defs> 做同样的事:在 SVG 树任意位置发现定义元素就提升它们,无需 portal、登记簿,也不用别扭的 useEffect

问题是 react-dom 不可扩展:没有插件 API,也没有钩子去拦截特定元素类型的处理。r3f 通过 react-reconciler 自带 host config,在自己的树里替换掉默认行为。SVG 已经在 DOM 里,于是你得在 userland 里重做 react-dom 的一小块工作,同时仍要把其余一切委托回去——实际上就意味着 fork react-dom 或维护一套并行 renderer。

所以我们才在 userland 里做完上面这一切。

脚注

脚注保持与正文编号一一对应,便于回溯每个论断所依赖的规范原文或官方文档段落。

对含长引文的条目,正文只保留必要上下文,完整语句与出处统一放在脚注区,避免打断主线阅读节奏。

若后续更新原文版本或链接失效,建议优先在脚注区替换来源链接并保留原编号,减少正文改动范围。

术语表(本篇命中)

英文/原文中文译法备注
colocate / colocation同位放置 / 同区存放与使用方紧邻组织代码与资源
defsdefs(SVG 定义容器)元素名保留,语义为「可复用定义」
hoist / hoistable提升 / 可提升与 React 19 metadata 的 hoist 用法对齐
portalPortal(传送门)createPortal 将子树挂到指定 DOM
registry登记簿 / 注册表文中自建的去重与所有权跟踪结构
referenced elements被引用元素SVG 规范术语
instance-proof实例安全每实例唯一 id,避免冲突

术语冲突清单(待确认)

  • Portal 是否译作「传送门」:团队若偏好保留英文 Portal,可将正文统一为「Portal」并在首次出现时附简短说明。
  • hoistable 与 React Float:是否统一译为「可提升元素」或保留英文 hoistable,需与团队既有 React 19 文档用语对齐。

参考资料与延伸阅读

Footnotes

  1. SVG 允许先定义图形对象以供后续复用。为此,它大量使用 IRI 引用(见 [RFC3987])指向其他对象。例如,要用线性渐变填充矩形,可先定义 linearGradient 并赋予 ID,如:<linearGradient id="MyGradient">...</linearGradient>;再在矩形的 fill 属性里引用该渐变,如:<rect style="fill:url(#MyGradient)"/>。某些元素(如渐变)自身不会产生可见图形,因此可放在任意方便的位置。但有时我们希望定义一个图形对象并阻止其直接绘制——它只存在于别处被引用。为此,也为了便于分组管理已定义内容,SVG 提供了 defs 元素。SVG1.1 规范:5.3 定义可复用内容与 defs 元素

  2. defs 元素是被引用元素的容器元素。出于可理解性与可访问性,规范建议在可能的情况下,把被引用元素定义在 defs 内。[……] 鼓励 SVG 内容作者把所有作为局部 IRI 引用目标的元素,放在引用元素的某个祖先之下的 defs 里,且该 defs 为该祖先的直接子元素SVG1.1 规范:5.3.2 defs 元素

  3. 在 HTML 中,<title><link><meta> 等文档元数据标签约定放在文档的 <head>。而在 React 里,决定应用元数据的组件,往往离真正渲染 <head> 的地方很远,甚至应用根本不渲染 <head>。过去这些元素通常要靠 effect 手动插入,或借助 react-helmet 等库,并在服务端渲染时格外小心。React 19 起,我们原生支持在组件里渲染文档元数据标签:[……] 当 React 渲染该组件时,会看到 <title><link><meta>,并自动把它们提升到文档的 <head>。原生支持后,纯客户端应用、流式 SSR 与 Server Components 都能正确工作。React v19 — 文档元数据支持

  4. 这是个很棒的功能,因为它保留了我们熟悉的 React 组合式写法。无需再为访问 <head> 额外搭 useEffect 之类的结构。即便组件渲染在 body 里的某个 div 中,我们也能在组件内直接管理 head 内容,React 会负责把它们提升到该去的地方。在 React 19 中提升 title 与 meta — egghead.io

  5. useId 会返回一个唯一 ID 字符串,与这一次 useId 调用、这一个组件实例相关联。React 文档:useId

  6. Portal 只改变 DOM 节点的物理位置。除此之外,你渲染进 portal 的 JSX 在 React 里仍作为渲染它的组件的子节点。例如,子节点可以访问父树提供的 context,事件也会按 React 树从子到父冒泡。React 文档:createPortal

  7. setter 具有稳定身份,因此你常会看到 Effect 依赖里省略它;但即便写上,也不会导致 Effect 额外触发。若 linter 允许你省略某个依赖且不报错,那就是安全的。React 文档:useState — 注意事项

  8. 当通过 Portal 传入组件时,它不会替换你提供节点中的子内容,而是追加jjenz 站点:关于 Portal 与追加渲染行为的说明

  9. React 维护一份内部数据结构,跟踪应用中当前存在的所有组件实例。其核心是一个称为「fiber」的对象,包含描述「此刻在树中该渲染何种组件类型」、当前 props 与 state、指向父/兄弟/子节点的指针,以及 React 用于跟踪渲染过程的其他元数据。Mark Erikson:博文答疑:一份(基本)完整的 React 渲染行为指南

  10. 老版 React,基于栈的那种,像个天真的递归树遍历器,一路往组件里钻 [……] 只不过那条「梯子」是直接焊在 JavaScript 引擎调用栈上的,所以一旦下到半途就停不下来。于是 React 团队基本等于说:整套模型都不要了,自己再造一个栈——不是比喻,是字面意义上的。这就是 fiber:一整条用节点链表实现的假调用栈。每个 fiber 像一小块由 React 完全掌控的内存单元,存着组件类型、props、state、子指针、兄弟指针、父指针、优先级信息、effects 标记,以及其它内部状态。

    @infinterenders on X