Nextjs的实践 - 豆米的博客

发表于 2024-03-28
更新于 2024-05-23
分类于 技术专栏
阅读量 125
字数统计 12512

1、背景

原有的博客网站采用malagu前后端一体化框架,前端静态文件部署在CDN上,服务端部署在ECS上,所有的请求都是走的jsonRPC的方式,这导致了整体的博客内容无法SEO,并且首屏加载时间很慢,得先请求到前端静态资源之后才可以请求接口,因此本次重构就是为了解决上述的问题。其实博客网站的整体重构,算上这次,已经两次了,最开始的版本是Sailjs + Jquery,之后Malagu + React,到如今的Nextjs。

整体代码开源:https://github.com/linxiaowu66/doumi-nextjs

2、开始开发

首先我们按照官网给的初始化方式,初始化一下整体的代码结构(我们使用官方推荐的APP Router):

npx create-next-app@latest

PS:关于新版Next.js的APP Router和Page Router的区别,看下面的表格

FeatureApp RouterPages Router
Routing typeServer-centricClient-side
Support for Server ComponentsYesNo
ComplexityMore complexSimpler
PerformanceBetterWorse
FlexibilityMore flexibleLess flexible
初始化完成之后,开始将旧版的代码迁移到Next.js上,组件库我们继续使用@mui,升级到最新版本。

2.1、开始迁移

目录结构如下:(为了展示,有所删除)

1. 2├── app 3│ ├── about 4│ │ ├── blog -> /about/blog 路由 5│ │ │ ├── Timeline // 私域内的组件,不会形成路由,下同 6│ │ │ └── page.tsx // 主渲染文件,名字一定得是page,下同 7│ ├── api 8│ │ ├── archive 9│ │ │ └── list -> /api/archive/{method},api路由 10│ │ │ └── route.ts // api接口处理文件,名字一定得是route 11│ │ ├── auth 12│ │ │ ├── [...nextauth] -> /api/auth/[...nextauth],捕获/api/auth/x/xx 后面的所有路由 13│ │ │ │ └── route.ts 14│ ├── auth 15│ │ └── [type] -> /auth/:type,捕获/auth/login(register)的路由 16│ │ └── page.tsx 17│ ├── layout.tsx // 主应用的布局文件,后续会拆分两个 18│ ├── page.tsx // 主应用入口 19│ └── providers.tsx 20├── database // 数据库的定义以及实例初始化 21│ ├── entities 22│ └── index.ts 23├── features // 存放所有公共的业务组件 24│ ├── BlogConfig 25├── logger // 服务端日志服务 26│ └── index.ts 27├── middleware.ts // 服务端中间件,用来中间鉴权 28├── service // 服务端原子服务 29├── theme.ts // 主题定义 30

上述的目录结构涉及到Next.js的路由定义的部分知识,这里讲解一下:

2.1.1、Pages和Layouts

每一个UI页面的路由下面都可以创建layout.js、page.js、template.js三个文件来形成我们想要表达的UI界面;因为支持嵌套Layout,所以如果声明多个,渲染的结果会是这样:

至于template.js文件,比较特殊(此次实践没有用到该能力),按照官网的解释:

与Layout持续跨路由和保存状态不同,template为导航上的每个子级创建一个新实例。这意味着当用户在共享模板的路由之间导航时,一个新实例组件将被挂载,DOM 元素将重新创建,不保留状态,并且重新同步效果。 在某些情况下,您可能需要这些特定行为,而模板将是比布局更合适的选择。例如: 依赖于 useEffect (例如记录页面视图)和 useState (例如每页反馈表)的功能。 更改默认框架行为。例如,布局内的 Suspense Boundaries 仅在第一次加载布局时显示回退,而不是在切换页面时显示回退。对于模板,回退可以显示在每次路由切换上。

官网提到的局部加载以及流式传输,这次的博客也暂时没机会用到;

2.1.2、动态路由

上述路由的/auth/:type用的就是动态路由,比如/auth/login就能够匹配上该路由,并且可以从page.tsx文件中获取对应的参数:

1import { useParams, usePathname } from "next/navigation"; 2 3// 使用Hook,这个只能在前端使用 4const params = useParams<{ type: "login" | "register" }>(); 5// 获取path 6const pathname = usePathname(); 7 8// 或者直接在props里面传进来,这个在后端使用 9export default function Page({ params }: { params: { slug: string } }) { 10 return <div>My Post: {params.slug}</div> 11}

还有另外一种是直接捕获命中前缀之后的所有路由格式:/api/auth/[...nextauth],这个路由就可以捕获所有带有前缀/api/auth的路由,比如:/api/auth/callback/credentials/api/auth/session,如果把中括号改成两个中括号[[...nextauth]],那么这个匹配就是可选的,可以匹配上/api/auth这样的路由

2.1.3、平行路由

平行路由允许在同一个layout之下同时或者条件渲染一个或者更多的页面。类似于vue的slot理念,在某些条件下使用插槽A,某些条件下使用插槽B。如下图,文件命名是使用@folder的格式,目前的博客貌似可以改成这样的结构

2.1.4、拦截路由

拦截路由的意思就是可以做到不跳转页面的情况下,也能够查看别的页面。Nextjs通过提供诸如(.)这种关键词,来帮助你匹配找到你想跳转的路由界面,这个功能一般都是和上面的平行路由一起使用,官方提供了一个例子:nextgram,一看就能明白是做了个啥

2.1.5、路由处理

除了使用默认的page.js来处理界面渲染,我们还可以使用RequestResponse的API自定义创建一些处理函数,也就是api请求,上述结构中的 /api/archive/{method}就是这样的例子。

在使用route的过程中,我们遇到一些问题的解决,收集在这里:

  • 解析query参数,可以使用:req.nextUrl.searchParams.get("slug")
  • 获取登录用户的session,可以使用:getServerSession(authOptions);

2.2、迁移后的写法改变

Nextjs区分服务端组件和客户端组件,默认都是服务端组件,并且支持服务端组件里面嵌入客户端组件,因此在代码编写的时候就需要格外注意,哪些组件是服务端组件,哪些是客户端组件。

举个🌰,博客首页有个热门文章:

我们SSR肯定想着可以进行后端直接获取数据后渲染进去的,于是我们就写成这样:

1export default async function Home() { 2 // 这里直接调用后端服务获取最热门的文章 3 const res = await queryArticles(1, 5, JSON.stringify({ pv: "DESC" })); 4 5 return ( 6 <Container maxWidth="md" className={styles.homeContainer}> 7 <DouMiIntroduction avatarSize={120} fontSize={16} /> 8 <DoumiLinks /> 9 <HottestArticles list={res.list} /> 10 <FootPrint /> 11 </Container> 12 ); 13}

这样在获取首页的时候就可以看到,页面已经是同构直出了:

但是并不是所有的页面都是可以这样做的,只要涉及到状态报错的、人机交互的、事件监听的,你都必须加上use client这个声明,以表示该组件是客户度渲染的组件。

但是一个页面是可以做到服务端组件与客户端组件共同交错使用的,比如博客列表页面,它是一个分页的页面,存在人机交互行为,但是我们又想在首次加载的时候,第一页数据可以直接从服务端直出的,于是我们在写的时候就会这样:

1const BlogList = async () => { 2 // ...省略一些代码 3 // 后端直接获取前6篇文章,作为初始化参数给BlogPagination组件 4 const result = await queryArticles( 5 1, 6 6, 7 null, 8 queryTag, 9 queryArch, 10 queryCat, 11 "published" 12 ); 13 14 return ( 15 <BlogContainer contentClass={styles.blogListWrapper}> 16 <BlogPagination 17 initialPosts={result} 18 queryArch={queryArch} 19 queryCat={queryCat} 20 queryTag={queryTag} 21 /> 22 </BlogContainer> 23 ); 24};

之后BlogPagination组件初始化的时候接收这个初始化列表:

1"use client"; 2import { DouMiBlog } from "@/interface"; 3import { Pagination } from "@mui/material"; 4import { useState } from "react"; 5import BlogItem from "../BlogItem"; 6import axios from "axios"; 7import styles from "./index.module.css"; 8 9export default function BlogPagination({ 10 initialPosts, 11 queryArch, 12 queryCat, 13 queryTag, 14}: { 15 initialPosts: { 16 list: DouMiBlog.ArticleBrief[]; 17 currentPage: number; 18 pageCount: number; 19 }; 20 isLogin?: boolean; 21 queryArch?: string | null; 22 queryCat?: string | null; 23 queryTag?: string | null; 24}) { 25 const [blogList, setBlogList] = useState<DouMiBlog.ArticleBrief[]>( 26 initialPosts.list 27 ); 28 const [pageCount, setPageCount] = useState(initialPosts.pageCount); 29 const [currentPage, setCurrentPage] = useState(initialPosts.currentPage); 30 31 // ... 省略一些代码 32} 33

最后当访问列表页面的时候,就可以看到我们第一页的数据其实已经跟着html渲染回来了,减少了一次请求,整体页面也做到了秒出;

2.3、集成数据库

原先的博客使用的typeorm+mysql,为了不再折腾,继续复用这个组合;那么摆在我们目前的一个问题是,正常nodejs服务器启动的时候,是需要创建连接实例化的,但是nextjs没有提供这样的入口给我们呀?怎么办?

于是我们取巧,借助nodejs中关于模块的加载原理,在database文件夹中创建index.ts文件,将实例化缓存在该文件:

1const AppDataSource = new DataSource({ 2 type: "mysql", 3 host: process.env.DB_HOST, 4 port: 3306, 5 username: process.env.DB_USER, // 替换成你自己的用户名 6 password: process.env.DB_PASS, // 替换成你自己的密码 7 database: "douMiBlog", 8 synchronize: false, 9 logging: false, 10 entities: [Archive, Article, Category, Reader, Tag, User, Website], 11 subscribers: [], 12 migrations: [], 13}); 14 15export const getDataSource = (delay = 3000): Promise<DataSource> => { 16 if (AppDataSource.isInitialized) return Promise.resolve(AppDataSource); 17 18 return new Promise((resolve, reject) => { 19 setTimeout(() => { 20 if (AppDataSource.isInitialized) resolve(AppDataSource); 21 else reject("Failed to create connection with database"); 22 }, delay); 23 }); 24};

一开始我是直接export AppDataSource出去给应用方使用的,但每次重启服务器后第一次使用都会报:EntityMetadataNotFoundError的错误,很明显,当应用要使用的时候,该实例其实还没初试完的,因此加上getDataSource保证实例是可用的。

之后,在所有地方就可以正常这么使用了:

1const AppDataSource = await getDataSource(); 2const repo = AppDataSource.getRepository(Archive); 3 4const result = await repo.find({ relations: ["articles"] });

一开始使用mysql,会报错Client does not support authentication protocol requested by server; consider upgrading MySQL client,发现mysql包好几年没更新了,有个新的适配包mysql2,更新一下,问题解决~~

2.4、集成next-auth

数据对接ok之后,开始接入认证模块,我们使用了NextAuth.js。按照官方文档正常接入即可,没遇到太多问题,期间会用到中间件的能力,刚好介绍一下中间件吧。

2.4.1、中间件

Nextjs的中间件运行在缓存内容和路由匹配之前,可以基于入口的请求,对响应进行重写,重定向,修改请求或者响应的头部信息,比如我就在中间件里埋了个x-url的数据,记录请求的url:

1 const requestHeaders = new Headers(req.headers); 2 requestHeaders.set("x-url", req.url);

之后在渲染文件中可以这样直接取出header里面的参数:

1import { headers } from "next/headers"; 2 3... 4const heads = headers(); 5const url = heads.get("x-url");

我们还可以api请求的结果进行整体包装,而不需要每个api请求都返回一样的数据结构,比如:

中间件还允许针对某些api才启用,可以在middleware.ts文件中增加:

1export const config = { matcher: '/api/:function*',}

PS:目前中间件最不好的地方就是不支持Nodejs运行时,而只能用在边缘计算运行时,什么意思呢?就是Nodejs的一些模块,比如fspath等模块都是在中间件文件里面不能够直接require的。

2.4.2、NextAuth.js

我们使用jwt的方式来做鉴权校验,nextAuth直接使用一个配置生成一个通用的handler来处理所有/api/auth的请求:

1// 有删减,完整的参考博客源码 2export const authOptions: NextAuthOptions = { 3 session: { 4 strategy: "jwt", 5 maxAge: 24 * 60 * 60, // 24 hours 6 }, 7 // 必须加这个,否则会报错:'JWEDecryptionFailed: decryption operation failed\n' 8 secret: process.env.NEXTAUTH_SECRET, 9 callbacks: { 10 // session会把下面的user信息塞到/api/auth/session里面,前端就会拿到用户信息,后端使用getServerSession拿到的session数据也是{id, email},尝试追加更多信息,做不到! 11 session: async ({ session, token }) => { 12 session.user.id = token.uid as number; 13 session.user.email = token.email as string; 14 return session; 15 }, 16 jwt: async ({ user, token }) => { 17 if (user) { 18 token.uid = user.id; 19 token.email = user.email; 20 // TODO: 如何加入用户名字? 21 } 22 return token; 23 }, 24 }, 25 providers: [ 26 CredentialsProvider({ 27 credentials: { 28 email: { label: "Email", type: "email" }, 29 password: { label: "Password", type: "password" }, 30 }, 31 // /api/auth/login会进入这里的函数 32 async authorize(credentials): Promise<any> { 33 const { email, password } = credentials ?? {}; 34 if (!email || !password) { 35 throw new Error("邮箱或者密码必填,请重新输入"); 36 } 37 const AppDataSource = await getDataSource(); 38 const user = await AppDataSource.getRepository(User).findOne({ 39 where: { 40 email, 41 }, 42 }); 43 // if user doesn't exist or password doesn't match 44 if (!user || !(await compare(password, user.password))) { 45 throw new Error("邮箱或者密码不正确,请重新输入"); 46 } 47 return user; 48 }, 49 }), 50 ], 51}; 52 53const handler = NextAuth(authOptions); 54 55// 拦截GET/POST请求,统一都是handler函数通吃 56export { handler as GET, handler as POST };

在前端使用const res = await signIn("credentials", { ...data, redirect: false });的方式进行登录请求,里面的redirect参数是表示如果登录失败了,也不要重定向。

目前这里还有一点问题是,如果失败了,NextAuth直接throw Error,返回非我们规定的数据结构,而是这样的一串:

1http://localhost:3000/api/auth/error?error=邮箱或者密码不正确,请重新输入

这样导致前端都无法规范化处理,后续看看怎么解决?

2.5、集成winston

数据和权限集成ok之后,就离部署不远了,这个时候我们需要加下服务端日志,以打印一些重要的节点。

src/logger下新建主文件,引入winstonwinston-daily-rotate-file,按照正常的配置即可;之后导出logger实例,在任意文件(Server Component)使用即可;

1import { logger } from "@/logger"; 2 3logger.info( 4 `search blog list with query condition: ${JSON.stringify(finalQuery)}` 5 );

2.6、部署前的准备

快要开发完了,需要设置一些环境变量,Nextjs的环境有点意思,容易让人混淆,这里需要特意提一下。

Nextjs默认使用.env.local环境变量,因此只要有.env.local存在的话,你其他环境定义的环境变量都会被其覆盖;但是如果有些公共配置,可以放在.env下面,因此整体体验下来,最好的配置应该是:

1.env // 公共配置 2.env.development // 开发环境配置 3.env.production // 线上配置

3、线上编译遇到的问题

开发完成了,准备部署,部署之前需要编译,编译的时候就发现报错了:

3.1、TypeORM又报错了

1initialization TypeORMError: Entity metadata for r#articles was not found. Check if you specified a correct entity object and if it's connected in the connection options. 2

从错误提示发现,代码被混淆了,导致Typeorm的Entity找不到了。Nextjs看样子是对所有的代码进行预编译和预执行了,这个错误全网搜了好久,终于发现有个配置解决:

next.config.mjs文件中增加如下配置,让代码不要进行压缩混淆即可:

1experimental: { 2 serverComponentsExternalPackages: ["typeorm"], 3 serverMinification: false, // 不能压缩,一压缩在生产环境下就会报 xx#Aritcles metadata not found,本质原因就是代码被混淆压缩了导致的 4 },

3.2、预渲染页面报错

所有的api预渲染都报错了如下的错误:

1Error occurred prerendering page "/api/archive/list". Read more: https://nextjs.org/docs/messages/prerender-error 2

想了一下api的接口没必要预渲染呀,那为啥会进行预渲染?预渲染应该只只对静态的页面的,于是又阅读了官网文章,发现# Route Segment Config有提及到允许开发者配置PageLayoutRoute Handler的行为。也就是可以对其进行强制动态或者强制静态的配置。因此我们在所有绝对动态的api请求中全部加入:force-dynamic

export const dynamic = "force-dynamic";

其表达的意义是:强制动态渲染,每个用户在每次请求的时候重新渲染,不使用任何缓存,不使用任何# Revalidating的能力;

4、继续探索

Nextjs还有很多高阶的能力目前没有体验到,包括:

  • 错误处理
  • 流式传输
  • 缓存(使用fetch
    • 缓存数据避免每次请求的时候都要重新请求一次
    • 重新校验数据,为了每次能够获取最新的数据
    • 不使用fetch的话,也可以使用cache,这块可以进行针对性优化
  • Server Actions
  • 优化,比如图片、字体等
  • 平行路由+拦截路由+路由组(就是将一些共同的路由放在一起,共享一些比如layout之类的,有点类似于通过目录聚合,但是该目录名称不会作为路由解释)
  • Layout嵌套+template能力

后续会持续对博客进行相关优化,优化的记录也会产出对应的文章;敬请期待~

公众号关注一波~

微信公众号

关于评论和留言

如果对本文 Nextjs的实践 - 豆米的博客 的内容有疑问,请在下面的评论系统中留言,谢谢。

网站源码:linxiaowu66 · 豆米的博客

Follow:linxiaowu66 · Github