perf(ui): on-demand Element Plus, tree-shake unused components

This commit is contained in:
matevip 2026-07-08 15:54:18 +08:00
parent 1318a32b71
commit 5b948f7852
13 changed files with 240 additions and 19 deletions

View File

@ -55,6 +55,7 @@
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^4.0.6",
"typescript": "~5.7.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^7.3.1",
"vitest": "^4.1.7",
"vue-tsc": "^3.2.6"

View File

@ -126,6 +126,9 @@ importers:
typescript:
specifier: ~5.7.2
version: 5.7.3
unplugin-vue-components:
specifier: ^32.1.0
version: 32.1.0(esbuild@0.27.5)(rollup@4.60.1)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3))
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)
@ -1096,6 +1099,10 @@ packages:
resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==}
engines: {node: '>=22.0.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
@ -1125,6 +1132,9 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -1477,6 +1487,9 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exsolve@1.1.0:
resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -1791,6 +1804,10 @@ packages:
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -1958,6 +1975,9 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@ -1994,10 +2014,17 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -2120,6 +2147,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
@ -2150,6 +2181,53 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unplugin-utils@0.3.2:
resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==}
engines: {node: '>=20.19.0'}
unplugin-vue-components@32.1.0:
resolution: {integrity: sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@nuxt/kit': ^3.2.2 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@nuxt/kit':
optional: true
unplugin@3.3.0:
resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@farmfe/core': '*'
'@rspack/core': '*'
bun-types-no-globals: '*'
esbuild: '*'
rolldown: '*'
rollup: '*'
unloader: '*'
vite: '*'
webpack: '*'
peerDependenciesMeta:
'@farmfe/core':
optional: true
'@rspack/core':
optional: true
bun-types-no-globals:
optional: true
esbuild:
optional: true
rolldown:
optional: true
rollup:
optional: true
unloader:
optional: true
vite:
optional: true
webpack:
optional: true
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@ -2312,6 +2390,9 @@ packages:
typescript:
optional: true
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
@ -3255,6 +3336,10 @@ snapshots:
'@chevrotain/types': 12.0.0
'@chevrotain/utils': 12.0.0
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
@ -3279,6 +3364,8 @@ snapshots:
confbox@0.1.8: {}
confbox@0.2.4: {}
convert-source-map@2.0.0: {}
copy-anything@4.0.5:
@ -3719,6 +3806,8 @@ snapshots:
expect-type@1.3.0: {}
exsolve@1.1.0: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@ -3984,6 +4073,12 @@ snapshots:
lit-element: 4.2.2
lit-html: 3.3.2
local-pkg@1.2.1:
dependencies:
mlly: 1.8.2
pkg-types: 2.3.1
quansync: 0.2.11
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@ -4149,6 +4244,12 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
pkg-types@2.3.1:
dependencies:
confbox: 0.2.4
exsolve: 1.1.0
pathe: 2.0.3
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@ -4182,8 +4283,12 @@ snapshots:
punycode@2.3.1: {}
quansync@0.2.11: {}
react@19.2.5: {}
readdirp@5.0.0: {}
resolve-from@4.0.0: {}
rfdc@1.4.1: {}
@ -4302,6 +4407,11 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
ts-dedent@2.2.0: {}
@ -4320,6 +4430,44 @@ snapshots:
undici-types@7.24.6: {}
unplugin-utils@0.3.2:
dependencies:
pathe: 2.0.3
picomatch: 4.0.4
unplugin-vue-components@32.1.0(esbuild@0.27.5)(rollup@4.60.1)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3)):
dependencies:
chokidar: 5.0.0
local-pkg: 1.2.1
magic-string: 0.30.21
mlly: 1.8.2
obug: 2.1.1
picomatch: 4.0.4
tinyglobby: 0.2.17
unplugin: 3.3.0(esbuild@0.27.5)(rollup@4.60.1)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))
unplugin-utils: 0.3.2
vue: 3.5.31(typescript@5.7.3)
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
- bun-types-no-globals
- esbuild
- rolldown
- rollup
- unloader
- vite
- webpack
unplugin@3.3.0(esbuild@0.27.5)(rollup@4.60.1)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
optionalDependencies:
esbuild: 0.27.5
rollup: 4.60.1
vite: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@ -4440,6 +4588,8 @@ snapshots:
optionalDependencies:
typescript: 5.7.3
webpack-virtual-modules@0.6.2: {}
whatwg-mimetype@3.0.0: {}
which@2.0.2:

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus/es/components/message/index'
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import JsonView from './JsonView.vue'

View File

