Inertia

本指南介绍在 AdonisJS 中使用 Inertia 构建单页应用(SPA)。你将学习如何:

  • 从控制器和路由渲染 Inertia 页面,并向前端组件传递 props
  • 使用 make:page 命令脚手架式生成页面组件
  • 组织 inertia/ 目录并理解关键配置文件
  • 为页面和共享数据生成端到端类型
  • 使用可选(optional)、延迟(deferred)和可合并(mergeable) props 等数据加载模式
  • 使用 LinkForm 组件构建表单与导航
  • 全局共享数据,并使用 error bags 隔离验证错误
  • 使用 @inertia@inertiaHead 标签自定义根 Edge 模板
  • 控制重定向、浏览器历史记录和加密历史记录
  • 启用服务端渲染(SSR)
  • 理解 Inertia 应用中的请求生命周期

概述

Inertia 在 AdonisJS 与 React、Vue 等前端框架之间架起了一座桥梁。它通过采用服务端优先(server-first)架构,消除了对客户端路由或复杂状态管理库的需求。你完全可以像在传统的服务端渲染应用中那样编写控制器和路由,但与其返回 HTML 或 JSON,你返回的是由前端框架显示的 Inertia 页面。

这种方式让你两全其美:服务端路由和数据获取的简洁性,加上 React 或 Vue 在视图层带来的丰富交互性。AdonisJS 通过 Inertia 起步套件正式支持这两个框架。

另请参阅:Inertia 官方文档中的 Inertia 工作原理

基础示例

让我们端到端地走一遍渲染文章列表的流程。整个流程包含三个部分:一个路由、一个调用 inertia.render() 的控制器,以及 inertia/pages/ 目录中的一个页面组件。

  1. 注册路由

    路由看起来与任何 AdonisJS 路由完全一样。Inertia 没有专门的路由层。

    start/routes.ts
    router.get('/posts', [controllers.Posts, 'index'])
  2. 从控制器渲染页面

    HTTP 上下文暴露了一个 inertia 对象。调用 inertia.render() 并传入两个参数:页面组件路径(相对于 inertia/pages/)以及该组件接收的 props 对象。

    app/controllers/posts_controller.ts
    import Post from '#models/post'
    import type { HttpContext } from '@adonisjs/core/http'
    import PostTransformer from '#transformers/post_transformer'
    
    export default class PostsController {
      async index({ inertia }: HttpContext) {
        const posts = await Post.all()
    
        return inertia.render('posts/index', {
          posts: PostTransformer.transform(posts)
        })
      }
    }

    使用 transformer 将模型实例序列化为普通对象。转换器还会在 Data 命名空间下生成前端类型,使 props 与后端保持同步。

  3. 创建页面组件

    字符串 'posts/index' 会解析为 inertia/pages/posts/index.tsx(或 .vue)。使用 node ace make:page posts/index 脚手架式生成该文件。组件会直接接收来自 inertia.render() 的 props。

    inertia/pages/posts/index.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '@generated/data'
    
    type PageProps = InertiaProps<{ posts: Data.Post[] }>
    
    export default function PostsIndex({ posts }: PageProps) {
      return (
        <>
          {posts.map((post) => (
            <div key={post.id}>
              <h2>{post.title}</h2>
            </div>
          ))}
        </>
      )
    }
    inertia/pages/posts/index.vue
    <script setup lang="ts">
    import { Data } from '@generated/data'
    
    defineProps<{ posts: Data.Post[] }>()
    </script>
    
    <template>
      <div v-for="post in posts" :key="post.id">
        <h2>{{ post.title }}</h2>
      </div>
    </template>

    InertiaProps 辅助函数将你的页面专属 props 与 共享数据 合并,因此像 userflash 这样的全局 props 会与 posts 一起被类型化。

从路由渲染

对于没有控制器逻辑的页面,可以跳过控制器,直接在路由定义中使用 renderInertia() 渲染。

start/routes.ts
router.on('/about').renderInertia('about')

router.on('/pricing').renderInertia('marketing/pricing', {
  plans: ['starter', 'pro', 'enterprise'],
})

组件名称会针对生成的 InertiaPages 接口进行类型检查,因此拼写错误会在编译时被捕获。

幕后发生了什么

