App下載

react hooks線上 bug后復(fù)盤

猿友 2020-09-03 11:37:34 瀏覽數(shù) (2539)
反饋

文章轉(zhuǎn)載自公眾號(hào):前端之露

最近團(tuán)隊(duì)內(nèi)有同學(xué),由于寫 react hooks 引發(fā)了一些 bug,甚至有 1 例是線上問(wèn)題。團(tuán)隊(duì)內(nèi)也因此發(fā)起了一些爭(zhēng)執(zhí),到底要不要寫 hooks?到底要不要加 lint?到底要不要加 autofix?爭(zhēng)論下來(lái)結(jié)論如下:

  1. 寫還是要寫的;
  2. 寫 hooks 前一定要先學(xué)習(xí) hooks;
  3. 團(tuán)隊(duì)再出一篇必讀文檔,必須要求每位同學(xué),先讀再寫。

因此便有了此文。

本文主要講兩大點(diǎn):

  1. 寫 hooks 前的硬性要求;
  2. 寫 hooks 常見的幾個(gè)注意點(diǎn)。

硬性要求

1. 必須完整閱讀一次 React Hooks 官方文檔

英文文檔:https://reactjs.org/docs/hooks-intro.html

中文文檔:https://zh-hans.reactjs.org/docs/hooks-intro.html

其中重點(diǎn)必看 hooks: useStateuseReducer、useEffect、useCallback、useMemo 另外推薦閱讀:

  1. Dan的《useEffect完全指南》
  2. 衍良同學(xué)的《React Hooks完全上手指南》

2. 工程必須引入 lint 插件,并開啟相應(yīng)規(guī)則

lint 插件:https://www.npmjs.com/package/eslint-plugin-react-hooks 必開規(guī)則:

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

其中, react-hooks/exhaustive-deps 至少 warn,也可以是 error。建議全新的工程直接配 "error",歷史工程配 "warn"。

切記,本條是硬性條件。

如果你的工程,當(dāng)前沒(méi)開啟 hooks lint rule,請(qǐng)不要編寫任何 hooks 代碼。如果你 CR 代碼時(shí),發(fā)現(xiàn)對(duì)方前端工程,沒(méi)有開啟相應(yīng)規(guī)則,并且提交了 hooks 代碼,請(qǐng)不要合并。該要求適應(yīng)于任何一個(gè) React 前端工程。

這兩條規(guī)則會(huì)避免我們踩坑。雖然對(duì)于 hooks 新手,這個(gè)過(guò)程可能會(huì)比較“痛苦”。不過(guò),如果你覺(jué)得這兩個(gè)規(guī)則對(duì)你編寫代碼造成了困擾,那說(shuō)明你還未完全掌握 hooks。

如果對(duì)于某些場(chǎng)景,確實(shí)不需要「exhaustive-deps」,可在代碼處加: // eslint-disable-next-line react-hooks/exhaustive-deps

切記只能禁本處代碼,不能偷懶把整個(gè)文件都禁了。

3. 如若有發(fā)現(xiàn) hooks 相關(guān) lint 導(dǎo)致的 warning,不要全局 autofix

除了 hooks 外,正常的 lint 基本不會(huì)改變代碼邏輯,只是調(diào)整編寫規(guī)范。但是 hookslint 規(guī)則不同,exhaustive-deps 的變化會(huì)導(dǎo)致代碼邏輯發(fā)生變化,這極容易引發(fā)線上問(wèn)題,所以對(duì)于 hookswaning,請(qǐng)不要做全局 autofix 操作。除非保證每處邏輯都做到了充分回歸。

另外公司內(nèi)部有個(gè)小姐姐補(bǔ)充道:eslint-plugin-react-hooks 從2.4.0版本開始,已經(jīng)取消了 exhaustive-deps 的autofix。所以,請(qǐng)盡量升級(jí)工程的lint插件至最新版,減少出錯(cuò)風(fēng)險(xiǎn)。

然后建議開啟 vscode 的「autofix on save」。未來(lái)無(wú)論是什么問(wèn)題,能把 error 與 warning 盡量遏制在最開始的開發(fā)階段,保證自測(cè)跟測(cè)試時(shí)就是符合規(guī)則的代碼。

常見注意點(diǎn)

依賴問(wèn)題

依賴與閉包問(wèn)題是一定要開啟exhaustive-deps 的核心原因。最常見的錯(cuò)誤即:mount 時(shí)綁定事件,后續(xù)狀態(tài)更新出錯(cuò)。

錯(cuò)誤代碼示例:(此處用 addEventListener 做 onclick 綁定,只是為了方便說(shuō)明情況)

function ErrorDemo() {
  const [count, setCount] = useState(0);
  const dom = useRef(null);
  useEffect(() => {
    dom.current.addEventListener('click', () => setCount(count + 1));
  }, []);
  return <div ref={dom}>{count}</div>;
}

這段代碼的初始想法是:每當(dāng)用戶點(diǎn)擊 dom,count 就加1。理想中的效果是一直點(diǎn),一直加。但實(shí)際效果是 {count} 到「1」以后就加不上了。

我們來(lái)梳理一下, useEffect(fn, []) 代表只會(huì)在 mount 時(shí)觸發(fā)。也即是首次 render 時(shí),fn 執(zhí)行一次,綁定了點(diǎn)擊事件,點(diǎn)擊觸發(fā) setCount(count + 1) 。乍一想,count 還是那個(gè) count,肯定會(huì)一直加上去呀,當(dāng)然現(xiàn)實(shí)在啪啪打臉。

狀態(tài)變更觸發(fā)頁(yè)面渲染的本質(zhì)是什么?本質(zhì)就是 ui = fn(props, state, context) 。props、內(nèi)部狀態(tài)、上下文的變更,都會(huì)導(dǎo)致渲染函數(shù)(此處就是ErrorDemo)的重新執(zhí)行,然后返回新的 view。

那現(xiàn)在問(wèn)題來(lái)了, ErrorDemo 這個(gè)函數(shù)執(zhí)行了多次,第一次函數(shù)內(nèi)部的 count 跟后面幾次的 count 會(huì)有關(guān)系嗎?這么一想,感覺(jué)又應(yīng)該沒(méi)有關(guān)系了。那為什么第二次又知道 count 是1,而不是 0 了呢?第一次的setCount 跟后面的是同一個(gè)函數(shù)嗎?這背后涉及到 hooks 的一些底層原理,也關(guān)系到了為什么 hooks 的聲明需要聲明在函數(shù)頂部,不允許在條件語(yǔ)句中聲明。在這里就不多講了。

結(jié)論是:每次 count 都是重新聲明的變量,指向一個(gè)全新的數(shù)據(jù);每次的setCount 雖然是重新聲明的,但指向的是同一個(gè)引用。

回到正題,我們知道了每次 render,內(nèi)部的 count 其實(shí)都是全新的一個(gè)變量。那我們綁定的點(diǎn)擊事件方法,也即:setCount(count + 1) ,這里的 count,其實(shí)指的一直是首次 render 時(shí)的那個(gè) count,所以一直是 0 ,因此 setCount,一直是設(shè)置 count 為1。

那這個(gè)問(wèn)題怎么解?

首先,應(yīng)該遵守前面的硬性要求,必須要加 lint 規(guī)則,并開啟 autofix on save。然后就會(huì)發(fā)現(xiàn),其實(shí)這個(gè) effect 是依賴 count 的。autofix 會(huì)幫你自動(dòng)補(bǔ)上依賴,代碼變成這樣:

useEffect(() => {
  dom.current.addEventListener('click', () => setCount(count + 1));
}, [count]);

那這樣肯定就不對(duì)了,相當(dāng)于每次 count 變化,都會(huì)重新綁定一次事件。所以對(duì)于事件的綁定,或者類似的場(chǎng)景,有幾種思路,我按我的常規(guī)處理優(yōu)先級(jí)排列:

思路1:消除依賴

在這個(gè)場(chǎng)景里,很簡(jiǎn)單,我們主要利用 setCount 的另一個(gè)用法 functional updates。這樣寫就好了: () => setCount(prevCount => ++prevCount) ,不用關(guān)心什么新的舊的、什么閉包,省心省事。

思路2:重新綁定事件

那如果我們這個(gè)事件就是要消費(fèi)這個(gè) count 怎么辦?比如這樣:

dom.current.addEventListener('click', () => {
  console.log(count);
  setCount(prevCount => ++prevCount);
});

我們不必執(zhí)著于一定只在 mount 時(shí)執(zhí)行一次。也可以每次重新 render 前移除事件,render 后綁定事件即可。這里利用 useEffect 的特性,具體可以自己看文檔:

useEffect(() => {
  const $dom = dom.current;
  const event = () => {
    console.log(count);
    setCount(prev => ++prev);
  };
  $dom.addEventListener('click', event);
  return () => $dom.removeEventListener('click', event);
}, [count]);

思路3

如果嫌這樣開銷大,或者編寫麻煩,也可以用 useRef 其實(shí)用 useRef 也挺麻煩的,我個(gè)人不太喜歡這樣操作,但也能解決問(wèn)題,代碼如下:

const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
  dom.current.addEventListener('click', () => {
    console.log(countRef.current);
    setCount(prevCount => {
      const newCount = ++prevCount;
      countRef.current = newCount;
      return newCount;
    });
  });
}, []);

useCallback 與 useMemo

這兩個(gè) api,其實(shí)概念上還是很好理解的,一個(gè)是「緩存函數(shù)」, 一個(gè)是緩存「函數(shù)的返回值」。但我們經(jīng)常會(huì)懶得用,甚至有的時(shí)候會(huì)用錯(cuò)。

從上面依賴問(wèn)題我們其實(shí)可以知道,hooks對(duì)「有沒(méi)有變化」這個(gè)點(diǎn)其實(shí)很敏感。如果一個(gè) effect 內(nèi)部使用了某數(shù)據(jù)或者方法。若我們依賴項(xiàng)不加上它,那很容易由于閉包問(wèn)題,導(dǎo)致數(shù)據(jù)或方法,都不是我們理想中的那個(gè)它。如果我們加上它,很可能又會(huì)由于他們的變動(dòng),導(dǎo)致 effect 瘋狂的執(zhí)行。真實(shí)開發(fā)的話,大家應(yīng)該會(huì)經(jīng)常遇到這種問(wèn)題。

所以,在此建議:

  1. 在組件內(nèi)部,那些會(huì)成為其他 useEffect 依賴項(xiàng)的方法,建議用 useCallback 包裹,或者直接編寫在引用它的useEffect中。
  2. 己所不欲勿施于人,如果你的 function 會(huì)作為 props傳遞給子組件,請(qǐng)一定要使用 useCallback 包裹,對(duì)于子組件來(lái)說(shuō),如果每次render都會(huì)導(dǎo)致你傳遞的函數(shù)發(fā)生變化,可能會(huì)對(duì)它造成非常大的困擾。同時(shí)也不利于 react 做渲染優(yōu)化。

不過(guò)還有一種場(chǎng)景,大家很容易忽視,而且還很容易將 useCallbackuseMemo 混淆,典型場(chǎng)景就是:節(jié)流防抖。

舉個(gè)例子:

function BadDemo() {
  const [count, setCount] = useState(1);
  const handleClick = debounce(() => {
    setCount(c => ++c);
  }, 1000);
  return <div onClick={handleClick}>{count}</div>;
}

我們希望防止用戶連續(xù)點(diǎn)擊觸發(fā)多次變更,加了防抖,停止點(diǎn)擊1秒后才觸發(fā) count + 1 ,這個(gè)組件在理想邏輯下是OK的。但現(xiàn)實(shí)是骨感的,我們的頁(yè)面組件非常多,這個(gè) BadDemo 可能由于父級(jí)什么操作就重新 render 了?,F(xiàn)在假使我們頁(yè)面每500毫秒會(huì)重新 render 一次,那么就是這樣:

function BadDemo() {
  const [count, setCount] = useState(1);
  const [, setRerender] = useState(false);
  const handleClick = debounce(() => {
    setCount(c => ++c);
  }, 1000);
  useEffect(() => {
    // 每500ms,組件重新render
    window.setInterval(() => {
      setRerender(r => !r);
    }, 500);
  }, []);
  return <div onClick={handleClick}>{count}</div>;
}

每次 render 導(dǎo)致 handleClick 其實(shí)是不同的函數(shù),那么這個(gè)防抖自然而然就失效了。這樣的情況對(duì)于一些防重點(diǎn)要求特別高的場(chǎng)景,是有著較大的線上風(fēng)險(xiǎn)的。

那怎么辦呢?自然是想加上 useCallback :

const handleClick = useCallback(debounce(() => {
  setCount(c => ++c);
}, 1000), []);

現(xiàn)在我們發(fā)現(xiàn)效果滿足我們期望了,但這背后還藏著一個(gè)驚天大坑。

假如說(shuō),這個(gè)防抖的函數(shù)有一些依賴呢?比如 setCount(c => ++c); 變成了 setCount(count + 1) 。那這個(gè)函數(shù)就依賴了 count 。代碼就變成了這樣:

const handleClick = useCallback(
  debounce(() => {
    setCount(count + 1);
  }, 1000),
  []
);

大家會(huì)發(fā)現(xiàn),你的 lint 規(guī)則,竟然不會(huì)要求你把 count 作為依賴項(xiàng),填充到 deps 數(shù)組中去。這進(jìn)而導(dǎo)致了最初的那個(gè)問(wèn)題,只有第一次點(diǎn)擊會(huì) count++。這是為什么呢?

因?yàn)閭魅?useCallback 的是一段執(zhí)行語(yǔ)句,而不是一個(gè)函數(shù)聲明。只是說(shuō)它執(zhí)行以后返回的新函數(shù),我們將其作為了 useCallback 函數(shù)的入?yún)?,而這個(gè)新函數(shù)具體是個(gè)啥,其實(shí) lint 規(guī)則也不知道。

更合理的姿勢(shì)應(yīng)該是使用 useMemo :

const handleClick = useMemo(
  () => debounce(() => {
    setCount(count + 1);
  }, 1000),
  [count]
);

這樣保證每當(dāng) count 發(fā)生變化時(shí),會(huì)返回一個(gè)新的加了防抖功能的新函數(shù)。

總而言之,對(duì)于使用高階函數(shù)的場(chǎng)景,建議一律使用 useMemo

有些網(wǎng)友提供了寶貴的反饋,我繼續(xù)補(bǔ)充:剛使用useMemo,依舊存在一些問(wèn)題。

問(wèn)題1useMemo「將來(lái)」并不「穩(wěn)定」

react 的官方文檔中提到:你可以把 useMemo 作為性能優(yōu)化的手段,但不要把它當(dāng)成語(yǔ)義上的保證。將來(lái),React 可能會(huì)選擇“遺忘”以前的一些 memoized 值,并在下次渲染時(shí)重新計(jì)算它們,比如為離屏組件釋放內(nèi)存。先編寫在沒(méi)有 > useMemo 的情況下也可以執(zhí)行的代碼 —— 之后再在你的代碼中添加 > useMemo,以達(dá)到優(yōu)化性能的目的。也就是說(shuō),在將來(lái)的某種特殊情況下,這個(gè)防抖函數(shù)依舊會(huì)失效。當(dāng)然,這種情況是發(fā)生在「將來(lái)」,且相對(duì)比較極端,出現(xiàn)概率較小,即使出現(xiàn),也不會(huì)“短時(shí)間內(nèi)連續(xù)”出現(xiàn)。所以對(duì)于不是 「前端防不住抖就要完蛋」的場(chǎng)景,風(fēng)險(xiǎn)相對(duì)較小。

問(wèn)題2useMemo 并不能一勞永逸解決所有高階函數(shù)場(chǎng)景

在示例的場(chǎng)景中,防抖的邏輯是:「連續(xù)點(diǎn)擊后1秒,真正執(zhí)行邏輯,在這過(guò)程中的重復(fù)點(diǎn)擊失效」。而如果業(yè)務(wù)邏輯改成了「點(diǎn)擊后立即發(fā)生狀態(tài)變更,再之后的1秒內(nèi)重復(fù)點(diǎn)擊無(wú)效」,那么我們的代碼可能就變成了。

const handleClick = useMemo( 
  () => throttle(() => { setCount(count + 1); }, 1000), [count] );

然后發(fā)現(xiàn)又失效了。原因是點(diǎn)擊以后,count 立即發(fā)生了變化,然后 handleClick 又重復(fù)生成了新函數(shù),這個(gè)節(jié)流就失效了。

所以這種場(chǎng)景,思路又變回了前面提到的,「消除依賴」 或 「使用ref」。

當(dāng)然啦,也可以選擇自己手動(dòng)實(shí)現(xiàn)一個(gè) debouncethrottle 。我建議可以直接使用社區(qū)的庫(kù),比如 react-use,或者參考他們的實(shí)現(xiàn)自己寫兩個(gè)實(shí)現(xiàn)。

以上就是W3Cschool編程獅關(guān)于react hooks線上 bug后復(fù)盤的相關(guān)介紹了,希望對(duì)大家有所幫助。

0 人點(diǎn)贊