创建一个构建器

了解 Nuxt 构建器如何工作,以及如何编写你自己的构建器。

Maschinelle Übersetzung ·0 % geprüft

构建器是 Nuxt 中负责打包应用的部分。Nuxt 内置了三个官方构建器:Vite(默认构建器)、webpack 和 Rspack,你可以通过 builder 选项选择其中一个,也可以提供自己的构建器。

本指南解释构建器如何融入 Nuxt 构建过程、构建器必须满足什么契约,以及如何编写一个构建器。

编写构建器是一个高级主题。大多数应用都不需要自定义构建器;如果你只想影响打包结果,通常使用注册打包器插件的 module 才是合适的工具。

构建器的作用

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

Nuxt 核心(Nuxt 的工作原理中所述的 nuxt 上下文)和你的模块会生成虚拟应用:入口、路由表、插件、组件注册表,以及 #build 下的其余虚拟文件系统。构建器会将这个虚拟应用转换为实际的 JavaScript 和 CSS 包,并在开发环境中运行一个开发服务器,用于提供这些包并进行热重载。

具体来说,构建器负责:

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

可部署的服务器本身是由 Nitro 通过 @nuxt/nitro-server 生成的,而不是由构建器生成。构建器通过一个带类型的契约将其输出交给 Nitro;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
}

Nuxt 会根据 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 会在 nuxt build 和 nuxt dev 期间调用一次 bundle(nuxt),并且是在虚拟应用生成完成且 build:before 钩子触发之后。Nuxt 会对你的 bundle 进行包装,因此任何抛出的错误都会自动触发 build:error 钩子。

只有三个官方构建器标识符(@nuxt/vite-builder、@nuxt/webpack-builder、@nuxt/rspack-builder)会在 Nuxt 的其他地方被按名称识别为构建器特定行为。自定义构建器仍然可以通过下面描述的通用契约正常工作。

构建生命周期

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

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

你的 bundle 实现通常会根据 nuxt.options.dev 进行分支:

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

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

让模块扩展打包器

模块通过 Nuxt Kit 辅助函数影响打包结果。构建器应支持相关辅助函数:

官方构建器也会发出它们自己的钩子(例如 vite:extendConfig、vite:serverCreated、webpack:config),以便模块和 Nitro 能参与构建。自定义构建器也可以发出自己的钩子,但下面的构建输出契约才是它能够与 Nuxt 服务端运行时互操作的关键。

构建输出契约

服务器运行时(@nuxt/nitro-server)并不知道是哪个构建器生成了应用。它通过稳定的 nuxt/* 子路径导入每个构建产物,而当前启用的构建器会用 nuxt.buildOutputs 填充这些子路径。这就是构建输出契约。

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

export interface NuxtBuildOutputs {
  /** 重新导出 SSR 应用入口的模块主体。 */
  serverEntry: () => string | Promise<string>
  /** 生成的按组件 SSR 样式映射文件路径;如果不产生内联样式则为 `undefined`。 */
  ssrStyles: string | undefined
  /** `vue-bundle-renderer` 使用的序列化客户端清单。 */
  clientManifest: () => string | Promise<string>
  /** `vue-bundle-renderer` 使用的序列化预计算客户端依赖数据。 */
  clientPrecomputed: () => string | Promise<string>
  /** 为 import maps 导出带哈希的入口 chunk 文件名的模块主体。 */
  entryChunkName: () => string | Promise<string>
  /** 导出用于内联样式提取的入口模块 ID 的模块主体。 */
  entryIds: () => string | Promise<string>
}

