Inertia

Inertia

Inertia 是一种与框架无关的方式,可让你在无需现代 SPA 大部分复杂性的情况下创建单页应用。

它是传统的服务端渲染应用(使用模板引擎)与现代 SPA(带有客户端路由和状态管理)之间的一个很好的中间方案。

使用 Inertia 将允许你使用最喜欢的前端框架(Vue.js、React、Svelte 或 Solid.js)创建 SPA,而无需创建一个单独的 API。

import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async index({ inertia }: HttpContext) {
const users = await User.all()
return inertia.render('users/index', { users })
}
}

安装

你正在启动一个新项目并想使用 Inertia 吗?请查看 Inertia 启动套件

从 npm 仓库运行以下命令安装该包:

npm i @adonisjs/inertia

完成后,运行以下命令配置该包。

node ace configure @adonisjs/inertia
  1. adonisrc.ts 文件中注册以下服务提供者(service provider)和命令。

    {
    providers: [
    // ...other providers
    () => import('@adonisjs/inertia/inertia_provider')
    ]
    }
  2. start/kernel.ts 文件中注册以下中间件

    router.use([() => import('@adonisjs/inertia/inertia_middleware')])
  3. 创建 config/inertia.ts 文件。

  4. 将一些桩(stub)文件复制到你的应用中以帮助你快速开始。每个复制的文件都会根据你之前选择的前端框架进行调整。

  5. 创建一个 ./resources/views/inertia_layout.edge 文件,用于渲染用于启动 Inertia 的 HTML 页面。

  6. 创建一个 ./inertia/css/app.css 文件,其中包含为 inertia_layout.edge 视图设置样式所需的内容。

  7. 创建一个 ./inertia/tsconfig.json 文件,以区分服务端和客户端侧的 TypeScript 配置。

  8. 创建一个 ./inertia/app/app.ts 文件用于启动 Inertia 和你选择的前端框架。

  9. 创建一个 ./inertia/pages/home.{tsx|vue|svelte} 文件来渲染应用的 home 页面。

  10. 创建 ./inertia/pages/server_error.{tsx|vue|svelte}./inertia/pages/not_found.{tsx|vue|svelte} 文件来渲染错误页面。

  11. vite.config.ts 文件中添加正确的 vite 插件来编译你选择的前端框架。

  12. start/routes.ts 文件中添加一个位于 / 的哑路由,作为使用 Inertia 渲染 home 页面的示例。

  13. 根据所选的前端框架安装包。

完成后,你应该可以在 AdonisJS 应用中使用 Inertia 了。启动你的开发服务器,访问 localhost:3333 即可看到使用你选择的前端框架通过 Inertia 渲染的 home 页面。

阅读 Inertia 官方文档

Inertia 是一个与后端无关的库。我们只是创建了一个适配器(adapter)让它能与 AdonisJS 一起工作。在阅读本文档之前,你应该熟悉 Inertia 的概念。

本文档中我们只会涵盖 AdonisJS 特有的部分。

客户端入口

如果你使用了 configureadd 命令,该包会已经在 inertia/app/app.ts 创建了一个入口文件,因此你可以跳过这一步。

基本上,这个文件将是你前端应用的主入口,用于启动 Inertia 和你选择的前端框架。这个文件应该是被你根 Edge 模板通过 @vite 标签加载的入口。

