2019年9月14日 星期六

Web Front-end 菜鳥心得:將 stateful components 遷移到 React hooks 的第 17 週

灰底是各種跨 component 共用的東西,白底是會實際 render 到畫面上的部分

去年十月開始,egghead 電子報就陸陸續續提到 React hooks 的消息。今年二月隨著 React 16.8 發佈,hooks 正式成為內建的功能。今年四月底,Create React App 3.0 也開始支援 hooks。

這段時間,菜鳥我一直抱持觀望的態度,直到最後確定 hooks 是 React 家族不可逆的大轉向,就決定要盡可能趁手上專案還沒大到改不動的時候遷移過去。現在雖然還沒全數改版完,但算是稍微做到一個段落,趕快趁記憶新鮮時記錄一下,給其他有類似改版需求的人參考。


時程與規模


從五月底開始研究並導入 hooks,到現在共進行了 17 週。目前 327 個檔案中,剩下 12 個 class component,使用 state hooks 的 functional component 則有 43 個。也就是說,平均一個禮拜遷移 2 ~ 3 個 stateful component。(p.s. 以上是沒寫測試的狀況,有寫測試的話速度應該會減半吧 😂)

由於產品還在不斷新增跟修改功能的 prototyping 階段,有既定的 milestone 要完成,因此改版的進度要跟 milestone 配合。除了一開始的技術研究排了一整週、基礎架構設計也排了一整週之外,多半是做到哪個畫面就順手改版相關的 component,讓業主(內部客戶,也就是負責提需求的同事)可以一直都覺得有進度,但我的基礎建設也不會卡住。

後來覺得還好是這樣慢慢改版,因為有些 hooks 的用法是寫熟了以後遇到需求才會發現,因此寫到後面的時候,跟最先改版的地方比起來,寫法已經不一樣。要是一開始就大改,那等發現新用法以後,就要再度大改了 😱...

踩過的地雷們


違反基本規則


剛開始犯的低級錯誤是把 hooks 的宣告寫在 if 判斷式內,不過馬上就被 linter 抓出來了。

寫了幾個月,到了以為自己不會再犯這種低級錯誤的最近,linter 竟然又出現類似的警告,說前後兩次 render 之間執行的 hooks 不一致。結果是因為我偷懶,想說反正表單都已經改成 functional component 了,就有幾個會隨著狀態而顯示跟隱藏的表單,沒有寫成正常的 jsx 格式,而是把 props 直接用 function 參數的方式傳遞進去,然後剛好那個表單裡面也有用到 hooks,於是在這些表單們切換的時候,對 react 來說就變成同一個 component 裡面有不同的 hooks 被呼叫了... 好吧寫得有點繞口令,總之結論就是沒事的時候不要因為人家是 functional component 就隨便當作普通 function 來呼叫 😅

製造出無窮迴圈


以前 class component 的年代,我就已經很擅長用 ComponentDidUpdate 來製造無窮迴圈了,到了 functional component 的時代,換成用 useEffect 製造無窮迴圈一樣難不倒我喔喔喔 ٩( ᐛ )و

製造出活在過去的 function


就是 useCallback 的 dependency array 少了東西,雖然 linter 會叫,但因為改版到一半的時候,常常同時幹了太多 linter 會叫的事情,所以有時候就沒仔細看他在吵什麼... (被拖走)

這同時有另一個層面的問題,是 useCallback 的濫用,後面會寫。

state 更新之後浪追過前浪


通常發生在表單的 handleInputChange 的 function 裡面。簡單說就是,當使用者在表單輸入東西時,我更新表單資料的 state,然後緊接著在下一行,馬上用表單資料去更新按鈕的 state。由於 state 的更新需要一點點時間,因此通常更新按鈕 state 的時候,拿到的表單資料還是舊版的,於是就會計算出錯誤的按鈕 state 來。

第一時間的解決方式,是把緊鄰的兩個 state 更新套在一起,也就是在更新表單 state 的函式裡面,先用新版表單資料更新按鈕 state 以後,再把它 return 回去。後來則是改成用 useEffect 訂閱表單資料,當 react 偵測到表單有變動,就自動去計算按鈕的 state。

state 更新慘遭無視


這其實跟 hooks 沒啥關係,但我就是常撞到 😓 因為 javascript 的傳址特性,在更新 state 的時候,如果那個 state 的值剛好是個物件,而 return 時是回傳修改過的物件、不是複製一個新物件的話,那對 react 來說這個 state 就等於沒更新過,也不會觸發 re-render。撞過好幾次以後,現在在 return 時看到是 prev 而不是 {...prev} 的話,會反射性地感到一股惡寒... 算是有被訓練成功吧 😆

因為 hooks 而改變的小習慣


把 state 相關操作拆散到下層 component


