明明 next.js 已经关闭严格模式,为什么 useState() 还会跑两次?

其实这次有点标题党,但确实是我在写小玩具的过程中遇到的问题。

省流回答: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)

我看到这里说 React’s APIs,因为不知道会用哪个 React 的 api,所以就选择性忽视了

这一句说明,无论是 client component,还是 server component,在第一次加载的时候,next.js 都会先跑一次渲染函数生成一个静态的 HTML。

所以,这个问题的根本原因是:

在 dev 状态下,访问这个页面有两个步骤:

  1. next.js 跑组件的渲染函数,生成静态的初始 HTML。
  2. 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.

作者: 梁小顺

脑子不太好用的普通人。 顺带一提性格也有点古怪。 在老妈子和厌世肥宅中来回切换。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据