import { createApp, h } from 'vue'
import type { DefineComponent } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'
const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS'
createInertiaApp({
title: (title) => {{ `${title} - ${appName}` }},
resolve: (name) => {
return resolvePageComponent(
`../pages/${name}.vue`,
import.meta.glob<DefineComponent>('../pages/**/*.vue'),
)
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})

这个文件的作用是创建一个 Inertia 应用并解析页面组件(page component)。你在使用 inertia.render 时编写的页面组件会被向下传递给 resolve 函数,该函数的作用就是返回需要渲染的组件。

渲染页面

在配置你的包时,一个 inertia_middleware 已被注册在 start/kernel.ts 文件中。该中间件负责在 HttpContext 上设置 inertia 对象。

要使用 Inertia 渲染视图,请使用 inertia.render 方法。该方法接受视图名称以及要作为 props 传递给组件的数据。

app/controllers/home_controller.ts
export default class HomeController {
async index({ inertia }: HttpContext) {
return inertia.render('home', { user: { name: 'julien' } })
}
}

你看到传给 inertia.render 方法的 home 了吗?它应该是相对于 inertia/pages 目录的组件文件路径。我们在这里渲染的是 inertia/pages/home.(vue,tsx) 文件。

你的前端组件将作为 prop 接收 user 对象:

<script setup lang="ts">
defineProps<{
user: { name: string }
}>()
</script>
<template>
<p>Hello {{ user.name }}</p>
</template>

就这么简单。

在将数据传递给前端时,所有内容都会被序列化为 JSON。不要指望能够传递模型实例、日期或其他复杂对象。

根 Edge 模板

根模板(Root template)是一个常规的 Edge 模板,会在你应用的首次页面访问时加载。你应该在这里包含你的 CSS 和 JavaScript 文件,以及 @inertia 标签。一个典型的根模板如下所示:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>AdonisJS x Inertia</title>
@inertiaHead()
@vite(['inertia/app/app.ts', `inertia/pages/${page.component}.vue`])
</head>
<body>
@inertia()
</body>
</html>

你可以在 config/inertia.ts 文件中配置根模板路径。默认情况下,它假设你的模板位于 resources/views/inertia_layout.edge

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
// The path to the root template relative
// to the `resources/views` directory
rootView: 'app_root',
})

如果需要,你可以将函数传递给 rootView 属性,以动态决定应该使用哪个根模板。

import { defineConfig } from '@adonisjs/inertia'
import type { HttpContext } from '@adonisjs/core/http'
export default defineConfig({
rootView: ({ request }: HttpContext) => {
if (request.url().startsWith('/admin')) {
return 'admin_root'
}
return 'app_root'
}
})

根模板数据

你可能想要与你的根 Edge 模板共享数据。例如,用于添加 meta 标题或 open graph 标签。你可以通过使用 inertia.render 方法的第三个参数来做到这一点:

app/controllers/posts_controller.ts
export default class PostsController {
async index({ inertia }: HttpContext) {
return inertia.render('posts/details', post, {
title: post.title,
description: post.description
})
}
}

titledescription 现在将对根 Edge 模板可用:

resources/views/root.edge
<html>
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
<body>
@inertia()
</body>
</html

重定向

在 AdonisJS 中你应该这样做:

export default class UsersController {
async store({ response }: HttpContext) {
await User.create(request.body())
// 👇 You can use standard AdonisJS redirections
return response.redirect().toRoute('users.index')
}
async externalRedirect({ inertia }: HttpContext) {
// 👇 Or use the inertia.location for external redirects
return inertia.location('https://adonisjs.com')
}
}

更多信息请参阅 官方文档

与所有视图共享数据

有时,你可能需要跨多个视图共享相同的数据。例如,我们正在与所有视图共享当前用户信息。对每个控制器都这样做会变得很繁琐。幸运的是,我们有两个解决方案来解决这个问题。

sharedData

config/inertia.ts 文件中,你可以定义一个 sharedData 对象。该对象允许你定义要与所有视图共享的数据。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
sharedData: {
appName: 'My App', // 👈 This will be available in all views
user: (ctx) => ctx.auth?.user, // 👈 Scoped to the current request
},
})

从中间件共享

有时,从中间件而不是 config/inertia.ts 文件共享数据可能更方便。你可以使用 inertia.share 方法来做到这一点:

import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class MyMiddleware {
async handle({ inertia, auth }: HttpContext, next: NextFn) {
inertia.share({
appName: 'My App',
user: (ctx) => ctx.auth?.user
})
}
}

部分重载与惰性数据求值

首先阅读 官方文档 以了解什么是部分重载以及它们如何工作。

