React 性能優(yōu)化的那些事兒
要講清楚性能優(yōu)化的原理,就需要知道它的前世今生,需要回答如下的問題:
React 是如何進行頁面渲染的? 造成頁面的卡頓的罪魁禍首是什么呢? 我們?yōu)槭裁葱枰阅軆?yōu)化? React 有哪些場景會需要性能優(yōu)化? React 本身的性能優(yōu)化手段? 還有哪些工具可以提升性能呢?
為什么頁面會出現(xiàn)卡頓的現(xiàn)象?
為什么瀏覽器會出現(xiàn)頁面卡頓的問題?是不是瀏覽器不夠先進?這都 2202 年了,怎么還會有這種問題呢?
實際上問題的根源來源于瀏覽器的刷新機制。
我們人類眼睛的刷新率是 60Hz,瀏覽器依據(jù)人眼的刷新率 計算出了
1000 Ms / 60 = 16.6ms
也就是說,瀏覽器要在16.6Ms 進行一次刷新,人眼就不會感覺到卡頓,而如果超過這個時間進行刷新,就會感覺到卡頓。
而瀏覽器的主進程在僅僅需要頁面的渲染,還需要做解析執(zhí)行Js,他們運行在一個進程中。
如果js的在執(zhí)行的長時間占用主進程的資源,就會導致沒有資源進行頁面的渲染刷新,進而導致頁面的卡頓。
那么這個又和 React 的性能優(yōu)化又有什么關系呢?
React 到底是在哪里出現(xiàn)了卡頓?
基于我們上的知識,js 長期霸占瀏覽器主線程造成無法刷新而造成卡頓。
那么 React 的卡頓也是基于這個原因。
React 在render的時候,會根據(jù)現(xiàn)有render產生的新的jsx的數(shù)據(jù)和現(xiàn)有fiberRoot 進行比對,找到不同的地方,然后生成新的workInProgress,進而在掛載階段把新的workInProgress交給服務器渲染。
在這個過程中,React 為了讓底層機制更高效快速,進行了大量的優(yōu)化處理,如設立任務優(yōu)先級、異步調度、diff算法、時間分片等。
整個鏈路就是了高效快速的完成從數(shù)據(jù)更新到頁面渲染的整體流程。
為了不讓遞歸遍歷尋找所有更新節(jié)點太大而占用瀏覽器資源,React 升級了fiber架構,時間分片,讓其可以增量更新。
為了找出所有的更新節(jié)點,設立了diff算法,高效的查找所有的節(jié)點。
為了更高效的更新,及時響應用戶的操作,設計任務調度優(yōu)先級。
而我們的性能優(yōu)化就是為了不給 React 拖后腿,讓其更快,更高效的遍歷。
那么性能優(yōu)化的奧義是什么呢??
就是控制刷新渲染的波及范圍,我們只讓改更新的更新,不該更新的不要更新,讓我們的更新鏈路盡可能的短的走完,那么頁面當然就會及時刷新不會卡頓了。
React 有哪些場景會需要性能優(yōu)化?
父組件刷新,而不波及子組件 組件自己控制自己是否刷新 減少波及范圍,無關刷新數(shù)據(jù)不存入state中 合并 state,減少重復 setState 的操作 如何更快的完成diff的比較,加快進程
我們分別從這些場景說一下:·
一:父組件刷新,而不波及子組件。
我們知道 React 在組件刷新判定的時候,如果觸發(fā)刷新,那么它會深度遍歷所有子組件,查找所有更新的節(jié)點,依據(jù)新的jsx數(shù)據(jù)和舊的 fiber ,生成新的workInProgress,進而進行頁面渲染。
所以父組件刷新的話,子組件必然會跟著刷新,但是假如這次的刷新,和我們子組件沒有關系呢?怎么減少這種波及呢?
如下面這樣:
export default function Father1 (){
let [name,setName] = React.useState('');
return (
<div>
<button onClick={()=>setName("獲取到的數(shù)據(jù)")}>點擊獲取數(shù)據(jù)</button>
{name}
<Children/>
</div>
)
}
function Children(){
return (
<div>
這里是子組件
</div>
)
}
復制代碼
運行結果: 
可以看到我們的子組件被波及了,解決辦法有很多,總體來說分為兩種。
子組件自己判斷是否需要更新 ,典型的就是 PureComponent,shouldComponentUpdate,memo 父組件對子組件做個緩沖判斷
第一種:使用 PureComponent
使用 PureComponent 的原理就是它會對state 和props進行淺比較,如果發(fā)現(xiàn)并不相同就會更新。
export default function Father1 (){
let [name,setName] = React.useState('');
return (
<div>
<button onClick={()=>setName("父組件的數(shù)據(jù)")}>點擊刷新父組件</button>
{name}
<Children1/>
</div>
)
}
class Children extends React.PureComponent{
render() {
return (
<div>這里是子組件</div>
)
}
}
復制代碼
執(zhí)行結果:

實際上PureComponent就是在內部更新的時候調用了會調用如下方法來判斷 新舊state和props
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
復制代碼
它的判斷步驟如下:
第一步,首先會直接比較新老 props或者新老state是否相等。如果相等那么不更新組件。第二步,判斷新老 state或者props,有不是對象或者為null的,那么直接返回 false ,更新組件。第三步,通過 Object.keys將新老props或者新老state的屬性名key變成數(shù)組,判斷數(shù)組的長度是否相等,如果不相等,證明有屬性增加或者減少,那么更新組件。第四步,遍歷老 props或者老state,判斷對應的新props或新state,有沒有與之對應并且相等的(這個相等是淺比較),如果有一個不對應或者不相等,那么直接返回false,更新組件。 到此為止,淺比較流程結束,PureComponent就是這么做渲染節(jié)流優(yōu)化的。
在使用PureComponent時需要注意的細節(jié):
由于PureComponent 使用的是淺比較判斷state和props,所以如果我們在父子組件中,子組件使用PureComponent,在父組件刷新的過程中不小心把傳給子組件的回調函數(shù)變了,就會造成子組件的誤觸發(fā),這個時候PureComponent就失效了。
細節(jié)一:函數(shù)組件中,匿名函數(shù),箭頭函數(shù)和普通函數(shù)都會重新聲明
下面這些情況都會造成函數(shù)的重新聲明:
箭頭函數(shù)
<Children1 callback={(value)=>setValue(value)}/>
復制代碼
匿名函數(shù)
<Children1 callback={function (value){setValue(value)}}/>
復制代碼
普通函數(shù)
export default function Father1 (){
let [name,setName] = React.useState('');
let [value,setValue] = React.useState('')
const setData=(value)=>{
setValue(value)
}
return (
<div>
<button onClick={()=>setName("父組件的數(shù)據(jù)"+Math.random())}>點擊刷新父組件</button>
{name}
<Children1 callback={setData}/>
</div>
)
}
class Children1 extends React.PureComponent{
render() {
return (
<div>這里是子組件</div>
)
}
}
復制代碼
執(zhí)行結果:

可以看到子組件的 PureComponent 完全失效了。這個時候就可以使用useMemo或者 useCallback 出馬了,利用他們緩沖一份函數(shù),保證不會出現(xiàn)重復聲明就可以了。
export default function Father1 (){
let [name,setName] = React.useState('');
let [value,setValue] = React.useState('')
const setData= React.useCallback((value)=>{
setValue(value)
},[])
return (
<div>
<button onClick={()=>setName("父組件的數(shù)據(jù)"+Math.random())}>點擊刷新父組件</button>
{name}
<Children1 callback={setData}/>
</div>
)
}
復制代碼
看結果:
可以看到我們的子組件這次并沒有參與父組件的刷新,在React Profiler中也提示,Children1并沒有渲染。
細節(jié)二:class組件中不使用箭頭函數(shù),匿名函數(shù)
原理和函數(shù)組件中的一樣,class 組件中每一次刷新都會重復調用render函數(shù),那么render函數(shù)中使用的匿名函數(shù),箭頭函數(shù)就會造成重復刷新的問題。
export default class Father extends React.PureComponent{
constructor(props) {
super(props);
this.state = {
name:"",
count:"",
}
}
render() {
return (
<div>
<button onClick={()=>this.setState({name:"父組件的數(shù)據(jù)"+Math.random()})}>點擊獲取數(shù)據(jù)</button>
{this.state.name}
<Children1 callback={()=>this.setState({count:11})}/>
</div>
)
}
}
復制代碼
執(zhí)行結果:

而優(yōu)化這個非常簡單,只需要把函數(shù)換成普通函數(shù)就可以。
export default class Father extends React.PureComponent{
constructor(props) {
super(props);
this.state = {
name:"",
count:"",
}
}
setCount=(count)=>{
this.setState({count})
}
render() {
return (
<div>
<button onClick={()=>this.setState({name:"父組件的數(shù)據(jù)"+Math.random()})}>點擊獲取數(shù)據(jù)</button>
{this.state.name}
<Children1 callback={this.setCount(111)}/>
</div>
)
}
}
復制代碼
執(zhí)行結果:

