现代前端的极致性能 icon 加载方案(死磕成功版)

📅 2026/6/30 10:57:51
现代前端的极致性能 icon 加载方案(死磕成功版)
中大项目中有很多动态设置icon的场景比如通过管理页面动态设置菜单的 icon条目的 icon此时就会遇到一个问题我们希望有很多icon可供使用但是又希望这些icon是按需加载的。这是两个矛盾的场景即要在图标选择器组件加载所有图标平时图标的显示又是按需加载的目前已有的方案均不能实现需求。你说不对啊按需导入使用不就行了吗按需导入写法script setup import { Smile } from lucide/vue; /script template Smile color#3e9392 / /template以上写法是不能用于动态设置 icon 的因为 icon 的 name 被写入配置你需要以 name 字符串渲染出 icon而 import 语句不支持变量。目标写法script setup langts const name smile /script template !-- lucide 图标 -- Icon :namelucide-${iconName} color#3e9392 size24 / !-- Element Puls 图标 -- Icon :nameel-${iconName} color#3e9392 size24 / /templateimport()函数方案我想到的第一个方案是 import() 函数像这样constmodawaitimport(lucide/vue/dist/icons/${name}.js)拿到 icon name 的 string 之后过一遍单独的图标加载函数就行了然而实测中发现这个方案问题太多了频繁网络请求我们的图标选择器组件需要加载所有的图标供用户选择假设有 2000 个那么该页会触发 2000 个请求处理不好重复的图标会重复发起请求打包产物碎片化打包工具会静态拆分独立的供后续异步加载的 chunk2000 个图标会拆出来 2000 个 chunk构建产物文件数量巨增渲染卡顿、闪烁由于图标分的实在是太细了await 异步加载的图标一多容易出现卡顿问题。其他常用方案unplugin-auto-import静态编译按需打包只打包页面写死使用的图标打包时就确定依赖不能处理运行时变量图标名称我们的图标选择器组件会导致图标提前全量加载。图标组件全局注册这就是全量加载只是使用方便没有按需加载的特性。import.meta.glob效果和import()函数类似。思考自己结合 AI想出了很多可能性比如在编译期间确定使用了那些图标然后写入到一个文件内记录程序内再完成动态注册是一种别样的按需加载。可惜突然发现这种方式我们的图标选择器组件还是会导致图标全量加载。写个脚本将 icon 按首字母分组提前制备 chunk 文件然后 import 时根据图标首字母按需加载不同的 chunk。这种方式还行只是有日常维护成本。…最终方案提前制备 chunk 文件给了我灵感我们可以做个 Vite 插件将图标按首字母范围拆分为 5 个虚拟模块每个虚拟模块被 import 时产生一个独立 chunk不会存在维护成本因为是虚拟模块chunk 不需要落盘。Lucide 图标库的拆分方案如下┌───────────────────────┬──────────────┬────────┬────────────────────┐ │ Chunk │ min /gzip│ 图标数 │ 触发条件 │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ a-c-*.js │140/36KB │514│ 首字母 a–c │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ d-l-*.js │124/33KB │424│ 首字母 d–l │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ m-p-*.js │73/20KB │262│ 首字母 m–p │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ q-s-*.js │95/24KB │315│ 首字母 q–s │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ t-z-*.js │62/18KB │223│ 首字母 t–z │ ├───────────────────────┼──────────────┼────────┼────────────────────┤ │ createLucideIcon-*.js │1.3/0.8KB │ — │ 公共依赖仅一次 │ └───────────────────────┴──────────────┴────────┴────────────────────┘使用了图标选择器组件时所有的图标都会被加载5 个 Chunk未使用图标选择器组件的页面根据图标首字母的不同系统会按需的加载对应的 Chunk没使用的 Chunk 不会加载首屏不加载总 Chunk 数非常少不会造成打包产物碎片化。开发环境下还是全量加载图标包即可无需拆分。测试对比数据准备由于需要和未实现按需加载之前做对比我将本次工作区的全部改动使用git stash暂存起来恢复工作区至初始状态未加载图标库的状态。未加载任何图标前浏览器禁用缓存总是使用同一 vue 页面测试开发环境下47 个请求传输 4.7MB4.7MB 项资源。构建产物assets内共 17 个项目1.55MB14 个请求传输 1.6MB1.6MB 项资源同时记录下加载的较大文件以做对比http://localhost:8000/assets/index-Cww9–Nk.js1078kbhttp://localhost:8000/assets/vue.runtime.esm-bundler-58Gmz7_F.js111kbhttp://localhost:8000/assets/style-C1fZPW92.css370kb以上数据是未加载图标库的如果全量加载图标库应该会增加 500-800kb未启用 gzip方案数据首次测试渲染了一个 a 开头的图标开发环境下51 个请求传输 5.5MB5.5MB 项资源。构建产物assets内共 24 个项目2.17 MB17 个请求传输 1.9MB1.9MB 项资源上 100kb 的文件多了以下一个http://localhost:8000/assets/a-c-CdQL3xxB.js144kb请求数合理请求大小合理宣布方案成功接下来逐一使用 a d m q t 开头的图标并重新编译确定以下文件是按需加载的文件大小分布也较为合理http://localhost:8000/assets/a-c-Byxc3NbE.js144kbhttp://localhost:8000/assets/d-l-D6J0NQGY.js127kbhttp://localhost:8000/assets/m-p-Cti2KRmr.js75.2kbhttp://localhost:8000/assets/q-s-CGvOdbjT.js97.3kbhttp://localhost:8000/assets/t-z-d79zgzJ8.js63.5kb代码分享完整代码开源于github | giteetypes/lucide.d.ts 文件declaremodulevirtual:lucide-icons/*{importtype{Component}fromvueconsticons:Recordstring,Componentexporticons}src\components\icon\vitePlugin.ts 文件import{readdirSync}fromfsimport{camelCase,upperFirst}fromlodash-esimport{join}frompathimporttype{Plugin}fromvite/** * Lucide icon 模块拆分加载插件 * Lucide 模块总大小约 615kbgzip 压缩后 128kb * 插件实现了生产环境下 Lucide 模块的拆分加载将 Lucide 图标按首字母范围拆分为 5 个虚拟模块 * 每个虚拟模块被 import 时产生一个独立 chunk */exportfunctionlucideIconSplitPlugin():Plugin{constVIRTUAL_PREFIXvirtual:lucide-icons/constRESOLVED_PREFIX\0VIRTUAL_PREFIXconstBATCHES:Recordstring,RegExp{a-c:/^[a-c]/,d-l:/^[d-l]/,m-p:/^[m-p]/,q-s:/^[q-s]/,t-z:/^[t-z0-9]/,}leticonFiles:string[]return{name:lucide-icon-batches,enforce:pre,configResolved(){constdirjoin(process.cwd(),node_modules/lucide/vue/dist/esm/icons)try{iconFilesreaddirSync(dir).filter((f)f.endsWith(.mjs)f!index.mjs)}catch{iconFiles[]}},resolveId(id){if(id.startsWith(VIRTUAL_PREFIX)){returnRESOLVED_PREFIXid.slice(VIRTUAL_PREFIX.length)}},load(id){if(!id.startsWith(RESOLVED_PREFIX))returnconstbatchNameid.slice(RESOLVED_PREFIX.length)constpatternBATCHES[batchName]if(!pattern||!iconFiles)returnexport {}constmatchediconFiles.filter((f)pattern.test(f.replace(.mjs,)))// 使用显式 import 具名 export 而非 re-export// 确保 Rolldown 将所有图标模块内联到同一个 chunk 中constimports:string[][]constexports:string[][]matched.forEach((f,i){constpascalNameupperFirst(camelCase(f.replace(.mjs,)))constvarName_${i}imports.push(import${varName}from lucide/vue/dist/esm/icons/${f})exports.push(export const${pascalName}${varName})})returnimports.join(\n)\nexports.join(\n)},}}src\components\icon\index.ts 文件import*aselIconsfromelement-plus/icons-vueimport{camelCase,kebabCase,upperFirst}fromlodash-esimport{App,defineAsyncComponent,typeComponent}fromvuetypeIconMapRecordstring,ComponentconstlucideCache:IconMap{}exportfunctiongetLucideComponent(name:string):Component|null{constkeyupperFirst(camelCase(name))if(lucideCache[key]){returnlucideCache[key]}letloader:()PromiseComponent|{render:()null}if(import.meta.env.DEV){leticonsPromise:PromiseIconMap|nullnullloader()(iconsPromise??import(lucide/vue).then((m)m.iconsasIconMap)).then((icons)icons[key]||{render:()null})}else{constbatchLoaders:Recordstring,()PromiseIconMap{a-c:()import(virtual:lucide-icons/a-c)asPromiseIconMap,d-l:()import(virtual:lucide-icons/d-l)asPromiseIconMap,m-p:()import(virtual:lucide-icons/m-p)asPromiseIconMap,q-s:()import(virtual:lucide-icons/q-s)asPromiseIconMap,t-z:()import(virtual:lucide-icons/t-z)asPromiseIconMap,}constfirstkey.charAt(0).toLowerCase()constbatchabc.includes(first)?a-c:defghijkl.includes(first)?d-l:mnop.includes(first)?m-p:qrs.includes(first)?q-s:t-zloader()batchLoaders[batch]().then((icons)icons[key]||{render:()null})}constasyncCompdefineAsyncComponent(loader)lucideCache[key]asyncCompreturnasyncComp}src\components\icon\index.vue 文件script langts import { createVNode, defineComponent, h, resolveComponent } from vue import { getLucideComponent } from ./index export default defineComponent({ name: Icon, props: { name: { type: String, required: true, }, size: { type: [Number, String], default: 24, }, color: { type: String, default: undefined, }, strokeWidth: { type: [Number, String], default: undefined, }, }, setup(props, { attrs }) { // Element Plus 的 icon通过 registerIcons 全局批量注册为 el-icon-kebabCase(name) 的组件 if (props.name.indexOf(el-) 0) { const name props.name.replace(el-, el-icon-) return () { return createVNode( resolveComponent(el-icon), { class: icon ai-go-icon, ...props, ...attrs }, { default: () h(resolveComponent(name)) } ) } } // lucide 的 iconlucideIconSplitPlugin 将按首字母将全部 lucide icon 分为虚拟包并按需加载 if (props.name.indexOf(lucide-) 0) { const name props.name.replace(lucide-, ) return () { const component getLucideComponent(name) if (component) { return h(component, { ...props, ...attrs }) } } } }, }) /scriptvite.config.ts 文件增加 lucideIconSplitPlugin 插件的注册importvuefromvitejs/plugin-vueimport{resolve}frompathimporttype{ConfigEnv,UserConfig}fromviteimport{loadEnv}fromviteimport{lucideIconSplitPlugin}from./src/components/icon/vitePlugin// https://vitejs.cn/config/constviteConfig({mode}:ConfigEnv):UserConfig{const{VITE_PORT,VITE_OPEN,VITE_BASE_PATH,VITE_OUT_DIR}loadEnv(mode,process.cwd())return{plugins:[vue(),lucideIconSplitPlugin()],root:process.cwd(),resolve:{alias:{/:resolve(__dirname,src),},},base:VITE_BASE_PATH,server:{port:parseInt(VITE_PORT),open:VITE_OPEN!false,},build:{cssCodeSplit:false,sourcemap:false,outDir:VITE_OUT_DIR,emptyOutDir:true,chunkSizeWarningLimit:1500,},}}exportdefaultviteConfig