mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +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 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
|
||||
| components/
|
||||
@ -227,6 +268,10 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic
|
||||
</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
|
||||
|
||||
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 { parseQuery, parseURL } from 'ufo'
|
||||
import type { Component, ComponentsOptions } from '@nuxt/schema'
|
||||
@ -11,6 +11,7 @@ interface LoaderOptions {
|
||||
mode: 'server' | 'client'
|
||||
sourcemap?: boolean
|
||||
transform?: ComponentsOptions['transform']
|
||||
experimentalComponentIslands?: boolean
|
||||
}
|
||||
|
||||
function isVueTemplate (id: string) {
|
||||
@ -43,6 +44,7 @@ function isVueTemplate (id: string) {
|
||||
export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
|
||||
const exclude = options.transform?.exclude || []
|
||||
const include = options.transform?.include || []
|
||||
const serverComponentRuntime = fileURLToPath(new URL('./runtime/server-component', import.meta.url))
|
||||
|
||||
return {
|
||||
name: 'nuxt:components-loader',
|
||||
@ -65,12 +67,23 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
|
||||
const s = new MagicString(code)
|
||||
|
||||
// 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)
|
||||
if (component) {
|
||||
let identifier = map.get(component) || `__nuxt_component_${num++}`
|
||||
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'
|
||||
if (isClientOnly) {
|
||||
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']) {
|
||||
const id = pascalCase(name).replace(/["']/g, '')
|
||||
// Prefer exact match
|
||||
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')
|
||||
if (component) { return component }
|
||||
|
||||
// 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({
|
||||
sourcemap: nuxt.options.sourcemap[mode],
|
||||
getComponents,
|
||||
mode
|
||||
mode,
|
||||
experimentalComponentIslands: nuxt.options.experimental.componentIslands
|
||||
}))
|
||||
if (nuxt.options.experimental.treeshakeClientOnly && isServer) {
|
||||
config.plugins.push(TreeShakeTemplatePlugin.vite({
|
||||
@ -212,7 +213,8 @@ export default defineNuxtModule<ComponentsOptions>({
|
||||
config.plugins.push(loaderPlugin.webpack({
|
||||
sourcemap: nuxt.options.sourcemap[mode],
|
||||
getComponents,
|
||||
mode
|
||||
mode,
|
||||
experimentalComponentIslands: nuxt.options.experimental.componentIslands
|
||||
}))
|
||||
if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') {
|
||||
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> = {
|
||||
// components.islands.mjs'
|
||||
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) => {
|
||||
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
|
||||
const comment = createImportMagicComments(c)
|
||||
|
@ -57,6 +57,8 @@ describe('pages', () => {
|
||||
expect(html).toContain('This is a custom component with a named export.')
|
||||
// should apply attributes to client-only components
|
||||
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
|
||||
expect(html).toContain('global component registered automatically')
|
||||
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="`with${'-'.toString()}suffix`" />
|
||||
<ClientWrapped ref="clientRef" style="color: red;" class="client-only" />
|
||||
<ServerOnlyComponent class="server-only" style="background-color: gray;" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user