以往 class component 的年代,一來因為傳遞 function 時要考慮 this 的 binding 問題(我懶得 bind 所以後來都改用 => 宣告)讓人有種莫名的負擔感,二來因為 stateless component 轉成 stateful component 要改變宣告方式、增加縮排、還要注意 props 跟 state 是不是有應該同步但意外脫勾的地方八拉八拉... 覺得很煩,因此自然而然地就會傾向於把「那一陀阿雜的東西」也就是 state 相關操作全部塞在同一層 component 裡面,反正出事的話往這個檔案去 debug 就對了,下層的零件則盡量維持 stateless。

換到 hooks 之後,變數 foo 就是變數 foo,再也沒有 this.foo 或 this.state.foo 跟 this.props.foo 之分,一切都變得簡潔明瞭。stateless 跟 stateful 之間也不需要刻意轉換,操作 state 這件事情,變得跟打開水龍頭一樣自然。就這樣不知不覺的,等我自己發現的時候,state 的操作已經拆得好散了。比如說一個顯示資料需要經過額外計算、且跟其他表單共享重複欄位的複雜的表單,最上層的 component 只宣告 [formData, setFormData] 跟 handleSubmit,欄位內容的計算跟 handleInputChange 則全都寫在底下的 component 裡面,這些共享的子元件不再是單純 render 用,而是可以自己把事情處理完的獨立個體了。

把表單驗證跟按鈕狀態檢查移到 useEffect


如同前面提過的,以往都是在 handleChange 的時候自己手動做表單驗證跟更新 button 的狀態,遇到複雜的表單,同時有多個承接 event 的 functions 的時候,每個 function 裡面都要手動重複寫這個驗證的動作,寫到會懷疑人生。

自從有了 useEffect,不管總共有幾個 functions 在處理表單的變更,我只要請 useEffect 在表單資料本人有變化的時候自動幫我做驗證就可以了,多麼的清爽,多麼的愉快,感覺身體都變輕盈了有沒有 😃✨

一言不合就 useContext 


以往 context consumer 是以 render props 的型態出現,要多包一層東西、多增加兩層縮排,跟取用 state 一樣給人一種大費周章的感覺,自然而然地就不是很想用。自從有了 context hook,取用 context 這件事也變得跟打開水龍頭一樣自然,於是我終於可以盡情使用 context api 了 ☺️

一言不合就抽 custom hook


傳說中任何需要重複使用的 state 操作邏輯都可以抽成 custom hook,不過因為我到上個月才開始比較熟悉 custom hook 的用法,所以目前為止除了 api 相關的東西以外,只抽了一個 custom hook 是處理表單按鈕狀態用的。可能要再過一陣子才會有進一步的領悟吧 🤔

做過頭的事


濫用 useCallback


傳說中,由於 functional component 的 re-render 會變得很頻繁,因此用 useCallback 把 function 包起來,可以省下每次 re-render 時重新宣告 function 的成本。

不過這禮拜看 useCallback 跟 useMemo 的比較文的時候,才知道用 useCallback 或 useMemo 打包 function 的話,一來光是打包這個動作,就會比純 function 多出一些成本,二來打包以後的 function 不再會被正常地 gc,所以過度濫用的話,可能反而會在效能上因小失大。

前兩天推友也有討論到這件事,目前為止的結論:

多餘的 React.memo


傳說中,使用 context 後,re-render 也會變頻繁,因為只要 context 的 value 有變化,它的 consumer 就會重新 render。在這過程中,不想殃及池魚的話,可以用 memo 把無辜的 component 包起來... 不過因為我的主要 component 幾乎都吃 context,沒有一個是無辜的,所以有包跟沒包其實差不多 😆

查資料的過程中,看到 react 的 github 上有人針對 context 導致的 re-render 開 issue,而 Dan 的回應是第一推薦把 context 拆開,這樣至少 A context 更新的時候,吃 B context 的 components 不會受到影響;在拆 context 不能的狀態下,才考慮 React.memo 或 useMemo。

由於目前的效能感覺還 ok,這個問題我可能就先放一邊,等哪天真的覺得 re-render 造成問題的時候再來拆 context 吧 🍵

總結


這 17 週以來深深覺得,在有 hooks 的年代寫 React app 真的是一件很幸福的事,完全無法想像以前的我到底怎麼忍受 class component 那些 this 啦 life cycle 的囉唆事。

話說有個蠻常看到人家討論、但還沒去研究的東西,叫 useReducer。我原本跟 Redux 就不熟,加上目前為止沒遇到非它不可解決的問題,自然也就很難想像應用的方式。可能再過一陣子才會有所體悟吧 🤔

嗯,原本以為只會寫個兩三段,沒想到一邊寫一邊勾起回憶,最後變成這麼一大篇 😂 希望對其他菜鳥有幫助,有老鳥願意指導的話更是歡迎 😃

沒有留言:

張貼留言