路由、控制器与视图
在上一章中,我们创建了 Post 与 Comment 模型,以及它们的数据库表和关联。现在,我们将通过构建用户实际能看到帖子的页面,让这些模型"活"起来。
本教程涵盖基础的路由、控制器与视图。有关路由分组、中间件、路由参数验证以及自定义 Inertia 组件等进阶主题,请参阅 路由指南 、 控制器指南 以及 Inertia 文档 。
概述
目前,你的帖子和评论只存在于数据库中。让我们构建两个页面:一个列出所有帖子,另一个展示单篇帖子及其评论。
这正是你能看到完整流程运作的地方——模型处理数据,转换器将其序列化供前端使用,控制器协调逻辑,React 组件向用户展示一切。
开始之前,请确保你的开发服务器正在运行。
node ace serve --hmr
展示帖子列表
让我们构建展示帖子列表这一完整功能。我们将创建一个转换器来序列化帖子数据,添加一个获取帖子的控制器方法,注册一条路由,并创建 React 组件。
-
创建转换器
转换器将你的 Lucid 模型转换为可以安全发送到 React 前端的普通 JSON 对象。它们显式控制哪些数据被序列化,并为你的组件生成 TypeScript 类型。
创建帖子的转换器:
node ace make:transformer post这会创建
app/transformers/post_transformer.ts。打开它并定义要序列化哪些数据:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } }我们使用
this.pick()从 Post 模型中选择特定字段,并使用UserTransformer转换关联的user。入门套件已经包含了UserTransformer,它序列化用户数据(id、fullName、email)。 -
创建控制器
现在创建一个控制器来处理与帖子相关的请求。
node ace make:controller posts这会创建
app/controllers/posts_controller.ts。添加一个列出所有帖子的方法:app/controllers/posts_controller.tsimport 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.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } }这里有几点需要注意:
- 我们预加载了
user关联,这样无需额外查询就能显示作者姓名 - 我们按创建日期排序,最新的在前
- 我们使用
PostTransformer.transform()来序列化帖子
- 我们预加载了
-
定义路由
打开你的路由文件并注册一条路由。
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.on('/').renderInertia('home').as('home') router.get('/posts', [controllers.Posts, 'index'])该路由将
/postsURL 连接到你控制器的index方法。当有人访问/posts时,AdonisJS 调用PostsController.index(),Inertia 用帖子数据渲染 React 组件。Note#generated/controllers导入由 AdonisJS 自动生成,提供对你控制器的类型安全引用。开发服务器会监听新控制器的创建,并自动重新生成该文件——这就是创建新控制器时开发服务器必须处于运行状态的缘故。有关其工作原理的更多细节,请参阅 控制器指南 。 -
创建 React 组件
是时候创建用于展示帖子的 React 组件了。
node ace make:page posts/index这会创建
inertia/pages/posts/index.tsx。打开它并添加以下代码:inertia/pages/posts/index.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex(props: PageProps) { const { posts } = props return ( <div className="container"> <div className="posts-list-title"> <h1>Posts</h1> </div> {posts.map((post) => ( <div key={post.id} className="post-item"> <h2>{post.title}</h2> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> <span>.</span> <div> <a href={`/posts/${post.id}`}>View comments</a> </div> </div> </div> ))} </div> ) }让我们拆解一下发生了什么:
- TypeScript props:我们结合使用
InertiaProps与生成的Data.Post类型来获得完全的类型安全。Data.Post类型是从我们的PostTransformer自动生成的。 - 遍历帖子:我们遍历 posts 数组,显示每篇帖子的标题、作者和 URL。
- 类型安全:你的编辑器会自动补全
post.title、post.author.fullName等,TypeScript 会捕获任何拼写错误。
- TypeScript props:我们结合使用
访问
/posts
,你应该能看到所有帖子的列表!
展示单篇帖子
现在让我们添加查看单篇帖子及其详情的功能。我们将实现控制器方法,用动态参数注册路由,并创建 React 组件。
-
实现控制器方法
将
show方法添加到你的控制器:app/controllers/posts_controller.tsimport 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.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } }我们使用
firstOrFail(),如果不存在具有该 ID 的帖子,它会自动抛出 404 错误。其模式与index相同:获取数据、转换它,并将其作为 props 传递。 -
注册路由
为这个控制器方法注册路由:
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) router.get('/posts/:id', [controllers.Posts, 'show']):id部分是一个路由参数。当有人访问/posts/5时,AdonisJS 会捕获那个5,并使其在控制器中作为params.id可用。 -
创建 React 组件
创建用于展示单篇帖子的组件:
node ace make:page posts/show这会创建
inertia/pages/posts/show.tsx。打开它并添加以下代码:inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> </div> </div> ) }同样,我们结合使用
InertiaProps与生成的Data.Post类型,为postprop 获得自动的类型安全。尝试从你的 列表页 点击一篇帖子。你现在应该能看到包含标题、作者与内容的完整帖子。
使用客户端导航
目前,在页面间点击会导致整页重新加载。Inertia 提供了一个 Link 组件来启用客户端导航——点击链接时,它会仅通过 AJAX 获取新页面数据并替换组件,让你的应用感觉瞬时响应。
让我们更新帖子列表以使用 Link 组件:
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
const { posts } = props
return (
<div className="container">
<div className="posts-list-title">
<h1>Posts</h1>
</div>
{posts.map((post) => (
<div key={post.id} className="post-item">
<h2>{post.title}</h2>
<div className="post-meta">
<div>By {post.author.fullName}</div>
<span>.</span>
<div>
<a href={post.url} target="_blank" rel="noreferrer">
{post.url}
</a>
</div>
<span>.</span>
<div>
<a href={`/posts/${post.id}`}>View comments</a>
<Link route="posts.show" routeParams={{ id: post.id }}>
View comments
</Link>
</div>
</div>
</div>
))}
</div>
)
}
Link 组件接受两个重要属性:
route:要导航到的路由名称。在本例中,posts.show指向我们的PostsController.show方法。routeParams:一个包含路由参数的对象。这里,我们传入帖子的 ID 来填充:id参数。
关于命名路由
你可能会疑惑 posts.show 从何而来。当你用控制器定义路由时,AdonisJS 会根据控制器和方法名自动生成路由名称。例如,[controllers.Posts, 'show'] 会自动得到名称 posts.show。
这比硬编码 href={/posts/${post.id}} 这样的 URL 要好得多,因为:
- 可维护性:如果你在路由文件中将 URL 模式从
/posts/:id改为/showcase/:id,你所有的链接会自动适配新模式。无需查找替换。 - 类型安全:
route属性期望有效的路由名称,因此 TypeScript 会捕获拼写错误。 - 集中式路由:所有 URL 模式都在一个地方(你的路由文件)定义,使应用更易于理解和修改。
现在当你点击"View comments"时,Inertia 会在不整页刷新的情况下处理导航。浏览器的后退按钮仍然有效,URL 也会正确更新——但感觉快得多。
在帖子视图中添加评论
最后,让我们展示每篇帖子的评论。首先,我们需要为评论创建一个转换器,更新 Post 转换器以将其包含进来,在控制器中预加载它们,最后在 React 组件中展示它们。
-
创建 Comment 转换器
为评论创建一个转换器:
node ace make:transformer comment打开它并定义要序列化哪些数据:
app/transformers/comment_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Comment from '#models/comment' import UserTransformer from '#transformers/user_transformer' export default class CommentTransformer extends BaseTransformer<Comment> { toObject() { return { ...this.pick(this.resource, ['id', 'content', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } } -
更新 Post 转换器
现在更新
PostTransformer以包含评论:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' import CommentTransformer from '#transformers/comment_transformer' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments))?.depth(2), } } }我们使用
this.whenLoaded()来防止未定义关联的问题。这意味着只有当我们在控制器中预加载了该关联时,comments字段才会被包含。 -
在控制器中预加载评论
更新
show方法以预加载评论及其作者:app/controllers/posts_controller.tsimport 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.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .preload('comments', (query) => { query.preload('user').orderBy('createdAt', 'asc') }) .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } }我们预加载了评论以及每条评论的用户(作者),并按创建日期排序,最旧的在前。
-
在 React 组件中展示评论
更新组件以展示评论:
inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> <div className="post-comments"> <h2>Comments</h2> {post.comments && post.comments.length > 0 ? ( post.comments.map((comment) => ( <div key={comment.id} className="comment-item"> <p>{comment.content}</p> <div className="comment-meta"> By {comment.author.fullName} on{' '} {comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} </div> </div> )) ) : ( <p>No comments yet.</p> )} </div> </div> </div> ) }注意 TypeScript 是如何知道
post.comments存在、以及每条评论是什么形状的。这都要归功于我们的转换器所生成的Data.Post类型。
刷新你的帖子详情页,现在你会看到帖子内容下方列出了所有评论!
你已构建的内容
你刚刚完成了 AdonisJS + Inertia 应用中的完整数据流:
- 路由 —— 将 URL 映射到控制器动作
- 控制器 —— 从模型获取数据
- 转换器 —— 将模型序列化为供前端使用的类型安全 JSON
- React 组件 —— 接收并展示具有完整 TypeScript 支持的数据
- 关联 —— 让你高效地预加载关联数据
- 客户端导航 —— 使用 Inertia 的
Link组件带来类 SPA 的体验