在对 /posts 的首次请求中,Inertia 返回一个 HTML 外壳,其中包含一个根 <div>,该 div 带有页面组件名称和作为 data-page 属性的序列化 props。前端 bundle 读取该属性并启动 React 或 Vue。

在后续的每次导航(点击链接、提交表单)中,Inertia 会发起一个带有 X-Inertia 请求头的 fetch 请求。服务器运行相同的控制器,但返回一个 JSON 页面对象而非 HTML。客户端替换新组件并更新 URL。没有完整的页面刷新,也没有独立的 API。

inertia 目录

inertia/ 目录包含你的前端应用。以下是起步套件创建的目录结构:

inertia/
├── app.tsx (或 app.vue)     # 前端应用入口
├── client.ts                # Tuyau API 客户端配置
├── ssr.tsx (或 ssr.vue)     # SSR 入口(启用时)
├── tsconfig.json            # 前端代码的 TypeScript 配置
├── types.ts                 # 共享类型定义
├── css/
│   └── app.css              # 全局样式
├── layouts/                 # 可复用的布局组件
│   └── default.tsx
└── pages/                   # 由控制器渲染的页面组件
    └── home.tsx

pages/ 目录是 Inertia 在调用 inertia.render() 时查找组件的位置。你传入的路径(如 posts/index)直接映射到该目录下的一个文件(inertia/pages/posts/index.tsx)。

app.tsx(或 app.vue)文件是启动前端应用的入口。它使用你的页面组件和任何全局配置来初始化 Inertia。ssr.tsx 文件为服务端渲染提供相同的用途。

随着项目增长,你可以创建额外的目录,例如用于共享 UI 元素的 components/ 或用于自定义 React hooks 的 hooks/

配置文件

两个配置文件控制 Inertia 在 AdonisJS 应用中的工作方式。

config/inertia.ts 文件定义了 Inertia 适配器设置。

config/inertia.ts
import { defineConfig } from '@adonisjs/inertia'

const inertiaConfig = defineConfig({
  rootView: 'inertia_layout',

  ssr: {
    enabled: false,
    entrypoint: 'inertia/ssr.tsx',
  },
})

export default inertiaConfig

支持的选项如下:

rootView

渲染初始 HTML 外壳的 Edge 模板。默认为 inertia_layout。传入一个函数可以为每个请求选择不同的模板,例如为未认证用户渲染营销布局。

rootView: (ctx) => ctx.auth.isAuthenticated ? 'app_layout' : 'marketing_layout'
encryptHistory

加密存储在浏览器历史记录状态中的敏感页面 props。默认为 false。请参阅 Inertia 文档中的 历史记录加密

assetsVersion

固定用于 资源版本控制 的资源版本字符串。省略时,版本由 Vite manifest 计算。设置此值可使用 git 提交哈希、构建时间戳或任何自定义标识符覆盖默认值。

ssr.enabled

启用服务端渲染。请参阅 服务端渲染

ssr.entrypoint

相对于项目根目录的 SSR 入口文件路径。默认为 inertia/ssr.tsx

ssr.bundle

由 Vite 生成的生产环境 SSR bundle 路径。默认为 ssr/ssr.js

ssr.pages

将 SSR 限制为页面的一个子集。传入组件名称数组,或传入一个为每页返回布尔值的函数。

ssr: {
  enabled: true,
  entrypoint: 'inertia/ssr.tsx',
  pages: ['home', 'marketing/pricing'],
}

resources/views/inertia_layout.edge 模板渲染初始 HTML 外壳,其中包含前端应用挂载的根 div。可用 Edge 标签请参阅 根模板

根模板

rootView 下配置的 Edge 模板会对首次请求进行渲染。它包含前端应用挂载的根元素,以及 SSR 输出需要插入其中的任何 HTML。

Inertia 包为该模板注册了两个 Edge 标签。

resources/views/inertia_layout.edge
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  @inertiaHead()
  @vite(['inertia/app.tsx'])
</head>
<body>
  @inertia()
</body>
</html>

@inertia 标签渲染一个 div,其 data-page 属性中包含编码后的页面对象。前端读取该属性来启动 SPA。默认情况下,该元素为 <div id="app">。传入一个对象可以覆盖标签、id 或 class。

