【譯】快取 React 中事件監聽來提升效能
在 JavaScript 中物件和函式是怎麼被引用好像不被人重視的,但它卻直接影響了 React 的效能。假設你分別創造了兩個完全相同的函式,它們還是不相等的。如下:
const functionOne = function() { alert('Hello world!'); }; const functionTwo = function() { alert('Hello world!'); }; functionOne === functionTwo; // false 複製程式碼
如果你把一個早已經存在的函式賦值給一個變數,比較它們時,你又會發現:
const functionThree = function() { alert('Hello world!'); }; const functionFour = functionThree; functionThree === functionFour; // true 複製程式碼
物件也是同樣的情況。(記住 JavaScript 中函式即物件)
const object1 = {}; const object2 = {}; const object3 = object1; object1 === object2; // false object1 === object3; // true 複製程式碼
如果你其他語言程式設計經驗,你應該熟悉指標的。每次你建立一個物件,計算機都會分配一些記憶體儲存它。當我宣告object1 = {}
,會在記憶體分配空間object1
的變數。object1
又指向了儲存{}
那塊空間的地址。當我又聲明瞭object2 = {}
,又會在記憶體中開闢另一個空間儲存這個新的{}
,將object2
的變數指向了那塊空間的地址。所以object1
和object2
指向的地址是不匹配的,這也就是為什麼兩個變數比較不相等的原因。儘管兩個變數指向的地址的內容的鍵-值是一致的,但它們代表的地址指標是不一樣的。
當我進行賦值object3 = object1
,其實我是把object3
和object1
指向了記憶體中同一塊空間的地址。 它不是一個新的物件。你可以這樣驗證:
const object1 = { x: true }; const object3 = object1; object3.x = false; object1.x; // false 複製程式碼
這個例子中,在記憶體中建立一個物件,object1
指向了那個物件的地址。把object1
賦值給object3
的時候,object3
也指向了同一個物件的地址。當改變object3
的時候,改變了它指向的記憶體空間的物件的鍵-值, 那麼其它所有引用到這個記憶體空間物件的地方都會發生改變。故object1
也就會發生相同的變化。
對於初級開發者,這是一個常見的錯誤,需要儘量深入的去了解它(本文沒有深入涉及,可以看看《JavaScript高階程式設計》 );這篇文章主要是針對 React 效能進行討論的,可能有很多經驗的開發者都沒有考慮過引用型別變數對 React 效能的影響。
你會疑惑變數引用會影響 React 嗎? React 是一個性能很高,減少渲染時間的智慧的庫:如果元件的 state 和 props 沒有改變,那麼 render 的輸出也不會改變。當然,所有的值都相等,根本不需要改變。假設沒有值改變, render 必須返回相同的輸出,因此沒有必要花費時間重新執行。這也是 React 快速的原因,它僅僅在需要的時候才 render。
React 確定元件 props 和 state 的值前後是否相等,用了 JavaScript 中簡單比較==
的操作符進行的。 React 比較它們是否相等不是對物件進行淺(shallow )比較或者深(deep)比較。淺比較用來描述比較物件的每個鍵值對的術語,通俗點,一般而言是對物件,遍歷它的列舉屬性,依次用Object.is()
對物件每個鍵對應的值進行比較,全部相等才判斷為相等。深比較是更進一步,如果這個物件的鍵值對的值是一個物件,則繼續對那個值進行嚴格的相等驗證(繼續用 Object.is()對那個物件的每一個鍵的值判斷),直到沒有物件為止,全部深層次的比較。React 不是如此,它是比較 props 和 state 的引用是否改變。(注意 React 中的 PureComponent 是對 props 和 state 進行的淺比較)。
假如你改變了元件的 props,從{ x: 1}
變到另外一個物件{ x: 1}
, React 是會重新 render,因為兩個物件在記憶體中的地址不一樣。假如你把元件 props 從object1
(上面例子中)變成boject3
, React 是不會重新 render 的,因為兩個物件是同一個的引用。
在 JavaScript 中,函式也是這種特性(函式即物件)。假如 React 元件 接受了一個功能相同但記憶體地址不同的函式,它也會重新 render。如果 React 接受相同功能的函式引用,它就不會重新 render。
不幸的是,這是我在 code review 中遇到的常見場景:
class SomeComponent extends React.PureComponent { get instructions() { if (this.props.do) { return 'Click the button: '; } return 'Do NOT click the button: '; } render() { return ( <div> {this.instructions} <Button onClick={() => alert('!')} /> </div> ); } } 複製程式碼
這是一個非常簡單的元件。它是一個按鈕,當點選的時候,它會alert('!')
,instructions
屬性告訴你是否應該點選它,這是由SomeComponent
的 prop 中的do
來控制的。
每次當SomeComponent
重新 render (例如do
從 true 變成 false),Button
元件也會重新 render。onClick
的事件儘管都是一樣的,但每次 render 呼叫都會重新建立。每次 render,一個新的函式在記憶體中儲存,當這個新的記憶體地址的引用傳遞給Button
元件的時候,Button
元件就會重新渲染,儘管它的輸出沒有什麼改變。
如何修復
如果函式沒有依賴於你的元件(沒有用this
),你可以在元件的外面的定義函式。你所有元件的例項都將會共享相同的一份函式的引用,假定那個函式在所有用例中功能都相同。
const createAlertBox = () => alert('!'); class SomeComponent extends React.PureComponent { get instructions() { if (this.props.do) { return 'Click the button: '; } return 'Do NOT click the button: '; } render() { return ( <div> {this.instructions} <Button onClick={createAlertBox} /> </div> ); } } 複製程式碼
與先前的例子相反的,每次 render,createAlertBox
都是指向了記憶體中相同的地址,Button
元件絕不會重新 render。
雖然Button
可能是很小的,渲染很快的元件,(你感受不出來),但是當你在更大的,複雜的元件上看到這些內嵌的函式定義時,你能真實地感受到效能的影響。這是一個非常棒的又簡單的實踐:不要再 render 的方法裡面去定義這些函式。
如果函式依賴於你的元件,你不在元件外部定義它,但你可以把元件的方法作為事件處理函式:
class SomeComponent extends React.PureComponent { createAlertBox = () => { alert(this.props.message); }; get instructions() { if (this.props.do) { return 'Click the button: '; } return 'Do NOT click the button: '; } render() { return ( <div> {this.instructions} <Button onClick={this.createAlertBox} /> </div> ); } } 複製程式碼
當然,每個SomeComponent
元件的例項的彈出框是不同的,這是無法避免的,每一個SomeComponent
中Button
元件的點選事件監聽器必須要唯一的(不能互相干擾)。通過呼叫createAlertBox
的方法,你不用關心SomeComponent
是否重新 render,props 的 message 是否改變,Button
元件都不會重新渲染,因為它永遠指向是元件例項的那個方法,這樣能減少不必要渲染,提高你應用的效能。
但是如果我的函式是動態生成的,怎麼處理呢?
(進階)的修復
作者筆記:作者不假思索的寫下下面的例子,來反覆引用記憶體中相同的函式。這些例子旨在讓你更容易地理解引用。作者建議你們閱讀文章這一部分內容來理解引用,更希望你們在評論處給出你自己的理解。一些讀者慷慨地給出了更好的實現,其中考慮到了快取失效和 React 中內建的記憶體管理器。
在單個元件的動態事件處理中,這是一種很常見不唯一的用法,像對一個數組遍歷:
class SomeComponent extends React.PureComponent { render() { return ( <ul> {this.props.list.map(listItem => <li key={listItem.text}> <Button onClick={() => alert(listItem.text)} /> </li> )} </ul> ); } } 複製程式碼
在這個例子中,你建立了SomeComponent
,聲明瞭動態的數量不固定的Button
,建立了動態的事件監聽器,每個事件監聽函式都是唯一不同的。怎麼解決這個難題呢?
進行記憶,或者更簡單的說法,快取。對於每一個唯一的值,建立並快取函式;對於那個唯一值的所有將來的引用,都返回以前快取的那個函式。
下面展示了我如何實現上面的方法:
class SomeComponent extends React.PureComponent { // Each instance of SomeComponent has a cache of click handlers // that are unique to it. clickHandlers = {}; // Generate and/or return a click handler, // given a unique identifier. getClickHandler(key) { // If no click handler exists for this unique identifier, create one. if (!Object.prototype.hasOwnProperty.call(this.clickHandlers, key)) { this.clickHandlers[key] = () => alert(key); } return this.clickHandlers[key]; } render() { return ( <ul> {this.props.list.map(listItem => <li key={listItem.text}> <Button onClick={this.getClickHandler(listItem.text)} /> </li> )} </ul> ); } } 複製程式碼
list
數組裡面的每一個目標值都是通過getClickHandler
的方法呼叫。這個方法在第一次用引數呼叫它時,就會建立一個函式對應那個值,然後返回那個建立的函式。所有將來對那個函式的呼叫都不用再建立新的函式,相反地,它將會返回先前在記憶體中建立的函式的引用。
結果,重新渲染SomeComponent
將不會導致Button
的重新渲染。
當它們不只由一個變數決定時,你需要發揮自己的聰明才智,給每一個事件處理生成一個唯一標誌。當然,它並不比簡單地為返回的每個 JSX 物件生成唯一的 key 難多少。
使用索引 index 作為唯一標誌符是需要警告的:如果這個列表 list 改變順序或者刪除某一項你將會得到錯誤的結果。當陣列從[ 'soda', 'pizza' ]
變為[ 'pizza' ]
, 你快取了你的事件監聽器像這樣listeners[0] = () => alert('soda')
,你會發現,當你點選索引是0的pizza
的 Button時,彈出來是soda
。
這也是 React 建議不要將陣列的索引作為 key 的原因。
最後?
如果你喜歡這篇文章,請點一下贊哦。如果你有任何問題或者更好的建議,請在評論區留言。
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝!