路由、控制器与视图

在上一章中,我们创建了 Post 与 Comment 模型,以及它们的数据库表和关联。现在,我们将通过构建用户实际能看到帖子的页面,让这些模型"活"起来。

Note

本教程涵盖基础的路由、控制器与视图。有关路由分组、中间件、路由参数验证以及自定义 Inertia 组件等进阶主题,请参阅 路由指南控制器指南 以及 Inertia 文档

概述

目前,你的帖子和评论只存在于数据库中。让我们构建两个页面:一个列出所有帖子,另一个展示单篇帖子及其评论。

这正是你能看到完整流程运作的地方——模型处理数据转换器将其序列化供前端使用控制器协调逻辑React 组件向用户展示一切

开始之前,请确保你的开发服务器正在运行。

node ace serve --hmr

展示帖子列表

让我们构建展示帖子列表这一完整功能。我们将创建一个转换器来序列化帖子数据,添加一个获取帖子的控制器方法,注册一条路由,并创建 React 组件。

  1. 创建转换器

    转换器将你的 Lucid 模型转换为可以安全发送到 React 前端的普通 JSON 对象。它们显式控制哪些数据被序列化,并为你的组件生成 TypeScript 类型。

    创建帖子的转换器:

    node ace make:transformer post

    这会创建 app/transformers/post_transformer.ts。打开它并定义要序列化哪些数据:

    app/transformers/post_transformer.ts
    import { 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)。

  2. 创建控制器

    现在创建一个控制器来处理与帖子相关的请求。

    node ace make:controller posts

    这会创建 app/controllers/posts_controller.ts。添加一个列出所有帖子的方法:

    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.query()
          .preload('user')
          .orderBy('createdAt', 'desc')
    
        return inertia.render('posts/index', {
          posts: PostTransformer.transform(posts),
        })
      }
    }

    这里有几点需要注意:

    • 我们预加载了 user 关联,这样无需额外查询就能显示作者姓名
    • 我们按创建日期排序,最新的在前
    • 我们使用 PostTransformer.transform() 来序列化帖子
  3. 定义路由

    打开你的路由文件并注册一条路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { controllers } from '#generated/controllers'
    
    router.on('/').renderInertia('home').as('home')
    router.get('/posts', [controllers.Posts, 'index'])

    该路由将 /posts URL 连接到你控制器的 index 方法。当有人访问 /posts 时,AdonisJS 调用 PostsController.index(),Inertia 用帖子数据渲染 React 组件。

    Note

    #generated/controllers 导入由 AdonisJS 自动生成,提供对你控制器的类型安全引用。开发服务器会监听新控制器的创建,并自动重新生成该文件——这就是创建新控制器时开发服务器必须处于运行状态的缘故。有关其工作原理的更多细节,请参阅 控制器指南

  4. 创建 React 组件

    是时候创建用于展示帖子的 React 组件了。

    node ace make:page posts/index

    这会创建 inertia/pages/posts/index.tsx。打开它并添加以下代码:

    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) {
      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.titlepost.author.fullName 等,TypeScript 会捕获任何拼写错误。

访问 /posts ,你应该能看到所有帖子的列表!

展示单篇帖子

现在让我们添加查看单篇帖子及其详情的功能。我们将实现控制器方法,用动态参数注册路由,并创建 React 组件。

  1. 实现控制器方法

    show 方法添加到你的控制器:

    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.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 传递。

  2. 注册路由

    为这个控制器方法注册路由:

    start/routes.ts
    import 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 可用。

  3. 创建 React 组件

    创建用于展示单篇帖子的组件:

    node ace make:page posts/show

    这会创建 inertia/pages/posts/show.tsx。打开它并添加以下代码:

    inertia/pages/posts/show.tsx
    import { 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 类型,为 post prop 获得自动的类型安全。

    尝试从你的 列表页 点击一篇帖子。你现在应该能看到包含标题、作者与内容的完整帖子。

使用客户端导航

目前,在页面间点击会导致整页重新加载。Inertia 提供了一个 Link 组件来启用客户端导航——点击链接时,它会仅通过 AJAX 获取新页面数据并替换组件,让你的应用感觉瞬时响应。

让我们更新帖子列表以使用 Link 组件:

inertia/pages/posts/index.tsx
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 组件中展示它们。

  1. 创建 Comment 转换器

    为评论创建一个转换器:

    node ace make:transformer comment

    打开它并定义要序列化哪些数据:

    app/transformers/comment_transformer.ts
    import { 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),
        }
      }
    }
  2. 更新 Post 转换器

    现在更新 PostTransformer 以包含评论:

    app/transformers/post_transformer.ts
    import { 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 字段才会被包含。

  3. 在控制器中预加载评论

    更新 show 方法以预加载评论及其作者:

    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.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),
        })
      }
    }

    我们预加载了评论以及每条评论的用户(作者),并按创建日期排序,最旧的在前。

  4. 在 React 组件中展示评论

    更新组件以展示评论:

    inertia/pages/posts/show.tsx
    import { 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 的体验