perf(ui): cut initial-load bundle via lazy model-viewer and preload fix

This commit is contained in:
matevip 2026-07-08 15:53:57 +08:00
parent 5db8fb14a4
commit 1318a32b71
4 changed files with 66 additions and 18 deletions

View File

@ -537,6 +537,7 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown' import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks' import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
import { ensureModelViewer } from '@/utils/lazyModelViewer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment' import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { useToolLabel } from '@/composables/useToolLabel' import { useToolLabel } from '@/composables/useToolLabel'
import { http } from '@/api' import { http } from '@/api'
@ -901,9 +902,14 @@ watch(audioAttachments, (atts) => {
if (atts.length > 0) loadAllAudios(atts) if (atts.length > 0) loadAllAudios(atts)
}, { immediate: true }) }, { immediate: true })
// 3D models also need the auth-blob loader <model-viewer src> doesn't carry // 3D models also need the auth-blob loader <model-viewer src> doesn't carry
// the Authorization header any more than <img>/<audio> do. // the Authorization header any more than <img>/<audio> do. The heavy
// @google/model-viewer Web Component is lazy-loaded here (only when a bubble
// actually has a 3D attachment) instead of eagerly in main.ts.
watch(model3dAttachments, (atts) => { watch(model3dAttachments, (atts) => {
if (atts.length > 0) loadAllModels(atts) if (atts.length > 0) {
ensureModelViewer()
loadAllModels(atts)
}
}, { immediate: true }) }, { immediate: true })
// --- --- // --- ---

View File

@ -9,10 +9,10 @@ import router from './router'
import './assets/main.css' import './assets/main.css'
import { i18n, initializeLocale } from './i18n' import { i18n, initializeLocale } from './i18n'
// Side-effect import: registers the <model-viewer> Web Component globally so // Note: the heavy <model-viewer> Web Component is no longer imported here. It is
// generated 3D assets (.glb) can be previewed inline in chat bubbles. Vue's // lazy-registered on demand (see src/utils/lazyModelViewer.ts) the first time a
// compiler is told to treat the tag as a custom element via vite.config.ts. // chat bubble renders a 3D (.glb) asset, keeping it out of the initial bundle.
import '@google/model-viewer' // Vue's compiler still treats <model-viewer> as a custom element via vite.config.ts.
async function bootstrap() { async function bootstrap() {
await initializeLocale() await initializeLocale()

View File

@ -0,0 +1,22 @@
/**
* Lazy registrar for the <model-viewer> Web Component.
*
* @google/model-viewer is a heavy dependency (~hundreds of KB) that used to be
* imported eagerly in main.ts, landing it in the initial entry chunk on every
* first paint even though 3D (.glb) previews are rare. This defers the import to
* the first time a chat bubble actually renders a 3D attachment. The module-level
* promise makes it a process-wide singleton the component registers exactly
* once no matter how many bubbles request it.
*
* Vue's compiler still treats <model-viewer> as a custom element via the
* isCustomElement option in vite.config.ts, so the tag resolves whether or not
* the definition has loaded yet; once this import resolves the element upgrades.
*/
let loadPromise: Promise<unknown> | null = null
export function ensureModelViewer(): Promise<unknown> {
if (!loadPromise) {
loadPromise = import('@google/model-viewer')
}
return loadPromise
}

View File

@ -88,18 +88,38 @@ export default defineConfig({
// chunk lets each Worker on the chunk run with a smaller // chunk lets each Worker on the chunk run with a smaller
// working set, which is what blew up the Docker build at // working set, which is what blew up the Docker build at
// 1.3.0 (needed --max-old-space-size=6144 as a band-aid). // 1.3.0 (needed --max-old-space-size=6144 as a band-aid).
manualChunks: { //
'vendor-monaco': ['monaco-editor', '@guolao/vue-monaco-editor'], // Function form (not the object map) so we can ISOLATE the CommonJS
'vendor-vue-flow': [ // interop helpers into their own tiny chunk. With the object form,
'@vue-flow/core', // Rollup hoisted the shared `commonjsHelpers` module into
'@vue-flow/background', // vendor-mermaid / vendor-markdown; because the eager entry chunk needs
'@vue-flow/controls', // that helper, it gained a *static* import of both ~1 MB vendor chunks,
'@vue-flow/minimap', // and Vite then `modulepreload`-ed them on first paint even though
], // mermaid/markdown are only used on the (lazy) chat route. Splitting the
'vendor-mermaid': ['mermaid'], // helper into its own leaf chunk lets the entry depend on a few-hundred-
'vendor-echarts': ['echarts'], // byte module instead, so the heavy vendors stay purely on-demand.
'vendor-element': ['element-plus', '@element-plus/icons-vue'], manualChunks(id) {
'vendor-markdown': ['marked', 'marked-highlight', 'highlight.js', 'dompurify'], // Isolate shared runtime helpers FIRST — before the node_modules gate.
// The Vite preload helper (\0vite/preload-helper, exported as the
// __vitePreload used by every lazy route import) and the CommonJS
// interop helpers are virtual modules with no node_modules path. If one
// lands inside a heavy vendor chunk, the entry's route-lazy imports pull
// that whole chunk in as a static dependency and Vite preloads it. A
// dedicated leaf chunk keeps them out of the vendors.
if (id.includes('vite/preload-helper') || id.includes('commonjsHelpers')
|| id.includes('commonjs-dynamic-modules') || id.includes('\0commonjs')) {
return 'vendor-runtime'
}
if (!id.includes('node_modules')) return undefined
if (id.includes('monaco-editor') || id.includes('vue-monaco-editor')) return 'vendor-monaco'
if (id.includes('@vue-flow')) return 'vendor-vue-flow'
if (id.includes('/mermaid/')) return 'vendor-mermaid'
if (id.includes('/echarts/') || id.includes('zrender')) return 'vendor-echarts'
if (id.includes('element-plus') || id.includes('@element-plus')) return 'vendor-element'
if (id.includes('/marked') || id.includes('highlight.js') || id.includes('dompurify')) {
return 'vendor-markdown'
}
return undefined
}, },
}, },
}, },