mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 23:22:02 +00:00
feat(nuxt3): allow separating client and server components (#4390)
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
This commit is contained in:
parent
ea47e56540
commit
b38dc097f6
@ -8,6 +8,10 @@
|
||||
<HelloWorld class="text-2xl" />
|
||||
<Nuxt3 class="text-2xl" />
|
||||
<ParentFolderHello class="mt-6" />
|
||||
<ClientAndServer style="color: red">
|
||||
<div>[Slot]</div>
|
||||
</ClientAndServer>
|
||||
<JustClient />
|
||||
<NuxtWithPrefix class="mt-6" />
|
||||
</div>
|
||||
</NuxtExampleLayout>
|
||||
|
@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
const width = window.innerWidth
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Window width: {{ width }}
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
Loading width... (server fallback)
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
console.log('Hi from Server Component!')
|
||||
</script>
|
@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
const height = window.innerHeight
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
This is client only.
|
||||
Window height: {{ height }}
|
||||
</div>
|
||||
</template>
|
@ -39,6 +39,7 @@ export async function addComponent (opts: AddComponentOptions) {
|
||||
pascalName: pascalCase(opts.name || ''),
|
||||
prefetch: false,
|
||||
preload: false,
|
||||
mode: 'all',
|
||||
|
||||
// Nuxt 2 support
|
||||
shortPath: opts.filePath,
|
||||
|
@ -17,3 +17,19 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function createClientOnly (component) {
|
||||
return defineComponent({
|
||||
name: 'ClientOnlyWrapper',
|
||||
setup (props, { attrs, slots }) {
|
||||
const mounted = ref(false)
|
||||
onMounted(() => { mounted.value = true })
|
||||
return () => {
|
||||
if (mounted.value) {
|
||||
return h(component, { props, attrs }, slots)
|
||||
}
|
||||
return h('div')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
8
packages/nuxt3/src/app/components/server-placeholder.ts
Normal file
8
packages/nuxt3/src/app/components/server-placeholder.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineComponent, createElementBlock } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ServerPlaceholder',
|
||||
render () {
|
||||
return createElementBlock('div')
|
||||
}
|
||||
})
|
@ -8,6 +8,7 @@ import { pascalCase } from 'scule'
|
||||
|
||||
interface LoaderOptions {
|
||||
getComponents(): Component[]
|
||||
mode: 'server' | 'client'
|
||||
}
|
||||
|
||||
export const loaderPlugin = createUnplugin((options: LoaderOptions) => ({
|
||||
@ -21,16 +22,20 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => ({
|
||||
return pathname.endsWith('.vue') && (query.type === 'template' || !!query.macro || !search)
|
||||
},
|
||||
transform (code, id) {
|
||||
return transform(code, id, options.getComponents())
|
||||
return transform(code, id, options.getComponents(), options.mode)
|
||||
}
|
||||
}))
|
||||
|
||||
function findComponent (components: Component[], name: string) {
|
||||
function findComponent (components: Component[], name: string, mode: LoaderOptions['mode']) {
|
||||
const id = pascalCase(name).replace(/["']/g, '')
|
||||
return components.find(component => id === component.pascalName)
|
||||
const component = components.find(component => id === component.pascalName && ['all', mode, undefined].includes(component.mode))
|
||||
if (!component && components.some(component => id === component.pascalName)) {
|
||||
return components.find(component => component.pascalName === 'ServerPlaceholder')
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
function transform (code: string, id: string, components: Component[]) {
|
||||
function transform (code: string, id: string, components: Component[], mode: LoaderOptions['mode']) {
|
||||
let num = 0
|
||||
const imports = new Set<string>()
|
||||
const map = new Map<Component, string>()
|
||||
@ -38,17 +43,21 @@ function transform (code: string, id: string, components: Component[]) {
|
||||
|
||||
// replace `_resolveComponent("...")` to direct import
|
||||
s.replace(/(?<=[ (])_?resolveComponent\(["'](lazy-|Lazy)?([^'"]*?)["']\)/g, (full, lazy, name) => {
|
||||
const component = findComponent(components, name)
|
||||
const component = findComponent(components, name, mode)
|
||||
if (component) {
|
||||
const identifier = map.get(component) || `__nuxt_component_${num++}`
|
||||
map.set(component, identifier)
|
||||
const isClientOnly = component.mode === 'client'
|
||||
if (isClientOnly) {
|
||||
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
|
||||
}
|
||||
if (lazy) {
|
||||
// Nuxt will auto-import `defineAsyncComponent` for us
|
||||
imports.add(`const ${identifier}_lazy = defineAsyncComponent(${genDynamicImport(component.filePath)})`)
|
||||
return `${identifier}_lazy`
|
||||
return isClientOnly ? `createClientOnly(${identifier}_lazy)` : `${identifier}_lazy`
|
||||
} else {
|
||||
imports.add(genImport(component.filePath, [{ name: component.export, as: identifier }]))
|
||||
return identifier
|
||||
return isClientOnly ? `createClientOnly(${identifier})` : identifier
|
||||
}
|
||||
}
|
||||
// no matched
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolve, basename } from 'pathe'
|
||||
import { defineNuxtModule, resolveAlias, addVitePlugin, addWebpackPlugin, addTemplate, addPluginTemplate } from '@nuxt/kit'
|
||||
import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit'
|
||||
import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
|
||||
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
|
||||
import { scanComponents } from './scan'
|
||||
@ -128,8 +128,22 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
const loaderOptions = { getComponents: () => options.components }
|
||||
addWebpackPlugin(loaderPlugin.webpack(loaderOptions))
|
||||
addVitePlugin(loaderPlugin.vite(loaderOptions))
|
||||
const getComponents = () => options.components
|
||||
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
|
||||
config.plugins = config.plugins || []
|
||||
config.plugins.push(loaderPlugin.vite({
|
||||
getComponents,
|
||||
mode: isClient ? 'client' : 'server'
|
||||
}))
|
||||
})
|
||||
nuxt.hook('webpack:config', (configs) => {
|
||||
configs.forEach((config) => {
|
||||
config.plugins = config.plugins || []
|
||||
config.plugins.push(loaderPlugin.webpack({
|
||||
getComponents,
|
||||
mode: config.name === 'client' ? 'client' : 'server'
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -60,6 +60,9 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
||||
*/
|
||||
let fileName = basename(filePath, extname(filePath))
|
||||
|
||||
const mode = fileName.match(/(?<=\.)(client|server)$/)?.[0] as 'client' | 'server' || 'all'
|
||||
fileName = fileName.replace(/\.(client|server)$/, '')
|
||||
|
||||
if (fileName.toLowerCase() === 'index') {
|
||||
fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
|
||||
}
|
||||
@ -81,20 +84,21 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
||||
}
|
||||
|
||||
const componentName = pascalCase(componentNameParts) + pascalCase(fileNameParts)
|
||||
const suffix = (mode !== 'all' ? `-${mode}` : '')
|
||||
|
||||
if (resolvedNames.has(componentName)) {
|
||||
if (resolvedNames.has(componentName + suffix) || resolvedNames.has(componentName)) {
|
||||
console.warn(`Two component files resolving to the same name \`${componentName}\`:\n` +
|
||||
`\n - ${filePath}` +
|
||||
`\n - ${resolvedNames.get(componentName)}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
resolvedNames.set(componentName, filePath)
|
||||
resolvedNames.set(componentName + suffix, filePath)
|
||||
|
||||
const pascalName = pascalCase(componentName).replace(/["']/g, '')
|
||||
const kebabName = hyphenate(componentName)
|
||||
const shortPath = relative(srcDir, filePath)
|
||||
const chunkName = 'components/' + kebabName
|
||||
const chunkName = 'components/' + kebabName + suffix
|
||||
|
||||
let component: Component = {
|
||||
filePath,
|
||||
@ -105,15 +109,16 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
||||
export: 'default',
|
||||
global: dir.global,
|
||||
prefetch: Boolean(dir.prefetch),
|
||||
preload: Boolean(dir.preload)
|
||||
preload: Boolean(dir.preload),
|
||||
mode
|
||||
}
|
||||
|
||||
if (typeof dir.extendComponent === 'function') {
|
||||
component = (await dir.extendComponent(component)) || component
|
||||
}
|
||||
|
||||
// Ignore component if component is already defined
|
||||
if (!components.find(c => c.pascalName === component.pascalName)) {
|
||||
// Ignore component if component is already defined (with same mode)
|
||||
if (!components.some(c => c.pascalName === component.pascalName && ['all', component.mode].includes(c.mode))) {
|
||||
components.push(component)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
|
||||
import { isAbsolute, relative } from 'pathe'
|
||||
import type { Component } from '@nuxt/schema'
|
||||
import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork'
|
||||
import { upperFirst } from 'scule'
|
||||
|
||||
export type ComponentsTemplateOptions = {
|
||||
buildDir?: string
|
||||
@ -53,10 +53,11 @@ export const componentsTemplate = {
|
||||
...options.components.flatMap((c) => {
|
||||
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
||||
const comment = createImportMagicComments(c)
|
||||
const nameWithSuffix = `${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}`
|
||||
|
||||
return [
|
||||
genExport(c.filePath, [{ name: c.export, as: c.pascalName }]),
|
||||
`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
|
||||
genExport(c.filePath, [{ name: c.export, as: nameWithSuffix }]),
|
||||
`export const Lazy${nameWithSuffix} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
|
||||
]
|
||||
}),
|
||||
`export const componentNames = ${JSON.stringify(options.components.map(c => c.pascalName))}`
|
||||
@ -73,8 +74,8 @@ ${options.components.map(c => ` '${c.pascalName}': typeof ${genDynamicImport(
|
||||
${options.components.map(c => ` 'Lazy${c.pascalName}': typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join(',\n')}
|
||||
}
|
||||
}
|
||||
${options.components.map(c => `export const ${c.pascalName}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}
|
||||
${options.components.map(c => `export const Lazy${c.pascalName}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}
|
||||
${options.components.map(c => `export const ${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}
|
||||
${options.components.map(c => `export const Lazy${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')}
|
||||
export const componentNames: string[]
|
||||
`
|
||||
}
|
||||
|
@ -97,6 +97,12 @@ async function initNuxt (nuxt: Nuxt) {
|
||||
filePath: resolve(nuxt.options.appDir, 'components/client-only')
|
||||
})
|
||||
|
||||
// Add <ServerPlaceholder>
|
||||
addComponent({
|
||||
name: 'ServerPlaceholder',
|
||||
filePath: resolve(nuxt.options.appDir, 'components/server-placeholder')
|
||||
})
|
||||
|
||||
// Add <NuxtLink>
|
||||
addComponent({
|
||||
name: 'NuxtLink',
|
||||
|
5
packages/nuxt3/test/fixture/components/Nuxt3.server.vue
Normal file
5
packages/nuxt3/test/fixture/components/Nuxt3.server.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<b style="color: #00C58E">
|
||||
From Nuxt 3
|
||||
</b>
|
||||
</template>
|
@ -58,6 +58,7 @@ const dirs: ComponentsDir[] = [
|
||||
|
||||
const expectedComponents = [
|
||||
{
|
||||
mode: 'all',
|
||||
pascalName: 'HelloWorld',
|
||||
kebabName: 'hello-world',
|
||||
chunkName: 'components/hello-world',
|
||||
@ -68,20 +69,33 @@ const expectedComponents = [
|
||||
preload: false
|
||||
},
|
||||
{
|
||||
mode: 'client',
|
||||
pascalName: 'Nuxt3',
|
||||
kebabName: 'nuxt3',
|
||||
chunkName: 'components/nuxt3',
|
||||
shortPath: 'components/Nuxt3.vue',
|
||||
chunkName: 'components/nuxt3-client',
|
||||
shortPath: 'components/Nuxt3.client.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
prefetch: false,
|
||||
preload: false
|
||||
},
|
||||
{
|
||||
mode: 'server',
|
||||
pascalName: 'Nuxt3',
|
||||
kebabName: 'nuxt3',
|
||||
chunkName: 'components/nuxt3-server',
|
||||
shortPath: 'components/Nuxt3.server.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
prefetch: false,
|
||||
preload: false
|
||||
},
|
||||
{
|
||||
mode: 'server',
|
||||
pascalName: 'ParentFolder',
|
||||
kebabName: 'parent-folder',
|
||||
chunkName: 'components/parent-folder',
|
||||
shortPath: 'components/parent-folder/index.vue',
|
||||
chunkName: 'components/parent-folder-server',
|
||||
shortPath: 'components/parent-folder/index.server.vue',
|
||||
export: 'default',
|
||||
global: undefined,
|
||||
prefetch: false,
|
||||
|
@ -8,6 +8,7 @@ export interface Component {
|
||||
prefetch: boolean
|
||||
preload: boolean
|
||||
global?: boolean
|
||||
mode?: 'client' | 'server' | 'all'
|
||||
|
||||
/** @deprecated */
|
||||
level?: number
|
||||
|
Loading…
Reference in New Issue
Block a user