Assembler 钩子

本指南介绍 AdonisJS 中的 Assembler 钩子。你将学习如何:

  • 注册钩子以响应开发、测试和构建期间的声明周期事件
  • 在 watch 模式下对文件更改做出反应
  • 钩入路由扫描管道
  • 使用 IndexGenerator 生成 barrel 文件和类型声明
  • 创建自定义代码生成工作流

概述

Assembler 是 AdonisJS 的构建工具层,它将你的应用作为一个子进程来管理。它负责启动开发服务器、运行测试以及创建生产构建。钩子让你能够接入这个生命周期,以在特定时刻运行自定义操作,例如在控制器发生变化时生成 barrel 文件、在路由被扫描时创建类型声明,或在服务器启动时显示自定义信息。

由于 Assembler 在与你的 AdonisJS 应用不同的进程中运行,钩子无法访问 IoC 容器、路由服务或数据库连接等框架特性。相反,钩子会接收到专门构建的工具,例如用于代码生成的 IndexGenerator 以及用于路由分析的扫描器实例。

Assembler 钩子的常见用例包括:为惰性加载控制器生成 barrel 文件、从路由元数据创建类型安全的 API 客户端、在文件更改时运行代码生成器,以及显示自定义的启动信息。

钩子参考

下表列出了所有可用的钩子、它们的触发时机,以及它们接收的参数。

HookTriggered byDescription
initDevServer, TestRunner, Bundler执行的第一个钩子。用于初始化任务
devServerStartingDevServer在子进程启动之前
devServerStartedDevServer在子进程运行之后
testsStartingTestRunner在测试开始执行之前
testsFinishedTestRunner在测试完成之后
buildStartingBundler在生产构建开始之前
buildFinishedBundler在生产构建完成之后
fileChangedDevServer, TestRunner在 watch 模式下文件被修改时
fileAddedDevServer, TestRunner在文件被创建时
fileRemovedDevServer, TestRunner在文件被删除时
routesCommittedDevServer当应用注册了路由时
routesScanningDevServer在路由类型扫描开始之前
routesScannedDevServer在路由类型扫描完成之后

创建和注册钩子

钩子在 adonisrc.ts 文件的 hooks 属性下注册。每个钩子接受一个惰性加载的导入数组,使你能够将钩子逻辑拆分到单独的文件中,并仅在需要时加载它们。

adonisrc.ts
import { defineConfig } from '@adonisjs/core/app'

export default defineConfig({
  hooks: {
    devServerStarted: [() => import('./hooks/on_server_started.ts')],
    fileChanged: [() => import('./hooks/on_file_changed.ts')],
  },
})

钩子文件必须导出一个默认函数,该函数接收钩子的参数。每个钩子都有一个来自 @adonisjs/core/app 的带类型辅助函数,为参数提供完整的 TypeScript 支持。

hooks/on_server_started.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.devServerStarted((devServer, info, instructions) => {
  /**
   * info.host - 服务器绑定的主机地址
   * info.port - 服务器运行的端口号
   * instructions - 用于显示格式化输出的 UI 辅助函数
   */
  console.log(`Server running at http://${info.host}:${info.port}`)
})

你可以为同一个事件注册多个钩子。它们按注册的顺序执行。

adonisrc.ts
import { defineConfig } from '@adonisjs/core/app'

export default defineConfig({
  hooks: {
    devServerStarted: [
      () => import('./hooks/log_server_info.ts'),
      () => import('./hooks/notify_external_service.ts'),
    ],
  },
})
Warning

Assembler 钩子在与你的 AdonisJS 应用不同的进程中运行。它们无法访问 IoC 容器、路由器、数据库或任何其他框架服务。如果你需要与你的应用交互,请使用路由扫描钩子来提取元数据,或通过 HTTP/IPC 进行通信。

Init 钩子

init 钩子是 Assembler 启动任何操作时执行的第一个钩子。它接收父实例(DevServer、TestRunner 或 Bundler)、一个用于注册额外运行时钩子的钩子管理器,以及用于代码生成任务的 IndexGenerator。

hooks/init.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.init((parent, hooksManager, indexGenerator) => {
  /**
   * 通过检查父类型来确定正在运行的操作。
   * 使用 indexGenerator 来设置 barrel 文件或类型生成。
   */
  console.log('Assembler initialized')
})

init 钩子是为 IndexGenerator 配置 barrel 文件和类型生成的推荐位置,因为它在任何其他操作开始之前运行。

开发服务器钩子

开发服务器钩子在启动和运行开发服务器时执行。devServerStarting 钩子在子进程启动之前触发,devServerStarted 在服务器开始接受连接后触发。

