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'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
import { ensureModelViewer } from '@/utils/lazyModelViewer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { useToolLabel } from '@/composables/useToolLabel'
import { http } from '@/api'
@ -901,9 +902,14 @@ watch(audioAttachments, (atts) => {
if (atts.length > 0) loadAllAudios(atts)
}, { immediate: true })
// 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) => {
if (atts.length > 0) loadAllModels(atts)
if (atts.length > 0) {
ensureModelViewer()
loadAllModels(atts)
}
}, { immediate: true })
// --- ---

View File

@ -9,10 +9,10 @@ import router from './router'
import './assets/main.css'
import { i18n, initializeLocale } from './i18n'
// Side-effect import: registers the <model-viewer> Web Component globally so
// generated 3D assets (.glb) can be previewed inline in chat bubbles. Vue's
// compiler is told to treat the tag as a custom element via vite.config.ts.
import '@google/model-viewer'
// Note: the heavy <model-viewer> Web Component is no longer imported here. It is
// lazy-registered on demand (see src/utils/lazyModelViewer.ts) the first time a
// chat bubble renders a 3D (.glb) asset, keeping it out of the initial bundle.
// Vue's compiler still treats <model-viewer> as a custom element via vite.config.ts.
async function bootstrap() {
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
// working set, which is what blew up the Docker build at
// 1.3.0 (needed --max-old-space-size=6144 as a band-aid).
manualChunks: {
'vendor-monaco': ['monaco-editor', '@guolao/vue-monaco-editor'],
'vendor-vue-flow': [
'@vue-flow/core',
'@vue-flow/background',
'@vue-flow/controls',
'@vue-flow/minimap',
],
'vendor-mermaid': ['mermaid'],
'vendor-echarts': ['echarts'],
'vendor-element': ['element-plus', '@element-plus/icons-vue'],
'vendor-markdown': ['marked', 'marked-highlight', 'highlight.js', 'dompurify'],
//
// Function form (not the object map) so we can ISOLATE the CommonJS
// interop helpers into their own tiny chunk. With the object form,
// Rollup hoisted the shared `commonjsHelpers` module into
// vendor-mermaid / vendor-markdown; because the eager entry chunk needs
// that helper, it gained a *static* import of both ~1 MB vendor chunks,
// and Vite then `modulepreload`-ed them on first paint even though
// mermaid/markdown are only used on the (lazy) chat route. Splitting the
// helper into its own leaf chunk lets the entry depend on a few-hundred-
// byte module instead, so the heavy vendors stay purely on-demand.
manualChunks(id) {
// 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
},
},
},