細節(jié)三:在 class 組件的render函數(shù)中調用bind 函數(shù)
這個細節(jié)是我們在class組件中,沒有在constructor中進行bind的操作,而是在render函數(shù)中,那么由于bind函數(shù)的特性,它的每一次調用都會返回一個新的函數(shù),所以同樣會造成PureComponent的失效
export default class Father extends React.PureComponent{
//...
setCount(count){
this.setCount({count})
}
render() {
return (
<div>
<button onClick={()=>this.setState({name:"父組件的數(shù)據(jù)"+Math.random()})}>點擊獲取數(shù)據(jù)</button>
{this.state.name}
<Children1 callback={this.setCount.bind(this,"11111")}/>
</div>
)
}
}
復制代碼
看執(zhí)行結果:

優(yōu)化的方式也很簡單,把bind操作放在constructor中就可以了。
constructor(props) {
super(props);
this.state = {
name:"",
count:"",
}
this.setCount= this.setCount.bind(this);
}
復制代碼
執(zhí)行結果就不在此展示了。
而實際上上訴所說的三個細節(jié)同樣對React.memo有效,它同樣也會淺比較傳入的props.
第二種:shouldComponentUpdate
class 組件中 使用 shouldComponentUpdate 是主要的優(yōu)化方式,它不僅僅可以判斷來自父組件的nextprops,還可以根據(jù)nextState和最新的nextContext來決定是否更新。
class Children2 extends React. PureComponent{
shouldComponentUpdate(nextProps, nextState, nextContext) {
//判斷只有偶數(shù)的時候,子組件才會更新
if(nextProps !== this.props && nextProps.count % 2 === 0){
return true;
}else{
return false;
}
}
render() {
return (
<div>
只有父組件傳入的值等于 2的時候才會更新
{this.props.count}
</div>
)
}
}
復制代碼
它的用法也是非常簡單,就是如果需要更新就返回true,不需要更新就返回false.
第三種:函數(shù)組件如何判斷props的變化的更新呢? 使用 React.memo函數(shù)
React.memo的規(guī)則是如果想要復用最后一次渲染結果,就返回true,不想復用就返回false。 所以它和shouldComponentUpdate的正好相反,false才會更新,true就返回緩沖。
const Children3 = React.memo(function ({count}){
return (
<div>
只有父組件傳入的值是偶數(shù)的時候才會更新
{count}
</div>
)
},(prevProps, nextProps)=>{
if(nextProps.count % 2 === 0){
return false;
}else{
return true;
}
})
復制代碼
如果我們不傳入第二個函數(shù),而是默認讓 React.memo包裹一下,那么它只會對props淺比較一下,并不會有比較state之類的邏輯。
以上三種都是我們?yōu)榱藨獙Ω附M件更新觸發(fā)子組件,子組件決定是否更新的實現(xiàn)。 下面我們講一下父組件對子組件緩沖實現(xiàn)的情況:
使用 React.useMemo來實現(xiàn)對子組件的緩沖
看下面這段邏輯,我們的子組件只關心count數(shù)據(jù),當我們刷新name數(shù)據(jù)的時候,并不會觸發(fā)刷新 Children1子組件,實現(xiàn)了我們對組件的緩沖控制。
export default function Father1 (){
let [count,setCount] = React.useState(0);
let [name,setName] = React.useState(0);
const render = React.useMemo(()=><Children1 count = {count}/>,[count])
return (
<div>
<button onClick={()=>setCount(++count)}>點擊刷新count</button>
<br/>
<button onClick={()=>setName(++name)}>點擊刷新name</button>
<br/>
{"count"+count}
<br/>
{"name"+name}
<br/>
{render}
</div>
)
}
class Children1 extends React.PureComponent{
render() {
return (
<div>
子組件只關系count 數(shù)據(jù)
{this.props.count}
</div>
)
}
}
復制代碼
執(zhí)行結果: 當我們點擊刷新name數(shù)據(jù)時,可以看到沒有子組件參與刷新
當我們點擊刷新count 數(shù)據(jù)時,子組件參與了刷新