@inertia({ as: 'main', id: 'app-root', class: 'min-h-screen' })

@inertiaHead 标签输出在服务端渲染期间收集的 head 片段(标题、meta 标签)。只要启用了 SSR 就应包含它。对于非 SSR 响应,它是一个空操作。

向模板传递数据

inertia.render() 的第三个参数会作为视图 props 转发给根模板。对于属于 HTML 外壳而非页面 props 的值(例如页面标题,或非 SSR 页面的 <meta> 标签),可以使用此项。

app/controllers/posts_controller.ts
return inertia.render(
  'posts/show',
  { post: PostTransformer.transform(post) },
  { title: post.title, description: post.summary }
)
resources/views/inertia_layout.edge
<head>
  <title>{{ title ?? 'My App' }}</title>
  @if(description)
    <meta name="description" content="{{ description }}">
  @end
  @inertiaHead()
</head>

生成的类型

AdonisJS 中的 Inertia 是端到端完全类型安全的。这由两件生成的产物驱动:

  • 位于 .adonisjs/client/data.d.tsData 命名空间,它镜像转换器的输出,因此从控制器传递的 props 在页面组件中是类型化的。请参阅 Transformers
  • 位于 .adonisjs/server/pages.d.tsInertiaPages 接口,它将 inertia/pages/ 中的每个文件映射到其组件 prop 类型。这正是 inertia.render('posts/index', { posts }) 能够对组件名称和 props 进行自动补全和类型检查的原因。

InertiaPages 类型由 indexPages Assembler 钩子生成。在 adonisrc.ts 中注册它,并传入你使用的框架。

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

export default defineConfig({
  hooks: {
    onDevServerStarted: [indexPages({ framework: 'react' })],
    onBuildStarting: [indexPages({ framework: 'react' })],
  },
})

framework 选项接受 'vue3''react'。传入 source 可以扫描 inertia/pages 以外的目录。

为共享数据添加类型

从 Inertia 中间件返回的共享数据,通过 InertiaProps 辅助函数在每个页面上可用。要使其类型安全,需要使用 share() 方法的推断返回类型来扩展 SharedProps 接口。

app/middleware/inertia_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { InferSharedProps } from '@adonisjs/inertia/types'

export default class InertiaMiddleware {
  share(ctx: HttpContext) {
    return {
      user: ctx.auth?.user,
      flash: ctx.session?.flashMessages.all(),
    }
  }
}

declare module '@adonisjs/inertia/types' {
  interface SharedProps extends InferSharedProps<InertiaMiddleware> {}
}

扩展后,props.userprops.flash 在每个页面组件内部都会是类型化的,无需重新声明。

数据加载模式

Inertia 提供了多种高效加载数据的模式。AdonisJS 在 inertia 对象上暴露了辅助函数以支持每种模式。

Tip

可选(optional)props 和延迟(deferred)props 看起来相似,但行为不同。可选 props 仅在前端通过局部重新加载(partial reload)显式请求时才求值。延迟 props 在 Inertia 于页面挂载后自动发起的后续请求中求值。当某个值很少需要(用户可能永远不会点击的标签页)时使用 optional;当某个值始终需要但计算缓慢(仪表盘图表)时使用 defer

可选 props

可选 props 仅在请求方在前端通过局部重新加载显式请求时才求值。这对于并非每次页面加载都需要的昂贵查询非常有用。

app/controllers/users_controller.ts
return inertia.render('users/index', {
  /**
   * 数据库查询仅在请求方在前端发起的
   * 局部重新加载中包含 'users' 时才会运行。
   */
  users: inertia.optional(async () => {
    const users = await User.all()
    return UserTransformer.transform(users)
  })
})

另请参阅:Inertia 文档中的 局部重新加载

Always props

always 辅助函数确保某个 prop 始终包含在响应中,即使在未显式请求的局部重新加载期间也是如此。这与可选 props 正好相反。

app/controllers/users_controller.ts
return inertia.render('users/index', {
  /**
   * 无论前端请求什么,权限都会被计算并包含在内。
   */
  permissions: inertia.always(async () => {
    const permissions = await Permissions.all()
    return PermissionTransformer.transform(permissions)
  })
})

延迟 props

