React effect hooks各种使用场景
react rendering原理
举个栗子
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>
</div>
);
}
以上程序,react是这样渲染的。
//first render
function Counter() {
const count = 0;
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
// ...
<p>You clicked {count} times</p>
}
//second render
function Counter() {
const count = 1;
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
// ...
<p>You clicked {count} times</p>
}
每一次渲染中,count的值是固定的,在每一次更新状态引起的渲染中,count值独立于其他的渲染。
划重点:每一次渲染都有它自己的Props and State, 在单次渲染中,props和state始终不变。
如果点击counter增加到3,点击show alert,点击增加 counter到5并且在定时器回调触发前完成,alert弹出count的值是3.
因为在事件处理函数属于某一次特定的渲染,在任意一次渲染中,props和state是始终保持不变的,而且用到他们的任何值也是独立的(包括事件处理函数)。
每次渲染都有它自己的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>
);
}
上面提到每次渲染都有独立的props,state和事件处理函数,同样,effect也是独立的,每次渲染都是一个不同的effect函数。
做一道题
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
如果连续点击2次button,输出结果是?
因为状态每次变更,都会有一次新的渲染,每次渲染都有一个独立的effect函数,props也是特定的。所以结果是
You clicked 0 times
You clicked 1 times
You clicked 2 times
逆潮而动
什么是逆潮而动?
effect的回调函数里每次读取的是具体每次渲染时机,props或者state的值,如果我们想要在effect回调中读取最新的值,而不是捕获的值。这种做法就叫逆潮而动。
简单来说就是从过去渲染中的函数里读取未来的props和state。
可以通过useRef来实现
function Example() {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
// Set the mutable latest value
latestCount.current = count;
setTimeout(() => {
// Read the mutable latest value
console.log(`You clicked ${latestCount.current} times`);
}, 3000);
});
以上代码每次获取的都是最新值。
useRef hooks返回一个可变的 ref 对象,其 .current 属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。
需要注意的是,内容更改时useRef不会通知。.current属性更改也不会导致重新渲染。
以上代码,在页面重新渲染,effect函数执行时,latestCount.current中的始终是最新值。
Effect是如何清理副作用的?
举个例子
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
假设第一次渲染的时候props是{id: 10}
,第二次渲染的时候是{id: 20}
。
那第一次的订阅是在什么时候被清除的?
上一次的effect会在重新渲染后被清除。
过程如下:
React 渲染{id: 20}的UI。
浏览器绘制。我们在屏幕上看到{id: 20}的UI。
React 清除{id: 10}的effect。
React 运行{id: 20}的effect。
有个问题:如果清除上一次的effect发生在props变成{id: 20}之后,那它为什么还能“看到”旧的{id: 10}?
这里用到我们以上说的一个知识点:
组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的props和state。
React运行effects的机制
React只会在浏览器绘制后运行effects。这使得你的应用更流畅因为大多数effects并不会阻塞屏幕的更新。Effect的清除同样被延迟了。
从一个定时器例子开始
例子:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
上面的程序运行结果:定时器只会递增一次。
原因是设置了[ ]依赖,effect不会再重新运行,它后面每一秒都会调用setCount(0 + 1)
过程如下:
// First render, state is 0
function Counter() {
// ...
useEffect(
// Effect from first render
() => {
const id = setInterval(() => {
setCount(0 + 1); // Always setCount(1)
}, 1000);
return () => clearInterval(id);
},
[] // Never re-runs
);
// ...
}
// Every next render, state is 1
function Counter() {
// ...
useEffect(
// This effect is always ignored because
// we lied to React about empty deps.
() => {
const id = setInterval(() => {
setCount(1 + 1);
}, 1000);
return () => clearInterval(id);
},
[]
);
// ...
}
若要实现每秒数字加一,有以下两种方式:
第一种:通过以下方式可以实现定时器更新
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
这种方式能解决问题,但是我们的定时器会在每一次count改变后清除和重新设定。
第二种:通过以下去除依赖的方式也可以实现定时器更新
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
重点:当我们想要根据前一个状态更新状态的时候,我们可以使用setState的函数形式。
因为我们在effect中写了setCount(count + 1),所以count是一个必需的依赖。但是,我们真正想要的是把count转换为count+1,然后返回给React。可是React其实已经知道当前的count。我们需要告知React的仅仅是去递增状态,不管它现在具体是什么值。
这正是setCount(c => c + 1)做的事情。你可以认为它是在给React“发送指令”告知如何更新状态。
effect只运行了一次,第一次渲染中的定时器回调函数可以完美地在每次触发的时候给React发送c => c + 1更新指令。它不再需要知道当前的count值。因为React已经知道了。
扩展:定时器每次在count上增加一个step
第一种方案
在effect中添加step依赖
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))} />
</>
);
}
这种方法问题是修改step会重启定时器,清除上一次的effect然后重新运行新的effect。
第二种方案
dispatch依赖去替换effect的step依赖
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]);
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();
}
}
这种方案好处是:React会保证dispatch在组件的声明周期内保持不变。所以不再需要重新订阅定时器。
扩展:依赖属性的定时器
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>;
}
我们需要依赖props去计算下一个状态,可以把reducer函数放到组件内去读取props
在之前渲染中调用的reducer怎么“知道”新的props?
答案是当你dispatch的时候,React只是记住了action - 它会在下一次渲染中再次调用reducer。在那个时候,新的props就可以被访问到,而且reducer调用也不是在effect里。
调用两次useEffect
以下场景:组件内有几个effect使用了相同的函数,若不想在每个effect里复制黏贴一遍这个逻辑,将该函数提取出来
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
// ...
}
这个例子中:为了复用逻辑,不把getFetchUrl移到effects中,
如果我们按照上面的错误提示,将函数依赖添加到effect依赖数组中。因为函数每次渲染都会改变,我们的两个effects都依赖getFetchUrl,而它每次渲染都不同,所以我们的依赖数组会变得无用。
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
// ...
}
有两种解决方案:
第一种:将函数提到组件外面去定义
// ✅ Not affected by the data flow
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, []); // ✅ Deps are OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, []); // ✅ Deps are OK
// ...
}
不需要该函数设为依赖,因为它不在渲染范围内,因此不会被数据流影响。它不可能突然意外地依赖于props或state。
第二种:把该函数包装成useCallBack hook
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;
}, []); // ✅ 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
// ...
}
useCallback本质上是getFetchUrl添加了一层依赖检查,使函数本身只在需要的时候才改变,而不是去掉对函数的依赖。
**继续扩展一下上面的例子 **
函数getFetchUrl的query不通过传参,而是从状态中获取。
function SearchResults() {
const [query, setQuery] = useState('react');
// ✅ Preserves identity until query changes
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]); // ✅ Callback deps are OK
useEffect(() => {
const url = getFetchUrl();
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect deps are OK
// ...
}
以上代码中,如果query保持不变,getFetchUrl也会保持不变,我们的effect也不会重新运行。但是如果query修改了,getFetchUrl也会随之改变,因此会重新请求数据。
useReducer
使用场景: 当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时。
干的事情:reducer可以让你把组件内发生了什么(actions)和状态如何响应并更新分开表述。
总结
- useEffect 是componentDidMount 和 componentDidUpdate结合
- 想要避免effects不必要的重复调用,你可以提供给useEffect一个依赖数组参数。
- 如果useEffect()不传递任何参数,则页面每次重新渲染后,会调用useEffect()函数。
effects会在每次渲染后运行 - 如实告知effect函数具体的依赖,不要撒谎
- 只在effects中传递最小的信息
- 从依赖中去除dispatch, setState, 和useRef包裹的值,因为React会确保它们是静态的
- React保证dispatch在每次渲染中都是一样的
- 如果某些函数仅在effect中调用,可以把它们的定义移到effect中。这么做有什么好处呢?我们不再需要去考虑这些“间接依赖”。
- 在一个函数里面,可以写多个useEffect()
- react中,函数每次渲染都会改变
- 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义
- useCallback的用法,使用useCallback,函数完全可以参与到数据流中。我们可以说如果一个函数的输入改变了,这个函数就改变了。如果没有,函数也不会改变
- 在class组件中,函数属性本身并不是数据流的一部分
本文章非原创,是用来知识总结沉淀的。
参考:https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/