qiankun子应用加载大量icon时页面卡死
发布时间: 2025-07-15
问题概述
我有一个比较复杂的系统,由数个子项目组成,由qiankun微前端缝合它们,最近整个这些项目都是出海,部署到海外,从国内移植之后,发现一个问题,
我们的系统配置的侧边栏配置页面,里面会有一个选择这个菜单的图标的能力,提供了2000来个图标选择, 使用的 tdesign 开源ui的icon,代码大概是这样的,
import { Select } from 'tdesign-react';
import { Icon, manifest } from 'tdesign-icons-react';
const { Option } = Select;
<Select
filterable
value={value}
scroll={{ type: 'lazy' }}
onChange={handleChange} prefixIcon={<Icon name={value} style={{ marginRight: '8px' }} />}>
{manifest.map(item => (
<Option
key={item.stem}
value={item.stem}
label={item.stem}
style={{ display: 'inline-block', fontSize: '20px' }}
>
<Icon name={item.stem} />
</Option>
))}
</Select>
因为这部分配置不会开放出去给用户,因此当年写这个代码的朋友 也没有考虑对2000个图标的加载做优化,就是硬循环渲染,即便如此,在我们的电脑上也能在1秒内渲染完毕。
从子应用访问一切正常,能在1秒内加载完成,从基座访问呢?噩梦发生,当点击select组件的时候就直接整个页面卡死、崩溃了。
怀疑沙箱导致问题
然后我着手对这个问题排查,因为只有从基座加载才崩溃,因此我怀疑是基座的原因,而基座能导致这个问题的唯一原因就只有预加载和沙箱,因为不管是直接访问子应用 还是从基座去加载子应用,子应用的代码都是部署的同一套,只能是因为加载方式的不同导致的。
prefetch: true,
sandbox: {
experimentalStyleIsolation: false,
// experimentalStyleIsolation: true,
},
因此我首先怀疑预加载和沙箱,当我逐步关闭预加载没有变化,但是关闭沙箱时,就验证了我的猜测,打开沙箱配置后就会出现卡死的问题。
prefetch: true,
sandbox: false,
那究竟是沙箱代理dom操作导致性能下滑?还是沙箱代理window对象导致的性能下滑?
对比火焰图
我首先去调试工具看了一下子应用直接访问和通过基座访问各自的火焰图,希望可以看到是哪个函数之类的导致渲染卡死。
子应用
首先访问子应用查看火焰图:
可以看出来这个火焰图没有明显的长任务和异常的dom重绘,看这个只是为了对比基座访问的火焰图差异。
然后我又看了一下通过基座,再点击select组件之前开始录制,点了select之后等了10来秒再结束,然后我得到这样的火焰图:
基座
这下就明显不对了,看后面有规律的蓝色的部分,它正在执行一种重复的任务,图形几乎一致,我只采集了10多秒就暂停了,就已经足以查看到问题了。
放大其中一小段图看看这些规律执行的任务的详细情况:
可以看到这个任务,首先是 import-html-entry
插件触发到 https://tdesign.gtimg.com/icon/0.3.1/fonts/index.js
中的一个函数 insertAdjacentHTML
,
然后就导致了解析HTML的任务,这两个任务加起来是20多ms,但是这样的任务好像陷入了循环,一直在执行。
import-html-entry
是qiankun用来加载js、css等资源的插件,比如原来你的子应用插入一个js到dom中,但是qiankun基座会拦截你的操作,在基座中替你加载资源、执行资源。
https://tdesign.gtimg.com/icon/0.3.1/fonts/index.js
放到url中打开可以看到内容其实就是写入js文件中的svg 雪碧图, insertAdjacentHTML
是将雪碧图插入到html的函数。
从这里得到结论,qiankun会不断地去加载雪碧图js,然后往html中插入,导致解析html的任务一直在执行,从而导致页面卡死。为了弄清原因,我又去读了tdesign-icon的源码。
查阅tdesign-icon源码
通过阅读tdesign-icon源码,我看到他里面的大致逻辑是这样的,首先从这里拿到Icon
Icon组件的逻辑
export { Icon } from './svg-sprite/svg-sprite.js';
import { loadStylesheet, loadScript } from '../util/check-url-and-load.js';
var CDN_SVGSPRITE_URL = "https://tdesign.gtimg.com/icon/0.3.1/fonts/index.js";
var Icon = /*#__PURE__*/forwardRef(function (props, ref) {
var _useConfig = useConfig(),
classPrefix = _useConfig.classPrefix;
var name = props.name,
......
var _useSizeProps = useSizeProps(size),
sizeClassName = _useSizeProps.className,
sizeStyle = _useSizeProps.style;
var className = useMemo(function () {
var iconName = url ? name : "".concat(classPrefix, "-icon-").concat(name);
return classNames("".concat(classPrefix, "-icon"), iconName, sizeClassName, customClassName);
}, [classPrefix, customClassName, name, sizeClassName]);
useEffect(function () {
loadStylesheet();
}, []);
useEffect(function () {
if (!loadDefaultIcons) {
return;
}
loadScript(CDN_SVGSPRITE_URL, "".concat(classPrefix, "-svg-js-stylesheet--unique-class"));
}, [classPrefix, loadDefaultIcons]);
useEffect(function () {
var urls = Array.isArray(url) ? url : [url];
urls.forEach(function (url2) {
loadScript(url2, "".concat(classPrefix, "-svg-js-stylesheet--unique-class"));
});
}, [classPrefix, url]);
......
});
它在内部会去加载这个svg雪碧图所在的js文件(28行),并且还会给script加一个 -svg-js-stylesheet--unique-class
的标志,每次加载先检查这个script标签是否已经存在,这个逻辑在
check-url-and-load.js
中
function loadScript(url, className) {
if (!document || !url || typeof url !== "string") return;
if (document.querySelectorAll(".".concat(className, "[src=\"").concat(url, "\"]")).length > 0) {
return;
}
var svg = document.createElement("script");
svg.setAttribute("class", className);
svg.setAttribute("src", url);
document.body.appendChild(svg);
}
这样就可以控制不论你使用多少次Icon始终只保持只加载一次雪碧图。我们现在的问题是反复在加载,那么问题一定是出现在 import-html-entry
拦截并解析appendChild的script标签的时候把这个用来判断你是否已经添加的 -svg-js-stylesheet--unique-class
标志弄丢了。
svg雪碧图文件
再继续看看雪碧图js内的代码为:
(function() {
;(function() {
with (this) {
const {Array, ArrayBuffer, Boolean, constructor, DataView, Date, decodeURI, decodeURIComponent, encodeURI, encodeURIComponent, Error, escape, EvalError, Float32Array, Float64Array, Function, hasOwnProperty, Infinity, Int16Array, Int32Array, Int8Array, isFinite, isNaN, isPrototypeOf, JSON, Map, Math, NaN, Number, Object, parseFloat, parseInt, Promise, propertyIsEnumerable, Proxy, RangeError, ReferenceError, Reflect, RegExp, Set, String, Symbol, SyntaxError, toLocaleString, toString, TypeError, Uint16Array, Uint32Array, Uint8Array, Uint8ClampedArray, undefined, unescape, URIError, valueOf, WeakMap, WeakSet, window, self, globalThis, requestAnimationFrame} = this;
// This file is generated automatically by `useSvgSpriteTemplate.ts`. DO NOT EDIT IT.
(function() {
var svgCode = '<?xml version="1.0" encoding="utf-8"?><svg style="position:absolute; width:0; height:0; visibility:hidden" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol fill="none" viewBox="0 0 24 24" id="t-icon-accessibility" xmlns="http://www.w3.org/2000/
【..此处省略无数字符,这里都是2000来个svg图标的svg标签...】
11.272l5.345 5.345-1.414 1.414-5.345-5.345A8.501 8.501 0 014.49 4.49zM6.5 11.5v-2h8v2h-8z" fill="currentColor"/></symbol><symbol fill="none" viewBox="0 0 24 24" id="t-icon-zoom-out-filled" xmlns="http://www.w3.org/2000/svg"><path d="M4.49 16.51a8.501 8.501 0 0011.272.666l5.344 5.345 1.415-1.414-5.345-5.345A8.501 8.501 0 004.49 4.49a8.5 8.5 0 000 12.02zM6.5 9.5h8v2h-8v-2z" fill="currentColor"/></symbol></svg>';
if (document.body) {
document.body.insertAdjacentHTML('afterbegin', svgCode);
} else {
document.addEventListener('DOMContentLoaded', function() {
document.body.insertAdjacentHTML('afterbegin', svgCode);
});
}
}());
//# sourceURL=https://tdesign.gtimg.com/icon/0.3.1/fonts/index.js
}
}
).bind(window.proxy)();
}
)
这里的 insertAdjacentHTML
正是前面火焰图中的耗时任务,作用就是把雪碧图插入到body下。这样使用雪碧的地方这样子就可以显示对应的图标,上面这些图标只需要加载一次,所有地方都是共用一份的。
<svg class="icon">
<use href="#icon-home"></use>
</svg>
修剪数据测试
首先看看直接访问子应用
可以看到body下挂了一个svg,能赌赢上上面的insertAdjacentHTML函数执行后的结果,底部有一个script标签,有一个class,对应到上面的代码
concat(classPrefix, "-svg-js-stylesheet--unique-class")
产生的结果。子应用的访问是完全符合预期的。
继续测试基座访问,但是因为基座加载直接卡死,因此我先修剪了一下数据,原来2000条,现在只加载10条看看
{manifest.slice(0, 10).map(item => (
<Option
key={item.stem}
value={item.stem}
label={item.stem}
style={{ display: 'inline-block', fontSize: '20px' }}
>
<Icon name={item.stem} />
</Option>
))}
这次可以不卡死,但是页面比较慢,2秒内渲染完成了,我们来看看它的dom
问题原因的推测得到验证,问题的发生步骤如下
子应用加载第一个图标
触发加载js,执行appendChild(svg)加载js时被拦截,import-html-entry 将js请求后,直接执行一次,并且保存在内存中。
子应用加载第二个图标
检测不到存在script标签,于是再次appendChild(svg)触发加载js,import-html-entry 检查有对应js已经请求过,从缓存拿出来直接执行。
循环以上步骤直到所有图标加载完
以上步骤有2000个icon就会循环执行2000次,导致雪碧图插入2000次到body下,由于每次插入的雪碧图中有2000个svg标签,相当于插入了 2000x2000=400w 个svg标签到body中,进而导致页面卡死。
验证问题后怎么解决
有两种方案可以解决这个问题,选哪个看实际的场景了。
修改沙箱配置?
no!
很遗憾,我去查了qiankun的沙箱配置,它仅支持 boolean
| { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
所以这种方式暂时行不通。
改为虚拟滚动?
no!
不好意思,这种方式也不能解决问题,甚至可能滚动到1000个图标的位置后,重新第一个图标的位置时,又要重新触发加载第一个图标的逻辑。因为虚拟滚动本质上就是移除掉之前的dom,滚 回来又重新渲染被移除的dom。
在基座手动加载svg雪碧图?
yes!
经实测是可行的,在基座中增加这个雪碧图的js标签,如果你是next.js就在_document.ts中去改,如果你是普通spa,则在你的html中增加这个标签。
{/* @ts-ignore */}
<script class="t-svg-js-stylesheet--unique-class" src="https://tdesign.gtimg.com/icon/0.3.1/fonts/index.js"></script>
这样问题就得到了解决, 但是这样解决的问题就是你在升级之后,需要手动修改这里对应版本的js地址,以及如果你改了classPrefix前缀,也需要再这里修改class。
最后
这个问题得到解决,同时也说明了,这种类似的防多次加载的处理方式(在script标签添加特殊的class)在qiankun这种微前端中会被无视掉,处理方法根据这个原理自己解决就是了。