关于惰性数据求值,它在 AdonisJS 中的工作方式如下:

export default class UsersController {
async index({ inertia }: HttpContext) {
return inertia.render('users/index', {
// ALWAYS included on first visit.
// OPTIONALLY included on partial reloads.
// ALWAYS evaluated
users: await User.all(),
// ALWAYS included on first visit.
// OPTIONALLY included on partial reloads.
// ONLY evaluated when needed
users: () => User.all(),
// NEVER included on first visit.
// OPTIONALLY included on partial reloads.
// ONLY evaluated when needed
users: inertia.optional(() => User.all())
}),
}
}

类型共享

通常,你会想要共享传递给前端页面组件的数据类型。一个简单的做法就是使用 InferPageProps 类型。

export class UsersController {
index() {
return inertia.render('users/index', {
users: [
{ id: 1, name: 'julien' },
{ id: 2, name: 'virk' },
{ id: 3, name: 'romain' },
]
})
}
}

如果你使用的是 Vue,你将不得不手动在 defineProps 中定义每个属性。这是 Vue 的一个令人讨厌的限制,请参阅 此 issue 了解更多信息。

<script setup lang="ts">
import { InferPageProps } from '@adonisjs/inertia/types'
defineProps<{
// 👇 You will have to manually define each prop
users: InferPageProps<UsersController, 'index'>['users'],
posts: InferPageProps<PostsController, 'index'>['posts'],
}>()
</script>

引用指令(Reference Directives)

由于你的 Inertia 应用是一个独立的 TypeScript 项目(带有自己的 tsconfig.json),你将需要帮助 TypeScript 理解某些类型。我们的许多官方包使用 模块扩充(module augmentation) 来向你的 AdonisJS 项目添加某些类型。

例如,HttpContext 上的 auth 属性及其类型,只有当你将 @adonisjs/auth/initialize_auth_middleware 导入到你的项目中时才可用。现在的问题是,我们在 Inertia 项目中并没有导入这个模块,所以如果你尝试从使用了 auth 的控制器推断页面 props,那么你很可能会收到 TypeScript 错误或无效的类型。

为了解决这个问题,你可以使用 引用指令(reference directives) 来帮助 TypeScript 理解某些类型。为此,你需要在 inertia/app/app.ts 文件中添加以下行:

/// <reference path="../../adonisrc.ts" />

根据你使用的类型,你可能需要添加其他引用指令,例如对同样使用模块扩充的某些配置文件的引用。

/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/ally.ts" />
/// <reference path="../../config/auth.ts" />

类型级别序列化

关于 InferPageProps 需要知道的一件重要事情是,它会在类型级别"序列化"你传递的数据。例如,如果你将一个 Date 对象传递给 inertia.render,那么 InferPageProps 得到的类型将是 string

export default class UsersController {
async index({ inertia }: HttpContext) {
const users = [
{ id: 1, name: 'John Doe', createdAt: new Date() }
]
return inertia.render('users/index', { users })
}
}

这完全说得通,因为当日期通过 JSON 在网络上传递时会被序列化为字符串。

模型序列化

记住上一点,另一件需要知道的重要事情是,如果你将一个 AdonisJS 模型传递给 inertia.render,那么 InferPageProps 得到的类型将是一个 ModelObject:一个几乎不包含任何信息的类型。这可能会是个问题。要解决这个问题,你有几个选项:

  • 在将模型传递给 inertia.render 之前,将其转换为简单的对象:
  • 使用 DTO(Data Transfer Object,数据传输对象)系统,在将模型传递给 inertia.render 之前将其转换为简单对象。
class UsersController {
async edit({ inertia, params }: HttpContext) {
const user = users.serialize() as {
id: number
name: string
}
return inertia.render('user/edit', { user })
}
}

你将在前端组件中获得准确的类型。

共享 Props

要在你的组件中获得共享数据的类型,请确保你已在 config/inertia.ts 文件中执行了如下的模块扩充:

// file: config/inertia.ts
const inertiaConfig = defineConfig({
sharedData: {
appName: 'My App',
},
});
export default inertiaConfig;
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {
// If necessary, you can also manually add some shared props,
// such as those shared from a middleware for example
propsSharedFromAMiddleware: number;
}
}

另外,请确保在你的 inertia/app/app.ts 文件中添加此引用指令

/// <reference path="../../config/inertia.ts" />

完成后,你就可以通过 InferPageProps 在组件中访问你的共享 props 了。InferPageProps 将包含你的共享 props 的类型,以及由 inertia.render 传递的 props 类型:

// file: inertia/pages/users/index.tsx
import type { InferPageProps } from '@adonisjs/inertia/types'
export function UsersPage(
props: InferPageProps<UsersController, 'index'>
) {
props.appName
// ^? string
props.propsSharedFromAMiddleware
// ^? number
}

如果需要,你可以通过 SharedProps 类型只访问共享 props 的类型:

import type { SharedProps } from '@adonisjs/inertia/types'
const page = usePage<SharedProps>()

CSRF

如果你为应用启用了 CSRF 保护,请在 config/shield.ts 文件中启用 enableXsrfCookie 选项。

启用此选项将确保 XSRF-TOKEN cookie 在客户端设置,并随每个请求发送回服务器。

要使 Inertia 与 CSRF 保护一起工作,无需额外配置。

资源版本控制

在重新部署应用时,你的用户应该始终获得客户端资源的最新版本。这是 Inertia 协议和 AdonisJS 开箱即支持的功能。

默认情况下,@adonisjs/inertia 包会为 public/assets/manifest.json 文件计算一个哈希,并将其用作你的资源版本。

如果你想调整此行为,可以编辑 config/inertia.ts 文件。assetsVersion 属性定义了你的资源版本,可以是一个字符串或一个函数。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
assetsVersion: 'v1'
})

更多信息请阅读 官方文档

SSR

启用 SSR

Inertia 启动套件 开箱即带有服务端渲染(SSR)支持。所以如果你想为应用启用 SSR,请确保使用它。

如果你在启动时没有启用 SSR,你随时可以按照以下步骤在以后启用它:

添加服务端入口

我们需要添加一个与服务端入口非常相似的服务端入口。这个入口将在服务端而不是浏览器上渲染首次页面访问。

你必须创建一个 inertia/app/ssr.ts,其默认导出一个如下的函数:

import { createInertiaApp } from '@inertiajs/vue3'
import { renderToString } from '@vue/server-renderer'
import { createSSRApp, h, type DefineComponent } from 'vue'
export default function render(page) {
return createInertiaApp({
page,
render: renderToString,
resolve: (name) => {
const pages = import.meta.glob<DefineComponent>('../pages/**/*.vue')
return pages[`../pages/${name}.vue`]()
},
setup({ App, props, plugin }) {
return createSSRApp({ render: () => h(App, props) }).use(plugin)
},
})
}

更新配置文件

前往 config/inertia.ts 文件并更新 ssr 属性以启用它。此外,如果你使用了不同的路径,请指向你的服务端入口。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
// ...
ssr: {
enabled: true,
entrypoint: 'inertia/app/ssr.ts'
}
})

更新 Vite 配置

首先,确保你已经注册了 inertia vite 插件。完成后,如果你使用了不同的路径,你应该在 vite.config.ts 文件中更新服务端入口的路径。

import { defineConfig } from 'vite'
import inertia from '@adonisjs/inertia/client'
export default defineConfig({
plugins: [
inertia({
ssr: {
enabled: true,
entrypoint: 'inertia/app/ssr.ts'
}
})
]
})

你现在应该能够在服务端渲染首次页面访问,然后继续客户端渲染。

SSR 允许列表

使用 SSR 时,你可能不想服务端渲染所有的组件。例如,你正在构建受身份认证保护的管理后台,因此这些路由没有理由在服务端渲染。但在同一个应用中,你可能有一个落地页,可以从 SSR 中受益以改善 SEO。

