React 中可提升的 SVG defs:用 Portal 与 Context 把定义和组件放在一起
-
原文作者:Jules Blom
将 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 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 标记。
上图对应原文在「Notice that definitions…」一段之后给出的结构示意:展示 marker-end 与 url(#…) 如何穿过 <svg> / <defs> / <path> 的层级关系。
实际上浏览器相当宽容,也允许把定义类元素写在 <defs> 之外。但若你更清楚利弊,就别这么做,把它们留在 <defs> 里。
规范建议:在可能的情况下,被引用元素应定义在 ‘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> 组件画一个框。
上图对应原文 Problem 2 小节中、在树状图上用虚线框标出「Arrow component」之后的那一版示意,与上一节「仅结构」的图区分开。
可见 url(#arrow) 这条引用在组件边界上「戳了一个洞」:这是隐式耦合,会破坏真正的封装。
我想要什么:可提升的 defs
React 19 为 HTML 的 <head> 解决过一个类似问题:借助特殊渲染行为,像 <link>、<meta> 这类本应出现在 <head> 里的标签,可以写在组件树的任意位置,react-dom 会把它们提升到 <head> 并去重 3 4。
上图对应原文在介绍 React 19 将 <link> / <meta> 提升到 <head> 时给出的 React 树与 DOM 树对照示意。
在React团队内部,这类可以提升到 <head> 的元素被称为 「hoistables」,对应 HostHoistable 这类 fiber 类型;该特性也叫 Float。
我想要的是 SVG <defs> 上的同款能力:
- 定义与使用它的组件同位(colocated);
- 只有一个
<defs>元素; - 没有重复定义;
- 当拥有某条定义的组件卸载时,定义也随之移除。
试试看。
用 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>
);
}
再用一张示意图概括(已转存为图片):
上图对应原文在「And as a diagram」之后展示的三列树(Parent tree / Owner tree / DOM tree),说明 DefsPortal 与 defsEl、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,会认出我们又回到了组件生命周期的思路上。这是我第一次想念
componentDidMount与componentWillUnmount。
现在清单上的最后一项也可以打勾了。
- ✅ 定义与使用该定义的组件同位存放
- ✅ 只有一个
<defs>元素 - ✅ 没有重复定义
- ✅ 当拥有定义的组件卸载时,定义随之移除
在 StackBlitz 上有可运行示例。体验很爽;同时这个模式又让我有点不是滋味。文末 Closing 还会再谈。
注意事项
有一点要清楚:登记簿按 id 去重,先注册的实例获胜。因此所有实例上的定义必须完全一致。若你的 <Arrow> 有 color prop 且会影响 marker,那么每一支箭头都会使用第一个实例的颜色。若定义需要随实例变化,就必须使用不同的 ID。
此模式与 SSR
DefsPortal通过 ref 回调拿到的 DOM 节点做 portal。服务端没有 DOM,因此defsEl为null,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 | 同位放置 / 同区存放 | 与使用方紧邻组织代码与资源 |
| defs | defs(SVG 定义容器) | 元素名保留,语义为「可复用定义」 |
| hoist / hoistable | 提升 / 可提升 | 与 React 19 metadata 的 hoist 用法对齐 |
| portal | Portal(传送门) | createPortal 将子树挂到指定 DOM |
| registry | 登记簿 / 注册表 | 文中自建的去重与所有权跟踪结构 |
| referenced elements | 被引用元素 | SVG 规范术语 |
| instance-proof | 实例安全 | 每实例唯一 id,避免冲突 |
术语冲突清单(待确认)
- Portal 是否译作「传送门」:团队若偏好保留英文
Portal,可将正文统一为「Portal」并在首次出现时附简短说明。 - hoistable 与 React Float:是否统一译为「可提升元素」或保留英文
hoistable,需与团队既有 React 19 文档用语对齐。
参考资料与延伸阅读
Footnotes
-
SVG 允许先定义图形对象以供后续复用。为此,它大量使用 IRI 引用(见 [RFC3987])指向其他对象。例如,要用线性渐变填充矩形,可先定义
linearGradient并赋予 ID,如:<linearGradient id="MyGradient">...</linearGradient>;再在矩形的fill属性里引用该渐变,如:<rect style="fill:url(#MyGradient)"/>。某些元素(如渐变)自身不会产生可见图形,因此可放在任意方便的位置。但有时我们希望定义一个图形对象并阻止其直接绘制——它只存在于别处被引用。为此,也为了便于分组管理已定义内容,SVG 提供了defs元素。SVG1.1 规范:5.3 定义可复用内容与defs元素 ↩ -
defs元素是被引用元素的容器元素。出于可理解性与可访问性,规范建议在可能的情况下,把被引用元素定义在defs内。[……] 鼓励 SVG 内容作者把所有作为局部 IRI 引用目标的元素,放在引用元素的某个祖先之下的defs里,且该defs为该祖先的直接子元素。SVG1.1 规范:5.3.2defs元素 ↩ -
在 HTML 中,
<title>、<link>、<meta>等文档元数据标签约定放在文档的<head>。而在 React 里,决定应用元数据的组件,往往离真正渲染<head>的地方很远,甚至应用根本不渲染<head>。过去这些元素通常要靠 effect 手动插入,或借助 react-helmet 等库,并在服务端渲染时格外小心。React 19 起,我们原生支持在组件里渲染文档元数据标签:[……] 当 React 渲染该组件时,会看到<title>、<link>、<meta>,并自动把它们提升到文档的<head>。原生支持后,纯客户端应用、流式 SSR 与 Server Components 都能正确工作。React v19 — 文档元数据支持 ↩ -
这是个很棒的功能,因为它保留了我们熟悉的 React 组合式写法。无需再为访问
<head>额外搭useEffect之类的结构。即便组件渲染在body里的某个div中,我们也能在组件内直接管理 head 内容,React 会负责把它们提升到该去的地方。在 React 19 中提升 title 与 meta — egghead.io ↩ -
useId会返回一个唯一 ID 字符串,与这一次useId调用、这一个组件实例相关联。React 文档:useId ↩ -
Portal 只改变 DOM 节点的物理位置。除此之外,你渲染进 portal 的 JSX 在 React 里仍作为渲染它的组件的子节点。例如,子节点可以访问父树提供的 context,事件也会按 React 树从子到父冒泡。React 文档:createPortal ↩
-
setter 具有稳定身份,因此你常会看到 Effect 依赖里省略它;但即便写上,也不会导致 Effect 额外触发。若 linter 允许你省略某个依赖且不报错,那就是安全的。React 文档:useState — 注意事项 ↩
-
当通过 Portal 传入组件时,它不会替换你提供节点中的子内容,而是追加。jjenz 站点:关于 Portal 与追加渲染行为的说明 ↩
-
React 维护一份内部数据结构,跟踪应用中当前存在的所有组件实例。其核心是一个称为「fiber」的对象,包含描述「此刻在树中该渲染何种组件类型」、当前 props 与 state、指向父/兄弟/子节点的指针,以及 React 用于跟踪渲染过程的其他元数据。Mark Erikson:博文答疑:一份(基本)完整的 React 渲染行为指南 ↩
-
老版 React,基于栈的那种,像个天真的递归树遍历器,一路往组件里钻 [……] 只不过那条「梯子」是直接焊在 JavaScript 引擎调用栈上的,所以一旦下到半途就停不下来。于是 React 团队基本等于说:整套模型都不要了,自己再造一个栈——不是比喻,是字面意义上的。这就是 fiber:一整条用节点链表实现的假调用栈。每个 fiber 像一小块由 React 完全掌控的内存单元,存着组件类型、props、state、子指针、兄弟指针、父指针、优先级信息、effects 标记,以及其它内部状态。