Zet Chou
iCHEF Lead Front-End Engineer
近十年前端開發經驗,九年 React 開發經
曾任 SITCON、JSDC 主議程講者
JSDC 2019 / 2020 議程組工作人員
第 14 屆 IThome 鐵人賽 Modern Web 組冠軍
《一次打破 React 常見的學習門檻與觀念誤解》
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》筆者
Zet
周昱安
只會呼叫語法,卻對 React 運作的原理與觀念一知半解
JSX、Reconciliation、immutable update、useEffect...
觀念與觀念之間的關聯破碎,無法融會貫通變成一個知識體系
以上的問題導致實作了很多專案但技術理解卻沒有什麼進展,很常憑感覺以及模糊的經驗在寫程式,為專案的程式碼品質和可靠性埋下隱患
source: 9GAG
componentDidMount
、componentWillUnmount
componentDidMount
、componentWillUnmount
核心觀念與原理
基礎語法、工具、API 的使用方法 & 基礎操作練習
專案實戰情境經驗累積
抽象化設計 & patterns
學習核心知識,了解程式運作的核心概念與原理。這是後續所有學習階段的重要根基,沒有先把觀念基本功打穩的話,後續的學習則大多都是在做白工,難以突破
學習實務操作的基本方法並動手練習,並且連結回核心觀念與知識,達到理論與實作的融會貫通
在實戰情境需求中實踐技術的應用,並從其中累積面對不同情境時需要額外注意並處理的面向的經驗
學習如何在正確滿足商業規格需求的同時,進一步讓程式碼設計得更有擴充彈性、可讀性,並易於測試
掌握基本技術能力
有一定的即戰力
通往 senior level 必須的能力之一
React element 是 React 基於 Virtual DOM 概念所實現的虛擬畫面結構元素,因此它是一種普通的 JavaScript 物件資料,用於描述一個預期的實際 DOM element 結構,同時也是作為在 React 中畫面結構描述的最小單位。
React element 可以透過呼叫 createElement
方法被建立,並且在經過 React 的處理轉換之後,就能自動產生對應的實際 DOM element。
雖然可以透過呼叫 createElement
方法來產生 React element 以定義預期的畫面結構,但是相較於以往習慣的 HTML 標籤語法的開發體驗與便利性,還是有段明顯的差距
JSX 是一種語法糖,用於以更好的語法體驗來協助開發者定義並建立 Reactelement
JSX 的語法長得很像 HTML 只是因為它被刻意設計成在模仿 HTML 的撰寫以及閱讀體驗,但其本質既不是 HTML 也不是 DOM element,而是 React element 建立方法的呼叫。
可以透過如 Babel 等 transpiler 將 JSX 語法轉譯成實際可執行的 React.createElement()
呼叫語法。
提高可維護性
提升程式碼的可讀性
減少資料意外出錯的風險
更好的效能優化
優點:
只要開發者 DOM 操作的夠簡潔精準的話,可以盡可能的減少因多餘DOM 操作造成的效能浪費
缺點:
完全依賴人為周全的判斷以及精確的操作 DOM 來維持單向資料流,在複雜的前端應用程式中非常困難
優點:
開發者只需要關注模板定義以及資料更新的處理,不需要手動去維護資料連動的畫面操作,要維持單向資料流非常直覺簡單
缺點:
隨著應用程式的龐大與複雜,一律重繪的方式會因為大量不必要的 DOM操作而遇到明顯的效能問題,影響使用者體驗
無論選擇以上的哪一種渲染策略,都有著明顯且難以解決的缺點
大多數前端框架或解決方案都能透過一些特殊的架構設計來幫助我們解決資料連動畫面更新的需求,在保留這些渲染策略的優點的同時解決其缺點
Virtual DOM 這種概念在效能優化上的效益,是當畫面需要更新時,可以透過產生新的 Virtual DOM 畫面結構,然後比較新舊 Virtual DOM 畫面結構上的差異,並根據差異之處來執行最小範圍的實際 DOM 操作,以減少效能成本的浪費
「資料更新後,一律清空畫面再重繪畫面」的 DOM 渲染策略,雖然開發者只需關注模板定義及資料更新的處理,不需手動維護資料連動的 DOM 細節操作,而使得單向資料流的維持變得直覺簡單,但是一律重繪畫面的方式會因為大量且不必要的 DOM element 增刪而造成效能問題,則也是無法忽略的缺點
既然一律重繪實際的 DOM 很浪費效能的話,
那我們改成一律重繪虛擬的 DOM 不就好了嗎?
既然一律重繪實際的 DOM 會導致大量的效能浪費,
那麼就改為一律重繪虛擬的畫面結構資料,也就是 React element
React 透過 Virtual DOM 概念與一律重繪渲染策略的結合,實現了可預測性高、易於維護且可靠的單向資料流,並同時避免了不必要的大量 DOM 操作所帶來的效能問題
值得一提的是,component 類型的 React element(例如 <MyComponent />
)嚴格上來說並不是 component 的
實例,而是「描述畫面中的該處需要維持一個 component 實例的標記」,React 在判讀到這個 React element 之後,才會在內部機制中自動建立並維持一個真正的 component 實例,這種實例被稱作「fiber node」
當我們在畫面中呼叫一個 component 時,會執行 component function 來產生區塊畫面的 React element 結果,這個過程被稱為「一次 render」
如果 component 的回傳內容包含其他子 component,則父 component 的 render 會觸發子 component 的 render,形成層次化結構,且整個過程由上而下、由外而內進行
當一個 component function 首次被呼叫並執行時,它會進行第一次的 render 來產生初始狀態的畫面的 React element
當 component 內部狀態更新,React 會重新執行 component function 以新版資料來產生構成對應新版畫面的 React element,即「re-render」
Re-render 的過程中,React 不會去修改舊的 React element,而是根據新的資料重新產生一份新版的 React element,然後透過比較新舊元素結構來找出差異,並針對這些差異之處更新實際 DOM
useState
useState
接收一個 initialState
參數作為該 state 的初始預設值,並回傳一個陣列,該陣列會包含兩個項目:
setState
方法」,是一個普通的 JavaScript 函式setState
方法透過呼叫 setState
方法來更新 state,並觸發該 component 的 re-render
Component 抽象化設計的基本概念
修改 props 但無法被 React 偵測到的危害案例
children
prop 在抽象化設計中的常見用途為什麼 component 命名中的首字母必須為大寫
為什麼 useState
的回傳值是一個陣列
React 如何辨認同一個 component 中的多個 state
...以及更多額外知識點補充
Component 是畫面組成的區塊藍圖,而 state 是依附於 component 來運作的「狀態資料」。State 作為 React 中單向資料流的起點,其 setState 方法也是發起畫面重繪的啟動點
Component 也是一律重繪的界線。當 component 中的 state 發起更新時,只會重繪該 component 以內(包含子孫代 component)的畫面區塊
Object.is()
檢查新舊 state 是否相同:
此時 React 會根據稍早呼叫 setState
方法時所指定的新值來更新 state,並再次執行 component function,以新版本的 props 與 state 等資料來 render 出一份新版的 React element
將新版 React element 與前一次 render 時產生的舊版 React element,以 diffing 演算法進行樹狀結構的比較
操作更新那些新舊 React element 結構差異之處所對應的實際 DOM element,以完成畫面的更新。
將新版 React element 與前一次 render 時產生的舊版 React element,以 diffing 演算法進行樹狀結構的比較
操作更新那些新舊 React element 結構差異之處所對應的實際 DOM element,以完成畫面的更新。
在 class component 中,儘管 props 是 immutable 的,但 this 不是。每次 rerender 時,React 會用新的 props 覆蓋 this 中的舊版 props。因此,使用 this.props 將獲得最新的 props 資料。這可能導致在非同步事件中,錯誤的讀取到最新版的 props,而非預期的舊版資料
React 不會去監聽資料的變化,你必須自己主動告知 React(也就是呼叫 setState
方法)有資料需要更新並觸發 re-render
React 不會在 setState
方法被呼叫時檢查新舊資料之間的詳細差異之處(例如陣列或物件裡的細節內容差異),而是只會以 Object.is
方法簡單的比較一下來決定是否繼續發起 reconciliation
一次 render 做的事情就是以當下版本的 props 與 state 重新執行一次 component function
每次 render 時都會捕捉到屬於它自己版本的 props 與 state 值作為快照,這個值是個只存在於該次 render 中的常數,其內容永遠不會改變
本次 render 所宣告的 count
變數為 2,且這個變數的值永遠不會改變
本次 render 宣告了一個新的 handleAlertButtonClick
函式作為 event handler,而其內容中會連帶宣告了一個新的 setTimeout
callback 函式,且該 callback 函式存取了本身作用域外的變數 count
,並且會因為 closure 的特性而一直記得這個變數。而由於 count
變數的值永遠不變的緣故,所以等同於這個 setTimeout
callback 函式所讀取到的資料也永遠不變
本次 render 最後會產生一份新的 React element,其中在按鈕的 onClick
上綁定本次 render 新產生的 handleAlertButtonClick
函式
Component 的生命週期
Immutable 資料與 closure 的結合
...以及更多額外知識點補充
副作用(effect)是指一個函式除了回傳結果值之外,還會與函式外部環境互動或產生其他影響,例如修改全域變數或進行網路請求
在程式設計中,一個函式中帶有副作用並不一定是絕對的壞事。在某些情境下,這些被認為是「副作用」的行為所帶來的影響與效果,反而才是該函式主要的執行作用。不過,比起沒有副作用的函式,帶有副作用的函式確實可能會造成一些負面的影響:
可預測性降低
測試困難
高耦合度
難以維護和理解
優化限制
useEffect
來處理副作用若在 component function 中直接執行副作用的處理,則當它隨著應用程式狀態 更新而多次 re-render 後,這些副作用所造成的影響就會不斷疊加,且其疊加次數難以預期
直接在 component function 中執行副作用的處理可能會阻塞其產生 React element 的過程,導致畫面更新的卡頓而引發效能問題
useEffect
effectFunction
:
是一個函式,可以在裡面放置你所需要的副作用處理邏輯。而如果這個副作用所造成的影響是需要被清理或逆轉的話,你可以讓 effectFunction
這個函式本身回傳另一個包含清理副作用流程的 cleanup 函式
會在 component 每次 render 完成且實際 DOM 被更新後被執行一次。每當一次 render 完成後,React 會先執行前一次 render 所對應的 cleanup 函式(如果有
提供的話),然後才執行本次 render 的 effect 函式
Component unmount 時也會執行最後一次 render 的 cleanup 函式
useEffect
dependencies
:
是一個可選填的陣列參數。這個陣列應包含在 effectFunction
中所有依賴到的
component 資料項目,例如:props、state 或任何會受到資料流影響的延伸資料
如果不提供 dependencies
陣列這個參數的話,effectFunction
預設會在每一次
render 之後都被執行一次。而如果有提供此參數的話,則 React 會在 re-render
時以 Object.is 方法來一一比較陣列中所有依賴項目的值與前一次 render 時的
版本是否相同,如果都相同的話則會跳過執行本次 render 的 effectFunction
Component 的每次 render 都有其自己版本的 props 與 state 快照,它們的值是永遠不變的
而 render 中所定義產生的各種函式 —— 包括 event handler 函式、effect 函式、cleanup 函式⋯等等,都會透過 closure「捕捉並記住」該次 render 中的 props 與 state 快照,因此無論這些函式在多久之後才被執行,它所讀取到的 props 與 state 內容永遠是固定不變的
useEffect
是一種宣告式的同步化,而不是 function component 的生命週期 APIuseEffect
讓你根據 render 中的 props 和 state 資料來同步化那些與畫面無關的其他東西,也就是副作用的處理這個副作用處理無論隨著 render 重複執行了多少次,你的資料流以及程式邏輯都應該保持同步化且正常運作
如果你嘗試去控制 effect 函式只會在第一次的 render 才執行的話,其實是違反了 useEffect
本身的設計思維
首次 render 時,props.name
的值是 'foo'
,state count 的值則是 0
:
Render 出畫面 React element,瀏覽器完成實際畫面 DOM 的處理。
執行本次 render 對應 props.name 的值為 'foo'
的 effect 函式
記憶這個 useEffect
的 dependencies
陣列中所有變數的值,也就是「props.
name
的值為 'foo'
」,以供下一次 render 時作為依賴比較的依據
經過使用者的按鈕點擊觸發 setCount
方法,而引發第二次 render 時,props.name
的值沒有改變仍是 'foo'
,state count
的值則是變成 1
:
Render 出畫面 React element,瀏覽器完成實際畫面 DOM 的處理
執行本次 render 對應 props.name 的值為 'foo'
的 effect 函式
記憶這個 useEffect
的 dependencies
陣列中所有變數的值,也就是「props.
name
的值為 'foo'
」,以供下一次 render 時作為依賴比較的依據
如果父 component re-render 而連帶引發第三次 render 時,假設 props.name
的值變為 'bar'
,state count
的值則仍是 1
:
檢查 useEffect
的 dependencies
陣列中所有項目的值(這個範例中只有 props.name
一個依賴),與前一次 render 時的版本是否全部相同(在之前的 render 時有記憶下來,所以可以拿出來比較)
前一次 render 版本的 props.name
為 'foo'
,而本次 render 版本的 props.name
為 'bar'
,因此判定依賴資料與前一次 render 版本的值有所不同,所以應該照常執行本次 render 的 effect 函式。
執行本次 render 對應 props.name 的值為 'bar'
的 effect 函式
記憶這個 useEffect
的 dependencies
陣列中所有變數的值,也就是「props.name
的值為 'bar'
」,以供下一次 render 時作為依賴比較的依據
這個觀念的邏輯其實很簡單,因為 dependencies 這種「依賴沒更新時可以安全的跳過」機制其實並不等同於「依賴沒更新時就保證會跳過」的效果。「可以安全的跳過」嚴謹的意思是「如果跳過也不會造成任何問題,但即使因為某些原因所以放棄該 次優化而不跳過、照常執行的話也不會有問題」。
讓 effect 函式對於依賴的資料自給自足
處理函式型別的依賴
以 linter 來輔助填寫 dependencies
Effect dependencies 常見的錯誤用法
React 18 的 effect 函式在 mount 時為何會執行兩次?
副作用處理的常見情境設計技巧
...以及更多額外知識點補充