延迟 props 在初始页面渲染之后加载,使页面能够立即显示,而较慢的数据在后台加载。在前端数据到达之前,会显示加载状态。

app/controllers/dashboard_controller.ts
return inertia.render('dashboard', {
  /**
   * 这些 props 在页面渲染后加载。
   * 前端可以显示加载指示器。
   */
  metrics: inertia.defer(async () => {
    return computeMetrics()
  }),
  newSignups: inertia.defer(async () => {
    return getNewSignups()
  })
})

你可以对延迟 props 进行分组,使它们在一个请求中一起加载。

app/controllers/dashboard_controller.ts
return inertia.render('dashboard', {
  /**
   * 这两个 props 在相同的延迟请求中获取,
   * 因为它们共享了 'dashboard' 组名。
   */
  metrics: inertia.defer(async () => {
    return computeMetrics()
  }, 'dashboard'),
  newSignups: inertia.defer(async () => {
    return getNewSignups()
  }, 'dashboard')
})

另请参阅:Inertia 文档中的 延迟 props

可合并 props

可合并 props 会与现有的前端数据合并,而不是替换它。这对于无限滚动或向列表追加新项非常有用。

app/controllers/users_controller.ts
return inertia.render('users/index', {
  /**
   * 新的通知会与现有通知合并,
   * 而不是替换整个数组。
   */
  notifications: inertia.merge(await fetchNotifications())
})

默认情况下,数据是浅合并(shallow merge)的。对于需要递归合并的嵌套对象,请改用 deepMerge()

app/controllers/users_controller.ts
return inertia.render('users/index', {
  /**
   * 嵌套的设置会与现有设置深度合并,
   * 而不是替换整个对象。
   */
  settings: inertia.deepMerge(await fetchSettings())
})

你可以通过将 merge()deepMerge() 方法链式调用,将合并与延迟加载结合起来。

app/controllers/users_controller.ts
return inertia.render('users/index', {
  notifications: inertia.defer(() => {
    return fetchNotifications()
  }).merge()
})
app/controllers/users_controller.ts
return inertia.render('users/index', {
  settings: inertia.defer(() => {
    return fetchSettings()
  }).deepMerge()
})

另请参阅:Inertia 文档中的 合并 props

Inertia 提供了 LinkForm 组件用于导航和表单提交。AdonisJS 对这些组件进行了封装,增加了额外的功能,使你可以通过名称引用路由,而不是硬编码 URL。

从 AdonisJS 包而非直接从 Inertia 导入这些组件。

import { Form, Link } from '@inertiajs/react'
import { Form, Link } from '@adonisjs/inertia/react'
<script setup>
import { Form, Link } from '@inertiajs/vue3'
import { Form, Link } from '@adonisjs/inertia/vue'
</script>

创建链接

Link 组件使用你在 AdonisJS 路由中定义的路由名称来创建导航链接。

<Link route="accounts.create">注册</Link>
<Link route="session.create">登录</Link>

创建表单

Form 组件处理表单提交,具备自动 CSRF 保护和错误处理。

inertia/pages/posts/edit.tsx
import { Form } from '@adonisjs/inertia/react'

export default function EditPost({ post }) {
  return (
    <Form route="posts.update" routeParams={{ id: post.id }}>
      {({ errors }) => (
        <>
          <div>
            <label htmlFor="title">文章标题</label>
            <input type="text" name="title" id="title" defaultValue={post.title} />
            {errors.title && <div>{errors.title}</div>}
          </div>

          <button type="submit">更新文章</button>
        </>
      )}
    </Form>
  )
}
inertia/pages/posts/edit.vue
<script setup lang="ts">
import { Form } from '@adonisjs/inertia/vue'

defineProps<{ post: { id: number; title: string } }>()
</script>

<template>
  <Form
    route="posts.update"
    :params="{ id: post.id }"
    v-slot="{ errors }"
  >
    <div>
      <label for="title">文章标题</label>
      <input type="text" name="title" id="title" :value="post.title" />
      <div v-if="errors.title">{{ errors.title }}</div>
    </div>

    <button type="submit">更新文章</button>
  </Form>
</template>

Form 组件会根据路由名称自动推断 HTTP 方法(POSTPUTPATCHDELETE)。你无需传递 method prop——事实上,AdonisJS 封装器从可接受的 props 中省略了 methodaction,因为两者都派生自路由定义。

当服务器端验证失败时,AdonisJS 会自动将验证错误添加到会话闪现消息中。随后 Inertia 中间件会将这些错误共享给前端,使它们可以通过表单中的 errors 对象使用。

使用 error bags 隔离错误

当一个页面渲染多个独立的表单时,来自一个表单的错误会泄漏到其它表单中,因为它们都从同一个 errors 对象读取。为了隔离它们,请在表单上设置 errorBag prop。Inertia 会在 X-Inertia-Error-Bag 请求头中发送该名称,中间件会将验证错误嵌套在该键下。

<Form route="comments.store" errorBag="newComment">
  {({ errors }) => (
    /**
     * errors.newComment.body 仅保存此表单的错误。
     */
    <textarea name="body" />
  )}
</Form>

路由参数

LinkForm 都接受带有动态段的路由的参数。对象中的键对应于路由中定义的参数名称:

start/routes.ts
// 单个参数 —— :id
router.get('posts/:id', [PostsController, 'show']).as('posts.show')

// 多个参数 —— :userId 和 :postId
router.get('users/:userId/posts/:postId', [PostsController, 'show']).as('users.posts.show')

将匹配的参数值传递给组件。在 React 中使用 routeParams,在 Vue 中使用 params

{/* 单个参数 */}
<Link route="posts.show" routeParams={{ id: post.id }}>
  {post.title}
</Link>

{/* 多个参数 */}
<Link route="users.posts.show" routeParams={{ userId: user.id, postId: post.id }}>
  查看文章
</Link>
<template>
  <!-- 单个参数 -->
  <Link route="posts.show" :params="{ id: post.id }">
    {{ post.title }}
  </Link>

  <!-- 多个参数 -->
  <Link route="users.posts.show" :params="{ userId: user.id, postId: post.id }">
    查看文章
  </Link>
</template>

TypeScript 强制要求你提供所有必需参数且名称正确。缺失或拼写错误的参数会在编译时被捕获。

查询参数

LinkForm 组件使用 route prop 进行类型安全的导航,但它们不直接接受查询参数。要添加查询参数(例如 ?page=2),请使用 urlFor 生成 URL,并作为 href prop 传入:

import { urlFor } from '~/client'

<Link href={urlFor('posts.index', {}, { qs: { page: 2, status: 'published' } })}>
  第 2 页
</Link>
Note

使用 href 时,你会失去 route prop 提供的类型安全路由名称检查。标准导航请使用带有路由参数的 route,仅在需要查询参数时回退到使用 urlForhref

共享数据

共享数据对你的应用中的每个页面都可用,而无需从每个控制器显式传递。这对于全局数据(如已认证用户、闪现消息或应用设置)非常有用。

InertiaMiddleware 定义了哪些数据被共享。该中间件存放在 app/middleware/inertia_middleware.ts,其中包含一个返回共享数据的 share 方法。

app/middleware/inertia_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'

export default class InertiaMiddleware {
  share(ctx: HttpContext) {
    /**
     * share 方法可能在所有中间件运行之前被调用。
     * 例如,在 404 响应期间。始终将上下文属性
     * 视为可能未定义。
     */
    const { session, auth } = ctx as Partial<HttpContext>

    const error = session?.flashMessages.get('error')
    const success = session?.flashMessages.get('success')

    return {
      /**
       * 使用 always() 确保这些 props 被包含,
       * 即使在局部重新加载期间也是如此。
       */
      errors: ctx.inertia.always(this.getValidationErrors(ctx)),
      flash: ctx.inertia.always({
        error,
        success,
      }),
      user: ctx.inertia.always(
        auth?.user ? UserTransformer.transform(auth.user) : undefined
      ),
    }
  }
}
Tip

share 方法可能在请求经过所有中间件或到达控制器之前被调用。这发生在渲染错误页面或提前中止请求时。在访问上下文属性前,始终检查它们是否存在。

访问共享数据

共享数据会自动包含在每页的 props 中。在 React 中,当你使用 InertiaProps 类型辅助函数定义页面 props 时,它同时包含你的页面专属 props 和所有共享数据。在 Vue 中,使用 usePage

