useEffect 完整指南 (opens new window)是一篇很好的文章;读完之后发现自己平时编写代码也有许多未曾注意的点;现简单整理一下 读书笔记;希望对大家有所帮助。
# React 每一次渲染都有自己的 props , state , 事件函数
如下代码,点击三次按钮,点一次 Show alert ;再点两次 按钮,再点 Show alert
最终,第一次 Show alert 会展示 3 第二次会展示 5;也就意味着:
TIP
在任意一次渲染中,props
和state
是始终保持不变的。如果props
和state
在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。它们都“属于”一次特定的渲染。即便是事件处理中的异步函数调用“看到”的也是这次渲染中的 count 值。
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
# React 每次渲染都有它自己的 Effects
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
我们点击之后发生了什么:
你的组件: 喂 React, 把我的状态设置为
1
。React: 给我状态为
1
时候的 UI。你的组件:
- 给你需要渲染的内容:
<p>You clicked 1 times</p>
。 - 记得在渲染完了之后调用这个 effect:
() => { document.title = 'You clicked 1 times' }
。
- 给你需要渲染的内容:
React: 没问题。开始更新 UI,喂浏览器,我修改了 DOM。
Browser: 酷,我已经将更改绘制到屏幕上了。
React: 好的, 我现在开始运行属于这次渲染的 effect
- 运行
() => { document.title = 'You clicked 1 times' }
。
- 运行
# 那 Effect 中的清理又是怎样的呢?
到目前为止,我们可以明确地喊出下面重要的事实:每一个组件内的函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获某次渲染中定义的 props 和 state
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
假设第一次渲染的时候 props 是{id: 10}
,第二次渲染的时候是{id: 20}
;
清除的顺序是:
- React 渲染
{id: 20}
的 UI。 - 浏览器绘制。我们在屏幕上看到
{id: 20}
的 UI。 - React 清除
{id: 10}
的 effect。 - React 运行
{id: 20}
的 effect。
造成此执行顺序的原因是:
React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数 effects 并不会阻塞屏幕的更新。Effect 的清除同样被延迟了
你可能会好奇:如果清除上一次的 effect 发生在 props 变成{id: 20}
之后,那它为什么还能“看到”旧的{id: 10}
?
引用上一部分的结论: 组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获定义它们的那次渲染中的 props 和 state。
# 注意 useEffect 的依赖项
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
因为 useEffect 中的依赖项没有设置 count,那么 setCount 种的 count 始终为 0,所以不管渲染多少次 count 最终的结果依然为 1;
# 办法一: 添加依赖项
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
现在依赖数组正确了。虽然解决问题,但是依然存在 每次 count 修改都会重新运行 effect 的问题
(依赖发生了变更,所以会重新运行 effect。)
![https://overreacted.io/5734271ddfa94d2d65ac6160515e0069/interval-rightish.gif]
# 办法二: 使用 setState 的函数形式
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
**在 effects 中传递最小的信息会很有帮助。**类似于setCount(c => c + 1)
这样的更新形式比setCount(count + 1)
传递了更少的信息,因为它不再被当前的 count 值“污染”。它只是表达了一种行为(“递增”)。“Thinking in React”也讨论了如何找到最小状态 (opens new window)。原则是类似的,只不过现在关注的是如何更新。
存在的问题:
**然而,即使是setCount(c => c + 1)
也并不完美。**它看起来有点怪,并且非常受限于它能做的事。举个例子,如果我们有两个互相依赖的状态,或者我们想基于一个 prop 来计算下一次的 state,它并不能做到。幸运的是, setCount(c => c + 1)
有一个更强大的姐妹模式,它的名字叫useReduce
# 办法三: useReducer
如果存在多个 state 互相依赖,当然你可以使用 useEffect 添加依赖数组的方式处理
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return (
<>
<h1>{count}</h1>
<input value={step} onChange={(e) => setStep(Number(e.target.value))} />
</>
);
}
但是假设如果你不想 useEffect 被执行多次,应该咋办呢?
当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用 useReducer 去替换它们。
const initialState = {
count: 0,
step: 1,
};
function reducer(state, action) {
const { count, step } = state;
if (action.type === "tick") {
return { count: count + step, step };
} else if (action.type === "step") {
return { count, step: action.step };
} else {
throw new Error();
}
}
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" }); // Instead of setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
React 会保证 dispatch 在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器
# useReducer 的注意事项
# useReducer 的值依赖于 props 咋办?
下面的方式虽说可以运行,下面这种方式会使一些优化失效,所以你应该避免滥用它;
可以运行的原理:
当你
dispatch
的时候,React 只是记住了 action - 它会在下一次渲染中再次调用 reducer。在那个时候,新的 props 就可以被访问到,而且 reducer 调用也不是在 effect 里。
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === "tick") {
return state + step;
} else {
throw new Error();
}
}
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
# useEffect 的间接依赖问题
如下的代码是有一定的问题的, useEffect 依赖于 fetchData 函数,fetchData 函数依赖于 query state;也就是说 useEffect 其实是依赖于 query state 的。 也就是 useEffect 的间接依赖
这样会导致的问题是: query 更新了 fetch 的还是 上一次渲染的值。(除非你真的想这样做)
function SearchResults() {
const [query, setQuery] = useState("react");
// Imagine this function is also long
function getFetchUrl() {
return "https://hn.algolia.com/api/v1/search?query=" + query;
}
// Imagine this function is also long
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
正确的办法应该是这样的
function SearchResults() {
const [query, setQuery] = useState("react");
useEffect(() => {
function getFetchUrl() {
return "https://hn.algolia.com/api/v1/search?query=" + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, [query]); // ✅ Deps are OK
// ...
}
# 函数复用多处, useEffect 应该如何处理
# Bad Case 🔴
一个常见的误解是,“函数从来不会改变”。实际上,在组件内定义的函数每一次渲染都在变。
function SearchResults() {
function getFetchUrl(query) {
return "https://hn.algolia.com/api/v1/search?query=" + query;
}
useEffect(() => {
const url = getFetchUrl("react");
// ... Fetch data and do something ...
}, []); // 🔴 Missing dep: getFetchUrl
useEffect(() => {
const url = getFetchUrl("redux");
// ... Fetch data and do something ...
}, []); // 🔴 Missing dep: getFetchUrl
// ...
}
# 请不要将普通函数作为 useEffect 的依赖项 🔴
在组件内定义的函数每一次渲染都在变, 所以我们的依赖数组会变得无用
function SearchResults() {
// 🔴 Re-triggers all effects on every render
function getFetchUrl(query) {
return "https://hn.algolia.com/api/v1/search?query=" + query;
}
useEffect(() => {
const url = getFetchUrl("react");
// ... Fetch data and do something ...
}, [getFetchUrl]); // 🚧 Deps are correct but they change too often
useEffect(() => {
const url = getFetchUrl("redux");
// ... Fetch data and do something ...
}, [getFetchUrl]); // 🚧 Deps are correct but they change too often
// ...
}
# 使用 useCallback 解决函数重复变更问题 ✅
我们要感谢 useCallback
,因为如果 query
保持不变,getFetchUrl
也会保持不变,我们的 effect
也不会重新运行。但是如果 query
修改了,getFetchUrl
也会随之改变,因此会重新请求数据
function SearchResults() {
// ✅ Preserves identity when its own deps are the same
const getFetchUrl = useCallback(
(query) => {
return "https://hn.algolia.com/api/v1/search?query=" + query;
},
[query]
); // ✅ Callback deps are OK
useEffect(() => {
const url = getFetchUrl("react");
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect deps are OK
useEffect(() => {
const url = getFetchUrl("redux");
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect deps are OK
// ...
}
对于通过属性从父组件传入的函数这个方法也适用
function Parent() {
const [query, setQuery] = useState("react");
// ✅ Preserves identity until query changes
const fetchData = useCallback(() => {
const url = "https://hn.algolia.com/api/v1/search?query=" + query;
// ... Fetch data and return it ...
}, [query]); // ✅ Callback deps are OK
return <Child fetchData={fetchData} />;
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect deps are OK
// ...
}
# 是否应该一直使用 useCallback 包裹函数
例子:
function App() {
const method1 = () => {
// ...
};
const method2 = useCallback(() => {
// 这是一个和 method1 功能一样的方法
}, [props.a, props.b]);
return (
<div>
<div onClick={method1}>button</div>
<div onClick={method2}>button</div>
</div>
);
}
我们的 App 函数在每一次更新的时候都会重新执行,由于这个原因,它内部的函数也都会重新生成一次,也就是说,我们的 method1
每次都会重新执行生成一遍。
而 method2
就不一样了,它是被 useCallback
包裹的返回值,除非依赖变化了,不然它不会重新生成,于是,你可能就会认为 method2
那种写法性能更高。
事实上并不一定如此:
对于 method1: 每次函数执行创建新的函数几乎是可以忽略不计的; 对于 method2: 每次 props 更新 重新执行,需要对比前后的 props 是否相同【增加了对比的成本】;为了能判断 useCallback 要不要更新结果,我们还要在内存保存上一次的依赖。【增加了数据存储】
# useCallback 使用场景 1: 需要将函数传递给子对象
// parent.tsx
const handleClick = () => {};
<Children count={count} onClick={handleClick} />;
如果子组件由于其他值的更改而发生了更新,子组件会重新渲染,由于 handleClick
是一个对象,每次渲染生成的 handleClick
都是新的。
这就会导致,尽管 Children
被 React.memo
包裹了一层,但是还是会重新渲染,为了解决这个问题,我们就要这样写 handleClick
函数了:
const handleClick = useCallback(() => {
// 原来的 handleClick...
}, []);
# useCallback 使用场景 2: useEffect 依赖函数变更而变化
这一块上面已经讲到过了,请见【使用 useCallback 解决函数重复变更问题】
# useCallback 使用场景 3: 避免重复的生成函数
获取 DOM 节点的位置或是大小的基本方式是使用 callback ref (opens new window)。每当 ref 被附加到一个另一个节点,React 就会调用 callback。这里有一个 小 demo (opens new window):
function MeasureExample() {
const [height, setHeight] = useState(0);
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>{" "}
<h2>The above header is {Math.round(height)}px tall</h2>
</>
);
}
在这个案例中,我们没有选择使用 useRef
,因为当 ref 是一个对象时它并不会把当前 ref 的值的 变化 通知到我们。使用 callback ref 可以确保 即便子组件延迟显示被测量的节点 (opens new window) (比如为了响应一次点击),我们依然能够在父组件接收到相关的信息,以便更新测量结果。
注意到我们传递了 []
作为 useCallback
的依赖列表。这确保了 ref callback 不会在再次渲染时改变,因此 React 不会在非必要的时候调用它。
在此示例中,当且仅当组件挂载和卸载时,callback ref 才会被调用,因为渲染的 <h1>
组件在整个重新渲染期间始终存在。如果你希望在每次组件调整大小时都收到通知,则可能需要使用 ResizeObserver
(opens new window) 或基于其构建的第三方 Hook。
如果你愿意,你可以 把这个逻辑抽取出来作为 (opens new window) 一个可复用的 Hook:
function MeasureExample() {
const [rect, ref] = useClientRect();
return (
<>
<h1 ref={ref}>Hello, world</h1>
{rect !== null && (
<h2>The above header is {Math.round(rect.height)}px tall</h2>
)}
</>
);
}
function useClientRect() {
const [rect, setRect] = useState(null);
const ref = useCallback((node) => {
if (node !== null) {
setRect(node.getBoundingClientRect());
}
}, []);
return [rect, ref];
}
# useState 依赖于 Props 创建咋办?
第一个常见的使用场景是当创建初始 state 很昂贵时:
function Table(props) {
// ⚠️ createRows() 每次渲染都会被调用
const [rows, setRows] = useState(createRows(props.count));
// ...
}
为避免重新创建被忽略的初始 state,我们可以传一个 函数 给 useState
:
function Table(props) {
// ✅ createRows() 只会被调用一次
const [rows, setRows] = useState(() => createRows(props.count));
// ...
}
React 只会在首次渲染时调用这个函数。参见 useState
API 参考 (opens new window) (opens new window)。
**你或许也会偶尔想要避免重新创建 useRef()
的初始值。**举个例子,或许你想确保某些命令式的 class 实例只被创建一次:
function Image(props) {
// ⚠️ IntersectionObserver 在每次渲染都会被创建
const ref = useRef(new IntersectionObserver(onIntersect));
// ...
}
useRef
不会 像 useState
那样接受一个特殊的函数重载。相反,你可以编写你自己的函数来创建并将其设为惰性的:
function Image(props) {
const ref = useRef(null);
// ✅ IntersectionObserver 只会被惰性创建一次
function getObserver() {
if (ref.current === null) {
ref.current = new IntersectionObserver(onIntersect);
}
return ref.current;
}
// 当你需要时,调用 getObserver()
// ...
}
这避免了我们在一个对象被首次真正需要之前就创建它。如果你使用 Flow 或 TypeScript,你还可以为了方便给 getObserver()
一个不可为 null 的类型
# Hook 会因为在渲染时创建函数而变慢吗?
不会。在现代浏览器中,闭包和类的原始性能只有在极端场景下才会有明显的差别。
除此之外,可以认为 Hook 的设计在某些方面更加高效:
- Hook 避免了 class 需要的额外开支,像是创建类实例和在构造函数中绑定事件处理器的成本。
- 符合语言习惯的代码在使用 Hook 时不需要很深的组件树嵌套。这个现象在使用高阶组件、render props、和 context 的代码库中非常普遍。组件树小了,React 的工作量也随之减少。
传统上认为,在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate
优化有关。Hook 从三个方面解决了这个问题。
useCallback
(opens new window) (opens new window) Hook 允许你在重新渲染之间保持对相同的回调引用以使得shouldComponentUpdate
继续工作:
// 除非 `a` 或 `b` 改变,否则不会变
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
useMemo
(opens new window) (opens new window) Hook 使得控制具体子节点何时更新变得更容易,减少了对纯组件的需要。最后,
useReducer
(opens new window) (opens new window) Hook 减少了对深层传递回调的依赖,正如下面解释的那样。
- 本文链接: https://mrgaogang.github.io/react/useEffect%E7%AE%80%E8%AE%B0.html
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 许可协议。转载请注明出处!