封面

image-20210614183732554

前言

读者好,我是 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-queryswr 这样的数据请求封装?它们两个我应该用哪个?在复杂的项目里,我应该如何正确地使用它们?这些都是我在头几次开发时候不知道的。

还好,在做了几个项目之后,我大概总结了一些非常有用的「最佳实践」,把上面提到的很多在 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 添加一篇文章:

image-20210612132751487

在这个例子里,共有两个请求,一个是用于获取所有文章的 GET 请求,另一个是用于创建文章 的 POST 请求。

在真实的应用中,我们通常需要知道每个异步请求的状态,以便显示不同的 UI. 例如,在获取所有文章时,如果请求尚未结束,则在 UI 中显示 <div>Loading...</div>. 另外,一个异步请求还有成功和失败的状态,分别也有对应的 UI.

所以,每一个异步请求,实际上是一个独立的「状态机」,它们都在几个状态中流转:

如果每一个异步请求都要我们都要为它们写维护这些状态的代码,那么代码量会非常多,而且都是基本相同的逻辑。

当然,我们可以把这些状态封装成一个可复用的 hooks. 但数据请求还面临另一个问题:当一个请求完成之后,它可能需要更新其它的请求数据。例如在上面的例子,当我们通过接口创建了一篇文章后,另一个获取文章的接口数据已经不是最新的了,如果我们想在 UI 尽快保持显示最新的数据,我们必须重新请求已经过期的那些数据对应的请求。然而异步请求散落在整个应用的不同地方,很难把它们都一起更新。

react-query 能很好地解决这些问题。

在 Next.js 中引入 react-query

pages/_app.tsx 中, 在顶层组件套上 QueryClientProvider

react-query 中,有两个关键的概念 —— QueryMutation.

Query

任何获取数据的请求都是 Query, 下面是用 react-query 获取所有文章数据的例子:

和之前的例子一样,在 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 的作用,他可以给请求一个标识,方便后续我们可以用这个标识对请求进行操作和优化。

Mutation

对资源进行修改的请求(POST, PUT, DELETE 等)都是 Mutation. 下面是在我们上面的例子中,加入了创建文章这个请求,像 useQuery 一样,用 useMutation() 嵌套这个请求方法。

Query 相同,Mutation 同样有 isLoading, error, data 这些状态。在点击 button 后,我们用 .muate() 方法执行这个请求,并且传入了一些变量,这些变量会被传递到方法 addPost 中。

我们还可以在 .muate() 中传入 onSuccessonError, 分别指定在请求成功和请求错误后执行的逻辑。比如弹一个 toast 之类的。

上面我们提到过,当用户创建了新文章后,理论上请求所有文章这个请求的数据就已经不是最新的了。这时如果我们想再次请求获取所有文章这个接口,可以在 onSuccess 中,手动地执行这个请求的 refetch 方法:

但这不是一个很好的实践,想像一下,这个获取文章的接口,有可能在这个应用中的多个组件用到,难道我们需要一个一个地执行 refetch 吗?当然不是。这时就要介绍 react-query 的一个重要功能 —— Query Invalidation.

Query Invalidation

Query Invalidation 的意思是「使请求失效」。我们可以用 queryClient 实例的 invalidateQueries() 方法,使某个 key 的请求失效。react-query 会自动重新请求失效的 Query.

我们用 queryClient.invalidateQueries('getPosts') 使用 key 为 getPosts 的 Query 失效,那么所有用到 key 为 getPosts 的 Query 都会被重新请求,并更新最新的数据到 UI 上。

Next.js 中缺失的技术

在 API route 中使用中间件

Next.js 没有中间件机制。首先让我简单解释一下什么是中间件,为什么我们需要中间件。

在 Express/Koa, 我们可以用中间件进入一个请求的生命周期,一个典型的中间件是一个函数,它接受 req, resnext 参数:

这个中间件的模式起源于一个 Node.js Web 框架 connect, 早期的 Express 也基于 Connect 开发,于是很多框架也兼容了这种模式,所以这种中间件模式我们通常称为 connect 中间件。

在中间件里,我们可以:

这使得中间件可以很好地使代码在不同的路由之间重用。假设我们需要在一个路由跟据 cookies 获取用户信息,我们可以把这个获取用户信息的方法写成中间件,然后把用户信息注入到 req.user,这样所以使用了这个中间件的路由可以通过 req.user 取得用户信息。而且在中间件中,如果判断用户没有登录,可以中止这个请求,并返回 403.

下面是 Express 编写和使用中间件的例子:

如果在 Next.js 要做同样的事,我们会这么做:

但在 Next.js, 我们没有任何办法中止请求。理论上 console.log('do other things') 在用户未登录时不应该被执行。

使用 next-connect

要在 Next.js 中像 Express/Koa 这样使用 connect 中间件,我们可以使用 next-connect 这个库。

安装 next-connect:

 