所以,你可以在 config/inertia.ts 文件中添加应该在服务端渲染的页面。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
ssr: {
enabled: true,
pages: ['home']
}
})

你也可以将函数传递给 pages 属性,以动态决定哪些页面应该在服务端渲染。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
ssr: {
enabled: true,
pages: (ctx, page) => !page.startsWith('admin')
}
})

测试

有几种方法可以测试你的前端代码:

  • 端到端测试。你可以使用 Browser Client,它是 Japa 和 Playwright 之间无缝的集成。
  • 单元测试。我们建议使用适配前端生态的测试工具,特别是 Vitest

最后,你也可以测试你的 Inertia 端点,以确保它们返回正确的数据。为此,我们在 Japa 中有一些测试辅助函数可用。

首先,如果你还没有在 test/bootsrap.ts 文件中配置 inertiaApiClientapiClient 插件,请确保配置它们:

tests/bootstrap.ts
import { assert } from '@japa/assert'
import app from '@adonisjs/core/services/app'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { apiClient } from '@japa/api-client'
import { inertiaApiClient } from '@adonisjs/inertia/plugins/api_client'
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
apiClient(),
inertiaApiClient(app)
]

接下来,我们可以使用 withInertia() 请求我们的 Inertia 端点,以确保数据以 JSON 格式正确返回。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertStatus(200)
response.assertInertiaComponent('home/main')
response.assertInertiaProps({ user: { name: 'julien' } })
})

让我们来看看可用于测试端点的各种断言:

withInertia()

为请求添加 X-Inertia 头。它确保数据以 JSON 格式正确返回。

assertInertiaComponent()

检查服务器返回的组件是否为预期的组件。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaComponent('home/main')
})

assertInertiaProps()

检查服务器返回的 props 是否正是作为参数传递的那些。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaProps({ user: { name: 'julien' } })
})

assertInertiaPropsContains()

检查服务器返回的 props 是否包含作为参数传递的某些 props。它在底层使用了 containsSubset

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaPropsContains({ user: { name: 'julien' } })
})

附加属性

除此之外,你还可以在 ApiResponse 上访问以下属性:

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
// 👇 The component returned by the server
console.log(response.inertiaComponent)
// 👇 The props returned by the server
console.log(response.inertiaProps)
})

FAQ

为什么我在更新前端代码时服务器一直在重新加载?

假设你正在使用 React。每次更新前端代码,服务器都会重新加载,浏览器也会刷新。你没有享受到热模块替换(HMR)特性。

你需要从根 tsconfig.json 文件中排除 inertia/**/* 才能使其工作。

{
"compilerOptions": {
// ...
},
"exclude": ["inertia/**/*"]
}

因为,负责重启服务器的 AdonisJS 进程正在监视包含在 tsconfig.json 文件中的文件。

为什么我的生产构建不工作?

如果你遇到如下错误:

X [ERROR] Failed to load url inertia/app/ssr.ts (resolved id: inertia/app/ssr.ts). Does the file exist?

一个常见的问题是你在运行生产构建时忘记设置 NODE_ENV=production

NODE_ENV=production node build/server.js

Top-level await is not available...

如果你遇到如下错误:

X [ERROR] Top-level await is not available in the configured target environment ("chrome87", "edge88", "es2020", "firefox78", "safari14" + 2 overrides)
node_modules/@adonisjs/core/build/services/hash.js:15:0:
15 │ await app.booted(async () => {
╵ ~~~~~

那么极有可能是你正在将后端代码导入到前端。仔细查看由 Vite 生成的错误,我们看到它正在尝试编译来自 node_modules/@adonisjs/core 的代码。所以,这意味着我们的后端代码最终会出现在前端包中。那可能不是你想要的。

通常,当你尝试与前端共享类型时会发生此错误。如果这是你试图实现的目标,请确保始终只通过 import type 而不是 import 导入该类型:

// ✅ Correct
import type { User } from '#models/user'
// ❌ Incorrect
import { User } from '#models/user'