构建 DevShow —— 一个社区展示网站

在本教程中,你将构建 DevShow。DevShow 是一个小型的社区展示网站,用户可以在这里分享自己构建的作品。 每个用户都能创建账户、发布一条"展示条目"(一个项目、工具、实验,或任何他们引以为傲的东西),并浏览他人创建的条目。

概述

本教程采用动手实践的方式,从零到一构建一个真实的应用。你不会孤立地学习各个功能,而是能看到 AdonisJS 与 React 中的一切是如何协同工作的:路由、控制器、模型、验证、身份验证、转换器与 React 组件汇聚在一起,构成一个可运行的 Web 应用

到本教程结束时,你将构建出:

  • 帖子列表与详情页 - 展示所有帖子以及带评论的单篇帖子详情
  • 帖子的创建与编辑 - 通过表单创建和更新帖子,并带验证
  • 评论系统 - 允许用户对帖子发表评论
  • 授权 - 确保用户只能编辑/删除自己的帖子与评论
  • 导航与样式 - 带有页面间合理导航的精致 UI

身份验证系统(注册、登录、登出)已包含在入门套件中,且功能完整。

理解入门套件

我们以 AdonisJS Inertia + React 入门套件为起点,它已经内置了身份验证。让我们通过打开路由文件,看看手头有哪些可用资源。

start/routes.ts
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'

router.on('/').renderInertia('home')

/**
 * 注册和登录路由 - 仅对访客开放
 */
router
  .group(() => {
    router.get('signup', [controllers.NewAccount, 'create'])
    router.post('signup', [controllers.NewAccount, 'store'])

    router.get('login', [controllers.Session, 'create'])
    router.post('login', [controllers.Session, 'store'])
  })
  .use(middleware.guest())

/**
 * 登出路由 - 仅对已登录用户开放
 */
router
  .group(() => {
    router.post('logout', [controllers.Session, 'destroy'])
  })
  .use(middleware.auth())

入门套件为我们提供了用户注册、登录与登出的路由。注意 middleware.guest() 如何确保只有未登录用户才能访问注册/登录页,而 middleware.auth() 则保护登出路由。

Note

在整个教程中,我们都会使用 auth 中间件来保护需要身份验证的路由。

控制器如何配合 Inertia 工作

让我们看看注册控制器,了解请求是如何在 Inertia 应用中流转的。

app/controllers/new_account_controller.ts
import User from '#models/user'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class NewAccountController {
  async create({ inertia }: HttpContext) {
    return inertia.render('auth/signup')
  }

  async store({ request, response, auth }: HttpContext) {
    /**
     * 验证提交的数据
     */
    const payload = await request.validateUsing(signupValidator)
    
    /**
     * 在数据库中创建新用户
     */
    const user = await User.create(payload)

    /**
     * 自动登录
     */
    await auth.use('web').login(user)
    
    /**
     * 重定向到首页
     */
    response.redirect().toRoute('home')
  }
}

每个控制器方法都以 HTTP 上下文对象作为第一个参数。该上下文包含了当前请求的一切信息:请求数据、响应对象、认证状态、Inertia 渲染器,以及更多。我们只需解构出需要的属性(inertia 用于渲染 React 组件,request 用于表单数据,response 用于重定向,auth 用于身份验证)。

create 方法使用 inertia.render() 渲染注册表单。与传统的服务端渲染应用返回 HTML 不同,Inertia 发送一个包含组件名称及任意 props 的 JSON 响应。你的 React 前端接收到它并渲染对应的组件。

store 方法承担了主要工作:它验证数据、创建用户、登录并重定向到首页。这种将验证器、模型与认证汇集在一起的模式,将会贯穿整个教程

你可能会注意到控制器引用了 User 模型和一个 signupValidator。入门套件已经包含了它们。我们将在 数据库与模型 章节了解模型如何工作,在 表单与验证 章节了解验证器。

关于 Inertia 与 React

如果你使用过 Next.js 或 Remix 这类元框架,这个入门套件可能看起来有些不同。我们的大部分代码位于 AdonisJS(后端)中,而 React 仅用于渲染视图。 没有前端路由,没有在服务端和客户端同时运行的同构代码,也没有复杂的状态管理库。

这是有意为之。Inertia 的理念很简单:让你的后端与前端保持分离,但让它们无缝协作。

  • 你的后端(AdonisJS)负责路由、身份验证、数据库查询、验证与业务逻辑
  • 你的前端(React)负责渲染与用户交互
  • Inertia 充当粘合剂,将来自控制器的 JSON 响应发送给你的 React 组件

如果你是第一次听说 Inertia,可以访问 inertiajs.com 进一步了解其理念。或者直接跟着本教程走下去,亲眼看看它与元框架那套复杂组合相比有多简单。

下面是请求在 AdonisJS + Inertia 应用中的流转方式:

浏览器请求
    ↓
AdonisJS 路由器
    ↓
控制器(验证、查询数据库等)
    ↓
inertia.render('组件名', props)
    ↓
React 组件(通过 Vite 渲染)
    ↓
浏览器响应

注册表单如何工作

当控制器调用 inertia.render('auth/signup') 时,Inertia 会在 inertia/pages/auth/signup.tsx 查找一个 React 组件并渲染它。让我们看看那个组件。

inertia/pages/auth/signup.tsx
import { Form } from '@adonisjs/inertia/react'

export default function Signup() {
  return (
    <div className="form-container">
      <div>
        <h1>Signup</h1>
        <p>Enter your details below to create your account</p>
      </div>

      <div>
        <Form route="new_account.store">
          {({ errors }) => (
            <>
              <div>
                <label htmlFor="fullName">Full name</label>
                <input
                  type="text"
                  name="fullName"
                  id="fullName"
                  data-invalid={errors.fullName ? 'true' : undefined}
                />
                {errors.fullName && <div>{errors.fullName}</div>}
              </div>

              <div>
                <label htmlFor="email">Email</label>
                <input
                  type="email"
                  name="email"
                  id="email"
                  autoComplete="email"
                  data-invalid={errors.email ? 'true' : undefined}
                />
                {errors.email && <div>{errors.email}</div>}
              </div>

              <div>
                <label htmlFor="password">Password</label>
                <input
                  type="password"
                  name="password"
                  id="password"
                  autoComplete="new-password"
                  data-invalid={errors.password ? 'true' : undefined}
                />
                {errors.password && <div>{errors.password}</div>}
              </div>

              <div>
                <label htmlFor="passwordConfirmation">Confirm password</label>
                <input
                  type="password"
                  name="passwordConfirmation"
                  id="passwordConfirmation"
                  autoComplete="new-password"
                  data-invalid={errors.passwordConfirmation ? 'true' : undefined}
                />
                {errors.passwordConfirmation && <div>{errors.passwordConfirmation}</div>}
              </div>

              <div>
                <button type="submit" className="button">
                  Sign up
                </button>
              </div>
            </>
          )}
        </Form>
      </div>
    </div>
  )
}

页面组件位于 inertia/pages 目录中。来自 @adonisjs/inertia/reactForm 组件负责处理表单提交。它接受一个 route 属性(要提交到的命名路由),并通过 render prop 模式提供一个 errors 对象。当你提交表单时,Inertia 会将请求发送到你的后端,并自动处理响应,包括展示验证错误。

试着创建一个账户

在继续之前,先用 node ace serve --hmr 启动你的开发服务器,并尝试创建一个账户。先熟悉一下入门套件的工作方式,我们将在此基础上继续构建。