其实这次有点标题党,但确实是我在写小玩具的过程中遇到的问题。
省流回答:next.js 对 component 的机制,无论是 client 还是 server。
问题如下:
我想要写一个 client 组件,这个组件里有一个按钮,组件维护一个 websocket 连接,点一下按钮往 websocket 发送一段文本。
于是我这么写的:
const generateWebsocket = () => {
return new WebSocket(url);
};
const ClientComp = () => {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
setWs(generateWebsocket());
return () => {
ws?.close();
};
}, []);
return (
<div></div>
);
};
结果很成功,服务端收到了连接。
但是我想去掉 useEffect() 里面的 ?.
操作符,这样看比较优雅(?),所以我改成了这样,使得 ws 不会为 null
const generateWebsocket = () => {
return new WebSocket(url);
};
const ClientComp = () => {
const [ws, setWs] = useState<WebSocket>(generateWebsocket);
useEffect(() => {
return () => {
ws.close();
};
}, []);
return (
<div></div>
);
};
这下问题来了,服务器这里接收到了两个连接:
而且关掉页面后,只有一个连接会断开。
这就奇怪了?!究竟是为什么?!于是我开始寻找原因。
是严格模式的锅吗?
我打开了 next.config.js,确认了 reactStrictMode: false
,按理说,严格模式关了之后,就不会有渲染两次的问题了。
在渲染函数和 generateWebsocket()
里打了 log 之后,也可以看到两个 log 只被打出来一次:
const generateWebsocket = () => {
console.log("generateWebsocket");
return new WebSocket(url);
};
const ClientComp = () => {
const [ws, setWs] = useState<WebSocket | null>(generateWebsocket);
console.log("render");
useEffect(() => {
return () => {
ws?.close();
};
}, []);
return <div></div>;
};
是 React 的锅?
既然不是严格模式的锅,那是不是 react 实际上多次计算了初始状态呢?
React useState 官方文档 useState – React
这样看来也不是,React 都说了 useState 如果初始值传了一个函数,那就保证这个函数只会被调用一次。
可是这和我遇到的表现不一致!在这里初始函数就是调用了两次!!
到底是 React 在骗我还是我搞错了什么?
一气之下,我用 React + vite 重新把这个代码写了一遍,结果出乎我意料:
为什么??为什么这个时候又正常了??为什么同一份代码在不同的地方表现就不一样??
在气愤之余,我想先把写完的产物构建出来自己跑一跑,结果在 next 构建的时候报了个错误:
嗯???为什么会因为连接失败而构建失败??你需要联网做什么东西吗?
仔细一看,发现连的 url 还是服务端 websocket 的 url!这是什么意思!你在构建的时候跑了我里面的代码?
难道说这一切都是 next.js 的锅吗?
next.js 的锅?
在我把服务端运行起来之后,next 构建就不报错了,这意味着,next 在构建的时候肯定跑了组件里的渲染函数。
而且当我运行构建好的产物之后,我发现连接居然正常了!只发送了一次连接请求,服务端也只收到一个请求!
此时我有一个思路:会不会是 next.js 在 dev 和 build 的时候运行了组件里面的代码,生成了某些东西,再提供给浏览器?
于是,我在官方文档找了一个下午有没有和这有关的描述,结果也没有找到。后来,别人告诉我,其实是有的,只不过是被我选择性忽略了。
Rendering: Client Components | Next.js (nextjs.org)
这一句说明,无论是 client component,还是 server component,在第一次加载的时候,next.js 都会先跑一次渲染函数生成一个静态的 HTML。
所以,这个问题的根本原因是:
在 dev 状态下,访问这个页面有两个步骤:
- next.js 跑组件的渲染函数,生成静态的初始 HTML。
- next.js 返回初始的 HTML 和一堆 js,浏览器负责运行 js 来渲染剩下的内容。
其中,useState 分别被 next.js 的服务端和浏览器的客户端运行了两次,所以才会创建两次 websocket 连接。
在 build 的时候,因为要运行渲染函数生成静态的初始 HTML,所以如果服务端没有在运行,则在运行渲染函数的过程中因为出错而无法继续,导致报错。
至此,谜题得到解答。
我的锅!
我在群里提出这个问题后,群友的评价是:
到这里,我才意识到真正的问题在哪。
真正的问题是,我为了让代码变得更“优雅”,没有把带有副作用的操作(创建连接)放在 useEffect() 中而是放在初始操作中,导致渲染函数变得不纯。
这种做法违反了 react 的设计理念,所以才会得到不符合预期的行为。
const generateWebsocket = () => {
return new WebSocket(url);
};
const ClientComp = () => {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
setWs(generateWebsocket());
return () => {
ws?.close();
};
}, []);
return (
<div></div>
);
};
像这样,在 useEffect() 里处理带有副作用的操作,才是正确的做法。
虽然其实这个做法也没有那么正确,更好的做法是用一个 useRef 来包住连接,这样就可以一直访问当前连接了。
什么函数是纯的?如果相同的输入在函数里能得到相同的输出,那么这个函数就是纯的。
next.js 和 react 都是基于这个想法来设计组件的,对于一个组件来说,相同的 props 能得到相同的渲染结果,那么这个组件就是纯的。next.js 之所以会先把组件渲染一次,也是因为,如果这个组件是纯的,那对于相同的输入来说,渲染一次之后就再也不用渲染第二次了。
写 react,最重要的是,保证组件里没有副作用,或者尽可能减少副作用的数目,记得处理副作用带来的影响。
You Might Not Need an Effect – React
官方文档常看常新。
Please be pure.