inertia/pages/posts/index.tsx
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'

type PageProps = InertiaProps<{
  posts: Data.Post[]
}>

export default function PostsIndex(props: PageProps) {
  /**
   * 与页面专属 props 一起访问共享数据。
   */
  if (props.flash.error) {
    console.log('Error:', props.flash.error)
  }

  return (
    <div>
      {props.user && <p>欢迎,{props.user.name}</p>}
      {/* 渲染文章 */}
    </div>
  )
}
inertia/pages/posts/index.vue
<script setup lang="ts">
import { computed, watch } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { Data } from '@generated/data'

defineProps<{
  posts: Data.Post[]
}>()

/**
 * 访问共享数据。
 */
const page = usePage<Data.SharedProps>()

const user = computed(() => page.props.user)

watch(
  () => page.props.flash,
  (flashMessages) => {
    if (flashMessages.error) {
      console.log('Error:', flashMessages.error)
    }
  },
  { immediate: true }
)
</script>

<template>
  <p v-if="user">欢迎,{{ user.name }}</p>
  <!-- 渲染文章 -->
</template>

分页

Inertia 应用中的分页需要控制器、转换器和前端组件之间的协调。以下是使用文章列表的完整示例。

控制器

使用转换器的 paginate 方法序列化数据和分页元数据,然后将所有内容传递给 inertia.render()

app/controllers/posts_controller.ts
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'

export default class PostsController {
  async index({ request, inertia }: HttpContext) {
    const page = request.input('page', 1)
    const posts = await Post.query().paginate(page, 10)

    return inertia.render('posts/index', {
      posts: PostTransformer.paginate(posts.all(), posts.getMeta()),
    })
  }
}

前端组件

使用 Data 命名空间对分页 props 进行类型化。分页元数据包含 currentPagelastPage 以及可用于渲染分页控件的其他字段:

inertia/pages/posts/index.tsx
import { Link } from '@adonisjs/inertia/react'
import { urlFor } from '~/client'
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'

type PageProps = InertiaProps<{
  posts: {
    data: Data.Post[]
    metadata: {
      total: number
      perPage: number
      currentPage: number
      lastPage: number
      firstPage: number
    }
  }
}>

export default function PostsIndex({ posts }: PageProps) {
  const { data, metadata } = posts

  return (
    <div>
      {data.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}

      <nav>
        {metadata.currentPage > 1 && (
          <Link href={urlFor('posts.index', {}, { qs: { page: metadata.currentPage - 1 } })}>
            上一页
          </Link>
        )}
        {metadata.currentPage < metadata.lastPage && (
          <Link href={urlFor('posts.index', {}, { qs: { page: metadata.currentPage + 1 } })}>
            下一页
          </Link>
        )}
      </nav>
    </div>
  )
}

分页链接使用带有 qs 选项的 urlFor 来生成像 /posts?page=2 这样的 URL。有关 paginate 方法以及元数据对象形状的详情,请参阅 Transformers

CSRF 保护

Inertia 起步套件已自动配置好 CSRF 保护。config/shield.ts 中的 enableXsrfCookie 选项设置了一个 cookie,Inertia 会读取它并在每次请求中附带。你无需手动为表单添加 CSRF token。

另请参阅: Shield ,了解更多关于 CSRF 保护的详情。

资源版本控制

资源版本控制告诉前端你的 JavaScript 或 CSS bundle 何时发生了变化,从而触发完整的页面重新加载,而不是局部更新。这确保用户在部署后始终运行最新版本的前端代码。

默认情况下,AdonisJS 会计算 .vite/manifest.json 文件(在你构建前端资源时创建)的哈希值,并将其用作版本标识符。要将版本固定为自定义值,请在 config/inertia.ts 中将 assetsVersion 设置为 git 提交哈希、构建时间戳或你控制的任何其他标识符。

config/inertia.ts
const inertiaConfig = defineConfig({
  assetsVersion: process.env.RELEASE_SHA,
})

Inertia 会在每次请求中通过 X-Inertia-Version 请求头发送当前资源版本。当服务器在 GET 请求上检测到不匹配时,它会以 409 响应并指示客户端在同一 URL 上执行完整的页面重新加载。闪现消息会自动重新闪现,以便在重新加载后仍然保留。

