我正在参加「掘金·启航计划」
大家好,我是晚天。
本文从一次 Debug 讲起,通过一次看似诡异的表现,揭开迷惑的面纱,探索根本的原因。
从一次 Debug 说起
为了方便理解,我们将实际代码简化成最易理解的代码,主要有两个文件:MyDemo.tsx 和 index.tsx。
具体如下:
MyDemo.tsx
import React, { useState } from "react";
function MyDemo() {
console.log("render...");
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
increment
</button>
</div>
);
}
export default MyDemo;
MyDemo.tsx 是我们主要关注的代码。上述代码非常简单,在函数是组件中通过 console.log 输出 "render..."。同时,使用 useState 定义一个 count 状态,每次点击 increment 按钮,count 加 1。
index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './MyDemo';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
上述代码是常见的 React 应用的入口文件,将 MyDemo.tsx 渲染到页面中。
我们看下执行效果:
通过上面的动图效果,我们可以看到,当我们刷新首次进入页面时,输出了 2 次 "render..."。当我们点击 increment 按钮后,会再次输出 2 次 "render..."。
这就非常反常识了,为啥函数组件每次渲染都会执行 2 次函数?理论上不应该只有 1 次么,难道无意间我们发现了 React 的 Bug?
其实,Dan 早在 2020 年就给出了解释,详见 issue。
This is not a bug. And you'll have the same behavior in Strict Mode too. We intentionally double-call render-phase lifecycles in development only (and function components using Hooks) to help people find issues caused by side effects in render. In our experience, them firing twice is enough for people to notice and fix such bugs.
If component output is always a function of props and state (and not outer scope variables, like in your example), the double rendering in development should have no observable effect.
简单来说,这是 React 官方故意为之,而且只有开启了 Strict Mode 才会出现这个问题。那么为什么会出现这种看似诡异的表现呢,我们通过下文中对 React StrictMode 的介绍来追根溯源。
抓到你了:StrictMode
React StrictMode 是 React 16.3 版本中引入的概念,旨在帮助开发人员更容易地发现潜在的问题。
在 React 16.3 之前,React 已经提供了一些开发者工具,例如 React Developer Tools Chrome 插件,帮助开发者进行调试。但是,这些工具并没有覆盖所有潜在问题,在开发复杂的应用时,仍然可能出现一些问题。
lets you find common bugs in your components early during development.
React StrictMode 的目的是通过在开发模式下增加额外的检查和警告来帮助开发人员更容易地发现这些潜在问题。这些额外的检查和警告包括:
- 检查不安全的生命周期方法:StrictMode 在生命周期方法中加入了额外的调用,以便开发人员能够更好地发现潜在的问题。
- 检查未使用的 props 和 state:StrictMode 在组件中检查未使用的 props 和 state,以便开发人员能够更好地发现潜在的问题。
- 检查过时的 context API:StrictMode 在 context API 中加入了额外的调用,以便开发人员能够更好地发现潜在的问题。
React StrictMode 的发展历史可以追溯到 React 16.3 版本的发布。在这个版本中,React 官方团队首次提出了 StrictMode 的概念,并希望将其作为一个开发者工具发布。在接下来的版本中,React 团队不断改进了 StrictMode 的功能,并将其纳入了 React 的核心代码中,使其成为了开发 React 应用的标准工具之一。随着时间的推移,React StrictMode 的功能不断完善,变得更加全面和强大。
简单示例
StrictMode 的用法也非常简单,如下:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
具体作用
React 18 之后的版本,开启严格模式将会带来如下只有在开发模式下才有的行为:
- 组件将重复多渲染一次,以发现由非纯函数渲染引起的 bug;
- 组件将重复多运行一次 Effect,以发现由遗漏的 Effect 清理引起的 bug;
- 组件将检查弃用 API 的使用情况。
举着栗子看效果
在应用全局开启严格模式
严格模式支持全局开启,具体用法:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
在应用局部开启严格模式
严格模式支持局部开启,具体用法:
import { StrictMode } from 'react';
function App() {
return (
<>
<Header />
<StrictMode>
<main>
<Sidebar />
<Content />
</main>
</StrictMode>
<Footer />
</>
);
}
开发阶段重复多渲染一次
React 假定每个组件都是一个纯函数。这意味着 React 组件在接收相同的输入(props、state 和 context)的情况下,总是返回相同的 jsx。但是组件还是会有打破上述规则的一些不可预期的行为,从而引发 bug。为了帮助开发者发现这些偶发的非纯函数的代码,严格模式会在开发阶段重复多调用一次那些理应是纯函数的函数。这些函数包括:
- 组件的主函数体,只包含顶层逻辑,不包含内部事件处理等逻辑;
- 传递给 useState、set 函数、useMemo、useReducer 的函数;
- 一些类组件方法,如 constructor、render、shouldComponentUpdate 等,详见完整列表;
如果一个函数是纯函数,那么两次渲染,它的行为将是一致的。如果一个函数不是纯函数,那么两次渲染它的行为将比较明显的不一致。这就帮我们能够在开发阶段快速发现 bug。
以下是一个在严格模式下渲染两次行为不一致的例子,通过这些不一致的行为,我们将很容易定位到 bug。
App.tsx
import React, { useState } from "react";
interface Props {
stories: { id: string; label: string }[];
}
export default function StoryTray({ stories }: Props) {
const items = stories;
items.push({ id: "create", label: "Create Story" });
const [count, setCount] = useState(1);
return (
<>
<ul>
{items.map((story) => (
<li key={story.id}>{story.label}</li>
))}
</ul>
<p>第 {count} 次渲染</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
重新渲染
</button>
</>
);
}
index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App stories={[]} />
</React.StrictMode>
);
下面,让我们执行下上述代码,具体效果如下:
从上面的运行结果,我们可以看出,每次渲染都会有两个条目自动增加进来,但是组件 props 传入的是一个空数组,代码中也只增加了一个条目。
通过渲染两次,我们就可以肉眼可见的发现问题。这个函数组件不是纯函数组件,因为在输入不变的情况下,他的两次输出是不一样的。进一步 review 代码,我们发现,props 输入 stories 是一个引用变量空数组,每次重新渲染,函数组件内部都是在对同一个引用变量做操作。因此,每次函数组件的渲染都会产生对 stories 的副作用,进而引起每次渲染的结果都是不一样的。
只需做如下修改,即在函数组件中对 props stories 进行克隆操作,即可消除这种副作用。
import React, { useState } from "react";
interface Props {
stories: { id: string; label: string }[];
}
export default function StoryTray({ stories }: Props) {
const items = stories.slice(); // 新增优化,克隆数组
items.push({ id: "create", label: "Create Story" });
const [count, setCount] = useState(1);
return (
<>
<ul>
{items.map((story) => (
<li key={story.id}>{story.label}</li>
))}
</ul>
<p>第 {count} 次渲染</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
重新渲染
</button>
</>
);
}
接下来,我们看下效果:
现在我们再看每次渲染,只会在页面上渲染出一个条目,符合预期,副作用消除,函数组件也重新回归为纯函数组件。
开发阶段重复多运行一次 Effect
useEffect 的使用也是会很容易引入非预期 bug 的场景,严格模式也通过多运行一次 useEffect 的方式,帮助开发者更容易发现 bug。
在 useEffect 中我们会进行各种副作用的处理,比如事件监听、轮训、setInterval、数据库连接等,这些副作用一般都是会大量占用内存资源的。因此,useEffect 提供了清理副作用的方式,即通过 return 一个函数来让用户自定义清理逻辑。
但是,因为开发者对 useEffect 熟悉度较低、疏忽等各种原因,清理副作用的逻辑会经常忘记添加,这样则极易引发 bug。严格模式通过在开发阶段多运行一次 Effect 的方式,帮助开发者更容易发现这些非预期 bug。
下面,我们来举一个简单的栗子。
import React, { useState, useEffect } from "react";
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log("count", count);
}, 1000);
}, [count]);
return (
<div>
<p>{count}</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
click
</button>
</div>
);
}
这段代码很简单,在 useEffect 中定义了一个 setInterval,每隔 1s 输出一个 log。
我们来看下运行效果,在控制台中,我们发现每隔 1s 会输出 2 条 “count 0”,和我们预期中的 1 条 log 是不一致的。
我们点击按钮,更新下 count,看起来会更加清晰。当我们点击按钮,将 count 更新为 1 后,控制台不仅会每个 1s 输出 2 条 “count 0”,还会每隔 1s 输出 2 条 “count 1”。很明显这是不符合我们预期的。因此,我们可以快速判断,这段代码是有问题的。通过 review 代码,我们可以发现这是因为我们没有在 useEffect 中定义 clearInterval 逻辑导致的。
接下来,我们对代码进行优化,在 useEffect 中增加 clearInterval 逻辑。
import React, { useState, useEffect } from "react";
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log("count", count);
}, 1000);
return () => clearInterval(interval); // 副作用清除逻辑
}, [count]);
return (
<div>
<p>{count}</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
click
</button>
</div>
);
}
接下来我们再看下运行效果。每隔 1s 只会有一条日志输出,且即使在我们点击按钮,更新 count 之后,也始终只会每隔 1s 输出一条日志,日志中的 count 也会更新为 count 的最新值。
bug 顺利发现并解决,bravo。
弃用 API 提示
严格模式下,会在开发环节对代码中使用的 React 弃用 API 进行提示。如:
- findDOMNode;
- UNSAFE_ 类声明周期方法,如 UNSAFE_componentWillMount;
- 旧版内容,如 childContextTypes/contextTypes/getChildContext 等;
- 旧版字符串 refs(this.refs)。
总结
在没有了解到根结之前,我们或许会被突然出现的「一次渲染两次执行」所震惊到。但是,追根溯源到 StrictMode 的出发点和用途之后,发现 StrictMode 还是蛮贴心的一个 React 特性。大家后续好好利用 StrictMode 吧,让开发过程少些坑,让世界更美好。