@ -1,7 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// Element Plus components and imperative APIs (ElMessage, ElMessageBox, …) are
// now resolved on demand by unplugin (see vite.config.ts) instead of registering
// the whole library via app.use(ElementPlus). Only the full stylesheet is still
// imported once here so every component's styles and theme CSS variables stay
// intact (no visual change); unused component *code* is tree-shaken out.
// Icons are imported locally where used — no more global registration of all ~300.
import 'element-plus/dist/index.css'
import App from './App.vue'
@ -19,14 +23,9 @@ async function bootstrap() {
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(ElementPlus)
// Global error handler — prevents uncaught Vue errors from causing white screens
app.config.errorHandler = (err, instance, info) => {

View File

@ -11,10 +11,21 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es/components/button/index')['ElButton']
ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es/components/date-picker/index')['ElDatePicker']
ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog']
ElDrawer: typeof import('element-plus/es/components/drawer/index')['ElDrawer']
ElDropdown: typeof import('element-plus/es/components/dropdown/index')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es/components/dropdown/index')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es/components/dropdown/index')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es/components/empty/index')['ElEmpty']
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
ElImageViewer: typeof import('element-plus/es/components/image-viewer/index')['ElImageViewer']
ElOption: typeof import('element-plus/es/components/select/index')['ElOption']
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
ElSelect: typeof import('element-plus/es/components/select/index')['ElSelect']
ElSkeleton: typeof import('element-plus/es/components/skeleton/index')['ElSkeleton']
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@ -273,13 +273,13 @@ let cachedAgents: import('@/types').Agent[] = []
</script>
<script setup lang="ts">
import { ElMessage } from 'element-plus/es/components/message/index'
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ChatDotRound, Delete, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi, approvalApi } from '@/api/index'
import { ElMessage } from 'element-plus'
import { copyToClipboard } from '@/utils/clipboard'
import { useFileDrop } from '@/composables/useFileDrop'
import { useIsMobile, useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'

View File

@ -292,9 +292,9 @@
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus/es/components/message/index'
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import {
Delete,
Lock,

View File

@ -72,7 +72,6 @@
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { featureFlagApi, type FeatureFlag } from '@/api/index'

View File

@ -148,7 +148,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { mcToast } from '@/composables/useMcToast'
import { skillApi } from '@/api/index'

View File

@ -78,7 +78,6 @@
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { hotCacheApi, type WikiHotCache } from '@/api/index'
import { useWikiStore } from '@/stores/useWikiStore'

View File

@ -26,7 +26,6 @@
* upstream so this component stays a thin coordinator.
*/
import { ref } from 'vue'
import { ElImageViewer } from 'element-plus'
const open = ref(false)
const urls = ref<string[]>([])

View File

@ -345,7 +345,6 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ElIcon } from 'element-plus'
import { Loading, WarningFilled } from '@element-plus/icons-vue'
import { wikiApi, modelApi } from '@/api/index'
import { useWikiStore, type WikiRawMaterial } from '@/stores/useWikiStore'

View File

@ -2,21 +2,86 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { visualizer } from 'rollup-plugin-visualizer'
import Components from 'unplugin-vue-components/vite'
import { resolve } from 'path'
// Sub-components that Element Plus re-exports from a parent package rather than
// shipping under their own es/components/<name> directory.
const EP_AGGREGATE: Record<string, string> = {
ElDropdownItem: 'dropdown',
ElDropdownMenu: 'dropdown',
ElOption: 'select',
ElOptionGroup: 'select',
ElCheckboxButton: 'checkbox',
ElCheckboxGroup: 'checkbox',
ElRadioButton: 'radio',
ElRadioGroup: 'radio',
ElCollapseItem: 'collapse',
ElBreadcrumbItem: 'breadcrumb',
ElStep: 'steps',
ElTabPane: 'tabs',
ElTimelineItem: 'timeline',
ElMenuItem: 'menu',
ElSubMenu: 'menu',
ElFormItem: 'form',
ElTableColumn: 'table',
ElCarouselItem: 'carousel',
}
// Resolve <el-*> components to their per-component subpath
// (element-plus/es/components/<dir>/index) instead of the element-plus/es
// barrel. The barrel statically imports every component, so importing any single
// name from it drags the whole library into the bundle — the stock
// ElementPlusResolver (for Element Plus 2.x) uses that barrel and relies on
// Rollup tree-shaking, which does not hold here. Per-component subpaths make the
// tree-shaking explicit. No style side effect is emitted because the full
// element-plus/dist/index.css is imported once in main.ts.
function elementPlusSubpathResolver() {
return {
type: 'component' as const,
resolve(name: string) {
if (!/^El[A-Z]/.test(name)) return
// <el-icon-xxx> style icon usage — resolve to the icons package.
if (/^ElIcon.+/.test(name)) {
return { name: name.replace(/^ElIcon/, ''), from: '@element-plus/icons-vue' }
}
const dir = EP_AGGREGATE[name]
?? name.slice(2).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
return { name, from: `element-plus/es/components/${dir}/index` }
},
}
}
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Treat <model-viewer> as a custom Web Component (registered globally
// via @google/model-viewer in main.ts) so Vue doesn't try to resolve
// it as a Vue component and emit a "Failed to resolve component"
// warning at runtime.
// Treat <model-viewer> as a custom Web Component (lazy-registered on
// demand via src/utils/lazyModelViewer.ts) so Vue doesn't try to
// resolve it as a Vue component and emit a "Failed to resolve
// component" warning at runtime.
isCustomElement: (tag) => tag === 'model-viewer',
},
},
}),
// On-demand Element Plus: resolve <el-*> components used in templates to
// their per-component subpath (element-plus/es/components/*) instead of
// registering the whole library via app.use(ElementPlus), so only the ~16
// components the app actually uses land in the bundle. importStyle:false
// because the full element-plus/dist/index.css is still imported once in
// main.ts — every component's styles and the theme CSS variables stay intact
// (no visual regression); only dead component *code* is tree-shaken out.
// dirs:[] so local components stay explicitly imported (no auto-scan).
// NOTE: imperative APIs (ElMessage) are imported explicitly from their
// subpath at the 3 call sites — auto-import resolved them from the
// `element-plus/es` barrel, which re-exports everything and defeated
// tree-shaking entirely.
Components({
dirs: [],
resolvers: [elementPlusSubpathResolver()],
dts: 'src/types/components.d.ts',
}),
tailwindcss(),
// ANALYZE=1 pnpm build writes dist/stats.html. Skipped on normal builds so
// CI artifact upload is opt-in and the Docker image build doesn't waste