NextJs - 服务端/客户端组件之架构多样性设计
- 前言
- 一. 架构设计
- 1.1 SSR+流式渲染常见错误设计之 - 根页面同步阻塞
- 1.2 架构设计之 - 客户端组件依赖于服务端组件数据
- ① 使用 Redux 完成数据共享
- 1.3 架构设计之 - 单页内的分步骤跳转
- ① 如何做到服务端组件和客户端组件之间的切换
- ② 进行UI切换的时候如何做到状态保持
前言
本篇文章主要讲解不同场景下,我们怎样去设计客户端和服务端组件的交互,或者是怎么去写代码。本篇文章建立于:使用SSR
渲染+Suspense
流式渲染,并且服务端/客户端组件混合使用的基础上讲解的。
一. 架构设计
我们知道,NextJs
的APP
路由模式下,在对应目录下创建一个page.tsx
文件,他就会生成对应的路由,我们可以称page.tsx
为根页面。
在此基础上,我们说下基本准则:
- 根页面(
page.tsx
)一般作为服务端组件,我们常用于获取一些上下文变量。 - 切记不可让根页面作为同步请求获取数据的地方,否则整个页面就会同步阻塞,等待请求返回才能开始渲染。
我们接下来先做个简单的讲解。
1.1 SSR+流式渲染常见错误设计之 - 根页面同步阻塞
在刚开始接触Nextjs
这类具备SSR
渲染的框架的时候,可能容易写出这样的代码:
- 我们在
page.tsx
根页面中同步阻塞获取接口数据,然后将数据通过Props
的形式传递给子组件 - 子组件可能是服务端组件、客户端组件。如图:
这种写法,从逻辑上它并没有任何问题,但是在Suspense
流式渲染的场景下,就没有任何意义。因为阻塞的动作发生在服务端,也就是说:
- 必须阻塞所有的异步接口返回,我们的服务器才会开始渲染组件。
- 哪怕我们的子组件使用
Suspense
包装,也没有任何作用。 - 我们的页面打开来就会白屏阻塞,阻塞时间取决于这个异步接口的等待返回时间。
正确设计如下:
- 我们让异步请求的逻辑,封装在一个粒度尽可能小的服务端组件中,然后使用
Suspense
包装这个服务端组件。 - 这样我们的页面,就不会因为这个请求发生阻塞。就会从上到下,依次渲染相关的组件,而使用
Suspense
包装的,就会返回对应的fallback
效果。
倘若在此基础上,我们的客户端组件,需要用到服务端组件中获取的数据,怎么交互?
1.2 架构设计之 - 客户端组件依赖于服务端组件数据
在上述架构图中,我们可以发现,我们的服务端组件是和客户端组件同一层级的。那么同一层级的就无法采用Props
的方式传递数据。
那么就可能有读者想:那如果我的客户端组件封装到服务端组件中不就好啦?如图:
如果这么做:我们的客户端组件就会随着服务端组件同时具备Suspense
效果,也就是客户端组件必须等待异步请求返回后才能完成渲染。 但是这样的设计是不合理的,因为我们的客户端组件的渲染不应该等待数据返回再完成渲染。
大家别忘了,我们的客户端组件是可以具备State
动态效果的,也就是可以使用useState
这样的勾子函数。因此我们可以做到立刻渲染客户端组件,让相关的数据通过State来传递,完成动态渲染。
那么我们如何做到服务端和客户端组件的数据共享呢?
① 使用 Redux 完成数据共享
我们服务端组件,拿到接口数据后,可以将它丢给一个专门的用于存储State
的客户端组件,这里我们称之为Context Compoent
。它的作用就是:
- 接收服务端传递的接口数据。
- 将接口数据保存在
Redux
中。
这么做的好处:
- 服务端组件的内部渲染,可以直接依赖于接口数据编译为
HTML
,但是切记服务端组件往往只用来做展示,不具备任何的交互(onChange
事件),同时服务端组件一般又通过Suspense
封装,可以完成loading
效果。 - 客户端组件几乎不受服务端组件影响,可以立刻完成渲染,将最基本的UI呈现给用户,而页面相关的数据来自于
Redux
。当ContextComponent
将服务端数据存储到Redux
中后,客户端组件自动完成动态渲染。
备注:这样的架构设计一般能满足大多数的开发需求,当然可能有更好的设计,这里只不过提供一种思路。
1.3 架构设计之 - 单页内的分步骤跳转
那么在这个架构设计基础上,倘若我的页面有这样的功能:
- 页面加载完毕之后,呈现第一页。
- 第一页可以点击:“下一步”,跳转到第二页(同一个
URL
) - 第二页还能够返回到:第一页。同时保持第一页的状态(例如
Checkbox
的勾选、Input
框的内容)
这个功能也就是单页内的分步骤跳转,说白了就是使用同一个URL
,但是具有多页效果。下一页的时候,上一页的状态还要保持。只不过UI呈现的是第二页。
但是想要实现单页内的分步骤跳转,有好几个问题需要解决:
- 我的首屏UI(第一页)是通过
SSR
渲染的,怎么做到下一步的时候,把第一页UI
切换到第二页的UI
?(别忘了,服务端组件是不具备State
效果的) - 如何控制
Redux
的初始化动作只做一次?
① 如何做到服务端组件和客户端组件之间的切换
1.我们在根页面下引入一个RoutePage
页面(客户端组件),然后将服务端组件通过Props
传递下去:
import ServerComponent from "./ServerComponent";
import RoutePage from "./RoutePage";
const Parent = () => {
return <>
<RoutePage slot={<ServerComponent/>}/>
</>
}
export default Parent
RoutePage
组件专门用来做UI
切换的,也就是控制渲染第一页还是第二页,然后使用Redux
来获取全局的状态,我们用一个变量来代表当前是第几页(因为本案例只有两页,就用isServer
来表达了)
'use client';
import ClientComponent from "./ClientComponent";
import { ReactNode } from "react";
const RoutePage = ({ slot }: { slot: ReactNode }) => {
// 假代码
const context = useRedux(testState);
return <>
{/* 如果当前是第一页,就渲染服务端组件,否则渲染客户端组件 */}
{context.isServer ? { slot } : <ClientComponent />}
</>
}
export default RoutePage;
那么isServer
的初始值我们设定为true
,就做到首屏渲染服务端组件了。我们只要在客户端组件和服务端组件中维护这个State
即可完成UI
的切换。
设计结构如下:
备注:
- 服务端组件中需要引入额外的一个客户端组件,专门用来控制
State
。不能在服务端组件中控制State
哦。
② 进行UI切换的时候如何做到状态保持
试想一下,第一页首屏加载的时候,数据必定来自于服务端,服务端组件里面会引用一个ContextComponent
组件,每次渲染的时候都会初始化一遍数据。 假设这里是数据A
倘若第一页有个按钮:加载更多数据。它会发送请求,拉取更多的数据然后呈现在页面上,假设这里获取的数据是:数据B
。
那么此时第一页呈现的数据是 数据A
和 数据B
的一个并集:数据C
。那么问题来了:当我们点击下一步,呈现第二页,再次返回第一页的时候,会做什么操作?
- 第一页重新触发渲染(但是这里不会触发服务器的
SSR
渲染),此时服务端组件通过Props
传递的初始数据:数据A 还在,会重新赋值给Redux
。即导致数据A
会覆盖数据C
。 - 那么回到第一页后,之前的数据就被覆盖了,状态也就被刷掉了。
因此我们需要控制,Redux
的初始化赋值动作只执行一次。
这个就比较好解决了,我们只需要在Redux
中增加一个变量:hasLoadedSSR
一类的标识,代表我们已经SSR
渲染过一次了,在Redux
赋值的时候加个判断即可,以下是ContextComponent
伪代码:
'use client';
const ContextComponent = (props)=>{
const context = useRedux(testState)
const dispatch = useDispatch();
const {data} = props;
// Redux初始化,如果没有经历过SSR,就完成初始化赋值
if(!context.hasLoadedSSR){
dispatch({context : {
...data,
// 再将标识赋值为true
hasLoadedSSR: true
}})
}
}
这样就能防止每次UI切换的时候,初始化状态覆盖当前状态的问题了。