
读者好,我是 Randy, 一个前端开发工程师,非常感谢你对这本书有兴趣。这是一本十分迷你的书,很快你就可以把它读完。
我在业余的时间做了很多全栈 SaaS 应用,这些应用都是使用 Next.js 来做的。Next.js 的好处是它让你不需要关注业务开发无关的东西(例如前端构建配置,前后端结合的胶水代码等等),你可以专心编写你的 React 页面,以及所需要的 HTTP API. 更神奇的是,前后端可以共用一份 TypeScript 类型声明,让你在写代码的时候更安全。
但事实上在头几次用 Next.js 开发应用的时候,它并没有我想像中那么好用。在像 Express/Koa/Nest/Hapi 这些框架,它们都自带了 Web Server 开发的一些 helper. 例如利用它们的中间件机制,可以实现面向切面编程(AOP),大量地降低编写重复代码的情况;它们都自带了统一的错误处理机制,你可以在程序的任何角落抛出一个 HTTP 异常。但在 Next.js 里,这些都是缺失的 —— 它没有中间件机制,也没有错误处理的机制(所有 throw error 在 Next.js 的 API route 都会向客户端响应一个无意义的 500 Internal Server Error)。甚至在 Next.js 里,也没有自带的向客户端写入 cookies 的 helper. 这使得我在实现用户登录的时候,要研究写入 cookies 的最佳实践。
全栈应用也少不了前端页面的开发。数据请求我应该直接用 axios, 还是使用像 react-query 和 swr 这样的数据请求封装?它们两个我应该用哪个?在复杂的项目里,我应该如何正确地使用它们?这些都是我在头几次开发时候不知道的。
还好,在做了几个项目之后,我大概总结了一些非常有用的「最佳实践」,把上面提到的很多在 Next.js 中缺失的东西,以及在前端开发中对数据请求的处理,都研究出了很好的解决方法。这让我在接下来开发新的应用的时候非常高效,而且写出来的 HTTP API 的健壮性可以和用传统 Web 框架(Express/Koa/Hapi)写出来的匹敌。当然我的这些最佳实践就是借鉴它们的。
本来在开发完 Cusdis 后我打算开始开发下一个 SaaS 应用,但仔细一想,不如先把我用 Next.js 开发全栈应用的这些宝贵经验总结起来,写成一本小书,分享给更多人。于是就有了你现在正在读的这一本小书。
这本书面向的读者,是那些想要或者已经正在使用 Next.js 开发一个全栈应用的开发者。如果你从来没有用 Next.js 完整开发过一个应用,你可以在本书看到一个大概的流程和实践。如果你已经有一定的使用经验,那么你可能仍然可以从这本书的例子中得到启发,让你可以把一些重复的代码变得通用。
这不是一本教你什么是 Next.js 的教程。我不会在这本书教你什么是 getServerSideProps, 什么是 res.status(), 更不会教你什么是箭头函数,什么是 async/await. 我假定读者已经知道这些基本的东西,我也不认为你应该把钱花在一本会变的书里学习这些经常会变的东西,你应该在它们的官方文档中学习。这就是为什么这本书这么便宜,因为在很多技术书里,读者会为很多在官方文档里已经有的东西买单。这本书只兜售在文档中没有的实践经验。
在头几个章节,我会大致介绍一些技术:
你会发现我并没有花很多笔墨去讲解这些库(再次重申,你应该在它们的官方文档去学习它们的 API, 而不是在一本书里)。我只会重点讲解它们是什么,它们解决了什么问题,它们如何与 Next.js 整合。
因为本书的精华部分在后面的两个「实例」,你可以从两个「实例」中学习到如何在 Next.js 中应用这些技术,从而使你的开发效率倍增。也就是说,头几个介绍这些库的章节,只是在为后面的两个「实例」作铺垫。我希望读者可以从这两个「实例」中提炼出其中那些不变的开发思路。
你可以免费阅读本书的 HTML 版本。也可以可以在 这里 购买这本书的 PDF 版本。 本书定价 12 元人民币,这是因为我人生中买的第一本编程书就是 12 块钱,仅以此对那本启蒙我的读物致敬。
如果你希望转载这本小书的某些章节,请联系我的邮箱,支付 30 元人民币作为转载费用。
如果读完本书后,你学到了一点什么,请不吝在 Twitter 上给我反馈,我的 Twitter ID 是 @randyloop. 又或者直接给我 ([email protected]) 写信。如果可以为此写一篇博客,那就更好了。
倘若你读完觉得这本书是垃圾,我只能说我为这本垃圾所花的时间和精力,应该也还是值 12 块钱。
无论如何,感谢你的阅读。
在这一章节,将介绍前端数据请求的方案。为什么前端数据请求需要专门讨论?甚至需要用一个专门的库?我们先来看一个最简单的,用 axios 发送请求的例子,我们通过请求 GET https://jsonplaceholder.typicode.com/posts 获取所有文章,通过 POST https://jsonplaceholder.typicode.com/posts 添加一篇文章:
xxxxxxxxxx481import axios from 'axios'2import React from 'react'34async function getPosts() {5 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')6 return result.data7}89async function addPost(title, body) {10 await axios.post('https://jsonplaceholder.typicode.com/posts', {11 title, body12 })13}1415function IndexPage() {1617 const [posts, setPosts] = React.useState([])1819 async function initial() {20 const result = await getPosts()21 setPosts(result)22 }2324 React.useEffect(() => {25 initial()26 }, [])2728 async function onClickAddPost() {29 await addPost('foo', 'bar')30 }3132 return (33 <>34 <div>35 {posts.map(post => {36 return (37 <div key={post.id}>38 {post.title}39 </div>40 )41 })}42 </div>43 <button onClick={onClickAddPost}>add post</button>44 </>45 )46}4748export default IndexPage
在这个例子里,共有两个请求,一个是用于获取所有文章的 GET 请求,另一个是用于创建文章 的 POST 请求。
在真实的应用中,我们通常需要知道每个异步请求的状态,以便显示不同的 UI. 例如,在获取所有文章时,如果请求尚未结束,则在 UI 中显示 <div>Loading...</div>. 另外,一个异步请求还有成功和失败的状态,分别也有对应的 UI.
所以,每一个异步请求,实际上是一个独立的「状态机」,它们都在几个状态中流转:
isLoading: 请求是否在进行data: 如果请求成功,它的响应数据error: 如果请求失败,它的响应数据如果每一个异步请求都要我们都要为它们写维护这些状态的代码,那么代码量会非常多,而且都是基本相同的逻辑。
当然,我们可以把这些状态封装成一个可复用的 hooks. 但数据请求还面临另一个问题:当一个请求完成之后,它可能需要更新其它的请求数据。例如在上面的例子,当我们通过接口创建了一篇文章后,另一个获取文章的接口数据已经不是最新的了,如果我们想在 UI 尽快保持显示最新的数据,我们必须重新请求已经过期的那些数据对应的请求。然而异步请求散落在整个应用的不同地方,很难把它们都一起更新。
react-query 能很好地解决这些问题。
在 Next.js 中引入 react-query
xxxxxxxxxx11$ yarn add react-query在
pages/_app.tsx中, 在顶层组件套上QueryClientProvider:xxxxxxxxxx151// pages/_app.tsx23import React from 'react'4import { QueryClient, QueryClientProvider } from 'react-query'56export const queryClient = new QueryClient()78export default function MyApp({ Component, pageProps }) {910return (11<QueryClientProvider client={queryClient}>12<Component {pageProps} />13</QueryClientProvider>14)15}
在 react-query 中,有两个关键的概念 —— Query 和 Mutation.
任何获取数据的请求都是 Query, 下面是用 react-query 获取所有文章数据的例子:
xxxxxxxxxx331// pages/index.tsx23import axios from 'axios'4import React from 'react'5import { useQuery } from 'react-query'67async function getPosts() {8 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')9 return result.data10}1112function IndexPage() {1314 const getPostsQuery = useQuery('getPosts', getPosts)1516 return (17 <>18 <div>19 {getPostsQuery.isLoading && <div>Loading...</div>}20 {getPostsquery.error && <div>Something error</div>}21 {getPostsQuery.data?.map(post => {22 return (23 <div key={post.id}>24 {post.title}25 </div>26 )27 })}28 </div>29 </>30 )31}3233export default IndexPage和之前的例子一样,在 getPosts() 中我们用 axios 发起了一个 GET 请求。不同的是,我们使用 useQuery() 把这个请求方法套了一层,并给了它一个 key: getPosts. 这个 key 的作用,我们之后会提到。
从上面的代码可以看到,用 useQuery() 这个 hooks 包装过的请求,他有 isLoading, error, data 这三个属性,我们不需要手动地管理这些状态,react-query 会处理好它们,我们只需要在 UI 上获取它们并按照我们的业务逻辑进行显示就可以了。
上面的例子中,
getPostsQuery.data?.map用了 optional chaining 的语法。因为在异步请求完成之前还没有响应的数据,它有可能是空的,这时如果用data.map, 会抛出异常。用 optional chaining 可以避免这种错误.
另外,react-query 会自动替你缓存 Query 的返回,当在另一个组件中,同样有 Query 使用了 getPosts 这个 key, react-query 会首先返回这个 key 对应的缓存数据,用户就可以直接看到数据,而非一个 loading 状态了。这就是 query key 的作用,他可以给请求一个标识,方便后续我们可以用这个标识对请求进行操作和优化。
对资源进行修改的请求(POST, PUT, DELETE 等)都是 Mutation. 下面是在我们上面的例子中,加入了创建文章这个请求,像 useQuery 一样,用 useMutation() 嵌套这个请求方法。
xxxxxxxxxx471// pages/index.tsx23import axios from 'axios'4import React from 'react'5import { useMutation, useQuery } from 'react-query'67async function getPosts() {8 const result = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=5')9 return result.data10}1112async function addPost({ title, body }) {13 await axios.post('https://jsonplaceholder.typicode.com/posts', {14 title, body15 })16}1718function IndexPage() {1920 const getPostsQuery = useQuery('getPosts', getPosts)2122 const addPostMutation = useMutation(addPost)2324 function onClickAddPost() {25 addPostMutation.mutate({ title: 'foo', body: 'bar' })26 }2728 return (29 <>30 <div>31 {getPostsQuery.isLoading && <div>Loading...</div>}32 {getPostsQuery.data?.map(post => {33 return (34 <div key={post.id}>35 {post.title}36 </div>37 )38 })}3940 {addPostMutation.isLoading && <div>Adding post...</div>}41 <button onClick={onClickAddPost}>Add post</button>42 </div>43 </>44 )45}4647export default IndexPage和 Query 相同,Mutation 同样有 isLoading, error, data 这些状态。在点击 button 后,我们用 .muate() 方法执行这个请求,并且传入了一些变量,这些变量会被传递到方法 addPost 中。
我们还可以在 .muate() 中传入 onSuccess 和 onError, 分别指定在请求成功和请求错误后执行的逻辑。比如弹一个 toast 之类的。
xxxxxxxxxx101function onClickAddPost() {2 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {3 onSuccess(data) {4 // ...5 },6 onError(err) {7 // ...8 }9 })10}上面我们提到过,当用户创建了新文章后,理论上请求所有文章这个请求的数据就已经不是最新的了。这时如果我们想再次请求获取所有文章这个接口,可以在 onSuccess 中,手动地执行这个请求的 refetch 方法:
xxxxxxxxxx101function onClickAddPost() {2 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {3 onSuccess(data) {4+ getPostsQuery.refetch()5 },6 onError(err) {7 // ...8 }9 })10}但这不是一个很好的实践,想像一下,这个获取文章的接口,有可能在这个应用中的多个组件用到,难道我们需要一个一个地执行 refetch 吗?当然不是。这时就要介绍 react-query 的一个重要功能 —— Query Invalidation.
Query Invalidation 的意思是「使请求失效」。我们可以用 queryClient 实例的 invalidateQueries() 方法,使某个 key 的请求失效。react-query 会自动重新请求失效的 Query.
xxxxxxxxxx121+ import { queryClient } from './_app'2function onClickAddPost() {3 addPostMutation.mutate({ title: 'foo', body: 'bar' }, {4 onSuccess(data) {5- getPostsQuery.refetch()6+ queryClient.invalidateQueries('getPosts')7 },8 onError(err) {9 // ...10 }11 })12}我们用 queryClient.invalidateQueries('getPosts') 使用 key 为 getPosts 的 Query 失效,那么所有用到 key 为 getPosts 的 Query 都会被重新请求,并更新最新的数据到 UI 上。
Next.js 没有中间件机制。首先让我简单解释一下什么是中间件,为什么我们需要中间件。
在 Express/Koa, 我们可以用中间件进入一个请求的生命周期,一个典型的中间件是一个函数,它接受 req, res 和 next 参数:
xxxxxxxxxx91function exampleMiddleware(req, res, next) {2 if (/** ...*/) {3 req.foo = 'bar'4 next()5 } else {6 res.statusCode = 4037 res.send('forbiddon')8 }9}这个中间件的模式起源于一个 Node.js Web 框架 connect, 早期的 Express 也基于 Connect 开发,于是很多框架也兼容了这种模式,所以这种中间件模式我们通常称为 connect 中间件。
在中间件里,我们可以:
req 对象注入一些属性,这些属性可以被下一个中间件或者 controller 获取到。next() 来中止请求,同时修改 res 的属性从而改变 response 的状态。这使得中间件可以很好地使代码在不同的路由之间重用。假设我们需要在一个路由跟据 cookies 获取用户信息,我们可以把这个获取用户信息的方法写成中间件,然后把用户信息注入到 req.user,这样所以使用了这个中间件的路由可以通过 req.user 取得用户信息。而且在中间件中,如果判断用户没有登录,可以中止这个请求,并返回 403.
下面是 Express 编写和使用中间件的例子:
xxxxxxxxxx231function authMiddleware(req, res, next) {2 // 假设 cookies 中用 `token` 保存用户信息3 if (req.cookies.token) {4 const user = getUserByToken(req.cookies.token)5 req.user = user6 next()7 } else {8 // cookies.token 不存在,中止请求并返回 4039 res.statusCode = 40310 res.send('please sign in first')11 }12}1314// 不使用这个中间件的路由15app.get('/', (req, res) => {16 res.send('hello world')17})1819// 使用这个中间件的路由20app.get('/profile', authMiddleware, (req, res) => {21 // 可以通过 `req.user` 取得用户信息22 res.send(`welcome! ${req.user.name}`)23})如果在 Next.js 要做同样的事,我们会这么做:
xxxxxxxxxx221// pages/api/example.ts23function auth(req, res) {4 if (req.cookies.token) {5 const user = getUserByToken(req.cookies.token)6 return user7 } else {8 // 用户未登录9 res.status(403)10 res.send('please sign in first')11 }12}1314export default (req, res) => {15 if (req.method === 'GET') {16 res.send('hello')17 } else if (req.method === 'POST') {18 const user = auth(req, res)19 console.log('do other things')20 res.send(`welcome! ${user.name}`)21 }22}但在 Next.js, 我们没有任何办法中止请求。理论上 console.log('do other things') 在用户未登录时不应该被执行。
next-connect要在 Next.js 中像 Express/Koa 这样使用 connect 中间件,我们可以使用 next-connect 这个库。
安装
next-connect:xxxxxxxxxx11$ yarn add next-connect
现在,让我们用 next-connect 重写上面的例子:
xxxxxxxxxx191// pages/api/example.ts23import nc from 'next-connect'45function authMiddleware(req, res, next) {6 res.status(403)7 res.send('please sign in first')8}910// 用 `nc()` 创建一个 api handler11const handler = nc()12 .get((req, res) => {13 res.send('hello')14 })15 .post(authMiddleware, (req,res) => {16 res.send('hello')17 })1819export default handler可以看到,现在我们在 Next.js 的API route 可以像在 Express 一样使用中间件。
在 authMiddleware中,我们返回了一个 403,并且没有执行 next(), 模拟了用户未登录的情况。由于 next() 没有执行,这个 POST 请求不会执行这个 POST handler 的代码。
用 next-connect 的另一个好处是,我们可以用.get(), .post(), put() 这样的 helper 来创建对应的 handler, 而不需要用 if (req.method === XXX) 这样的判断。让代码更好读。
因为 next-connect 兼容 connect 中间件,所以我们可以直接用社区上成熟的 connect 中间件,例如用于修改跨域设置的中间件 cors:
安装
cors:xxxxxxxxxx11$ yarn add cors
xxxxxxxxxx201// pages/api/example.ts23import nc from 'next-connect'4+ import * as cors from 'cors'56const corsOptions = {7 origin: 'http://example.com',8 optionsSuccessStatus: 2009}1011const handler = nc()12+ .use(cors(corsOptions))13 .get((req, res) => {14 res.send('hello')15 })16 .post(authMiddleware, (req,res) => {17 res.send('hello')18 })1920export default handler在实现 HTTP API 时,一个良好的实践应该在出现错误的时候响应错误对应的状态码。例如在资源不存在的时候返回 404,在没有权限时返回 403.
在一个复杂的 Web 应用,业务逻辑一般单独存在于 service (model) 层,例如一个根据 id 获取项目信息的 API:
xxxxxxxxxx101class ProjectService {2 getProjectById(id) {3 const project = db.project.find(id)4 if (!project) {5 // TODO: 响应 4046 } else {7 return project8 }9 }10}当 project 找不到时,我们应该返回一个 404 的响应。但在 service (model) 层,我们很难接触到 res 对象,所以很难在 service (model) 层修改响应。也无法在这个层面中止响应。
我们来看看在 Express 中怎么做。Express 有自带的 error handler:,当 catch 到任何地方的 error 后,响应的格式会根据 error 的对象自动改变。也就是说,我们可以通过 throw error 来改变响应的信息:
xxxxxxxxxx181class ProjectService {2 getProjectById(id) {3 const project = db.project.find(id)4 if (!project) {5 throw {6 status: 4047 }8 } else {9 return project10 }11 }12}1314app.get('/project/:id', (req, res) => {15 const projectService = new ProjectService()16 const project = service.getProjectById(req.params.id)17 res.json({ data: project })18})上面的代码中,当 project 无法找到时,会抛出一个 { status: 404 }, Express 在捕获后,会把 res.statusCode 设为 404。
另一个流行的 web 框架 hapi 还提供了 @hapi/boom 这个模块,提供了很多 helper 用于抛出 HTTP 异常。例如,Boom.forbidden('Please sign in first') 会抛出一个这样的对象:
xxxxxxxxxx51{2 "statusCode": 403,3 "error": "Forbidden",4 "message": "Please sign in first"5}用 Boom 来改写我们上面的例子,可以变成:
xxxxxxxxxx181+import Boom as from '@hapi/boom'23class ProjectService {4 getProjectById(id) {5 const project = db.project.find(id)6 if (!project) {7+ throw Boom.notFound('Project not found')8 } else {9 return project10 }11 }12}1314app.get('/project/:id', (req, res) => {15 const projectService = new ProjectService()16 const project = service.getProjectById(req.params.id)17 res.json({ data: project })18})然而,在 Next.js, 无论你在 API route 中 throw 一个怎样的 error, 它都会响应一个 500 Internal Server Error. 并且你根本没有方法统一处理这些 error.
幸运的是,我们在上一节介绍的 next-connect 提供了一个统一的 error 处理机制。使用 next-connect 的所有 api route 或中间件,如果抛出异常,都可以在 onError 中捕获到:
xxxxxxxxxx131import nc from 'next-connect'23const handler = () => nc({4 onError(err, req, res) {5 console.log(err.message) // => 'oops'6 res.send(err.message)7 }8})910export default handler()11 .get((req, res) => {12 throw new Error('oops')13 })
因此,我们甚至可以借助 @hapi/boom 的力量,在 Next.js 中得到很好的错误处理机制:我们可以在 api route 或者中间件中抛出 Boom 异常,然后在 onError 中根据 Boom 异常的信息改变 res:
安装
@hapi/boom:xxxxxxxxxx11$ yarn add @hapi/boom
xxxxxxxxxx381// pages/api/example.ts23import nc from "next-connect";4import * as Boom from '@hapi/boom'56const handler = () =>7 nc({8 onError(err, req, res) {9 // 如果是一个 Boom 异常,则根据 Boom 异常结构修改 `res`10 if (Boom.isBoom(err)) {11 res.status(err.output.payload.statusCode);12 res.json({13 error: err.output.payload.error,14 message: err.output.payload.message,15 });16 } else {17 res.status(500);18 res.json({19 message: "Unexpected error",20 });21 console.error(err);22 // unexcepted error23 }24 },25 });2627function getProjects() {28 throw Boom.forbidden('Please sign in first')29}3031export default handler()32 .get(async (req, res) => {33 const projects = getProjects()34 res.json({35 projects36 })37 });38这样,在任何地方抛出了 Boom 异常,都可以得到对应的响应,而不需要手动修改 res 对象:

在 Node.js 中访问数据库的方法有很多,优秀的 ORM 也有很多。但根据我自己的使用经验,在 Next.js 使用 Prisma 的体验还是最好的。
Prisma 是一个 TypeScript 友好的 Node.js ORM. 如果你没有听说过 Prisma, 在这里我可以用一句话介绍它:Prisma 让你用一个非常简洁易懂的 DSL 语法描述你的数据库结构和表之前的关系,然后自动生成 TypeScript 友好的请求 SDK.
在项目里接入 Prisma 很简单:
xxxxxxxxxx31$ yarn add prisma @prisma/client23$ yarn prisma init运行 yarn prisma init 后,会在项目里创建 prisma/schema.prisma 文件。Prisma 支持 MySQL, SQLite, PostgreSQL. 为了方便,我们在这里使用 SQLite. 只要把 schema.prisma 里的 provider 改成 sqlite即可:
xxxxxxxxxx1112datasource db {3- provider = "postgresql"4- url = env("DATABASE_URL")5+ provider = "sqlite"6+ url = "file:../db.sqlite"7}89generator client {10 provider = "prisma-client-js"11}url 指定了数据库链接串,用 SQLite 的时候,可以指定为文件的目录。
It means Prisma will use the SQLite database in ../db.sqlite.
想像我们正在开发一个博客系统,我们需要三张表:Post, Comment, Tag:
Post 会有多个 Comment, 但一个 Comment 只属于一个 Post, 它们是一对多的关系。在一对多的关系中,我们通常会设置一个外键 (foreign key), 指向这条记录所属于的父级 idPost 会有多个 Tag, 一个 Tag 也可以属于多个 Post, 它们是多对多的关系。在多对多的关系中,我们通常会单独建立一张关系表。
或许你的 SQL 能力不是特别强,写下这样的建表 SQL 可能比较吃力(即使你精通 SQL, 每次开发项目都要写一堆 SQL 也不是什么好事)。幸运的是,如果你使用 Prisma, 你不需要自己写这些 SQL, 只需要在 prisma/schema.prisma 文件中定义你的数据模型,以及它们之前的对应关系即可:
xxxxxxxxxx361datasource db {2 provider = "sqlite"3 url = "file:../db.sqlite"4}56generator client {7 provider = "prisma-client-js"8}910model Post {11 id String @id1213 content String1415 createdAt DateTime @default(now())1617 tags Tag[] @relation("post_tag")1819 comments Comment[] @relation("post_comment")20}2122model Comment {23 id String @id24 content String2526 postId String27 post Post @relation(name: "post_comment", references: [id], fields: [postId])2829 createdAt DateTime @default(now())30}3132model Tag {33 label String @id3435 posts Post[] @relation("post_tag")36}即使你从来没有学过 Prisma Schema 的语法,你还是可以从中大概看出数据模型,和他们之间的关系。
[Prisma Schema 语法文档
Schema 文件写好后,运行 yarn prisma db push. Prisma 会按照这个 Schema, 把数据模型应用到数据库中,它会处理好一对多,多对多的关系,帮助你生成外键、关系表等等。
数据库生成后,运行 yarn prisma generate, 这个命令会在 node_modules/@prisma/clinet 生成操作数据库的 SDK —— PrismaClient. 在代码中你可以直接引入使用:
xxxxxxxxxx101import { PrismaClient } from '@prisma/client'23const prisma = new PrismaClient()45export default (req, res) => {6 const posts = await prisma.post.findMany()7 res.json({8 posts9 })10}
yarn prisma db push 只能用于项目的原型阶段,因为一但数据库有改变,有可能导致数据丢失。正常的做法应该在每次修改完数据结构以后,生成一份迁移文件,在部署上线前执行这份迁移,安全地变更数据库结构。
Primsa 自带了生成 Migration 的功能:
xxxxxxxxxx11$ yarn prisma migrate dev
迁移文件会生成到 prisma/migrations 中,当你需要把他应用到生产数据库时,执行:
xxxxxxxxxx11$ yarn prisma migrate deploy
在本地开发 Next.js 时,每次代码更改会导致热加载,导致 PrismaClient 一直被重复地实例化,造成出现重复的数据库连接。
我们应该让 Prisma 保持是一个单例,把它缓存到 global 对象中:
xxxxxxxxxx41import { PrismaClient } from '@prisma/client'23export const prisma = global.prisma || new PrismaClient()4if (process.env.NODE_ENV !== "production") global.prisma = prisma之后在应用所有的地方只通过这个单例引入 prisma.
前面几个简短的章节已经大概介绍完了一些我们会用到的技术和方法论,接下来在这一个章节,我们就开始利用这些技术,开发一些真实的应用了。
以下实例可以在 https://github.com/djyde/fullstack-nextjs-in-action-example 获取源码。
xxxxxxxxxx11git clone https://github.com/djyde/fullstack-nextjs-application-in-action.git
这个仓库的 master 分支是一个把以上技术都整合到一起的基础 Next.js 项目。你可以选择以 master 分支为基础开始一步一步地跟着本书开始。
你也可以选择只用来读代码。在实例教程中,每一个小节我都会在标题下方留下对应的 commit 的 hash, 你可以使用 git checkout xxx 直接跳到该小节的代码中进行阅读。
请注意,我们将会提供两个实例,这两个实例并非各自独立,实例 2 是基于实例 1 开发的,所以请勿直接跳过实例 1.
第一个实例是实现一个用户系统。用户可以在页面中创建账号,登录和登出。本实例主要内容:
Prisma 的基本使用react-query 的基本使用在这个实例中,我们需要实现的是:
/login 页面,提供用户登录和创建账号的表单/user 页面,展示用户信息,如果用户未登录,则自动跳转到 /login 页面/api/login 登录/api/signup 创建账号/api/logout 登出c1695d97d65b0
首页,创建一个文件 pages/login.tsx:
xxxxxxxxxx491// pages/login.tsx23function LoginForm() {4 return (5 <>6 <div>7 <label>Username: </label>8 <input type="text" />9 </div>10 <div>11 <label>Password: </label>12 <input type="password" />13 </div>14 <button>Login</button>15 </>16 )17}1819function SignUpForm() {20 return (21 <>22 <div>23 <label>Username: </label>24 <input type="text" />25 </div>26 <div>27 <label>Password: </label>28 <input type="password" />29 </div>30 <button>create account</button>31 </>32 )33}3435function LoginPage() {36 return (37 <>38 <div>39 <h1>Login</h1>40 <LoginForm />4142 <h1>Create account</h1>43 <SignUpForm />44 </div>45 </>46 )47}4849export default LoginPage这是给用户登录和创建账号的页面 UI http://localhost:3000/login:

f3d9abb6cef
首先我们需要在数据库中创建一个存放用户的 User 表。编辑 prisma/schema.prisma,添加一个 User model:
xxxxxxxxxx141datasource db {2 provider = "sqlite"3 url = "file:../db.sqlite"4}56generator client {7 provider = "prisma-client-js"8}910+ model User {11+ name String @id12+ password String13+ }14然后运行 yarn prisma db push,把模型应用到数据库中。
推荐使用 DB Browser for SQLite 查看数据库

这时,就可以创建一个 API 用于创建账号 pages/api/singup.ts:
xxxxxxxxxx211import { apiHandler, prisma } from "../../utils.server";2import bcrypt from 'bcrypt'34export default apiHandler()5 .post(async (req, res) => {6 const body = req.body as {7 username: string,8 password: string9 }1011 const created = await prisma.user.create({12 data: {13 name: body.username,14 password: bcrypt.hashSync(body.password, 10)15 }16 })1718 res.json({19 message: 'success'20 })21 })
apiHandler是对next-connect的封装,详细可读源码
在这个 API 中,我们在请求体中取 username 和 password 这两个字段,然后使用 prisma.user.create() 在 User 表中创建一条记录。
我们使用
bcrypt加密用户的密码。请记住任何时候都不要明文保存用户的密码。xxxxxxxxxx11$ yarn add bcrypt
Now, add a create account mutation in the create accout form:
xxxxxxxxxx391// pages/login.tsx23async function createAccount({ username, password }) {4 await axios.post(`/api/signup`, {5 username, password6 })7}89function SignUpForm() {1011 const $username = useRef(null)12 const $password = useRef(null)1314 const createAccountMutation = useMutation(createAccount, {15 onSuccess() {16 alert('created!')17 }18 })1920 function onClickCreateAccount() {21 const username = $username.current.value22 const password = $password.current.value23 createAccountMutation.mutate({ username, password })24 }2526 return (27 <>28 <div>29 <label>Username: </label>30 <input ref={$username} type="text" />31 </div>32 <div>33 <label>Password: </label>34 <input ref={$password} type="password" />35 </div>36 <button disabled={createAccountMutation.isLoading} onClick={onClickCreateAccount}>create account</button>37 </>38 )39}createAccount() 是请求的方法,我们用 useMutation 把它封装成了一个 Mutation (createAccountMutation).
当用户点击 button 时,就通过 createAccountMutation.mutate() 执行这个 Mutation.
现在试一试创建一个账号:


在数据库中看到这个创建的用户。
保存用户的登录状态主流的实现方式有两种,一种是 Session, 一种是 JWT. 两种方法各有区别,不在本书讨论的范围内。这里为了方便,我们使用 JWT 来实现。
5d6970536afb
无论是用 JWT 还是 Session, 在登录的接口中,都需要实现验证用户名和密码的方法。首先创建登录 API 的接口 pages/api/login.tsx
xxxxxxxxxx361// pages/api/login.tsx23import Boom from "@hapi/boom";4import { apiHandler, prisma } from "../../utils.server";5import bcrypt from 'bcrypt'67async function validate(username, password) {8 // validate the username and password9 const user = await prisma.user.findUnique({10 where: {11 name: username,12 },13 });1415 if (!user) {16 throw Boom.unauthorized("user not found");17 }1819 if (bcrypt.compareSync(password, user.password)) {20 return user21 } else {22 throw Boom.unauthorized('username or password not correct')23 }24}2526export default apiHandler()27 .post(async (req, res) => {28 const body = req.body as {29 username: string;30 password: string;31 };3233 const user = await validate(body.username, body.password)3435 res.json({})36 })我们实现了一个 validate 方法,在数据库中寻找对应用户名的记录,然后匹配密码是否正确。如果正确,返回用户对象,相反,抛出一个 unauthorized 的 401 异常。
回顾:
在「API route 错误统一处理」一节中我们介绍了抛出和统一处理 Boom 异常的方法
现在我们为这个登录接口在页面创建一个 Mutation:
xxxxxxxxxx381// pages/login.tsx23async function login({ username, password }) {4 const result = await axios.post(`api/login`, {5 username, password6 })7 return result.data8}910function LoginForm() {1112 const loginMutation = useMutation(login)1314 const $username = useRef(null)15 const $password = useRef(null)1617 function onClickLogin() {18 const username = $username.current.value19 const password = $password.current.value20 loginMutation.mutate({ username, password })21 }2223 return (24 <>25 {loginMutation.error && <div style={{ color: 'red' }}>{loginMutation.error.response.data.message}</div>}26 <div>27 <label>Username: </label>28 <input ref={$username} type="text" />29 </div>30 <div>31 <label>Password: </label>32 <input ref={$password} type="password" />33 </div>34 <button onClick={onClickLogin}>Login</button>35 </>36 )37}38在上面的代码中,我们还可以通过 loginMutation.error.response.data.message 获取请求出现错误时返回的消息体。我们现在用一个错误的密码进行登录:

ef9529bf6db5
我们继续完成这个登录 API, 在验证完用户名和密码后,我们就要把用户信息加密到 JWT 中。我们使用 jsonwebtoken 来生成 JWT:
xxxxxxxxxx11$ yarn add jsonwebtoken
xxxxxxxxxx211// pages/api/login.ts23import jwt from 'jsonwebtoken'45export default apiHandler()6 .post(async (req, res) => {7 const body = req.body as {8 username: string;9 password: string;10 };11 12 const user = await validate(body.username, body.password)1314 // generate jwt1516+ const token = jwt.sign({17+ username: user.name18+ }, process.env.JWT_SECRET, { expiresIn: '3 days' })1920 res.json({})21 })我们通过 jwt.sign() 生成一个 JWT. 把 { username: user.name } 放到这个 JWT 中,并且设置了 JWT 过期的时间是 3 天。
生成 JWT 需要一个只有服务器端知道的私钥,为了安全起见,我们不能把私钥写在代码里,而是放在环境变量中。
在项目中创建一个 .env 文件,然后把私钥设置到 JWT_SECRET 变量中,Next.js 会在启动时读取 `.env
xxxxxxxxxx21# .env2JWT_SECRET=ofcourseistillloveyou客户端在哪里存放这个 JWT? 比较好的一种做法是保存在 httpOnly 的 cookies 中。然而 Next.js 没有自带一个比较方便的设置 Cookie 的方法,所以我们使用 cookie 这个库:
Install
cookie:xxxxxxxxxx11$ yarn add cookie
xxxxxxxxxx231// pages/api/login.ts23//...4import cookie from 'cookie'56export default apiHandler()7 .post((req, res) => {89 // ... 1011 const token = jwt.sign({12 username: user.name13 }, process.env.JWT_SECRET, { expiresIn: '3 days' })1415 // set a cookie named `token`16 res.setHeader("Set-Cookie", cookie.serialize('token', token, {17 httpOnly: true,18 path: '/',19 maxAge: 60 * 60 * 24 * 320 }));2122 res.json({})23 })我们把 JWT 保存在了名为 token 的 cookies 中。
现在我们尝试用正确的用户名和密码登录,可以看到登录成功后,cookies 被正确保存了。在这之后,所有请求都会带上这个 cookie.

21e4111ffbfb
现在,我们创建一个 /user 页面用来展示用户信息。这个页面只能被已登录的用户访问,如果未登录,就跳转到 /login.
如何在访问时判断是否已登录?我们可以在 getServerSideProps(ctx) 中从 ctx.req.cookies 中取得登录成功时设置好的 token cookies, 并验证 JWT 是否合法。
首先在 utils.server.ts 实现一个从 req 取 JWT 且验证 JWT 的方法:
xxxxxxxxxx251// utils.server.ts23import jwt from 'jsonwebtoken'45export const getUserFromReq = async (req) => {67 // get JWT `token` on cookies8 const token = req.cookies['token']910 try {11 // if token is invalid, `verify` will throw an error12 const payload = jwt.verify(token, process.env.JWT_SECRET)1314 // find user in database15 const user = await prisma.user.findUnique({16 where: {17 name: payload.username18 }19 })2021 return user22 } catch (e) {23 return null24 }25}然后创建 /user 页面 /pages/user.tsx:
xxxxxxxxxx401// pages/user.tsx23import { getUserFromReq } from "../utils.server"45function UserPage(props: {6 user: {7 name: string8 }9}) {10 return (11 <>12 <div>13 Hello, {props.user.name}14 </div>15 </>16 )17}1819export async function getServerSideProps(ctx) {20 const user = await getUserFromReq(ctx.req)2122 if (!user) {23 return {24 redirect: {25 permanent: false,26 destination: '/login'27 }28 }29 }3031 return {32 props: {33 user: {34 name: user.name35 }36 }37 }38}3940export default UserPage在 getServerSideProps 中调用 getUserFromReq, 如果返回是一个 user 对象,则表明用户已登录,我们可以把用户信息通过 props 传到页面。
相反,如果返回 null, 代表用户没有登录。我们可以在这里返回一个 redirect 对象(形如 { destination: string, permanent: boolean }), 用户就会被跳转到指定的路由。这是 Next.js 其中一个特性。

思考题
你学会了如何在页面访问时判断用户是否登录且控制是否跳转到其它路由。现在如果我们想要实现当已登录的用户在访问
/login时把他跳转到/user路由,你会怎么做?
6d0483a7a53
实现退出登录,我们只需要创建一个路由 /api/logout 把 cookies 设置为一个不合法的值,然后跳转到其它路由即可:
xxxxxxxxxx151// pages/api/logout.ts23import { apiHandler } from "../../utils.server";4import cookie from 'cookie'56export default apiHandler()7 .get(async (req, res) => {8 res.setHeader('Set-Cookie', cookie.serialize('token', 'invalid', {9 httpOnly: true,10 path: '/'11 }))1213 res.redirect('/login')14 })15然后在页面添加一个退出登录的链接:
xxxxxxxxxx191// pages/user.tsx23function UserPage(props: {4 user: {5 name: string6 }7}) {8 return (9 <>10 <div>11 Hello, {props.user.name}1213 <div>14+ <a href="/api/logout">Logout</a>15 </div>16 </div>17 </>18 )19}来到第二个实例,我们基于实例 1 实现一个最简单的 HackerNews: 用户可以在上面提交链接和标题,也可以编辑和删除他们提交链接。
本实例主要内容:
react-query 的 Query Invalidation 的使用f84d8006e31
在实现表单之前,我们先在网站的首页显示一个导航栏,如果用户已经登录,则显示他的用户名,以及一个退出登录的链接。如果用户未登录,就显示一个登录入口:
xxxxxxxxxx371// pages/index.tsx23import React from 'react'4import { getUserFromReq } from '../utils.server'56function IndexPage(props: {7 user?: {8 name: string9 }10}) {1112 return (13 <>14 <div>15 {props.user ? <>16 <span>Hi, {props.user.name}, </span>17 <a href="/api/logout">Logout</a>18 </> : <>19 <a href="/login">Login</a>20 </>}21 </div>22 </>23 )24}252627export async function getServerSideProps(ctx) {28 const user = await getUserFromReq(ctx.req)2930 return {31 props: {32 user: user ? { name: user?.name } : null33 }34 }35}3637export default IndexPage和实例 1 一样,我们在 getServerSideProps 中用 getUserFromReq 获取用户信息,然后通过 props 传递到页面。页面根据判断 props.user 是否为 null 进行不同的展示。

现在我们编写用于提交链接的表单,这个表单只在用户已登录时可见:
xxxxxxxxxx421// pages/index.tsx23function SubmitLinkForm() {4 return (5 <>6 <h2>Submit link</h2>7 <div>8 <label>URL: </label>9 <input type="text" />10 </div>11 <div>12 <label>Title: </label>13 <input type="text" />14 </div>15 </>16 )17}1819function IndexPage(props: {20 user?: {21 name: string22 }23}) {2425 return (26 <>27 <div>28 {props.user ? <>29 <span>Hi, {props.user.name}, </span>30 <a href="/api/logout">Logout</a>31 </> : <>32 <a href="/login">Login</a>33 </>}34 </div>3536 {/* 只在用户登录后可见 */}37 {props.user && <div>38 <SubmitLinkForm />39 </div>}40 </>41 )42}
51282d8145347
用户可以提交链接,所以我们需要一个 Link 表,每一个 Link 都有标题 title 和链接 url. 每个 Link 都是被某个用户提交的,所以我们还需要用一个外键 creatorName 标识 Link 是被哪个用户提交的。

把数据模型写成 Prisma Schema (prisma/schema.prisma):
xxxxxxxxxx181model User {2name String @id3password String45links Link[] @relation("link_creator")6}78model Link {9id String @id @default(uuid())1011title String12url String1314creatorName String15creator User @relation(name: "link_creator", fields: [creatorName], references: [name])1617createdAt DateTime @default(now())18}
然后运行 yarn prisma db push 把数据模型应用到数据库:

35b7bc1407
现在我们创建一个 API /api/link 用来给用户提交链接,创建 pages/api/link:
xxxxxxxxxx241// pages/api/link23import { apiHandler, prisma } from "../../utils.server";45export default apiHandler()6 .post(async (req, res) => {7 const body = req.body as {8 url: string,9 title: string10 }1112 await prisma.link.create({13 data: {14 url: body.url,15 title: body.title,16 // TODO: how to get creator's username17 // creatorName: ''18 }19 })2021 res.json({22 message: 'Success'23 })24 })/api/link 接受一个 POST 请求,然后用 prisma.link.create 把链接存进数据库。
但代码其实还没完成,因为我们需要在 API 里知道请求这个接口的登录用户是谁。
我们可以写一个中间件专门用于取用户信息,然后把用户信息放在 req.user 中,我们把这个中间件写在 utils.server.ts:
xxxxxxxxxx111// utils.server.ts23export const authMiddleware = () => async (req, res, next) => {4 const user = await getUserFromReq(req)5 if (!user) {6 throw Boom.forbidden('Please login first')7 } else {8 req.user = user9 next()10 }11}这个中间件不止把用户信息放在 req.user, 当一个未登录的用户请求了进入了这个中间件时,中间件会抛出一个 403 响应,从而阻止未登录用户。
把这个 authMiddleware() 用在路由里:
xxxxxxxxxx231import { apiHandler, authMiddleware, prisma } from "../../utils.server";23export default apiHandler()4+ .post(authMiddleware(), async (req, res) => {5 const body = req.body as {6 url: string,7 title: string8 }910+ const user = req.user1112 await prisma.link.create({13 data: {14 url: body.url,15 title: body.title,16+ creatorName: user.name17 }18 })1920 res.json({21 message: 'Success'22 })23 })现在,试试未登录用户请求这个 API:

可以看到响应了 403.
1406f062d3f
现在,让我们在页面中获取所有提交的链接并展示出来。首先我们要创建一个 GET /api/link 来获取所有链接:
xxxxxxxxxx191// pages/api/link.ts23import { apiHandler, authMiddleware, prisma } from "../../utils.server";45export default apiHandler()6 .get(async (req, res) => {7 const links = await prisma.link.findMany({8 orderBy: {9 createdAt: 'desc'10 }11 })1213 res.json({14 data: links15 })16 })17 .post(authMiddleware(), async (req, res) => {18 // ......19 })用 findMany() 查询所有记录,然后根据 createdAt 排序。
在页面中,创建一个 Query 来请求这个接口,并渲染到页面中:
xxxxxxxxxx471// pages/index.tsx23// the query method4async function fetchAllLinks() {5 const result = await axios.get(`/api/link`)6 return result.data.data7}89function IndexPage(props: {10 user?: {11 name: string12 }13}) {1415 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)1617 return (18 <>19 <div>20 {props.user ? <>21 <span>Hi, {props.user.name}, </span>22 <a href="/api/logout">Logout</a>23 </> : <>24 <a href="/login">Login</a>25 </>}26 </div>2728 {/* Only signed in user can see the submit link form */}29 {props.user && <div>30 <SubmitLinkForm />31 </div>}3233 {/* fetch all links and render them */}34 <div>35 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}36 {fetchAllLinksQuery.data?.map(link => {37 return (38 <div key={link.id}>39 <a href={link.url}>{link.title}</a>40 </div>41 )42 })}43 </div>44 </>45 )46}47然后编写提交链接的 Mutation:
xxxxxxxxxx411// pages/index.tsx23async function submitLink(body: {4 title: string,5 url: string6}) {7 await axios.post(`/api/link`, body)8}910function SubmitLinkForm() {1112 const $title = React.useRef(null)13 const $url = React.useRef(null)1415 const submitLinkMutation = useMutation(submitLink, {16 onSuccess() {17 console.log('submitted!')18 }19 })2021 function onClickSubmit() {22 submitLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })23 }2425 return (26 <>27 <h2>Submit link</h2>28 <div>29 <label>URL: </label>30 <input ref={$url} type="text" />31 </div>32 <div>33 <label>Title: </label>34 <input ref={$title} type="text" />35 </div>3637 <button disabled={submitLinkMutation.isLoading} onClick={onClickSubmit}>Submit</button>38 </>39 )40}41当 submitLinkMutation 请求成功后,可以在日志中看到 submitted.

当用户提交新的链接后,他并不能马上看到新的链接,因为这需要重新请求获取链接的接口。这里我们就可以用到 react-query 的 Query Invalidation 功能。回看一下我们在实现获取链接的 Query 的时候,给了这个 Query 一个 fetchAllLinks 的 key:
xxxxxxxxxx11 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)因为,我们可以在用户提交新的链接成功后,调用 queryClient.invalidateQueries('fetchAllLinks'), 让 react-query 知道 fetchAlllinks 这个 Query 的数据已经过时,需要重新请求。
xxxxxxxxxx171// pages/index.tsx2+ import { queryClient } from './_app'34const $title = React.useRef(null)5const $url = React.useRef(null)67const submitLinkMutation = useMutation(submitLink, {8 onSuccess() {9- console.log('submitted!')10+ queryClient.invalidateQueries('fetchAllLinks')11 }12})1314function onClickSubmit() {15 submitLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })16}17现在,当用户提交链接后,react-query 就会重新请求获取链接的 Query, 然后展示最新的数据:

607310047a29cc0f
用户可以编辑和删除他们提交的链接。现在创建一个 PUT /api/link/:linkId 的 API 在编辑链接的时候请求。在 Next.js 中,我们可以通过创建 /pages/api/link/[linkId]/index.ts 这样的文件来创建这样的动态路由。然后通过 req.query.linkId 来获取路由上面的参数:
xxxxxxxxxx81// pages/api/link/[linkId]/index.ts23import { apiHandler } from "../../../../utils.server";45export default apiHandler()6 .put(async (req, res) => {7 res.send(req.query.linkId)8 })
我们用 prisma.link.update 来更新一条 Link 的记录:
xxxxxxxxxx281// pages/api/link/[linkId]/index.ts23import { apiHandler, prisma } from "../../../../utils.server";45export default apiHandler()6 .put(async (req, res) => {7 const body = req.body as {8 title?: string,9 url?: string10 }1112 const linkId = req.query.linkId as string1314 await prisma.link.update({15 where: {16 id: linkId17 },18 data: {19 title: body.title,20 url: body.url21 }22 })2324 res.json({25 message: 'success'26 })27 })28「编辑链接」的业务逻辑这样其实已经算是完成,但是,这个 API 不应该让未登录的用户,或不是这条链接的提交者请求。我们可以直接使用上一节封装的 authMiddleware, 把非登录用户排除在外。然后,再通过判断 Link 的 creatorName 是否和 req.user.name 相同即可:
xxxxxxxxxx421import Boom from "@hapi/boom";2import { apiHandler, authMiddleware, prisma } from "../../../../utils.server";34export default apiHandler()5 .put(authMiddleware(), async (req, res) => {6 const body = req.body as {7 title?: string,8 url?: string9 }1011 const linkId = req.query.linkId as string12 13 // get current logged in user's information14 const user = req.user1516 // get link's information17 const link = await prisma.link.findUnique({18 where: {19 id: linkId20 }21 })2223 // check the link's creator is current logged in user. If not, response a 403 error24 if (link.creatorName !== user.name) {25 throw Boom.forbidden('Permission Denined!')26 }2728 await prisma.link.update({29 where: {30 id: linkId31 },32 data: {33 title: body.title,34 url: body.url35 }36 })3738 res.json({39 message: 'success'40 })41 })42在这一步,我们就见识到了中间件对于代码复用的好处。我们甚至可以再进一步,封装一个专门用于判断请求者是否是链接作者的中间件 linkCreatorGuard:
xxxxxxxxxx201// utils.server.ts23export const linkCreatorGuard = (getLinkId: (req) => string) => async (req, res, next) => {4 const user = req.user5 const linkId = getLinkId(req)6 const link = await prisma.link.findUnique({7 where: {8 id: linkId9 },10 select: {11 creatorName: true12 }13 })1415 if (user.name !== link.creatorName) {16 throw Boom.forbidden('Permission Denined')17 } else {18 next()19 }20}它接受一个函数,用于在 req 对象中取出链接的 id, 然后返回中间件。
现在我们就把他用在 PUT 的 api 接口:
xxxxxxxxxx281import { apiHandler, authMiddleware, linkCreatorGuard, prisma } from "../../../../utils.server";23export default apiHandler()4 .put(5 authMiddleware(), 6 linkCreatorGuard(req => req.query.linkId),78 async (req, res) => {9 const body = req.body as {10 title?: string,11 url?: string12 }1314 await prisma.link.update({15 where: {16 id: req.query.linkId17 },18 data: {19 title: body.title,20 url: body.url21 }22 })2324 res.json({25 message: 'success'26 })27 })28我们给 linkCreatorGuard 传的第一个参数就是从 req.query 中取出 linkId 的方法。
接口准备就绪,现在就在页面实现编辑链接的表单和 Mutation:
xxxxxxxxxx491// pages/index.tsx2import { Link } from '@prisma/client'3import { queryClient } from './_app'45const editLink = (linkId: string) => async (body: {6 title?: string,7 url?: string8}) => {9 await axios.put(`/api/link/${linkId}`, body)10}1112function EditLinkForm(props: {13 link: Link14}) {1516 const $title = React.useRef(null)17 const $url = React.useRef(null)1819 const editLinkMutation = useMutation(editLink(props.link.id), {20 onSuccess() {21 // mark `fetchAllLinks` query as stale after editing a link22 queryClient.invalidateQueries('fetchAllLinks')23 },24 onError(err) {25 // if error, alert the error message26 alert(err.response.data.message)27 }28 })2930 function onClickSave() {31 editLinkMutation.mutate({ title: $title.current.value, url: $url.current.value })32 }3334 return (35 <>36 <div>37 <label>URL: </label>38 <input defaultValue={props.link.url} ref={$url} type="text" />39 </div>40 <div>41 <label>Title: </label>42 <input defaultValue={props.link.title} ref={$title} type="text" />43 </div>4445 <button disabled={editLinkMutation.isLoading} onClick={onClickSave}>Save</button>46 </>47 )48}49和提交链接一样,修改链接这个 Mutation 成功以后,调用 queryClient.invalidateQueries('fetchAllLinks') 让链接列表更新。
让这个编辑链接的表单显示在每条链接的下方:
xxxxxxxxxx431function IndexPage(props: {2 user?: {3 name: string4 }5}) {67 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)89 return (10 <>11 <div>12 {props.user ? <>13 <span>Hi, {props.user.name}, </span>14 <a href="/api/logout">Logout</a>15 </> : <>16 <a href="/login">Login</a>17 </>}18 </div>1920 {/* Only signed in user can see the submit link form */}21 {props.user && <div>22 <SubmitLinkForm />23 </div>}2425 <div>26 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}27 {fetchAllLinksQuery.data?.map(link => {28 return (29 <div style={{ marginTop: '1rem' }}>30 <div key={link.id}>31- <a href={link.url}>{link.title}</a>32+ <a href={link.url}>{link.title}</a> <span>by: {link.creatorName}</span>33 </div>34 <div>35+ <EditLinkForm link={link} />36 </div>37 </div>38 )39 })}40 </div>41 </>42 )43}现在页面就变成了这个模样:

我创建了两个用户,分别提交了一些链接。现在我们试试看编辑非自己提交的链接会怎么样:

非常棒,这说明 linkCreatorGuard 是有效的。现在编辑自己提交的链接,成功后可以立即看到更新后的数据:

f64677053742f
删除链接和修改链接大致相同,但路由是 DELETE /api/link/:linkId. 我们用 prisma.link.delete 来删除一条数据库记录。
同样,只有自己可以删除自己的链接。然而因为我们在实现编辑链接的时候封装了 linkCreatorGuard, 所以在删除链接的 API 只需要实现删除链接的业务逻辑,而不需要再实现判断是否是提交者的逻辑了,这大大减少了工作量:
xxxxxxxxxx501// pages/api/link/[linkId]/index.ts23import {4 apiHandler,5 authMiddleware,6 linkCreatorGuard,7 prisma,8} from "../../../../utils.server";910export default apiHandler()11 .put(12 authMiddleware(),13 linkCreatorGuard((req) => req.query.linkId),14 async (req, res) => {15 const body = req.body as {16 title?: string;17 url?: string;18 };1920 await prisma.link.update({21 where: {22 id: req.query.linkId,23 },24 data: {25 title: body.title,26 url: body.url,27 },28 });2930 res.json({31 message: "success",32 });33 }34 )35 // delete a link36 .delete(37 authMiddleware(),38 linkCreatorGuard((req) => req.query.linkId),39 async (req, res) => {40 await prisma.link.delete({41 where: {42 id: req.query.linkId43 }44 })45 res.json({46 message: "success",47 });48 }49 );50API 完成后,在前端页面增加删除链接的 Mutation:
xxxxxxxxxx651// pages/index.tsx23+async function deleteLink({ linkId }) {4+ await axios.delete(`/api/link/${linkId}`)5+}67function IndexPage(props: {8 user?: {9 name: string10 }11}) {1213 const fetchAllLinksQuery = useQuery('fetchAllLinks', fetchAllLinks)14+ const deleteLinkMutation = useMutation(deleteLink, {15+ onSuccess() {16+ queryClient.invalidateQueries('fetchAllLinks')17+ },18+ onError(err) {19+ alert(err.response.data.message)20+ }21+ })2223 return (24 <>25 <div>26 {props.user ? <>27 <span>Hi, {props.user.name}, </span>28 <a href="/api/logout">Logout</a>29 </> : <>30 <a href="/login">Login</a>31 </>}32 </div>3334 {/* Only signed in user can see the submit link form */}35 {props.user && <div>36 <SubmitLinkForm />37 </div>}3839 <div>40 {fetchAllLinksQuery.isLoading && <div>Loading...</div>}41 {fetchAllLinksQuery.data?.map(link => {42 return (43 <div style={{ marginTop: '1rem' }}>44 <div key={link.id}>45 <a href={link.url}>{link.title}</a> <span>by: {link.creatorName}</span>46 </div>47 <div>48+ <button49+ disabled={deleteLinkMutation.isLoading}50+ onClick={_ => deleteLinkMutation.mutate({ linkId: link.id })}51+ >52+ delete53+ </button>54 </div>55 <div>56 <EditLinkForm link={link} />57 </div>58 </div>59 )60 })}61 </div>62 </>63 )64}65删除一条不是自己提交的链接:

删除自己提交的链接,链接会马上从列表中消失。
