初學React useEffect Hook

React Hooks 是從功能組件訪問 React 的狀態和生命周期方法的最佳方式。 useEffect Hook 是一個在渲染之后和每次 DOM 更新時運行的函數(效果)。在本文中,將討論一些技巧以更好地使用 useEffect Hook。
通過項目來發現問題,加深對其理解應用到項目中。
項目GITHUB:https://github.com/QuintionTang/react-giant
開始之前先簡單來理解一下 useEffect 設計。
useEffect 設計
React 提供了一個 useEffect 鉤子函數來設置在更新后的回調:
const Title = () => {
useEffect(() => {
window.title = "Hello World";
return () => {
window.title = "NoTitle";
};
}, []);
};
useEffect 函數采用名為 create 的回調函數作為其第一個輸入參數來定義效果。上面的代碼,Effect 在安裝組件時將 window.title 設置為 Hello World。
create 函數可以返回一個名為 destroy 的函數來執行清理。這里有趣的是 destroy 函數由 create 函數的返回值提供。在前面的示例中,清理將 window.title 對象在卸載時設置為 NoTitle。
useEffect 參數列表中的第二個參數是一個名為 deps 的依賴項數組。如果未設置 deps,則在每次更新期間每次都會調用 Effect,而當給出 deps 時,Effect 只會在 deps 數組發生更改時調用。
子組件 Effects 優先觸發
將 useEffect Hook 視為 componentDidMount、componentDidUpdate 和 componentWillUnmount 的組合。所以 useEffect Hook 的行為類似于類生命周期方法。需要注意的一種行為是子回調在父回調之前觸發。
function ParentComponent() {
useEffect(() => {
console.log("我是父組件");
});
return <ChildComponent />;
}
function ChildComponent({ fetchProduct }) {
useEffect(() => {
console.log("我是子組件");
});
}
假設必須自動觸發付款。這段代碼寫在 render 之后運行的子組件中,但是實際支付所需的詳細信息(總金額、折扣等)是在父組件的 effect 中獲取的。在這種情況下,由于在設置所需的詳細信息之前觸發了付款,因此就會出現實現邏輯不對。
因此在構建代碼的時候需要考慮子組件的
useEffect會優先執行。
依賴數組
從基礎開始。 useEffect Hook 接受第二個參數,稱為依賴數組,以控制回調何時觸發。
對每個 DOM 更新運行效果
不傳遞依賴項數組將在每次 DOM 更新時運行回調。
useEffect(() => {
console.log("每次DOM更新時,我都會被調用");
});
在初始渲染上運行效果
傳入空數組僅在初始渲染后運行效果。至此,狀態已更新為初始值。 DOM 中的進一步更新不會調用此效果。
useEffect(() => {
console.log("我只在初始渲染后被調用一次");
}, []);
這類似于 componentDidMount 和 componentWillUnmount(返回)生命周期方法。這是添加頁面所需的所有偵聽器和訂閱的地方。
對特定 props 變化的運行效果
假設必須根據用戶感興趣的產品來獲取數據(產品詳細信息),如,所選產品有一個 productId,需要在每次 productId 更改時運行回調——而不僅僅是在初始渲染或每次 DOM 更新時。
useEffect(() => {
getProductDetails(productId);
}, [productId]);
這基本上復制了 componentDidUpdate 生命周期方法。還可以將多個值傳遞給依賴數組。
一個經典的反例可以幫助更好地理解這一點:
useEffect(() => {
console.log(`當counter1: ${counter1}或counter2: ${counter2}發生變化時,我會被調用。`);
}, [counter1, counter2]);
在上面的示例中,counter1 或 counter2 中的更新將觸發。
在依賴數組中傳遞對象
現在,如果回調依賴是一個對象怎么辦。如果這樣做,effects 會成功運行嗎?
const [productId, setProductId] = useState(0);
const [obj, setObj] = useState({ a: 1 });
useEffect(() => {
// 對`obj`的變化做些什么
}, [obj]);
答案是否定的,因為對象是引用類型。對象屬性的任何更改都不會被依賴項數組監聽到,因為只檢查引用而不檢查內部的值。
可以遵循幾種方法在對象中執行深度比較。
JSON.stringify對象:
const [objStringified, setObj] = useState(JSON.stringify({ a: 1 }));
useEffect(() => {
//
}, [objStringified]);
現在,useEffect 可以檢測到對象的屬性何時發生變化并按預期運行。
- useRef 和 Lodash 進行比較:
還可以編寫自定義函數以使用 useRef 進行比較。它用于在組件的當前屬性中的整個生命周期中保存可變值。
function deepCompareEquals(prevVal, currentVal) {
return _.isEqual(prevVal, currentVal);
}
function useDeepCompareWithRef(value) {
const ref = useRef();
if (!deepCompareEquals(value, ref.current)) {
ref.current = value;
}
return ref.current;
}
function MyComponent({ obj }) {
useEffect(() => {
//
}, [useDeepCompareWithRef(obj)]);
}
- 外部packages:如果對象太復雜而無法自己進行比較,推薦一個第三方庫 use-deep-compare-effect:
import useDeepCompareEffect from "use-deep-compare-effect";
function MyComponent({ obj }) {
useDeepCompareEffect(() => {}, [obj]);
}
useDeepCompareEffect 將進行深度比較并僅在對象 obj 更改時運行回調。
將 useEffect 用于單一目的
上面了解了依賴數組,可能需要分離 useEffect 以在組件的不同生命周期事件上運行,或者只是為了更清晰的代碼,函數應該服務于單一目的(就像一個句子應該只傳達一個想法一樣)。
將 useEffects 拆分為簡短單一用途函數可以降低BUG的出現。例如,假設有與 varB 無關的 varA,并且想要基于 useEffect(帶有 setTimeout)構建一個遞歸計數器,先來看一段不推薦的代碼:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
const [varB, setVarB] = useState(0);
useEffect(() => {
const timeoutA = setTimeout(() => setVarA(varA + 1), 1000);
const timeoutB = setTimeout(() => setVarB(varB + 2), 2000);
return () => {
clearTimeout(timeoutA);
clearTimeout(timeoutB);
};
}, [varA, varB]);
return (
<>
<span>
Var A: {varA}, Var B: {varB}
</span>
</>
);
}
上述代碼,變量 varA 和 varB 中的任何一個更改都會觸發兩個變量的更新。這就是為什么這個鉤子不能正常工作的原因。由于這是一個簡短的示例,可能會覺得它很明顯,但是,在具有更多代碼和變量的較長函數中,會因此錯過這一點。所以做正確的事并拆分 useEffect 的邏輯。
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
const [varB, setVarB] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, [varA]);
useEffect(() => {
const timeout = setTimeout(() => setVarB(varB + 2), 2000);
return () => clearTimeout(timeout);
}, [varB]);
return (
<>
<span>
Var A: {varA}, Var B: {varB}
</span>
</>
);
}
上述代碼僅為了說明問題,實際編碼有些地方可以用其他的方式。
盡可能使用自定義掛鉤
再次以上面的例子為例,如果變量 varA 和 varB 完全獨立怎么辦?在這種情況下,可以簡單地創建一個自定義鉤子來隔離每個變量。這樣,就可以確切地知道每個函數對哪個變量做了什么。
下面就來構建一些自定義鉤子。
import React, { useEffect, useState } from "react";
const useVarA = () => {
const [varA, setVarA] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, [varA]);
return [varA, setVarA];
};
const useVarB = () => {
const [varB, setVarB] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setVarB(varB + 2), 2000);
return () => clearTimeout(timeout);
}, [varB]);
return [varB, setVarB];
};
export default function Home() {
const [varA, setVarA] = useVarA();
const [varB, setVarB] = useVarB();
return (
<>
<span>
Var A: {varA}, Var B: {varB}
</span>
</>
);
}
這樣每個變量都有自己的鉤子,更易于維護和易于閱讀!
有條件地以正確的方式運行 useEffect
關于 setTimeout ,再來看個例子:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, [varA]);
return (
<>
<span>Var A: {varA}</span>
</>
);
}
出于某種原因,想將計數器的最大值限制為 5。有正確的方法和錯誤的方法。
先來看看錯誤的做法:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
useEffect(() => {
let timeout;
if (varA < 5) {
timeout = setTimeout(() => setVarA(varA + 1), 1000);
}
return () => clearTimeout(timeout);
}, [varA]);
return (
<>
<span>Var A: {varA}</span>
</>
);
}
雖然這有效,但 clearTimeout 將在 varA 發生更改時運行,而 setTimeout 是有條件地運行。
有條件地運行 useEffect 的推薦方法是在函數開頭執行條件返回,如下所示:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
useEffect(() => {
if (varA >= 5) return;
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, [varA]);
return (
<>
<span>Var A: {varA}</span>
</>
);
}
在依賴數組中輸入 useEffect 中的每個道具
如果正在使用 ESLint,那么可能已經看到來自 ESLint exhaustive-deps 規則的警告。這是至關重要的,當應用程序變得越來越大時,每個 useEffect 中都會添加更多的依賴項(props)。為了跟蹤所有這些并避免陳舊的閉包,應該將每個依賴項添加到依賴項數組中。
同樣,關于 setTimeout 的問題,假設只想運行一次 setTimeout 并添加到 varA:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, []); // 避免這種情況:varA 不在依賴數組中!
return (
<>
<span>Var A: {varA}</span>
</>
);
}
雖然上述代碼會正確執行,但是如果代碼變得更大或者更復雜,可能就會帶來問題。在這種情況下,需要將所有變量都映射出來,因為這樣可以更容易地測試和檢測可能出現的問題(例如過時的 props 和閉包)。
正確的做法應該是:
import React, { useEffect, useState } from "react";
export default function Home() {
const [varA, setVarA] = useState(0);
useEffect(() => {
if (varA > 0) return;
const timeout = setTimeout(() => setVarA(varA + 1), 1000);
return () => clearTimeout(timeout);
}, [varA]);
return (
<>
<span>Var A: {varA}</span>
</>
);
}
總結
上面學習了什么是 useEffect ?如何更好的使用 useEffect?如果了解基本概念,那么使用 useEffect 就不會有任何問題。學習的一些內容講通過一個個人項目的形式逐漸完善,豐富功能模塊。