二:組件自己控制自己是否刷新
這里就需要用到上面提到的shouldComponentUpdate以及PureComponent,這里不再贅述。
三:減少波及范圍,無關刷新數(shù)據(jù)不存入state中
這種場景就是我們有意識的控制,如果有一個數(shù)據(jù)我們在頁面上并沒有用到它,但是它又和我們的其他的邏輯有關系,那么我們就可以把它存儲在其他的地方,而不是state中。
場景一:無意義重復調用setState,合并相關的state
export default class Father extends React.Component{
state = {
count:0,
name:"",
}
getData=(count)=>{
this.setState({count});
//依據(jù)異步獲取數(shù)據(jù)
setTimeout(()=>{
this.setState({
name:"異步獲取回來的數(shù)據(jù)"+count
})
},200)
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log("渲染次數(shù),",++count,"次")
}
render() {
return (
<div>
<button onClick={()=>this.getData(++this.state.count)}>點擊獲取數(shù)據(jù)</button>
{this.state.name}
</div>
)
}
}
復制代碼
React Profiler的執(zhí)行結果:

可以看到我們的父組件執(zhí)行了兩次。 其中的一次是無意義的先setState保存一次數(shù)據(jù),然后又根據(jù)這個數(shù)據(jù)異步獲取了數(shù)據(jù)以后又調用了一次setState,造成了第二次的數(shù)據(jù)刷新.
而解決辦法就是把這個數(shù)據(jù)合并到異步數(shù)據(jù)獲取完成以后,一起更新到state中。
getData=(count)=>{
//依據(jù)異步獲取數(shù)據(jù)
setTimeout(()=>{
this.setState({
name:"異步獲取回來的數(shù)據(jù)"+count,
count
})
},200)
}
復制代碼
看執(zhí)行結果:只渲染了一次。

