创建应用构建器或服务器构建器

了解 Nuxt 应用构建器和服务器构建器的区别,以及如何编写其中一种。

你可以创建自定义应用构建器或自定义服务器构建器:

  • 应用构建器会打包你的应用:浏览器包,以及在启用 SSR 时生成的 SSR 应用入口。Nuxt 提供三个官方应用构建器:Vite(默认)、webpack 和 Rspack。你可以通过 builder 选项选择其中一个,也可以提供自己的构建器。在本指南的其余部分中,单独使用“构建器”一词时指应用构建器。
  • 服务器构建器会将应用构建转换为可部署的服务器:它会初始化 SSR 渲染器,解析渲染器导入的模块,并向部署目标描述其生成的内容。由 Nitro 提供支持的 @nuxt/nitro-server 是默认的服务器构建器;@nuxt/vite-server 是另一个实验性服务器构建器,它与 Vite Environment API 集成。

这两个构建器通过构建输出契约相互通信:应用构建器按键提供构建产物,服务器构建器则将其解析为渲染器可以导入的模块。双方都不会指定对方的名称,因此理论上任何应用构建器都应能与任何服务器构建器配合使用。

本指南将说明每种角色在 Nuxt 构建中的作用、它们各自遵循的契约,以及如何编写构建器。假设你熟悉 Nuxt 接口和模块的编写方式。

大多数应用都不需要自定义任何一种构建器。如果你只是想影响打包结果,通常应使用注册打包器插件的模块。

应用构建器的作用

Nuxt 将构建什么与如何构建分离开来。

Nuxt core 和你的模块会生成虚拟应用:入口点、路由表、插件、组件注册表,以及 #build 下的其余虚拟文件系统内容。构建器会将这个虚拟应用转换为真实的 JavaScript 和 CSS 包,并在开发环境中运行开发服务器,为其提供服务并进行热重载。

应用构建器负责:

  • 打包客户端构建(浏览器包),并在启用 SSR 时打包服务器构建(SSR 应用入口)。
  • 生成服务器运行时渲染和水合所需的构建产物,例如客户端清单和逐组件样式映射(参见构建输出契约)。
  • 在开发环境中提供开发服务器,并在构建发生变化时触发重新加载。

可部署的服务器本身由服务器构建器生成,而不是应用构建器。使用默认服务器构建器时,Nitro 会将应用构建器的输出打包到最终的 .output 中。

应用构建器接口

应用构建器是一个实现了 NuxtBuilder 接口的对象。唯一必需的方法是 bundle:

import type { Nuxt } from '@nuxt/schema'

export interface NuxtBuilder {
  bundle: (nuxt: Nuxt) => Promise<void>
  /**
   * 可选。当用户通过 `experimental.watcher: 'builder'` 选择启用时,
   * Nuxt 会改为调用这里,而不是启动自己的开发文件监听器,从而让
   * 构建器复用自身的监听器。构建器应注册一个
   * `nuxt.hook('close', ...)` 来进行清理。
   */
  setupWatcher?: (nuxt: Nuxt) => Promise<void> | void
}

builder 选项接受一个模块说明符(该模块默认导出 NuxtBuilder),或一个内联对象:

nuxt.config.ts
export default defineNuxtConfig({
  // 导出 `{ bundle }` 的包
  builder: '@nuxt/vite-builder',
})
nuxt.config.ts
import type { NuxtBuilder } from '@nuxt/schema'

const myBuilder: NuxtBuilder = {
  async bundle (nuxt) {
    // ...
  },
}

export default defineNuxtConfig({
  builder: myBuilder,
})

在 nuxt build 和 nuxt dev 期间,Nuxt 会在虚拟应用生成完毕且 build:before 钩子触发后,调用一次 bundle(nuxt)。Nuxt 会包装你的 bundle,因此你抛出的任何错误都会自动触发 build:error 钩子。

Nuxt 会按名称识别三个官方应用构建器说明符(@nuxt/vite-builder、@nuxt/webpack-builder、@nuxt/rspack-builder),以便在构建的其他部分执行特定于构建器的行为。自定义构建器通过下文所述的通用契约运行,并被视为旧版(非 Vite 环境)路径。

应用构建生命周期

运行 nuxt build 或 nuxt dev 时,Nuxt 会:

  1. 创建 nuxt 上下文并运行模块,填充 nuxt.options 和构建钩子。
  2. 将虚拟应用(模板、路由表、插件)生成到 #build 虚拟文件系统中。
  3. 触发 build:before。
  4. 解析应用构建器并调用 builder.bundle(nuxt)。应用构建器会在此处运行。
  5. 触发 build:done,并在生产环境中关闭 nuxt 实例。

在开发模式下(nuxt.options.dev),你的 bundle 实现通常会采用不同的模式:

  • 在生产环境中,它会完成客户端构建以及(如果 nuxt.options.ssr)服务端构建,将产物写入 nuxt.options.buildDir 并将其注册为构建输出。
  • 在开发环境中,它会设置开发服务器,启动监视构建,分配 nuxt.server,并持续运行。

应用构建器几乎完全通过钩子与 Nuxt 的其他部分通信。它会从 nuxt.options 读取构建配置,并在适当情况下允许模块扩展其打包器配置。

让模块扩展打包器

模块通过 Nuxt Kit 助手影响打包结果,应用构建器则会遵循相关助手的设置:

官方应用构建器也会调用自己的钩子(例如 vite:extendConfig、vite:serverCreated 和 webpack:config),以便模块和服务器构建器参与构建。你的应用构建器也可以调用自己的钩子,但正是下文的构建输出契约,让它能够与服务器构建器和 Nuxt 服务器运行时互操作。

构建输出契约

服务器运行时并不知道应用是由哪个应用构建器生成的。它会基于一组固定模块进行渲染,这些模块的内容只有构建过程才知道,而当前启用的应用构建器会通过 nuxt.buildOutputs 提供这些模块。这就是构建输出契约。

该契约包含两部分:

  • 应用构建器按键提供构建产物,具体说明见本节其余部分。
  • 服务器构建器将其解析为渲染器可以导入的模块,并向 Nuxt 请求这组模块,而不是逐一指定模块名称(参见编写服务器构建器)。
nuxt/internal/* 下的所有内容都是 Nuxt 与任一类型构建器之间契约的一部分,并非公共 API。应用代码以及构建器自身实现以外的模块代码绝不能导入这些内容。导入这些内容的构建器应从契约中获取说明符,而不是硬编码子路径,因为它们可能会在不发布主版本的情况下发生变化。

@nuxt/schema 中的 NuxtBuildOutputs 接口声明了该契约:

export interface NuxtBuildOutputs {
  /** 重新导出 SSR 应用入口的模块主体。 */
  serverEntry: () => string | Promise<string>
  /** Module body exporting the per-component SSR styles map, and the CSS inlined for each emitted file. */
  ssrStyles: () => string | Promise<string>
  /** Serialized client manifest for `vue-bundle-renderer`. */
  clientManifest: () => string | Promise<string>
  /** 用于 `vue-bundle-renderer` 的序列化预计算客户端依赖数据。 */
  clientPrecomputed: () => string | Promise<string>
  /** 为 import map 导出哈希后的入口 chunk 文件名的模块主体。 */
  entryChunkName: () => string | Promise<string>
  /** 导出用于内联样式提取的入口模块 ID 的模块主体。 */
  entryIds: () => string | Promise<string>
}

每个键都是一个返回模块主体字符串的函数。Nuxt 会将该字符串原样内联到服务器包中,因此它不能依赖文件在磁盘上的位置。例如,serverEntry 会返回一个通过绝对说明符重新导出已构建 SSR 入口的主体:

setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)

作为应用构建器,只需提供构建实际生成的内容。每个键都有一个默认值,用于描述不具备相应功能的构建(空清单、无内联样式、未定义入口 chunk),因此即使应用构建器尚未运行,服务器运行时也能完成类型检查和构建。禁用 SSR 时,serverEntry 的默认值是一个空操作应用,且大多数其他输出都不会使用。

设置构建输出

使用 @nuxt/kit 中的 setBuildOutput 助手:

import { setBuildOutput } from '@nuxt/kit'

setBuildOutput('clientManifest', () => 'export default ' + serializedManifest)

setBuildOutput 会写入 nuxt.buildOutputs[key],并通过 useNuxt() 解析 nuxt 实例。若你在已持有该实例的打包器插件中操作,可以直接赋值给 nuxt.buildOutputs[key]。

提供器可以是异步的,Nuxt 会在服务器构建解析其对应模块时惰性读取它。这样一来,构建器可以提前注册提供器,在客户端构建完成之前完成注册,并在数据就绪后返回最终值。

在 Nitro 的 Vite 环境(experimental.nitroViteEnvironment)中运行的应用构建器,其 clientManifest、clientPrecomputed、entryIds 和 ssrStyles 会在服务器包生成后读取并插入其中。这些主体必须采用 export default <expression> 的形式,后面可以跟 export const <name> = <expression> 行;不接受诸如 export { default } from '...' 的重新导出。像下面的示例那样单独构建的应用构建器不受此限制。

一个最小示例

下面是一个应用构建器的基本框架,可满足生产构建的契约:

import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { setBuildOutput } from '@nuxt/kit'
import type { NuxtBuilder } from '@nuxt/schema'

export const bundle: NuxtBuilder['bundle'] = async (nuxt) => {
  const serverDir = resolve(nuxt.options.buildDir, 'dist/server')

  // ...在这里运行你的客户端和服务端打包,并将产物写入磁盘...
  const { serializedClientManifest } = await runBundles(nuxt, serverDir)

  if (nuxt.options.ssr) {
    // Point `nuxt/internal/entry` at the built SSR app entry.
    const serverEntryURL = pathToFileURL(resolve(serverDir, 'server.mjs')).href
    setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)

    // 提供由客户端构建生成的客户端清单。
    setBuildOutput('clientManifest', () => `export default ${serializedClientManifest}`)

    // If you emit a per-component styles map alongside its CSS chunks:
    setBuildOutput('ssrStyles', () => `export { default, inlinedCSS } from ${JSON.stringify(pathToFileURL(resolve(serverDir, 'styles.mjs')).href)}`)
  }
}

应用构建器的开发服务器

在开发环境中,应用构建器还会为应用提供服务并进行热重载。有两点需要注意:

  • nuxt.server 保存正在运行的开发服务器。Nuxt CLI 会使用它,官方应用构建器会在其上提供一个 handler(Node 请求监听器)、一个 fetch(Web fetch 处理器),以及 reload 和 close 方法。你可以自行构建开发服务器,也可以委托给 Nitro 的开发服务器(来自 nitro/builder 的 createDevServer)。
  • 重新加载会在编译完成时通过调用 nuxt.server.reload() 触发。官方应用构建器会发出一个 compiled 钩子(例如 vite:compiled 或 webpack:compiled),服务器集成会监听该钩子以便重新加载。

在开发环境中,构建输出通常会连接到实时的内存源,而不是磁盘文件。例如,SSR 入口可能由打包器从内存输出提供,而客户端清单则可能根据开发模块图计算得出,而不是从 nuxt.options.buildDir 读取。

应用构建器与 Vite Environment API

上述契约刻意设计为与构建器无关。它既适用于旧版路径(应用构建器运行自己的打包流程,而服务器构建器通过自身流程单独打包可部署服务器),也适用于较新的 Vite Environment API 路径(Nitro 作为 Vite 环境运行)。