每个键都映射到服务器运行时会导入的一个 nuxt/* 子路径:

构建输出子路径导入为
serverEntrynuxt/entry传递给 createRenderer 的 SSR 应用工厂
clientManifestnuxt/manifestvue-bundle-renderer 客户端清单
clientPrecomputednuxt/precomputed预计算的依赖数据
ssrStylesnuxt/styles按组件的内联样式映射
entryChunkNamenuxt/entry-chunk带哈希的入口 chunk 文件名(import map)
entryIdsnuxt/entry-ids用于样式提取的入口模块 ID

每个 nuxt/* 子路径都会在 nuxt 包中附带一个默认占位实现,因此即使在构建器运行之前,服务器运行时也始终能够通过类型检查并完成构建。构建器通过设置对应的构建输出来覆盖该占位实现;它提供的值会在构建时替换占位内容。

两种构建输出

这些键分为两种形态:

  • 值提供器(serverEntry、clientManifest、clientPrecomputed、entryChunkName、entryIds)是返回字符串形式模块主体的函数。该字符串会按原样内联到服务器包中,因此不能依赖文件在磁盘上的位置。比如,serverEntry 返回的模块主体会通过绝对 specifier 重新导出已构建的 SSR 入口:

    setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)
  • 已发出文件路径(ssrStyles)是一个绝对路径(不是代码),指向构建器发出的真实模块。运行时对 nuxt/styles 的导入会解析到该文件,因此部署产物的打包器会依据该文件自身的位置来解析样式映射中相对的兄弟导入。把它建模为代码字符串会丢失那个目录上下文并破坏相对导入,所以这里必须是路径:

    setBuildOutput('ssrStyles', resolve(serverOutDir, 'styles.mjs'))

    当构建不产生内联样式时,将 ssrStyles 保持为 undefined(其默认值);运行时会回退到空样式映射。

设置构建输出

使用来自 @nuxt/kit 的 setBuildOutput 辅助函数:

import { setBuildOutput } from '@nuxt/kit'

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

setBuildOutput 会写入 nuxt.buildOutputs[key]。在已经持有 nuxt 实例的打包器插件内部,你可以直接赋值给 nuxt.buildOutputs[key];对于通过 useNuxt() 获取 nuxt 的代码,setBuildOutput 则是一个便捷方法。

提供器可以是异步的,并且会在服务器构建解析对应的 nuxt/* 导入时按需读取。这使得构建器可以提前注册提供器(例如在客户端构建完成之前),并在数据可用后返回最终结果。

一个最小示例

下面是一个满足生产构建契约的构建器骨架:

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) {
    // 将 `nuxt/entry` 指向已构建的 SSR 应用入口。
    const serverEntryURL = pathToFileURL(resolve(serverDir, 'server.mjs')).href
    setBuildOutput('serverEntry', () => `export { default } from ${JSON.stringify(serverEntryURL)}`)

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

    // 如果你在输出 CSS chunk 的同时也生成了按组件样式映射:
    setBuildOutput('ssrStyles', resolve(serverDir, 'styles.mjs'))
  }
}
你很少需要提供每一个键。默认值已经足够合理(空清单、没有内联样式、未定义的入口 chunk),所以只需提供你的构建实际产出的内容即可。当 SSR 被禁用时,serverEntry 的默认值是一个无操作应用,而大多数其他输出都不会被使用。

开发服务器

在开发环境中,构建器还负责提供服务和热重载应用。有两件事很重要:

  • nuxt.server 持有正在运行的开发服务器。Nuxt 的 CLI 会使用它,官方的构建器会在其上暴露一个 handler(Node 请求监听器)、一个 fetch(Web fetch 处理器)以及 reload / close 方法。至于你是构建自己的开发服务器,还是委托给 Nitro 的(nitro/builder 中的 createDevServer),由构建器自行决定。
  • 重载 会在编译完成时通过调用 nuxt.server.reload() 来触发。官方的构建器会发出一个 compiled 钩子(例如 vite:compiled、webpack:compiled),服务器集成会监听它以执行重载。

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

向部署目标描述构建

服务器构建器会描述其构建产物,以便部署或预览构建的工具不必知道使用了哪个构建器。该描述保存在 nuxt.serverBuild 中,并通过 @nuxt/kit/internal 中的 useServerBuild() 读取:

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

const build = useServerBuild()

build.output.root() // das Projektstammverzeichnis: dort liegt die eigene Konfigurationsdatei eines Ziels
build.output.dir() // das Ausgabeverzeichnis des Builds
build.output.publicDir() // die darin enthaltenen bereitstellbaren statischen Assets

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

该描述还会说明服务器构建与应用构建的关系。服务器构建器会设置 buildsSeparately,以声明其构建是独立的一轮构建,在应用构建之后运行(如 @nuxt/nitro-server 所做的那样),还是应用构建器负责构建所有内容、不存在单独的一轮构建(如 @nuxt/vite-server 的情况)。任何需要了解这一点的代码都会从该描述中读取,而不是通过探测 Nitro 实例来判断。

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

nuxt.serverBuild 和 setServerBuild() / useServerBuild() 辅助函数是实验性的,并从 @nuxt/kit/internal 导出。在构建第二个服务器构建器期间,其结构可能会在不发布主版本的情况下发生变化。
详细了解 Nuxt 接口以及构建与运行时的分离。
查看用于注册打包器插件和构建输出的 Nuxt Kit 构建器工具。