hooks/on_dev_server_starting.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.devServerStarting((devServer) => {
  /**
   * 在服务器启动之前执行设置任务。
   * 子进程尚未生成。
   */
  console.log('Preparing to start dev server...')
})
hooks/on_dev_server_started.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.devServerStarted((devServer, info, instructions) => {
  /**
   * 服务器现在正在运行并接受连接。
   * 使用 instructions 来添加自定义的 UI 输出。
   */
  instructions.add('custom', `API docs: http://${info.host}:${info.port}/docs`)
})

这些钩子会在每次子进程重启时重新触发,例如由于文件更改而发生完全重载时。

测试运行器钩子

测试运行器钩子会在运行你的测试套件之前和之后执行。使用 testsStarting 来设置测试固件或数据库,使用 testsFinished 来生成报告或清理资源。

hooks/on_tests_starting.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.testsStarting((testRunner) => {
  console.log('Preparing test environment...')
})
hooks/on_tests_finished.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.testsFinished((testRunner) => {
  console.log('Tests complete, generating coverage report...')
})

当在 watch 模式下运行测试时,这些钩子会在测试套件每次重新运行时重新触发。

打包器钩子

打包器钩子(Bundler hooks)在使用 node ace build 创建生产构建时执行。使用 buildStarting 来执行构建前任务(如资源优化),使用 buildFinished 来显示构建统计信息或运行构建后脚本。

hooks/on_build_starting.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.buildStarting((bundler) => {
  console.log('Starting production build...')
})
hooks/on_build_finished.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.buildFinished((bundler, instructions) => {
  instructions.add('deploy', 'Run `npm run start` in the build folder to start the server')
})

观察器钩子

观察器钩子(Watcher hooks)在开发或测试 watch 模式期间文件发生更改时触发。在 HMR 模式下,Assembler 依赖 hot-hook 来检测更改;否则,内置的文件观察器会处理检测。

每个观察器钩子都会接收到相对路径(从你的应用根目录开始,使用类 Unix 风格斜杠)和受影响文件的绝对路径。

hooks/on_file_changed.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.fileChanged((relativePath, absolutePath, info, parent) => {
  /**
   * info.source - 要么是 'hot-hook',要么是 'watcher'
   * info.hotReloaded - 如果文件是在不重启的情况下热重载的则为 true
   * info.fullReload - 如果需要完全重启服务器则为 true
   */
  if (relativePath.startsWith('app/controllers/')) {
    console.log(`Controller changed: ${relativePath}`)
  }
})
hooks/on_file_added.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.fileAdded((relativePath, absolutePath, parent) => {
  console.log(`New file created: ${relativePath}`)
})
hooks/on_file_removed.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.fileRemoved((relativePath, absolutePath, parent) => {
  console.log(`File deleted: ${relativePath}`)
})

路由钩子

路由钩子提供了对你的应用路由定义及其关联类型的访问。这些钩子仅在开发服务器运行期间执行,而不会在构建或测试期间执行。

路由已提交

当你的 AdonisJS 应用注册其路由时,routesCommitted 钩子会触发。路由会通过 IPC 从子进程传输到 Assembler,使你无需自行解析文件即可访问路由元数据。

hooks/on_routes_committed.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.routesCommitted((devServer, routes) => {
  /**
   * routes 是一个以域名为键的对象。
   * 每个域都包含一个路由定义数组。
   */
  const defaultRoutes = routes['root'] || []
  console.log(`${defaultRoutes.length} routes registered`)
})

路由扫描中

routesScanning 钩子在 Assembler 开始分析你的路由以提取请求和响应类型之前触发。使用此钩子来配置扫描器,例如跳过某些不需要分析的路由。

hooks/on_routes_scanning.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.routesScanning((devServer, routesScanner) => {
  /**
   * 跳过不需要类型提取的路由,
   * 例如具有复杂流程的身份验证端点。
   */
  routesScanner.skip(['session.create', 'session.store', 'oauth.callback'])
})

路由已扫描

routesScanned 钩子在路由分析完成之后触发。扫描器包含提取的类型信息,你可以用它来生成 API 客户端或类型声明。

hooks/on_routes_scanned.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.routesScanned((devServer, routesScanner) => {
  const routes = routesScanner.getRoutes()
  const controllers = routesScanner.getControllers()
  
  /**
   * 使用此元数据来生成类型安全的 API 客户端。
   * 这些类型是内部导入引用,而非独立的类型。
   */
  console.log(`Scanned ${routes.length} routes from ${controllers.length} controllers`)
})
Note

路由扫描器提取的类型是指向你的控制器和验证器的内部导入引用。它们不是完全解析的、可在独立项目中使用的独立类型。生成独立类型需要额外的工具,例如 Tuyau