对于自定义应用构建器,只需满足通用契约即可。Vite Environment API 集成(experimental.nitroViteEnvironment)仅适用于 @nuxt/vite-builder;包括自定义应用构建器在内的其他应用构建器都使用旧版 Nitro Rollup 路径。

由服务器构建器决定使用哪条路径,而不是由该标志直接决定。服务器构建器会声明其构建是在应用构建之后单独运行的一轮构建,还是应用构建中的一个环境。@nuxt/nitro-server 根据 experimental.nitroViteEnvironment 声明路径;没有自身构建流程的服务器构建器(例如 @nuxt/vite-server)则声明由 Vite 构建所有内容。

编写服务器构建器

接下来的部分介绍另一种角色:接管应用构建并启动能够使用它进行渲染的服务器。服务器构建器不是通过 builder 选项选择的;它是一个注册渲染器模块并通过自己的打包器进行打包,同时描述其构建产物的模块。如果你正在编写应用构建器,到这里就可以结束了。

向部署目标描述构建

服务器构建器会描述其构建产物,这样负责部署或预览构建的工具就不必知道运行的是哪个构建器。该描述存储在 nuxt.serverBuild 上,你可以通过 @nuxt/kit/internal 中的 useServerBuild() 读取:

import { useServerBuild } from '@nuxt/kit/internal'

const build = useServerBuild()

build.output.root() // 项目根目录:部署目标自身配置文件所在的位置
build.output.dir() // 构建输出目录
build.output.publicDir() // 其中可部署的静态资源

每个路径都是一个函数,因为服务器构建器可能只有在初始化时才能解析它。请在服务器构建器运行之后触发的钩子中调用这些函数。

output.root() 刻意不采用打包器根目录。Nuxt 会将 Vite 的 root 指向 srcDir,因此,如果部署目标从打包器根目录解析其配置文件(或其中写入的路径),它就会在应用目录中查找,而不是在项目根目录中查找。读取此描述的目标可以正确解析这两者。

nuxt.serverBuild 以及 setServerBuild() 和 useServerBuild() 助手都是实验性的,并从 @nuxt/kit/internal 导出。在开发第二种服务器构建器期间,它们的结构可能会在不发布主版本的情况下发生变化。

启动 SSR 渲染器

Nuxt SSR 渲染器并不属于 @nuxt/nitro-server。它由 nuxt 包提供,并通过与构建器无关的说明符导入,因此,不由 Nitro 支持的服务器构建器也可以使用它进行渲染。@nuxt/nitro-server 是它的一个使用者,其自身的处理器只是一个轻量级适配器。

通过 createNuxtRenderer() 创建渲染器,它会返回一个符合 Web 标准的处理器。nuxt/internal/renderer 是实验性的,其说明符和接受的选项都可能在不发布主版本的情况下发生变化:

import { createNuxtRenderer } from 'nuxt/internal/renderer'

const renderer = createNuxtRenderer({
  runtimeConfig: () => useRuntimeConfig(),
  buildAssetsURL: (...path) => joinURL(baseURL, buildAssetsDir, ...path),
  publicAssetsURL: (...path) => joinURL(baseURL, ...path),
  getRouteRules: event => rulesFor(event.url.pathname),
  hooks: () => hooks,
  createResponse: (body, init) => new Response(body, init),
  createError: init => new HTTPError(init),
})

const response = await renderer.fetch(event)

渲染器会管理其读取的所有内容,因此一个包可以创建多个渲染器。若某个模块要渲染非路由内容(例如岛屿处理器),则应使用与页面渲染器相同的已加载构建产物进行渲染:使用 nuxt/internal/renderer/instance 中的 createRendererInstance() 创建一次状态,将其传递给 createNuxtRenderer(),并在其他位置直接从中渲染。

