如何在一周内使用 Next.js 构建 SaaS
更新:我写了一本关于使用 Next.js 构建全栈应用程序的小书https://fullstack-nextjs-in-action.taonan.lu/
我在 2021 年 4 月创建了Cusdis,这是一个开源、轻量级、隐私友好的 Disqus 替代品。我只用了一周时间就完成了它,并在HackerNews上引起了大量讨论,甚至登上了 HackerNews 的头版。
在这篇文章中,我想分享我是如何构建 Cusdis 的,以及我在这个项目中学到的东西。特别是一些使用 Next.js 高效构建应用程序的经验。
我怎么会想到这个主意
我在我的个人博客中使用了 Disqus。它运行良好,但有时加载整个评论线程会花费太多时间。有一天,我看到了一篇文章,讨论了 Disqus 的隐私问题和加载速度慢的问题。这让我萌生了构建一个 Disqus 替代方案来解决这些问题的想法。我把它命名为 Disqus 的反面——Cusdis。
系统设计
我认为一个最简约的评论系统至少包含三个部分:
- 一个数据库,用于存储线程信息、评论。
- 可以嵌入到网站的前端小部件。
- 用于管理(批准、回复、删除)评论的仪表板。
- 为小部件提供 API 并作为仪表板后端的服务器。
数据库
在我看来(我不是数据库专家),SQLite 足以实现评论系统。但由于我想要一个托管服务,SQLite 并不适合无服务器环境和并发用例。PostgreSQL 虽然适合这种情况,但对于自托管用户来说过于复杂。最终我决定:
- 对于自托管用户,默认使用 SQLite。他们无需设置 PostgreSQL 实例即可使用 Cusdis
- 提供使用 PostgreSQL 的选项(适用于自托管用户和我们的托管服务)
在 Node.js 中,我选择Prisma来 CURD 数据库。这是因为 Prisma 使用优雅的 schema 语法来描述数据模型,例如:
model User {
id String @id @default(uuid())
username String
password String
bio String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String
title String
userId String
user User
createdAt DateTime @default(now())
}
即使你没有听说过 Prisma,你大概也可以通过这个模式文件了解数据库是什么样的。
运行时,Prisma 会生成一个 js 客户端来查询数据库npx prisma generate
。这个 js 客户端是经过格式化的,以便于你获取智能感知。
使用 Next.js 作为仪表板和 API 服务器
Next.js 是一个非常优秀的全栈应用构建框架。你无需设置前端和后端,Next.js 会帮你搞定一切。
在 Next.js 中使用 Prisma
在 Next.js 中使用 Prisma 与在 Node.js 中使用 Prisma 并无区别。唯一需要注意的是,在开发环境中,Next.js 在热重载时会清除缓存。New 函数PrismaClient
会被多次初始化,这将引发警告:
warn(prisma-client) Already 10 Prisma Clients are actively running.
为了解决这个问题,我创建了一个singleton
助手来创建一个单例实例:
export const singleton = <T>(id: string, fn: () => T) => {
if (process.env.NODE_ENV === 'production') {
return fn()
} else {
if (!global[id]) {
global[id] = fn()
}
return global[id] as T
}
}
此辅助函数将实例缓存在 上global
,这在 Next.js 中不会清晰可见。我使用此辅助函数创建了一个单例prisma
,它可以从任何地方导入,而不必担心重复的数据库连接。
export const prisma = singleton('prisma', () => {
return new PrismaClient()
})
在 API 路由中使用中间件
我使用next-connect来使用类似 connect 的中间件。Next.js 中的传统 API 路由处理程序如下所示:
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
// ...
} else if (req.method === 'POST') {
// ...
}
}
有了next-connect
,它就变成:
import nc from "next-connect"
export default nc()
.use(someMiddleware())
.get(async (req, res) => {
// ...
})
.post(async (req, res) => {
// ...
})
因此我可以编写一个身份验证中间件,user
在其中注入一个对象req
:
function auth(req, res, next) {
const user = await getUser(req.cookies.token)
req.user = user
next()
}
export default nc()
.use(auth)
.post(async (req, res) => {
console.log(req.user)
// ...
})
处理 API 路由错误
我以前觉得 Next.js 的错误处理很困难。但next-connect
现在它改变了一切。我可以用一个 catch-all 错误处理程序来处理 api 路由处理程序中的所有错误,onError
例如:
// src/pages/api/foo.ts
const handler = nc({
onError(err, req, res, next) {
console.error(err)
next()
}
});
export handler
.get(async (req, res) => {
throw new Error('foo')
})
它使错误处理更加容易,因为我可以应用异常过滤器模式:
将统一的错误对象抛到任何地方并在一个地方处理它。
我选择@hapi/boom作为统一的 HTTP 异常对象。我可以Boom
在 API 路由中抛出异常,并在以下位置进行处理onError
:
import * as Boom from '@hapi/boom'
export const apiHandler = () => nc({
onError(err, req, res, next) {
if (Boom.isBoom(e)) {
res.status(e.output.payload.statusCode);
res.json({
error: e.output.payload.error,
message: e.output.payload.message,
});
} else {
res.status(500);
res.json({
message: "Unexpected error",
});
console.error(e);
// unexcepted error, catch with Sentry, etc.
}
}
});
apiHandler()
.get(async (req, res) => {
const post = await getPost(req.query.postId)
if (!post) {
throw Boom.notFound(`Post not found`)
}
})
我编写了一个
apiHandler
返回next-connect
实例的方法。
在上面的例子中,我Boom.notFound
在api路由处理程序中抛出了一个异常,这个异常将被捕获onError
,并设置相应的http响应状态代码和消息。
一分钟内设置身份验证
在托管服务中,我不想实现自己的用户系统,因为这会很复杂。我更喜欢通过 OAuth 使用第三方登录。
next-auth是 Next.js 的身份验证解决方案。我按照Prisma 适配器指南操作,只需一分钟即可集成 GitHub 提供程序!我只需创建一个 GitHub OAuth 应用程序,并在 中设置 next-auth 提供程序即可src/pages/api/[...nextauth].ts
。
在 API 路由中,我可以直接调用getSession()
来获取当前用户。还记得我们可以在 API 路由中使用中间件吗next-connect
?以下是如何通过中间件保护 API 路由的:
// middleware
import { getSession } from 'next-auth/client'
import * as Boom from '@hapi/boom'
export async function authGuard(req, res, next) {
const user = await getSession({ req })
if (!user) {
throw Boom.forbidden('Please sign in first')
}
req.user = user
next()
}
然后,我可以authGuard
在任何需要保护的 API 路由中使用这个中间件:
// src/pages/api/foo.ts
apiHandler()
.use(authGuard)
.get(async (req, res) => {
console.log(req.user)
res.json({})
})
安全的前端页面
如果未登录则无法看到整个页面,我会检查 中的会话getServerSideProps
。如果session
是null
,则返回重定向到登录页面:
import { getSession } from 'next-auth/client'
export default PageOne() {
return (
<div>
Not visible
</div>
)
}
export const getServerSideProps = (ctx) => {
const session = await getSession({ req: ctx.req })
if (!session) {
return {
redirect: {
destination: '/api/auth/signin',
permanent: false,
}
}
}
return {}
}
评论小工具
Cusdis 的主要目标之一是轻量级。我为这个组件设定了一个包大小“预算”,即嵌入到用户网站的 SDK 以及 SDK 加载的资源必须小于 10kb。
在这种情况下, Svetle是最佳选择。它不附带运行时,而是将组件编译为原生 JavaScript。
令我惊讶的是,最终的全功能 SDK 只有 5kb(gzip 压缩后)。
— Randy (@randyloop) 2021 年 4 月 28 日
小部件源代码
在第一个 SDK 版本中,SDK 会将小部件元素附加到用户的网站。起初效果不错。但问题是,用户网站的样式表会影响小部件组件中的样式。此外,我们必须在设置元素样式时为类名添加前缀,以防止我们的样式影响用户的网站。
最佳做法是将 Widget 组件放入 iframe 中。一切正常,除了一个问题:
如何根据内容高度自动设置iframe的高度?
iframe 的高度在创建时是固定的,无论其中的文档如何变化。为了使其高度自动设置为文档高度,我MutationObservable
在 iframe 中使用了 来观察文档的任何变化,当发生更改时,它postMessage
会将新的 通知给父窗口offsetHeight
,然后父窗口会更改 iframe 的高度:
// in iframe
function requestResize() {
window.parent.postMessage({
event: 'resize',
offsetHeight: document.documentElement.offsetHeight
})
}
const resizeObserve = new MutationObserver(() => {
requestResize()
})
resizeObserve.observe(target, {
childList: true,
subtree: true
})
// in main window
window.addEventListener('message', e => {
if (e.data.event === 'resize') {
iframe.style.height = `${e.data.offsetHeight}px`
}
})
我不确定这是否是最好的实现。如果你有更好的想法,请留言。
处理时区
评论创建时,其创建日期将以当前 UTC 时间的形式保存在数据库中。世界各地的用户在小部件中看到的是相同的时间,但并非他们当地的时间。
例如,当来自中国(UTC+8)的用户看到2021-04-28 10:00
(in UTC)时,他以为该评论是在北京时间创建的,2021-04-28 10:00
但事实并非如此。该评论是在2021-04-28 18:00
北京时间创建的。
这意味着当我们显示时间时,我们需要先根据用户的 UTC 偏移量进行转换。
幸运的是,浏览器中有一个 API getTimezoneOffset()
,可以获取 UTC 偏移值。我通过 HTTP 请求头发送了这个值:
import axios from 'axios'
export const apiClient = axios.create({
headers: {
'x-timezone-offset': -new Date().getTimezoneOffset()
}
});
const timezoneOffset = req.headers['x-timezone-offset']
const parsedCreatedAt = dayjs.utc(comment.createdAt).utcOffset(timezoneOffset).format('YYYY-MM-DD HH:mm')
使用 Vite 捆绑 SDK
SDK 应该打包在一个 JS 文件中。我选择Vite是因为它速度很快,而且内置了 PostCSS 支持。换句话说,我不需要关心打包工具,我唯一使用的插件是rollup-plugin-svelte
用于编译 Svelte 组件的:
// vite.config.js
module.exports = {
build: {
lib: {
entry: path.resolve(__dirname, '..', 'widget', 'sdk.js'),
name: 'cusdis',
},
outDir: path.resolve(__dirname, '..', 'widget', 'dist'),
},
plugins: [
require('rollup-plugin-svelte')({
emitCss: false,
}),
],
}
总结
我花了一周时间才在Cusdis上发布了第一个版本。现在大约有 50 个活跃网站使用 Cusdis 作为他们的评论系统。超过 10 人赞助了这个项目。所有这些工具的结合,让我能够如此快速地构建一个全栈 SaaS 项目:
- next-auth一分钟集成第三方登录
- next-connect在 Next.js 中添加中间件层,并附带更好的错误处理解决方案
- prisma生成类型化 ORM sdk
- chakra-ui编写可用且美观的 UI
- react-query处理 HTTP 请求
我还使用这些出色的工具制作了一个样板,以便您可以在一分钟内启动您的下一个 SaaS 项目!
文章来源:https://dev.to/djyde/how-i-built-a-saas-with-next-js-in-a-week-3jli