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" />
|
<HelloWorld class="text-2xl" />
|
||||||
<Nuxt3 class="text-2xl" />
|
<Nuxt3 class="text-2xl" />
|
||||||
<ParentFolderHello class="mt-6" />
|
<ParentFolderHello class="mt-6" />
|
||||||
|
<ClientAndServer style="color: red">
|
||||||
|
<div>[Slot]</div>
|
||||||
|
</ClientAndServer>
|
||||||
|
<JustClient />
|
||||||
<NuxtWithPrefix class="mt-6" />
|
<NuxtWithPrefix class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtExampleLayout>
|
</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 || ''),
|
pascalName: pascalCase(opts.name || ''),
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: false,
|
preload: false,
|
||||||
|
mode: 'all',
|
||||||
|
|
||||||
// Nuxt 2 support
|
// Nuxt 2 support
|
||||||
shortPath: opts.filePath,
|
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 {
|
interface LoaderOptions {
|
||||||
getComponents(): Component[]
|
getComponents(): Component[]
|
||||||
|
mode: 'server' | 'client'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loaderPlugin = createUnplugin((options: LoaderOptions) => ({
|
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)
|
return pathname.endsWith('.vue') && (query.type === 'template' || !!query.macro || !search)
|
||||||
},
|
},
|
||||||
transform (code, id) {
|
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, '')
|
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
|
let num = 0
|
||||||
const imports = new Set<string>()
|
const imports = new Set<string>()
|
||||||
const map = new Map<Component, string>()
|
const map = new Map<Component, string>()
|
||||||
@ -38,17 +43,21 @@ function transform (code: string, id: string, components: Component[]) {
|
|||||||
|
|
||||||
// replace `_resolveComponent("...")` to direct import
|
// replace `_resolveComponent("...")` to direct import
|
||||||
s.replace(/(?<=[ (])_?resolveComponent\(["'](lazy-|Lazy)?([^'"]*?)["']\)/g, (full, lazy, name) => {
|
s.replace(/(?<=[ (])_?resolveComponent\(["'](lazy-|Lazy)?([^'"]*?)["']\)/g, (full, lazy, name) => {
|
||||||
const component = findComponent(components, name)
|
const component = findComponent(components, name, mode)
|
||||||
if (component) {
|
if (component) {
|
||||||
const identifier = map.get(component) || `__nuxt_component_${num++}`
|
const identifier = map.get(component) || `__nuxt_component_${num++}`
|
||||||
map.set(component, identifier)
|
map.set(component, identifier)
|
||||||
|
const isClientOnly = component.mode === 'client'
|
||||||
|
if (isClientOnly) {
|
||||||
|
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
|
||||||
|
}
|
||||||
if (lazy) {
|
if (lazy) {
|
||||||
// Nuxt will auto-import `defineAsyncComponent` for us
|
// Nuxt will auto-import `defineAsyncComponent` for us
|
||||||
imports.add(`const ${identifier}_lazy = defineAsyncComponent(${genDynamicImport(component.filePath)})`)
|
imports.add(`const ${identifier}_lazy = defineAsyncComponent(${genDynamicImport(component.filePath)})`)
|
||||||
return `${identifier}_lazy`
|
return isClientOnly ? `createClientOnly(${identifier}_lazy)` : `${identifier}_lazy`
|
||||||
} else {
|
} else {
|
||||||
imports.add(genImport(component.filePath, [{ name: component.export, as: identifier }]))
|
imports.add(genImport(component.filePath, [{ name: component.export, as: identifier }]))
|
||||||
return identifier
|
return isClientOnly ? `createClientOnly(${identifier})` : identifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// no matched
|
// no matched
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { statSync } from 'node:fs'
|
import { statSync } from 'node:fs'
|
||||||
import { resolve, basename } from 'pathe'
|
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 type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema'
|
||||||
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
|
import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
|
||||||
import { scanComponents } from './scan'
|
import { scanComponents } from './scan'
|
||||||
@ -128,8 +128,22 @@ export default defineNuxtModule<ComponentsOptions>({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const loaderOptions = { getComponents: () => options.components }
|
const getComponents = () => options.components
|
||||||
addWebpackPlugin(loaderPlugin.webpack(loaderOptions))
|
nuxt.hook('vite:extendConfig', (config, { isClient }) => {
|
||||||
addVitePlugin(loaderPlugin.vite(loaderOptions))
|
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))
|
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') {
|
if (fileName.toLowerCase() === 'index') {
|
||||||
fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */
|
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 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` +
|
console.warn(`Two component files resolving to the same name \`${componentName}\`:\n` +
|
||||||
`\n - ${filePath}` +
|
`\n - ${filePath}` +
|
||||||
`\n - ${resolvedNames.get(componentName)}`
|
`\n - ${resolvedNames.get(componentName)}`
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolvedNames.set(componentName, filePath)
|
resolvedNames.set(componentName + suffix, filePath)
|
||||||
|
|
||||||
const pascalName = pascalCase(componentName).replace(/["']/g, '')
|
const pascalName = pascalCase(componentName).replace(/["']/g, '')
|
||||||
const kebabName = hyphenate(componentName)
|
const kebabName = hyphenate(componentName)
|
||||||
const shortPath = relative(srcDir, filePath)
|
const shortPath = relative(srcDir, filePath)
|
||||||
const chunkName = 'components/' + kebabName
|
const chunkName = 'components/' + kebabName + suffix
|
||||||
|
|
||||||
let component: Component = {
|
let component: Component = {
|
||||||
filePath,
|
filePath,
|
||||||
@ -105,15 +109,16 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr
|
|||||||
export: 'default',
|
export: 'default',
|
||||||
global: dir.global,
|
global: dir.global,
|
||||||
prefetch: Boolean(dir.prefetch),
|
prefetch: Boolean(dir.prefetch),
|
||||||
preload: Boolean(dir.preload)
|
preload: Boolean(dir.preload),
|
||||||
|
mode
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dir.extendComponent === 'function') {
|
if (typeof dir.extendComponent === 'function') {
|
||||||
component = (await dir.extendComponent(component)) || component
|
component = (await dir.extendComponent(component)) || component
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore component if component is already defined
|
// Ignore component if component is already defined (with same mode)
|
||||||
if (!components.find(c => c.pascalName === component.pascalName)) {
|
if (!components.some(c => c.pascalName === component.pascalName && ['all', component.mode].includes(c.mode))) {
|
||||||
components.push(component)
|
components.push(component)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { isAbsolute, relative } from 'pathe'
|
import { isAbsolute, relative } from 'pathe'
|
||||||
import type { Component } from '@nuxt/schema'
|
import type { Component } from '@nuxt/schema'
|
||||||
import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork'
|
import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork'
|
||||||
|
import { upperFirst } from 'scule'
|
||||||
|
|
||||||
export type ComponentsTemplateOptions = {
|
export type ComponentsTemplateOptions = {
|
||||||
buildDir?: string
|
buildDir?: string
|
||||||
@ -53,10 +53,11 @@ export const componentsTemplate = {
|
|||||||
...options.components.flatMap((c) => {
|
...options.components.flatMap((c) => {
|
||||||
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
||||||
const comment = createImportMagicComments(c)
|
const comment = createImportMagicComments(c)
|
||||||
|
const nameWithSuffix = `${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}`
|
||||||
|
|
||||||
return [
|
return [
|
||||||
genExport(c.filePath, [{ name: c.export, as: c.pascalName }]),
|
genExport(c.filePath, [{ name: c.export, as: nameWithSuffix }]),
|
||||||
`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
|
`export const Lazy${nameWithSuffix} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))`
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
`export const componentNames = ${JSON.stringify(options.components.map(c => c.pascalName))}`
|
`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 => ` '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 ${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}: 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[]
|
export const componentNames: string[]
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
@ -97,6 +97,12 @@ async function initNuxt (nuxt: Nuxt) {
|
|||||||
filePath: resolve(nuxt.options.appDir, 'components/client-only')
|
filePath: resolve(nuxt.options.appDir, 'components/client-only')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add <ServerPlaceholder>
|
||||||
|
addComponent({
|
||||||
|
name: 'ServerPlaceholder',
|
||||||
|
filePath: resolve(nuxt.options.appDir, 'components/server-placeholder')
|
||||||
|
})
|
||||||
|
|
||||||
// Add <NuxtLink>
|
// Add <NuxtLink>
|
||||||
addComponent({
|
addComponent({
|
||||||
name: 'NuxtLink',
|
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 = [
|
const expectedComponents = [
|
||||||
{
|
{
|
||||||
|
mode: 'all',
|
||||||
pascalName: 'HelloWorld',
|
pascalName: 'HelloWorld',
|
||||||
kebabName: 'hello-world',
|
kebabName: 'hello-world',
|
||||||
chunkName: 'components/hello-world',
|
chunkName: 'components/hello-world',
|
||||||
@ -68,20 +69,33 @@ const expectedComponents = [
|
|||||||
preload: false
|
preload: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
mode: 'client',
|
||||||
pascalName: 'Nuxt3',
|
pascalName: 'Nuxt3',
|
||||||
kebabName: 'nuxt3',
|
kebabName: 'nuxt3',
|
||||||
chunkName: 'components/nuxt3',
|
chunkName: 'components/nuxt3-client',
|
||||||
shortPath: 'components/Nuxt3.vue',
|
shortPath: 'components/Nuxt3.client.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
preload: 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',
|
pascalName: 'ParentFolder',
|
||||||
kebabName: 'parent-folder',
|
kebabName: 'parent-folder',
|
||||||
chunkName: 'components/parent-folder',
|
chunkName: 'components/parent-folder-server',
|
||||||
shortPath: 'components/parent-folder/index.vue',
|
shortPath: 'components/parent-folder/index.server.vue',
|
||||||
export: 'default',
|
export: 'default',
|
||||||
global: undefined,
|
global: undefined,
|
||||||
prefetch: false,
|
prefetch: false,
|
||||||
|
@ -8,6 +8,7 @@ export interface Component {
|
|||||||
prefetch: boolean
|
prefetch: boolean
|
||||||
preload: boolean
|
preload: boolean
|
||||||
global?: boolean
|
global?: boolean
|
||||||
|
mode?: 'client' | 'server' | 'all'
|
||||||
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
level?: number
|
level?: number
|
||||||
|
Loading…
Reference in New Issue
Block a user