渲染器接收的事件仅由 Web 标准描述(一个 Request、一个 URL、一个待影响的响应以及一个上下文),与 @nuxt/schema 中 RequestEvent 的结构相匹配。设置其 ~app 属性,以指定应用应看到的事件;useRequestEvent() 和渲染钩子也会接收该事件。runtimeConfig 也会接收此事件,以便运行时能按请求解析配置。渲染器无法从平台获取的所有内容(路由规则、钩子、响应和错误构造、早期提示、岛屿渲染以及预渲染缓存)都通过参数传入,因此渲染器本身不会导入任何服务器运行时。

Nuxt 提供的内容

渲染器导入的一些模块只能由应用构建器编写:客户端清单、SSR 入口、渲染器设置等。你不必了解其中任何模块的具体内容。getServerRuntime 会返回完整列表,你只需确保导入每个说明符时都能解析到对应模块:

import { SERVER_RUNTIME_VERSION, getServerRuntime } from '@nuxt/kit/internal'

const { version, modules, defines, entry } = getServerRuntime({ phase: 'server' })

if (version !== SERVER_RUNTIME_VERSION) {
  throw new Error(`[my-server] unsupported server runtime contract v${version}`)
}

for (const [specifier, module] of Object.entries(modules)) {
  // register each one however your bundler prefers: a virtual module, an alias, a file
  registerVirtualModule(specifier, () => module.code())
}

调用会返回四项内容:

  • modules 将模块说明符映射到对应的代码。遍历它们并逐一注册即可。由于你无需自行编写说明符,Nuxt 可以添加、移除或重命名模块,而你的构建器仍能正常工作。
  • defines 是针对 import.meta.dev、import.meta.server、import.meta.client 和 import.meta.prerender(以及可能的其他值)的文本替换。通过替换转换应用它们。如果不应用这些替换,未使用的分支会留在包中,载荷提取和预渲染缓存也会在不报错的情况下被禁用。
  • entry 是你的服务器入口导入 createNuxtRenderer 时使用的说明符。
  • version 用于验证你的构建器所使用的 Nuxt Kit 版本是否与 Nuxt 兼容。将其与 SERVER_RUNTIME_VERSION 比较,如果两者不同则使构建失败。这样一来,破坏性更改就会以清晰的错误形式呈现。

每当打包器需要模块主体时,读取 module.code() 即可。Nuxt 会惰性调用它,并且可能调用多次,因此你可以先注册模块,即使其值尚未生成,也能在之后获取最终值。如果你需要在自己构建的特定阶段获取特定构建产物,module.output 会告知你该模块对应哪个构建输出键;由 Nuxt 自行生成的模块没有此属性。

补充你掌握的信息

渲染器会使用一组常量进行编译。Nuxt 会填充所有能够根据 nuxt.options 确定的常量。其余常量——任何依赖路由规则、页面匹配器或你所生成文件的常量——默认都设为“功能关闭”。传入 overrides,即可用你自己的值替换这些常量,并以 JavaScript 表达式形式编写:

getServerRuntime({
  overrides: async () => ({
    NUXT_RUNTIME_PAYLOAD_EXTRACTION: String(hasCachedRoutes),
    spaTemplate: JSON.stringify(await spaLoadingTemplate(nuxt)),
  }),
})

如果某个值需要等到构建稍后阶段才能确定,请将 overrides 设为函数。每次读取模块主体时,Nuxt 都会调用该函数。

查找应用构建的输出

如果你的服务器构建会打包应用构建生成的文件,而不是内联模块主体,就需要知道这些文件的位置。请查询构建描述:

import { useServerBuild } from '@nuxt/kit/internal'

const build = useServerBuild()

