背景&现状
该文章不介绍SVG相关基础知识和使用的场景,仅作为遇到的linearGradient id相同时引发的表现问题的现象和原理分析。如果大家也遇到了相似的显隐问题,可以参考此文章的解法。
在某次重构中,我们遇到了一个诡异的SVG渲染问题:当一个包含相同渐变ID的SVG组件被 display: none
隐藏时,其他同类组件的渐变效果会同步消失。
问题分析
一开始我们以为这个是个偶现的问题,且在排查中也怀疑是我们组件写的问题,直到发现了多次渲染的SVG组件会跟着第一个SVG状态同步变化时,我们才定位到可能是SVG本身的渲染机制问题。
问题复现
首先,我们先看一个非常简单的SVG的代码,渲染个渐变的三角形,具体代码如下所示:
ini
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="myGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: red; stop-opacity: 1" />
<stop offset="100%" style="stop-color: yellow; stop-opacity: 1" />
</linearGradient>
</defs>
<polygon
points="50,10 90,90 10,90"
style="fill: url(#myGradient); stroke: black; stroke-width: 2"
/>
</svg>;
可能有些同学会看不懂上面的SVG在做什么,不过这个不是特别重要,大家只需要关注到,我们用的linearGradient
标签现在有一个id
属性,这个是后面的重点。
现在,我们将这个组件复制2次,得到3个渐变的三角形,具体展示如下:
到这里看起来一切都还非常正常,但是接下来我们就会让大家看到一个"违背认知"的现象。
如果这个时候我们给第一个元素设置display: none
,那么第二个元素和第三个元素的渐变也没了,具体表现如下:
大家可以看到,第一个元素因为CSS设置原因,从页面中隐藏,这个是符合我们的认知的,但是为什么第二个和第三个SVG的渐变效果会消失呢?然后还留下了一个黑色的边框。
原理分析
首先,我们一句话解释下这个问题的原因:id
必须在当前SVG文档中是唯一的,不能与其他元素的 id
重复。 fill:url(#id)
会优先使用首个匹配的渐变定义。当首个定义被隐藏( display: none
),所有引用该ID的渐变效果将失效。
我们先解释下:<linearGradient>
是SVG中用于定义线性渐变的元素。它允许你创建一个颜色渐变效果,并可以将其应用到SVG图形(如矩形、圆形、多边形等)的填充或描边中。线性渐变的特点是颜色沿着一条直线(称为渐变线)从一个颜色平滑过渡到另一个颜色。
其中的id
是用于唯一标识这个渐变,以便在图形中通过 fill:url(#id)
引用。
如果id
存在重复,那么就会出现我们上面遇到的那个问题,所有SVG中同一个id的<linearGradient>
的表现,都会跟第一个渲染的<linearGradient>
一样,大家可以简单理解为所有相同的id的标签都指向了同一个引用。
在这种情况下,如果第一个<linearGradient>
的显隐发生了改变,那么其他的<linearGradient>
的显隐会同步变化,也就出现了我们上面的那个情况:第一个SVG隐藏后,<linearGradient>
也消失了,所以其他SVG里面的<linearGradient>
也变成了消失的状态。
使用visibility: hidden
的话,不存在这个问题,因为第一个引用并没有从DOM中移除,不存在找不到的问题。
常见场景
在我们日常使用中,最常见的场景其实就是我遇到的那种,一个需要多次渲染的组件引用了同一个SVG组件,这个组件有没有做特殊的处理,然后我们在组件上使用display: none
来进行控制。例如我们常见的多Tab的场景。
例如我现在有2个Tab,每个Tab都是用到了同一个SVG组件来做ICON等,那么当第一个Tab被display: none
隐藏时,第二个Tab即使内容展示出来,SVG组件也是不完整甚至是不可见的。具体代码示例如下:
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<script>
document.addEventListener("DOMContentLoaded", () => {
const btn1 = document.getElementById("btn1");
const btn2 = document.getElementById("btn2");
const panel1 = document.getElementById("panel1");
const panel2 = document.getElementById("panel2");
btn1.addEventListener("click", () => {
panel1.style.display = "block";
panel2.style.display = "none";
});
btn2.addEventListener("click", () => {
panel1.style.display = "none";
panel2.style.display = "block";
});
});
</script>
<body>
<button id="btn1">打开Tab1</button>
<button id="btn2">打开Tab2</button>
<div id="panel1" style="display: none">
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 定义线性渐变 -->
<defs>
<linearGradient id="myGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: red; stop-opacity: 1" />
<stop offset="100%" style="stop-color: yellow; stop-opacity: 1" />
</linearGradient>
</defs>
<!-- 使用渐变填充三角形 -->
<polygon
points="50,10 90,90 10,90"
style="fill: url(#myGradient); stroke: black; stroke-width: 2"
/>
</svg>
</div>
<div id="panel2" style="display: none">
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 定义线性渐变 -->
<defs>
<linearGradient id="myGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: red; stop-opacity: 1" />
<stop offset="100%" style="stop-color: yellow; stop-opacity: 1" />
</linearGradient>
</defs>
<!-- 使用渐变填充三角形 -->
<polygon
points="50,10 90,90 10,90"
style="fill: url(#myGradient); stroke: black; stroke-width: 2"
/>
</svg>
</div>
</body>
</html>
效果如下:
解决方案
常规解决方案
既然我我们知道了我们需要控制id唯一,那么我们只需要在使用SVG时,给每一次渲染设置一个唯一标记id即可。下面我用我们常见的React组件封装作为例子,来给大家说下这个问题应该怎么解决。
javascript
import {v4 as uuid} from 'uuid'; // https://www.npmjs.com/package/uuid
import {useId} from 'React'; // 如果你用的是React 18,那么也可以用React提供的id
const SVGIcon = memo(() => {
const id = uuid();
return (
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={id} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color: red; stop-opacity: 1" />
<stop offset="100%" style="stop-color: yellow; stop-opacity: 1" />
</linearGradient>
</defs>
<polygon
points="50,10 90,90 10,90"
style="fill: url(#myGradient); stroke: black; stroke-width: 2"
/>
</svg>
);
});
如果大家对React的memo
不熟悉的话可能还会问,我们直接memo
住这个组件的话,那么前后多次渲染会不会有影响,比如我在多个组件里引用了SVG组件,是不是还会出现id相同的情况。
React组件的memo
其实是会根据上下文进行生效,如果你在不同的上下文中同时引入多个SVG组件,那么这个SVG组件是单独的memo实例,不会互相影响的。只有当上下文一样且props浅层比较一致的情况下,才会复用上次的渲染结果(即相同的id)。在这种情况下,是符合我们预期的。