mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +00:00
feat(nuxt): server-only components (#9972)
This commit is contained in:
parent
a3a0f005fa
commit
2d013c5fad
@ -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.
|
||||||
|
@ -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>
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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({
|
||||||
|
16
packages/nuxt/src/components/runtime/server-component.ts
Normal file
16
packages/nuxt/src/components/runtime/server-component.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -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')
|
||||||
|
5
test/fixtures/basic/components/ServerOnlyComponent.server.vue
vendored
Normal file
5
test/fixtures/basic/components/ServerOnlyComponent.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
server-only component
|
||||||
|
</div>
|
||||||
|
</template>
|
1
test/fixtures/basic/pages/index.vue
vendored
1
test/fixtures/basic/pages/index.vue
vendored
@ -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>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user