build.input.serverEntry() // 应用构建生成的 SSR 入口
build.input.serverDir() // 其 chunk 所在的目录
build.input.clientDir() // 客户端构建的资源
build.input.clientManifest() // 渲染器用于渲染的客户端清单
只在一个模块中从 nuxt/internal/renderer 导入 createNuxtRenderer:也就是作为服务器入口的模块。在其他任何地方,包括入口可能会访问到的模块(例如岛屿处理器或路由规则助手),都应从更精简的 nuxt/internal/renderer/* 路径导入。从共享模块导入该入口文件会在 chunk 之间产生循环依赖,而某些打包器会因此生成一个不导出任何内容的入口。

提供服务器导入

列表中的一个模块是 nuxt/server,即应用代码和模块代码在服务器上使用的服务器导入集合。类型始终来自 nuxt 包,但实现可能来自当前运行的任意服务器构建器。

默认情况下,nuxt/server 会解析到 Nuxt 提供的 Web 标准实现。这些实现基于 RequestEvent 结构编写,也是 @nuxt/vite-server 运行时使用的实现。

传递给处理器的任何事件都必须符合 RequestEvent。请在 @nuxt/schema 的 ServerTypes 注册表中注册你的事件类型,NuxtRequestEvent 和 useRequestEvent() 正是通过该注册表解析类型。如果你自己的事件对象不符合 Web 标准,请将符合 Web 标准的视图传递给处理器。若要让应用代码使用你自己的事件,则将其设置为传递给渲染器的事件的 ~app 属性。

如果你的运行时能够比 Nuxt 提供的版本更好地实现其中某些功能,可以让 Nuxt 使用你自己的模块:

import { setServerBuild } from '@nuxt/kit/internal'

setServerBuild({
  runtime: {
    runtimeConfig: 'my-server/runtime-config',
    server: 'my-server/runtime/nuxt-server',
  },
})

你的模块必须导出 nuxt/server 导出的每个名称。无论使用哪种实现,类型都来自 nuxt 包,因此缺少导出会导致运行时错误。对于不想更改的部分,请从 nuxt/internal/server-default 重新导出。在你的包中,nuxt/server 会解析到你的模块。

runtime.runtimeConfig 是提供 useRuntimeConfig 的模块。无论你是否替换其他内容,Nuxt 都会读取它。

为部署目标提供处理器

有些部署目标会自行运行构建,例如在 Worker 中,或在会向其传递 Request 的平台上运行。这些部署目标只需要一个导出 fetch 的模块。如果你的构建会生成这样的模块,请在生成该模块的构建完成后指定其名称:

setServerBuild({
  runtime: {
    runtimeConfig: 'my-server/runtime-config',
    handler: resolve(outputDir, 'server/index.mjs'),
  },
})

如果你的输出无法作为模块导入,请将 runtime.handler 留空。@nuxt/nitro-server 会将其留空,因为 Nitro 构建导出的内容取决于它所构建的预设。

部署目标也可能希望在自身源码中进行渲染,例如某个 Worker 入口需要自行响应某些路由,并对其余路由进行渲染。@nuxt/vite-server 会将 #server-entry 解析为渲染器模块,供目标自身的打包器进行构建。随后,应用会使用该环境的导出条件进行编译,这样它就能在 Worker 运行时中运行,而且无需硬编码构建目录中的路径:

worker/index.ts
import { fetch as render } from '#server-entry'

export default {
  fetch (request: Request) {
    return new URL(request.url).pathname === '/api/hello'
      ? Response.json({ message: 'hello from the worker' })
      : render(request)
  },
}

In development, the target's environment runs the same entry from its own module graph, so a worker entry like this also runs under nuxt dev, in the target's runtime (workerd, for example). Errors raised there are reported in the development error overlay.

getServerRuntime()、serverBuild.input、serverBuild.runtime、nuxt/internal/* 模块以及 @nuxt/kit/internal 中的渲染器助手都是实验性的。在开发第二种服务器构建器期间,它们可能会在不发布主版本的情况下发生变化。

后续步骤

进一步了解 Nuxt 接口,以及构建与运行时之间的分工。
浏览用于注册打包器插件和构建输出的 Nuxt Kit 构建器工具。