重定向与历史记录

Inertia 的重定向和历史记录行为与传统的服务端渲染应用不同,因为导航是通过 fetch 进行的。inertia 对象在 HTTP 上下文上暴露了一些辅助函数,用于处理框架无法自动处理的场景。

来自 mutating 请求的重定向

PUTPATCHDELETE 请求之后跟随一个 302 重定向时,浏览器会针对新 URL 重放原始方法。Inertia 中间件会自动将这些重定向升级为 303,以便浏览器发出 GET 请求。你无需自己设置状态码。

app/controllers/posts_controller.ts
async update({ request, response }: HttpContext) {
  await Post.updateOrFail(request.param('id'), request.all())
  return response.redirect().toRoute('posts.index')
}

外部重定向

Inertia 无法通过 fetch 跟随指向不同源的重定向。使用 inertia.location() 向客户端发送一个带有 X-Inertia-Location 请求头的 409 响应,这会触发到目标 URL 的完整浏览器导航。

async checkout({ inertia }: HttpContext) {
  const session = await stripe.createCheckoutSession()
  return inertia.location(session.url)
}

清除浏览器历史记录

在渲染之前调用 inertia.clearHistory() 以清除客户端历史记录栈。这在退出登录后非常有用,因为你不想让用户导航回已认证的页面。

async destroy({ inertia, auth }: HttpContext) {
  await auth.use('web').logout()
  inertia.clearHistory()
  return inertia.location('/')
}

加密历史记录状态

Inertia 将每次访问的页面对象存储在浏览器的历史记录状态中,以支持前进/后退导航。对于包含敏感数据(账户设置、账单详情)的页面,请启用加密,使数据无法通过历史 API 读取。

在调用 render() 之前,针对每个请求切换加密:

async settings({ inertia, auth }: HttpContext) {
  inertia.encryptHistory()
  return inertia.render('account/settings', {
    user: UserTransformer.transform(auth.user),
  })
}

或者通过 encryptHistory 配置选项全局启用。

有关权衡取舍,请参阅 Inertia 文档中的 历史记录加密

服务端渲染

服务端渲染(SSR)在服务器上生成初始 HTML,从而提升感知性能和 SEO。启用 SSR 需要在 Vite 和 AdonisJS 中同时进行配置。

首先,在 Vite 配置中启用 SSR。这会告诉 Vite 使用你的 ssr.tsxssr.vue 入口点创建一个单独的 SSR bundle。

vite.config.ts
export default defineConfig({
  plugins: [
    inertia({
      ssr: {
        enabled: true,
        entrypoint: 'inertia/ssr.tsx'
      }
    }),
  ],
})

然后在 AdonisJS 配置中启用 SSR,使服务器知道使用 SSR bundle 进行渲染。

config/inertia.ts
import { defineConfig } from '@adonisjs/inertia'

const inertiaConfig = defineConfig({
  ssr: {
    enabled: true,
    entrypoint: 'inertia/ssr.tsx',
  },
})

export default inertiaConfig

请求生命周期

理解请求如何流经 Inertia 应用,有助于调试或扩展默认行为。

当用户首次访问你的应用时,请求遵循以下路径:

  1. 请求命中你的 AdonisJS 路由,并由控制器处理
  2. 控制器调用 inertia.render(),传入页面组件和 props
  3. Inertia 中间件的 share() 方法将共享数据添加到 props 中
  4. 由于这是首次访问,Inertia 返回一个完整的 HTML 响应,其中包含外壳布局,该布局带有一个保存序列化页面组件名称和 props 的 div
  5. 前端 bundle 启动,从 div 读取 props,并渲染 React 或 Vue 组件

对于后续导航(点击链接或提交表单):

  1. Inertia 拦截导航,并发起一个带有 X-Inertia 请求头的 fetch 请求
  2. 请求像之前一样流经路由、控制器和中间件
  3. 由于存在 X-Inertia 请求头,Inertia 返回一个仅包含页面组件名称和 props 的 JSON 响应
  4. 前端接收到 JSON,并用新组件替换当前组件,在不重新加载整个页面的情况下更新 URL

这种架构让你拥有了传统服务端渲染应用的开发体验,以及现代 SPA 的用户体验。