場景二:和頁面刷新沒有相關的數(shù)據(jù),不存入state中
實際上我們發(fā)現(xiàn)這個數(shù)據(jù)在頁面上并沒有展示,我們并不需要把他們都存放在state 中,所以我們可以把這個數(shù)據(jù)存儲在state之外的地方。
export default class Father extends React.Component{
constructor(props) {
super(props);
this.state = {
name:"",
}
this.count = 0;
}
getData=(count)=>{
this.count = count;
//依據(jù)異步獲取數(shù)據(jù)
setTimeout(()=>{
this.setState({
name:"異步獲取回來的數(shù)據(jù)"+count,
})
},200)
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log("渲染次數(shù),",++count,"次")
}
render() {
return (
<div>
<button onClick={()=>this.getData(++this.count)}>點擊獲取數(shù)據(jù)</button>
{this.state.name}
</div>
)
}
}
復制代碼
這樣的操作并不會影響我們對它的使用。 在class組件中我們可以把數(shù)據(jù)存儲在this上面,而在Function中,則我們可以通過利用 useRef 這個 Hooks 來實現(xiàn)同樣的效果。
export default function Father1 (){
let [name,setName] = React.useState('');
const countContainer = React.useRef(0);
const getData=(count)=>{
//依據(jù)異步獲取數(shù)據(jù)
setTimeout(()=>{
setName("異步獲取回來的數(shù)據(jù)"+count)
countContainer.current = count++;
},200)
}
return (
<div>
<button onClick={()=>getData(++countContainer.current)}>點擊獲取數(shù)據(jù)</button>
{name}
</div>
)
}
復制代碼
場景三:通過存入useRef的數(shù)據(jù)中,避免父子組件的重復刷新
假設父組件中有需要用到子組件的數(shù)據(jù),子組件需要把數(shù)據(jù)回到返回給父組件,而如果父組件把這份數(shù)據(jù)存入到了 state 中,那么父組件刷新,子組件也會跟著刷新。 這種的情況我們就可以把數(shù)據(jù)存入到 useRef 中,以避免無意義的刷新出現(xiàn)。或者把數(shù)據(jù)存入到class的 this 下。
四:合并 state,減少重復 setState 的操作
合并 state ,減少重復 setState 的操作,實際上 React已經幫我們做了,那就是批量更新,在React18 之前的版本中,批量更新只有在 React自己的生命周期或者點擊事件中有提供,而異步更新則沒有,例如setTimeout,setInternal等。
所以如果我們想在React18 之前的版本中也想在異步代碼添加對批量更新的支持,就可以使用React給我們提供的api。
import ReactDOM from 'react-dom';
const { unstable_batchedUpdates } = ReactDOM;
復制代碼
使用方法如下:
componentDidMount() {
setTimeout(()=>{
unstable_batchedUpdates(()=>{
this.setState({ number:this.state.number + 1 })
console.log(this.state.number)
this.setState({ number:this.state.number + 1})
console.log(this.state.number)
this.setState({ number:this.state.number + 1 })
console.log(this.state.number)
})
})
}
復制代碼
而在 React 18中的話,就不需要我們這樣做了,它 對settimeout、promise、原生事件、react事件、外部事件處理程序進行自動批量處理。
五:如何更快的完成diff的比較,加快進程
diff算法就是為了幫助我們找到需要更新的異同點,那么有什么辦法可以讓我們的diff算法更快呢?
那就是合理的使用key
diff的調用是在reconcileChildren中的reconcileChildFibers,當沒有可以復用current fiber節(jié)點時,就會走mountChildFibers,當有的時候就走reconcileChildFibers。
而reconcilerChildFibers的函數(shù)中則會針render函數(shù)返回的新的jsx數(shù)據(jù)進行判斷,它是否是對象,就會判斷它的newChild.$$typeof是否是REACT_ELEMENT_TYPE,如果是就按單節(jié)點處理。 如果不是繼續(xù)判斷是否是REACT_PORTAL_TYPE或者REACT_LAZY_TYPE。
繼續(xù)判斷它是否為數(shù)組,或者可迭代對象。
而在單節(jié)點處理函數(shù)reconcileSingleElement中,會執(zhí)行如下邏輯:
通過 key,判斷上次更新的時候的Fiber節(jié)點是否存在對應的DOM節(jié)點。 如果沒有 則直接走創(chuàng)建流程,新生成一個 Fiber 節(jié)點,并返回如果有,那么就會繼續(xù)判斷, DOM節(jié)點是否可以復用?
如果有,就將上次更新的
Fiber節(jié)點的副本作為本次新生的Fiber節(jié)點并返回如果沒有,那么就標記
DOM需要被刪除,新生成一個Fiber節(jié)點并返回。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key; //jsx 虛擬 DOM 返回的數(shù)據(jù)
let child = currentFirstChild;//當前的fiber
// 首先判斷是否存在對應DOM節(jié)點
while (child !== null) {
// 上一次更新存在DOM節(jié)點,接下來判斷是否可復用
// 首先比較key是否相同
if (child.key === key) {
// key相同,接下來比較type是否相同
switch (child.tag) {
// ...省略case
default: {
if (child.elementType === element.type) {
// type相同則表示可以復用
// 返回復用的fiber
return existing;
}
// type不同則跳出switch
break;
}
}
// 代碼執(zhí)行到這里代表:key相同但是type不同
// 將該fiber及其兄弟fiber標記為刪除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不同,將該fiber標記為刪除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 創(chuàng)建新Fiber,并返回 ...省略
}
復制代碼
從上面的代碼就可以看出,React 是如何判斷一個 Fiber 節(jié)點是否可以被復用的。
第一步:判斷 element的key和fiber的key是否相同
如果不相同,就會創(chuàng)建新的
Fiber,并返回第二步:如果相同,就判斷
element.type和fiber的type是否相同,type就是他們的類型,比如p標簽就是p,div標簽就是div.如果type不相同,那么就會標識刪除。如果相同,那就可以可以判斷可以復用了,返回
existing。
而在多節(jié)點更新的時候,key的作用則更加重要,React 會通過遍歷新舊數(shù)據(jù),數(shù)組和鏈表來通過按個判斷它們的key和 type 來決定是否復用。
所以我們需要合理的使用key來加快diff算法的比對和fiber的復用。
那么如何合理使用key呢。
其實很簡單,只需要每一次設置的值和我們的數(shù)據(jù)一直就可以了。不要使用數(shù)組的下標,這種key和數(shù)據(jù)沒有關聯(lián),我們的數(shù)據(jù)發(fā)生了更新,結果 React 還指望著復用。
還有哪些工具可以提升性能呢?
實際的開發(fā)中還有其他的很多場景需要進行優(yōu)化:
頻繁輸入或者滑動滾動的防抖節(jié)流 針對大數(shù)據(jù)展示的虛擬列表,虛擬表格 針對大數(shù)據(jù)展示的時間分片 等等等等 后面再補充吧!
感謝大佬的文章:
React進階實踐指南-渲染控制篇[2]
over...
關于本文
作者:雨飛飛雨
https://juejin.cn/post/7146846541846675492
