diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 328ef7321a..5372d5faa4 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -1,14 +1,15 @@ import { statSync } from 'node:fs' import { relative, resolve } from 'pathe' -import { addPluginTemplate, addTemplate, defineNuxtModule, resolveAlias, updateTemplates } from '@nuxt/kit' +import { addPluginTemplate, addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, resolveAlias, updateTemplates } from '@nuxt/kit' import type { Component, ComponentsDir, ComponentsOptions } from 'nuxt/schema' import { distDir } from '../dirs' import { clientFallbackAutoIdPlugin } from './client-fallback-auto-id' -import { componentsIslandsTemplate, componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates' +import { componentNamesTemplate, componentsIslandsTemplate, componentsPluginTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' import { loaderPlugin } from './loader' import { TreeShakeTemplatePlugin } from './tree-shake' +import { createTransformPlugin } from './transform' const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string' const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch (_e) { return false } } @@ -18,7 +19,7 @@ function compareDirByPathLength ({ path: pathA }: { path: string }, { path: path const DEFAULT_COMPONENTS_DIRS_RE = /\/components(\/global|\/islands)?$/ -type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[] +export type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[] export default defineNuxtModule({ meta: { @@ -115,10 +116,8 @@ export default defineNuxtModule({ addTemplate({ ...componentsTypeTemplate, options: { getComponents } }) // components.plugin.mjs addPluginTemplate({ ...componentsPluginTemplate, options: { getComponents } } as any) - // components.server.mjs - addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } }) - // components.client.mjs - addTemplate({ ...componentsTemplate, filename: 'components.client.mjs', options: { getComponents, mode: 'client' } }) + // component-names.mjs + addTemplate({ ...componentNamesTemplate, options: { getComponents, mode: 'all' } }) // components.islands.mjs if (nuxt.options.experimental.componentIslands) { addTemplate({ ...componentsIslandsTemplate, filename: 'components.islands.mjs', options: { getComponents } }) @@ -126,16 +125,14 @@ export default defineNuxtModule({ addTemplate({ filename: 'components.islands.mjs', getContents: () => 'export default {}' }) } - nuxt.hook('vite:extendConfig', (config, { isClient }) => { - const mode = isClient ? 'client' : 'server' - ; (config.resolve!.alias as any)['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`) - }) - nuxt.hook('webpack:config', (configs) => { - for (const config of configs) { - const mode = config.name === 'server' ? 'server' : 'client' - ; (config.resolve!.alias as any)['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`) - } - }) + const unpluginServer = createTransformPlugin(nuxt, getComponents, 'server') + const unpluginClient = createTransformPlugin(nuxt, getComponents, 'client') + + addVitePlugin(unpluginServer.vite(), { server: true, client: false }) + addVitePlugin(unpluginClient.vite(), { server: false, client: true }) + + addWebpackPlugin(unpluginServer.webpack(), { server: true, client: false }) + addWebpackPlugin(unpluginClient.webpack(), { server: false, client: true }) // Do not prefetch global components chunks nuxt.hook('build:manifest', (manifest) => { diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index 7fd32f3cb7..b91ba871c6 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -1,5 +1,5 @@ import { isAbsolute, relative } from 'pathe' -import { genDynamicImport, genExport, genImport, genObjectFromRawEntries } from 'knitwork' +import { genDynamicImport } from 'knitwork' import type { Component, Nuxt, NuxtPluginTemplate, NuxtTemplate } from 'nuxt/schema' export interface ComponentsTemplateContext { @@ -25,59 +25,42 @@ const createImportMagicComments = (options: ImportMagicCommentsOptions) => { ].filter(Boolean).join(', ') } +const emptyComponentsPlugin = ` +import { defineNuxtPlugin } from '#app/nuxt' +export default defineNuxtPlugin({ + name: 'nuxt:global-components', +}) +` + export const componentsPluginTemplate: NuxtPluginTemplate = { filename: 'components.plugin.mjs', getContents ({ options }) { + const globalComponents = options.getComponents().filter(c => c.global) + if (!globalComponents.length) { return emptyComponentsPlugin } + return `import { defineNuxtPlugin } from '#app/nuxt' -import { lazyGlobalComponents } from '#components' +import { ${globalComponents.map(c => 'Lazy' + c.pascalName).join(', ')} } from '#components' +const lazyGlobalComponents = [ + ${globalComponents.map(c => `["${c.pascalName}", Lazy${c.pascalName}]`).join(',\n')} +] export default defineNuxtPlugin({ - name: 'nuxt:global-components',` + - (options.getComponents().filter(c => c.global).length - ? ` + name: 'nuxt:global-components', setup (nuxtApp) { - for (const name in lazyGlobalComponents) { - nuxtApp.vueApp.component(name, lazyGlobalComponents[name]) - nuxtApp.vueApp.component('Lazy' + name, lazyGlobalComponents[name]) + for (const [name, component] of lazyGlobalComponents) { + nuxtApp.vueApp.component(name, component) + nuxtApp.vueApp.component('Lazy' + name, component) } - }` - : '') + ` + } }) ` } } -export const componentsTemplate: NuxtTemplate = { - // components.[server|client].mjs' +export const componentNamesTemplate: NuxtPluginTemplate = { + filename: 'component-names.mjs', getContents ({ options }) { - const imports = new Set() - imports.add('import { defineAsyncComponent } from \'vue\'') - - let num = 0 - const components = options.getComponents(options.mode).filter(c => !c.island).flatMap((c) => { - const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` - const comment = createImportMagicComments(c) - - const isClient = c.mode === 'client' - const definitions = [] - if (isClient) { - num++ - const identifier = `__nuxt_component_${num}` - imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) - imports.add(genImport(c.filePath, [{ name: c.export, as: identifier }])) - definitions.push(`export const ${c.pascalName} = /* #__PURE__ */ createClientOnly(${identifier})`) - } else { - definitions.push(genExport(c.filePath, [{ name: c.export, as: c.pascalName }])) - } - definitions.push(`export const Lazy${c.pascalName} = /* #__PURE__ */ defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${isClient ? `createClientOnly(${exp})` : exp}))`) - return definitions - }) - return [ - ...imports, - ...components, - `export const lazyGlobalComponents = ${genObjectFromRawEntries(options.getComponents().filter(c => c.global).map(c => [c.pascalName, `Lazy${c.pascalName}`]))}`, - `export const componentNames = ${JSON.stringify(options.getComponents().filter(c => !c.island).map(c => c.pascalName))}` - ].join('\n') + return `export const componentNames = ${JSON.stringify(options.getComponents().filter(c => !c.island).map(c => c.pascalName))}` } } diff --git a/packages/nuxt/src/components/transform.ts b/packages/nuxt/src/components/transform.ts new file mode 100644 index 0000000000..d16b7b3538 --- /dev/null +++ b/packages/nuxt/src/components/transform.ts @@ -0,0 +1,95 @@ +import { isIgnored } from '@nuxt/kit' +import type { Nuxt } from 'nuxt/schema' +import type { Import } from 'unimport' +import { createUnimport } from 'unimport' +import { createUnplugin } from 'unplugin' +import { parseURL } from 'ufo' +import { parseQuery } from 'vue-router' +import type { getComponentsT } from './module' + +export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT, mode: 'client' | 'server' | 'all') { + const componentUnimport = createUnimport({ + imports: [ + { + name: 'componentNames', + from: '#build/component-names' + } + ], + virtualImports: ['#components'] + }) + + function getComponentsImports (): Import[] { + const components = getComponents(mode) + return components.flatMap((c): Import[] => { + return [ + { + as: c.pascalName, + from: c.filePath + (c.mode === 'client' ? '?component=client' : ''), + name: 'default' + }, + { + as: 'Lazy' + c.pascalName, + from: c.filePath + '?component=' + [c.mode === 'client' ? 'client' : '', 'async'].filter(Boolean).join(','), + name: 'default' + } + ] + }) + } + + return createUnplugin(() => ({ + name: 'nuxt:components:imports', + transformInclude (id) { + return !isIgnored(id) + }, + async transform (code, id) { + // Virtual component wrapper + if (id.includes('?component')) { + const { search } = parseURL(id) + const query = parseQuery(search) + const mode = query.component + const bare = id.replace(/\?.*/, '') + if (mode === 'async') { + return [ + 'import { defineAsyncComponent } from "vue"', + `export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => r.default))` + ].join('\n') + } else if (mode === 'client') { + return [ + `import __component from ${JSON.stringify(bare)}`, + 'import { createClientOnly } from "#app/components/client-only"', + 'export default createClientOnly(__component)' + ].join('\n') + } else if (mode === 'client,async') { + return [ + 'import { defineAsyncComponent } from "vue"', + 'import { createClientOnly } from "#app/components/client-only"', + `export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => createClientOnly(r.default)))` + ].join('\n') + } else { + throw new Error(`Unknown component mode: ${mode}, this might be an internal bug of Nuxt.`) + } + } + + if (!code.includes('#components')) { + return null + } + + componentUnimport.modifyDynamicImports((imports) => { + imports.length = 0 + imports.push(...getComponentsImports()) + return imports + }) + + const result = await componentUnimport.injectImports(code, id, { autoImport: false, transformVirtualImports: true }) + if (!result) { + return null + } + return { + code: result.code, + map: nuxt.options.sourcemap + ? result.s.generateMap({ hires: true }) + : undefined + } + } + })) +} diff --git a/test/bundle.test.ts b/test/bundle.test.ts index da2f1f322f..0c5ffe460c 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -34,7 +34,7 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e it('default client bundle size', async () => { stats.client = await analyzeSizes('**/*.js', publicDir) - expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"94.4k"') + expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"94.3k"') expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` [ "_nuxt/entry.js", @@ -45,10 +45,10 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e it('default server bundle size', async () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"67.3k"') + expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"66.8k"') const modules = await analyzeSizes('node_modules/**/*', serverDir) - expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2657k"') + expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2654k"') const packages = modules.files .filter(m => m.endsWith('package.json')) @@ -76,7 +76,6 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e "h3", "hookable", "iron-webcrypto", - "klona", "node-fetch-native", "ofetch", "ohash",