A Vite Plugin Splits Lucide Icons into 5 Chunks by First Letter for True On-Demand Loading
The Ultimate Performance Icon Loading Solution for Modern Frontends (Successfully Cracked)
In medium-to-large projects, there are many scenarios where icons need to be set dynamically, such as through an admin page: dynamically setting menu icons or item icons.
At this point, a problem arises: we want a large number of icons available for selection, but we also want these icons to be loaded on demand.
These are two contradictory scenarios. The icon selector component needs to load all icons, while regular icon display should be on-demand. None of the existing solutions can meet this requirement.
You might say, isn't on-demand importing enough?
On-demand import syntax:
<script setup>
import { Smile } from "@lucide/vue";
</script>
<template>
<Smile color="#3e9392" />
</template>
The above syntax cannot be used for dynamically setting icons because the icon's name is stored in configuration, and you need to render the icon from a name string. Import statements do not support variables.
Target syntax:
<script setup lang="ts">
const name = 'smile'
</script>
<template>
<!-- lucide icon -->
<Icon :name="`lucide-${iconName}`" color="#3e9392" size="24" />
<!-- Element Plus icon -->
<Icon :name="`el-${iconName}`" color="#3e9392" size="24" />
</template>
The import() Function Approach
The first solution I thought of was the import() function, like this:
const mod = await import(`@lucide/vue/dist/icons/${name}.js`)
After getting the icon name string, you just pass it through a dedicated icon loading function. However, in practice, this approach had too many problems 😂
- Frequent network requests: Our icon selector component needs to load all icons for users to choose from. Assuming there are 2000 icons, that page would trigger 2000 requests. Without careful handling, duplicate icons would trigger duplicate requests!
- Build output fragmentation: Build tools statically split independent chunks for subsequent asynchronous loading. 2000 icons would be split into 2000 chunks, causing a massive increase in the number of build output files!
- Rendering lag and flickering: Because the icons are split too granularly, loading many icons asynchronously with
awaitcan easily cause lag.
Other Common Solutions
unplugin-auto-import Static Compilation On-Demand Packaging
Only packages icons that are hardcoded for use on a page. Dependencies are determined at build time and cannot handle runtime variable icon names. Our icon selector component would still cause all icons to be loaded upfront.
Global Icon Component Registration
This is full loading. It's just convenient to use, but lacks on-demand loading characteristics.
import.meta.glob
The effect is similar to the import() function.
Thinking
With the help of AI, I came up with many possibilities, such as:
- During compilation, determine which icons are used, then write them to a file for recording, and complete dynamic registration within the program—a different kind of on-demand loading. Unfortunately, I suddenly realized that our icon selector component would still cause all icons to be loaded.
- Write a script to group icons by their first letter, prepare chunk files in advance, and then load different chunks on demand based on the icon's first letter when importing. This approach is okay, but it has ongoing maintenance costs.
- ......
The Final Solution
Preparing chunk files in advance gave me inspiration. We can create a Vite plugin that splits icons into 5 virtual modules based on the first letter range. Each virtual module, when imported, produces an independent chunk. There are no maintenance costs because they are virtual modules, and the chunks don't need to be written to disk.
The splitting scheme for the Lucide icon library is as follows:
┌───────────────────────┬──────────────┬────────┬────────────────────┐
│ Chunk │ min / gzip │ Icons │ Trigger Condition│
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ a-c-*.js │ 140 / 36 KB │ 514 │ First letter a–c │
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ d-l-*.js │ 124 / 33 KB │ 424 │ First letter d–l │
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ m-p-*.js │ 73 / 20 KB │ 262 │ First letter m–p │
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ q-s-*.js │ 95 / 24 KB │ 315 │ First letter q–s │
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ t-z-*.js │ 62 / 18 KB │ 223 │ First letter t–z │
├───────────────────────┼──────────────┼────────┼────────────────────┤
│ createLucideIcon-*.js │ 1.3 / 0.8 KB │ — │ Shared dep (once) │
└───────────────────────┴──────────────┴────────┴────────────────────┘
When the icon selector component is used, all icons will be loaded (5 chunks).
On pages where the icon selector component is not used, the system loads the corresponding chunk on demand based on the first letter of the icon. Unused chunks are not loaded, and nothing is loaded on the first screen. The total number of chunks is very small, preventing build output fragmentation.
In the development environment, just load the full icon package; no splitting is needed.
Testing
Comparison Data Preparation
Since I needed to compare with the state before implementing on-demand loading, I used git stash to temporarily save all changes in the workspace and restored the workspace to its initial state (the state without loading the icon library).
Before loading any icons, with the browser cache disabled, always testing on the same Vue page:
Development environment: 47 requests, 4.7MB transferred, 4.7MB resources.
Build output: 17 items in assets, 1.55MB; 14 requests, 1.6MB transferred, 1.6MB resources. Also recorded the larger files loaded for comparison:
http://localhost:8000/assets/index-Cww9--Nk.js 1078kb
http://localhost:8000/assets/vue.runtime.esm-bundler-58Gmz7_F.js 111kb
http://localhost:8000/assets/style-C1fZPW92.css 370kb
The above data is without loading the icon library. If the icon library were fully loaded, it should increase by 500-800kb (without gzip enabled).
Solution Data
The first test rendered an icon starting with 'a'.
Development environment: 51 requests, 5.5MB transferred, 5.5MB resources.
Build output: 24 items in assets, 2.17 MB; 17 requests, 1.9MB transferred, 1.9MB resources. The following file over 100kb was added:
http://localhost:8000/assets/a-c-CdQL3xxB.js 144kb
The number of requests is reasonable, the request size is reasonable. The solution is declared a success. Next, I used icons starting with a, d, m, q, t one by one and recompiled to confirm the following files are loaded on demand, and the file size distribution is also quite reasonable:
- http://localhost:8000/assets/a-c-Byxc3NbE.js
144kb - http://localhost:8000/assets/d-l-D6J0NQGY.js
127kb - http://localhost:8000/assets/m-p-Cti2KRmr.js
75.2kb - http://localhost:8000/assets/q-s-CGvOdbjT.js
97.3kb - http://localhost:8000/assets/t-z-d79zgzJ8.js
63.5kb
Code Sharing
The complete code is open-sourced at: github | gitee
types/lucide.d.ts file:
declare module 'virtual:lucide-icons/*' {
import type { Component } from 'vue'
const icons: Record<string, Component>
export = icons
}
src\components\icon\vitePlugin.ts file:
import { readdirSync } from 'fs'
import { camelCase, upperFirst } from 'lodash-es'
import { join } from 'path'
import type { Plugin } from 'vite'
/**
* Lucide icon module split loading plugin
* The total size of the Lucide module is about 615kb, 128kb after gzip compression
* The plugin implements split loading of the Lucide module in production, splitting Lucide icons into 5 virtual modules based on the first letter range
* Each virtual module produces an independent chunk when imported
*/
export function lucideIconSplitPlugin(): Plugin {
const VIRTUAL_PREFIX = 'virtual:lucide-icons/'
const RESOLVED_PREFIX = '\0' + VIRTUAL_PREFIX
const BATCHES: Record<string, RegExp> = {
'a-c': /^[a-c]/,
'd-l': /^[d-l]/,
'm-p': /^[m-p]/,
'q-s': /^[q-s]/,
't-z': /^[t-z0-9]/,
}
let iconFiles: string[]
return {
name: 'lucide-icon-batches',
enforce: 'pre',
configResolved() {
const dir = join(process.cwd(), 'node_modules/@lucide/vue/dist/esm/icons')
try {
iconFiles = readdirSync(dir).filter((f) => f.endsWith('.mjs') && f !== 'index.mjs')
} catch {
iconFiles = []
}
},
resolveId(id) {
if (id.startsWith(VIRTUAL_PREFIX)) {
return RESOLVED_PREFIX + id.slice(VIRTUAL_PREFIX.length)
}
},
load(id) {
if (!id.startsWith(RESOLVED_PREFIX)) return
const batchName = id.slice(RESOLVED_PREFIX.length)
const pattern = BATCHES[batchName]
if (!pattern || !iconFiles) return 'export {}'
const matched = iconFiles.filter((f) => pattern.test(f.replace('.mjs', '')))
// Use explicit import + named export instead of re-export,
// to ensure Rolldown inlines all icon modules into the same chunk
const imports: string[] = []
const exports: string[] = []
matched.forEach((f, i) => {
const pascalName = upperFirst(camelCase(f.replace('.mjs', '')))
const varName = `_${i}`
imports.push(`import ${varName} from '@lucide/vue/dist/esm/icons/${f}'`)
exports.push(`export const ${pascalName} = ${varName}`)
})
return imports.join('\n') + '\n' + exports.join('\n')
},
}
}
src\components\icon\index.ts file:
import * as elIcons from '@element-plus/icons-vue'
import { camelCase, kebabCase, upperFirst } from 'lodash-es'
import { App, defineAsyncComponent, type Component } from 'vue'
type IconMap = Record<string, Component>
const lucideCache: IconMap = {}
export function getLucideComponent(name: string): Component | null {
const key = upperFirst(camelCase(name))
if (lucideCache[key]) {
return lucideCache[key]
}
let loader: () => Promise<Component | { render: () => null }>
if (import.meta.env.DEV) {
let iconsPromise: Promise<IconMap> | null = null
loader = () => (iconsPromise ??= import('@lucide/vue').then((m) => m.icons as IconMap)).then((icons) => icons[key] || { render: () => null })
} else {
const batchLoaders: Record<string, () => Promise<IconMap>> = {
'a-c': () => import('virtual:lucide-icons/a-c') as Promise<IconMap>,
'd-l': () => import('virtual:lucide-icons/d-l') as Promise<IconMap>,
'm-p': () => import('virtual:lucide-icons/m-p') as Promise<IconMap>,
'q-s': () => import('virtual:lucide-icons/q-s') as Promise<IconMap>,
't-z': () => import('virtual:lucide-icons/t-z') as Promise<IconMap>,
}
const first = key.charAt(0).toLowerCase()
const batch = 'abc'.includes(first)
? 'a-c'
: 'defghijkl'.includes(first)
? 'd-l'
: 'mnop'.includes(first)
? 'm-p'
: 'qrs'.includes(first)
? 'q-s'
: 't-z'
loader = () => batchLoaders[batch]().then((icons) => icons[key] || { render: () => null })
}
const asyncComp = defineAsyncComponent(loader)
lucideCache[key] = asyncComp
return asyncComp
}
src\components\icon\index.vue file:
<script lang="ts">
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 icons, globally registered in batch as `el-icon-kebabCase(name)` components via registerIcons
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 icons, lucideIconSplitPlugin splits all lucide icons into virtual packages by first letter and loads them on demand
if (props.name.indexOf('lucide-') === 0) {
const name = props.name.replace('lucide-', '')
return () => {
const component = getLucideComponent(name)
if (component) {
return h(component, { ...props, ...attrs })
}
}
}
},
})
</script>
vite.config.ts file (adding registration of the lucideIconSplitPlugin):
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import type { ConfigEnv, UserConfig } from 'vite'
import { loadEnv } from 'vite'
import { lucideIconSplitPlugin } from './src/components/icon/vitePlugin'
// https://vitejs.cn/config/
const viteConfig = ({ 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,
},
}
}
export default viteConfig