feat(nuxt): server-only components (#9972)

This commit is contained in:
Daniel Roe 2023-01-09 11:20:33 +00:00 committed by GitHub
parent a3a0f005fa
commit 2d013c5fad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 127 additions and 9 deletions

View File

@ -210,7 +210,48 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic
## .server Components ## .server Components
`.server` components are fallback components of `.client` components. `.server` components can either be used on their own or paired with a `.client` component.
### Standalone server components
::StabilityEdge
Standalone server components will always be rendered on the server. When their props update, this will result in a network request that will update the rendered HTML in-place.
Server components are currently experimental and in order to use them, you need to enable the 'component islands' feature in your nuxt.config:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
experimental: {
componentIslands: true
}
})
```
Now you can register server-only components with the `.server` suffix and use them anywhere in your application automatically.
```bash
| components/
--| HighlightedMarkdown.server.vue
```
```html{}[pages/example.vue]
<template>
<div>
<!--
this will automatically be rendered on the server, meaning your markdown parsing + highlighting
libraries are not included in your client bundle.
-->
<HighlightedMarkdown markdown="# Headline" />
</div>
</template>
```
::
### Paired with a `.client` component
In this case, the `.server` + `.client` components are two 'halves' of a component and can be used in advanced use cases for separate implementations of a component on server and client side.
```bash ```bash
| components/ | components/
@ -227,6 +268,10 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic
</template> </template>
``` ```
::alert{type=warning}
It is essential that the client half of the component can 'hydrate' the server-rendered HTML. That is, it should render the same HTML on initial load, or you will experience a hydration mismatch.
::
## `<DevOnly>` Component ## `<DevOnly>` Component
Nuxt provides the `<DevOnly>` component to render a component only during development. Nuxt provides the `<DevOnly>` component to render a component only during development.

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
const props = defineProps({ foo: Number })
const colors = [
'red',
'blue',
'yellow'
]
const color = colors[(props.foo ?? 1) % colors.length]
</script>
<template>
<section class="flex flex-col gap-1 p-4">
I'm a server component with some reactive state: {{ foo }}
</section>
</template>
<style scoped>
.flex {
color: v-bind(color)
}
</style>

View File

@ -1,4 +1,4 @@
import { pathToFileURL } from 'node:url' import { fileURLToPath, pathToFileURL } from 'node:url'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo' import { parseQuery, parseURL } from 'ufo'
import type { Component, ComponentsOptions } from '@nuxt/schema' import type { Component, ComponentsOptions } from '@nuxt/schema'
@ -11,6 +11,7 @@ interface LoaderOptions {
mode: 'server' | 'client' mode: 'server' | 'client'
sourcemap?: boolean sourcemap?: boolean
transform?: ComponentsOptions['transform'] transform?: ComponentsOptions['transform']
experimentalComponentIslands?: boolean
} }
function isVueTemplate (id: string) { function isVueTemplate (id: string) {
@ -43,6 +44,7 @@ function isVueTemplate (id: string) {
export const loaderPlugin = createUnplugin((options: LoaderOptions) => { export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const exclude = options.transform?.exclude || [] const exclude = options.transform?.exclude || []
const include = options.transform?.include || [] const include = options.transform?.include || []
const serverComponentRuntime = fileURLToPath(new URL('./runtime/server-component', import.meta.url))
return { return {
name: 'nuxt:components-loader', name: 'nuxt:components-loader',
@ -65,12 +67,23 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const s = new MagicString(code) const s = new MagicString(code)
// replace `_resolveComponent("...")` to direct import // replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*?)["'][\s,]*[^)]*\)/g, (full, lazy, name) => { s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*?)["'][\s,]*[^)]*\)/g, (full: string, lazy: string, name: string) => {
const component = findComponent(components, name, options.mode) const component = findComponent(components, name, options.mode)
if (component) { if (component) {
let identifier = map.get(component) || `__nuxt_component_${num++}` let identifier = map.get(component) || `__nuxt_component_${num++}`
map.set(component, identifier) map.set(component, identifier)
const isServerOnly = component.mode === 'server' &&
!components.some(c => c.pascalName === component.pascalName && c.mode === 'client')
if (isServerOnly) {
imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }]))
imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(name)})`)
if (!options.experimentalComponentIslands) {
console.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`)
}
return identifier
}
const isClientOnly = component.mode === 'client' const isClientOnly = component.mode === 'client'
if (isClientOnly) { if (isClientOnly) {
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
@ -114,9 +127,16 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
function findComponent (components: Component[], name: string, mode: LoaderOptions['mode']) { function findComponent (components: Component[], name: string, mode: LoaderOptions['mode']) {
const id = pascalCase(name).replace(/["']/g, '') const id = pascalCase(name).replace(/["']/g, '')
// Prefer exact match
const component = components.find(component => id === component.pascalName && ['all', mode, undefined].includes(component.mode)) const component = components.find(component => id === component.pascalName && ['all', mode, undefined].includes(component.mode))
if (!component && components.some(component => id === component.pascalName)) { if (component) { return component }
return components.find(component => component.pascalName === 'ServerPlaceholder')
// Render client-only components on the server with <ServerPlaceholder> (a simple div)
if (mode === 'server' && !component) {
return components.find(c => c.pascalName === 'ServerPlaceholder')
} }
return component
// Return the other-mode component in all other cases - we'll handle createClientOnly
// and createServerComponent above
return components.find(component => id === component.pascalName)
} }

View File

@ -196,7 +196,8 @@ export default defineNuxtModule<ComponentsOptions>({
config.plugins.push(loaderPlugin.vite({ config.plugins.push(loaderPlugin.vite({
sourcemap: nuxt.options.sourcemap[mode], sourcemap: nuxt.options.sourcemap[mode],
getComponents, getComponents,
mode mode,
experimentalComponentIslands: nuxt.options.experimental.componentIslands
})) }))
if (nuxt.options.experimental.treeshakeClientOnly && isServer) { if (nuxt.options.experimental.treeshakeClientOnly && isServer) {
config.plugins.push(TreeShakeTemplatePlugin.vite({ config.plugins.push(TreeShakeTemplatePlugin.vite({
@ -212,7 +213,8 @@ export default defineNuxtModule<ComponentsOptions>({
config.plugins.push(loaderPlugin.webpack({ config.plugins.push(loaderPlugin.webpack({
sourcemap: nuxt.options.sourcemap[mode], sourcemap: nuxt.options.sourcemap[mode],
getComponents, getComponents,
mode mode,
experimentalComponentIslands: nuxt.options.experimental.componentIslands
})) }))
if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') { if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') {
config.plugins.push(TreeShakeTemplatePlugin.webpack({ config.plugins.push(TreeShakeTemplatePlugin.webpack({

View File

@ -0,0 +1,16 @@
import { defineComponent, h } from 'vue'
// @ts-expect-error virtual import
import { NuxtIsland } from '#components'
export const createServerComponent = (name: string) => {
return defineComponent({
name,
inheritAttrs: false,
setup (_props, { attrs }) {
return () => h(NuxtIsland, {
name,
props: attrs
})
}
})
}

View File

@ -86,7 +86,13 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
export const componentsIslandsTemplate: NuxtTemplate<ComponentsTemplateContext> = { export const componentsIslandsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
// components.islands.mjs' // components.islands.mjs'
getContents ({ options }) { getContents ({ options }) {
return options.getComponents().filter(c => c.island).map( const components = options.getComponents()
const islands = components.filter(component =>
component.island ||
// .server components without a corresponding .client component will need to be rendered as an island
(component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client'))
)
return islands.map(
(c) => { (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)

View File

@ -57,6 +57,8 @@ describe('pages', () => {
expect(html).toContain('This is a custom component with a named export.') expect(html).toContain('This is a custom component with a named export.')
// should apply attributes to client-only components // should apply attributes to client-only components
expect(html).toContain('<div style="color:red;" class="client-only"></div>') expect(html).toContain('<div style="color:red;" class="client-only"></div>')
// should render server-only components
expect(html).toContain('<div class="server-only" style="background-color:gray;"> server-only component </div>')
// should register global components automatically // should register global components automatically
expect(html).toContain('global component registered automatically') expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix') expect(html).toContain('global component via suffix')

View File

@ -0,0 +1,5 @@
<template>
<div>
server-only component
</div>
</template>

View File

@ -19,6 +19,7 @@
<component :is="`test${'-'.toString()}global`" /> <component :is="`test${'-'.toString()}global`" />
<component :is="`with${'-'.toString()}suffix`" /> <component :is="`with${'-'.toString()}suffix`" />
<ClientWrapped ref="clientRef" style="color: red;" class="client-only" /> <ClientWrapped ref="clientRef" style="color: red;" class="client-only" />
<ServerOnlyComponent class="server-only" style="background-color: gray;" />
</div> </div>
</template> </template>