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" />
<Nuxt3 class="text-2xl" />
<ParentFolderHello class="mt-6" />
<ClientAndServer style="color: red">
<div>[Slot]</div>
</ClientAndServer>
<JustClient />
<NuxtWithPrefix class="mt-6" />
</div>
</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 || ''),
prefetch: false,
preload: false,
mode: 'all',
// Nuxt 2 support
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 {
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

View File

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

View File

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

View File

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

View File

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

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 = [
{
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,

View File

@ -8,6 +8,7 @@ export interface Component {
prefetch: boolean
preload: boolean
global?: boolean
mode?: 'client' | 'server' | 'all'
/** @deprecated */
level?: number