傳入props.children后, 為什么會導致組件的重新渲染?
問題描述
在 react 中, 我想要對組件的渲染進行優化, 遇到了一個非常意思的問題, 當我向一個組件中傳入了 props.children 之后, 每次父組件重新渲染都會導致這個組件的重新渲染; 它看起來的表現就像是被memo包裹的組件, props和自身狀態未發生變化, 組件卻重新渲染了; 下面我寫了一個demo, 一起來看看這個問題吧:
父組件App中引入了一個Home組件:
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 中的組件, 如下:
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 元素):
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 包裹, 代碼如下:
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 組件, 如下所示:
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 組件外, 也可以做到這個效果, 如下所示:
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 組件傳入:
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 都會重新渲染:
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>);
}