IndexGenerator

IndexGenerator 是一个用于观察目录并根据其内容生成 barrel 文件或类型声明的工具。它会自动处理文件观察,并在源文件被添加或移除时重新生成输出文件。

配置 IndexGenerator

init 钩子中注册 IndexGenerator 配置。每个配置指定一个要观察的源目录、一个要生成的输出文件,以及如何转换源文件。

hooks/init.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.init((parent, hooksManager, indexGenerator) => {
  indexGenerator.add('controllers', {
    source: './app/controllers',
    importAlias: '#controllers',
    as: 'barrelFile',
    exportName: 'controllers',
    removeSuffix: 'controller',
    output: './.adonisjs/server/controllers.ts',
  })
})

配置选项如下:

OptionDescription
source用于扫描源文件的目录
importAlias在生成的导入中使用的导入别名(例如 #controllers
as要么是 'barrelFile' 以自动生成,要么是一个用于自定义输出的回调函数
exportNamebarrel 文件中导出的常量名
removeSuffix在生成属性键时要从文件名中剥离的后缀
output生成文件写入的路径

Barrel 文件生成

as 设置为 'barrelFile' 时,IndexGenerator 会递归地扫描源目录,并生成一个导出惰性加载导入的 barrel 文件。目录结构会作为嵌套对象被保留。

给定如下控制器结构:

app/controllers/
├── auth/
│   ├── login_controller.ts
│   └── register_controller.ts
├── blog/
│   ├── posts_controller.ts
│   └── post_comments_controller.ts
└── users_controller.ts

IndexGenerator 会生成:

.adonisjs/server/controllers.ts
export const controllers = {
  auth: {
    Login: () => import('#controllers/auth/login_controller'),
    Register: () => import('#controllers/auth/register_controller'),
  },
  blog: {
    Posts: () => import('#controllers/blog/posts_controller'),
    PostComments: () => import('#controllers/blog/post_comments_controller'),
  },
  Users: () => import('#controllers/users_controller'),
}

这个 barrel 文件使你能够在路由中惰性加载控制器,而无需手动管理导入。当你添加或移除控制器时,该文件会自动更新。

自定义类型生成

要生成类型声明或其他自定义输出,请向 as 选项传入一个回调函数。该回调接收一个文件集合以及一个用于构建输出字符串的 writer 工具。

文件集合是一个键值对象,其中每个键是来自源目录的相对路径(不含扩展名),每个值包含文件的 importPathrelativePathabsolutePath

hooks/init.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.init((parent, hooksManager, indexGenerator) => {
  indexGenerator.add('inertiaPages', {
    source: './inertia/pages',
    as: (files, writer) => {
      writer.writeLine(`declare module '@adonisjs/inertia' {`).indent()
      writer.writeLine(`export interface Pages {`).indent()

      for (const [filePath, file] of Object.entries(files)) {
        writer.writeLine(
          `'${filePath}': InferPageProps<typeof import('${file.importPath}').default>`
        )
      }

      writer.dedent().writeLine('}')
      writer.dedent().writeLine('}')

      return writer.toString()
    },
    output: './.adonisjs/server/inertia_pages.d.ts',
  })
})

这会生成一个类型声明文件,将页面组件路径映射到它们的 prop 类型,从而实现类型安全的 Inertia.js 页面渲染。

完整的 IndexGenerator 示例

以下示例展示了一个完整的 init 钩子,它为控制器、事件和监听器配置了 barrel 文件生成——这正是 AdonisJS 框架使用的默认设置。

hooks/init.ts
import { hooks } from '@adonisjs/core/app'

export default hooks.init((parent, hooksManager, indexGenerator) => {
  /**
   * 为控制器生成 barrel 文件。
   * 在路由中实现惰性加载:[controllers.Posts, 'index']
   */
  indexGenerator.add('controllers', {
    source: './app/controllers',
    importAlias: '#controllers',
    as: 'barrelFile',
    exportName: 'controllers',
    removeSuffix: 'controller',
    output: './.adonisjs/server/controllers.ts',
  })

  /**
   * 为事件类生成 barrel 文件。
   * 实现类型安全的事件发出。
   */
  indexGenerator.add('events', {
    source: './app/events',
    importAlias: '#events',
    as: 'barrelFile',
    exportName: 'events',
    output: './.adonisjs/server/events.ts',
  })

  /**
   * 为事件监听器生成 barrel 文件。
   * 在事件绑定中实现监听器的惰性加载。
   */
  indexGenerator.add('listeners', {
    source: './app/listeners',
    importAlias: '#listeners',
    as: 'barrelFile',
    exportName: 'listeners',
    output: './.adonisjs/server/listeners.ts',
  })
})