现在,让我们用 next-connect 重写上面的例子:

可以看到,现在我们在 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:

API route 错误统一处理

在实现 HTTP API 时,一个良好的实践应该在出现错误的时候响应错误对应的状态码。例如在资源不存在的时候返回 404,在没有权限时返回 403.

在一个复杂的 Web 应用,业务逻辑一般单独存在于 service (model) 层,例如一个根据 id 获取项目信息的 API:

project 找不到时,我们应该返回一个 404 的响应。但在 service (model) 层,我们很难接触到 res 对象,所以很难在 service (model) 层修改响应。也无法在这个层面中止响应。

我们来看看在 Express 中怎么做。Express 有自带的 error handler:,当 catch 到任何地方的 error 后,响应的格式会根据 error 的对象自动改变。也就是说,我们可以通过 throw error 来改变响应的信息:

上面的代码中,当 project 无法找到时,会抛出一个 { status: 404 }, Express 在捕获后,会把 res.statusCode 设为 404。

另一个流行的 web 框架 hapi 还提供了 @hapi/boom 这个模块,提供了很多 helper 用于抛出 HTTP 异常。例如,Boom.forbidden('Please sign in first') 会抛出一个这样的对象:

Boom 来改写我们上面的例子,可以变成:

然而,在 Next.js, 无论你在 API route 中 throw 一个怎样的 error, 它都会响应一个 500 Internal Server Error. 并且你根本没有方法统一处理这些 error.

幸运的是,我们在上一节介绍的 next-connect 提供了一个统一的 error 处理机制。使用 next-connect 的所有 api route 或中间件,如果抛出异常,都可以在 onError 中捕获到:

 

image-20210612044906647

因此,我们甚至可以借助 @hapi/boom 的力量,在 Next.js 中得到很好的错误处理机制:我们可以在 api route 或者中间件中抛出 Boom 异常,然后在 onError 中根据 Boom 异常的信息改变 res:

安装 @hapi/boom:

 

这样,在任何地方抛出了 Boom 异常,都可以得到对应的响应,而不需要手动修改 res 对象:

image-20210612045920643

访问数据库

在 Node.js 中访问数据库的方法有很多,优秀的 ORM 也有很多。但根据我自己的使用经验,在 Next.js 使用 Prisma 的体验还是最好的。

Prisma 是一个 TypeScript 友好的 Node.js ORM. 如果你没有听说过 Prisma, 在这里我可以用一句话介绍它:Prisma 让你用一个非常简洁易懂的 DSL 语法描述你的数据库结构和表之前的关系,然后自动生成 TypeScript 友好的请求 SDK.

在项目里接入 Prisma 很简单:

运行 yarn prisma init 后,会在项目里创建 prisma/schema.prisma 文件。Prisma 支持 MySQL, SQLite, PostgreSQL. 为了方便,我们在这里使用 SQLite. 只要把 schema.prisma 里的 provider 改成 sqlite即可:

url 指定了数据库链接串,用 SQLite 的时候,可以指定为文件的目录。

It means Prisma will use the SQLite database in ../db.sqlite.

定义数据模型

想像我们正在开发一个博客系统,我们需要三张表:Post, Comment, Tag:

image-20210612155159566

 

 

或许你的 SQL 能力不是特别强,写下这样的建表 SQL 可能比较吃力(即使你精通 SQL, 每次开发项目都要写一堆 SQL 也不是什么好事)。幸运的是,如果你使用 Prisma, 你不需要自己写这些 SQL, 只需要在 prisma/schema.prisma 文件中定义你的数据模型,以及它们之前的对应关系即可:

即使你从来没有学过 Prisma Schema 的语法,你还是可以从中大概看出数据模型,和他们之间的关系。

[Prisma Schema 语法文档

Schema 文件写好后,运行 yarn prisma db push. Prisma 会按照这个 Schema, 把数据模型应用到数据库中,它会处理好一对多,多对多的关系,帮助你生成外键、关系表等等。

数据库生成后,运行 yarn prisma generate, 这个命令会在 node_modules/@prisma/clinet 生成操作数据库的 SDK —— PrismaClient. 在代码中你可以直接引入使用:

image-20210612162004027

数据迁移(Migration)

yarn prisma db push 只能用于项目的原型阶段,因为一但数据库有改变,有可能导致数据丢失。正常的做法应该在每次修改完数据结构以后,生成一份迁移文件,在部署上线前执行这份迁移,安全地变更数据库结构。

Primsa 自带了生成 Migration 的功能:

迁移文件会生成到 prisma/migrations 中,当你需要把他应用到生产数据库时,执行:

Prisma Migrate 文档

在 Next.js 中使用 Prisma

在本地开发 Next.js 时,每次代码更改会导致热加载,导致 PrismaClient 一直被重复地实例化,造成出现重复的数据库连接。

我们应该让 Prisma 保持是一个单例,把它缓存到 global 对象中: