传入props.children后, 为什么会导致组件的重新渲染?
问题描述
在 react 中, 我想要对组件的渲染进行优化, 遇到了一个非常意思的问题, 当我向一个组件中传入了 props.children 之后, 每次父组件重新渲染都会导致这个组件的重新渲染; 它看起来的表现就像是被memo包裹的组件, props和自身状态未发生变化, 组件却重新渲染了; 下面我写了一个demo, 一起来看看这个问题吧:
父组件App中引入了一个Home组件:
js
import Home from "./pages/Home";
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
console.log("App is render");
return (
<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<Home></Home>
</div>
);
}
使用 memo 包裹 Home 子组件, 同时 Home 组件可以接收一个 props.children 展示传入到 Home 中的组件, 如下:
js
import React, { memo } from "react";
const Home = memo((props) => {
console.log("Home is render");
return (
<div>
Home
{props.children}
</div>
);
});
export default Home;
目前在 App 组件中, 没有向 Home 组件中传入 props.children, 此时第一次加载时 App 组件和 Home 组件都会重新渲染, 当我们点击 Increment 按钮让 count 的值变化时, App 组件重新渲染, 由于 Home 组件被 memo 包裹, 当 Home 组件的 props 和自身状态未发生变化时, 组件不进行重新渲染, 目前也正是我们所期望的这样, 没有问题。
但是, 当我们在 App 组件中向 Home 组件传入 props.children 时, 就会出现问题(此问题不仅限于我下面例子中传入了一个 About 组件, 传入任何元素都会出现这个问题, 即使我们传入一个简单的 div 元素):
js
import { useState } from "react";
import Home from "./pages/Home";
import About from "./pages/About";
function App() {
const [count, setCount] = useState(0);
console.log("App is render");
return (
<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<Home>
<About />
</Home>
</div>
);
}
About 组件同样使用 memo 包裹, 代码如下:
js
import React, { memo } from "react";
const About = memo(() => {
console.log("About is render");
return <div>About</div>;
});
export default About;
此时如果我们修改 count 的值, 会导致 App 组件重新渲染, 但是也会导致 Home 组件重新渲染。这就有些令人疑惑, 我们来分析一下:
首先我们知道, 在未经过任何优化的情况下, 父组件重新渲染一定会导致子组件的重新渲染, 那么也就会创建一个新的组件实例; 而如果使用 memo 对组件进行包裹, 那么在组件的 props 和自身状态没有发生变化的情况下, 父组件重新渲染子组件不会重新渲染, 是不是意味着不会创建一个新的组件实例呢? (这里进入了思维误区)
上面代码中, 我们向 Home 组件中传递了一个 About 组件, 目前 Home 组件中的表现就相当于 props.children = <About/>, 由于 Home 组件被 memo 包裹还重新渲染了, 那大几率是 props 发生了变化。纠结之处就在于, 此时 props 中又只有 children 一个属性, 值为 About 组件, About 组件同样被 memo 包裹, 且没有依赖任何 props 和状态, 如果 About 组件返回的结果应该是相同的, 就不应该导致 Home 组件的 props 发生变化才对。
这就是我所遇到的问题, 为什么 props.children 会影响组件的渲染呢?
问题分析
我依然怀疑是由 Home 组件的 props 发生了变化, 唯一可能变化的就是 About 组件, 为了验证我的想法, 于是我在Home 组件中定义了一个 aboutRef 变量, 使用 useRef 包裹 About 组件, 如下所示:
js
import Home from "./pages/Home";
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
// 使用useRef包裹
const aboutRef = useRef(<About/>);
console.log("App is render");
return (
<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<Home>{aboutRef.current}</Home>
</div>
);
}
此时我发现, 首次渲染时 App、Home、About 都会渲染, 而当 count 发生变化时, 只有 App 组件重新渲染了, 这也就达到了我最初期望的效果。但是为什么包裹了 useRef 才可以做到这个效果呢? 到这里已经可以确定的是 Home 组件的 props.children 一定是发生了变化的, 那么我们来探讨一下 About 组件为什么会变化。
变化的原因是因为组件每次重新渲染时都会创建 React 元素, 例如<About /> = jsx(About)
, 并且在调用时会返回一个新对象, 当然不只是 About 会这样创建, 其他组件和元素也是这样创建的。其中jsx()
只不过是React.createElement 的语法糖而已, 元素或组件都会通过 React.createElement 创建返回一个 ReactElement 对象, 这是因为 React 利用 ReactElement 对象组成了一个 Javascript 对象树(也就是虚拟 DOM )。前面我进入了一个思维误区, 认为 memo 包裹的组件不会再被重新创建了 , 其实不管是否有memo包裹, 都是会通过 React.createElement 来创建, 只不过被memo包裹的组件创建出来的 React 元素会有所不同, 具体的可以深入的学习 memo, 这里给大家推荐一篇文章《从源码学 API 系列之 React.memo》。
因此对于 props.children 而言, 每次得到的都是 React.createElement(About)
返回的一个新对象, 这也是 Home 组件的 props 改变了的原因; 而我们使用 useRef, 创建了一个不会改变的对象赋值给 Home 组件的 props, 所以 Home 组件的 props 没有发生变化, 就不会重新渲染。
解决方案
解决这个问题, 除了使用 useRef 之外, 我们还可以定义一个变量, 提到 App 组件外, 也可以做到这个效果, 如下所示:
js
import { useState } from "react";
import Home from "./pages/Home";
import About from "./pages/About";
// 在组件外定义变量
const about = <About />;
function App() {
const [count, setCount] = useState(0);
console.log("App is render");
return (
<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<Home>{about}</Home>
</div>
);
}
当 About 组件没有依赖于 App 组件中其他状态时, 我们可以采用上面的做法, 但是如果 About 组件还依赖 App 内的其他状态, 可以发现无论是提变量还是 useRef 的做法都无法实现, 例如 About 组件中接收一个 name 参数, 由 App 组件传入:
js
import React, { memo } from "react";
// 接收一个props.name
const About = memo(({ name }) => {
console.log("About is render");
return <div>About: {name}</div>;
});
export default About;
这个时候我们就需要借助于 useMemo 进行优化(不用 useCallback 的原因是 useCallback 作用于函数, useMemo 作用于返回值, 在这里很明显我们想要作用于函数返回的组件), 就做到了实现当 count 发生变化时, 只有 App 组件重新渲染, 而 name 属性变化时 App、Home、About 都会重新渲染:
js
function App() {
const [count, setCount] = useState(0);
// 传入About组件的状态
const [name, setName] = useState("Hello");
// 使用useMemo优化
const about = useMemo(() => <About name={name} />, [name]);
console.log("App is render");
return (
<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setName("abc")}>Change Name</button>
<Home>{about}</Home>
</div>
);
}