feat(nuxt3): allow separating client and server components (#4390)

Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
This commit is contained in:
Daniel Roe 2022-04-19 20:13:55 +01:00 committed by GitHub
parent ea47e56540
commit b38dc097f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 140 additions and 26 deletions

View File

@ -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>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
const width = window.innerWidth
</script>
<template>
<div>
Window width: {{ width }}
<slot />
</div>
</template>

View File

@ -0,0 +1,10 @@
<template>
<div>
Loading width... (server fallback)
<slot />
</div>
</template>
<script setup>
console.log('Hi from Server Component!')
</script>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
const height = window.innerHeight
</script>
<template>
<div>
This is client only.
Window height: {{ height }}
</div>
</template>

View File

@ -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,

View File

@ -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')
}
}
})
}

View File

@ -0,0 +1,8 @@
import { defineComponent, createElementBlock } from 'vue'
export default defineComponent({
name: 'ServerPlaceholder',
render () {
return createElementBlock('div')
}
})

View File

@ -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

View File

@ -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'
}))
})
})
} }
}) })

View File

@ -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)
} }
} }

View File

@ -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[]
` `
} }

View File

@ -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',

View File

@ -0,0 +1,5 @@
<template>
<b style="color: #00C58E">
From Nuxt 3
</b>
</template>

View File

@ -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,

View File

@ -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