大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。
开始之前非常建议在我的主页阅读前一篇相关文章《2024 年 React 开发必须知道的 5 个 Hooks 金句》。话不多说,直接开始!
1.useReducer 是对 Hooks 的欺骗上面了解了当 Effect 需要根据先前状态或另一个状态变量设置状态时如何删除依赖关系。 但如果需要 props 来计算下一个状态怎么办? 比如下面的 step:
// 这里的 step 依赖于 props 变量function Counter({step}) { const [count, dispatch] = useReducer(reducer, 0); // 下面是 reduer 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>;}以上方式禁用了一些优化,因此尽量少用,但如果确实需要,完全可以从 reducer 访问 props。即使在这种情况下,dispatch 仍然可保证在重新渲染之间保持稳定。 因此,依然可以从 Effect 依赖中省略而不会导致 Effect 重新运行。
问题来了,当从属于另一个 render 的 Efffect 内部调用时,reducer 如何 “知道新的” props? 答案是:当 dispatch 时,React 只是记住该操作 , 但会在下一次 render 期间调用 reducer。此时,新 props 存在而且不在 Effect 内。
这就是为何说 useReducer 为 Hooks 的 “作弊模式”, 其让开发者可以将更新逻辑与描述发生的事情分离。 这反过来又帮助开发者从 Effect 中删除不必要的依赖项,并避免不必要地、频繁地重新运行。
2. 可以让函数摆脱 Hooks 数据流也可以作为依赖数组元素按照这个规则,有些复用逻辑可以抽离到组件外部,此时函数也不再受到 React 数据流的影响而重新渲染。比如下面的 getFetchUrl 逻辑代码:
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 每次在组件渲染时也会重建(Every Render has Everything own)。解决该问题的第一种方法就是将函数移到组件外部,但是注意:此时 getFetchUrl 也不用作为依赖数组元素添加,因为根本不受 React 数据流的影响,即不依赖于 props 和 state。
// ✅ 不再受到 React 数据流的影响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 // ...}另一种方法就是使用 useCallback Hooks,让函数只在必要的时候进行重建:
function SearchResults() { // ✅ 当依赖相同时保持不变 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 的第一个函数接受了 query 字段,该字段由开发者自己作为参数传递。但是,如果要从 state 读取,情况又有所不同:
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(); }, [getFetchUrl]); // ✅ Effect deps are OK}此时 state 中 query 的任何变动都会导致 getFetchUrl 函数的变动,最后导致组件的重新渲染。下面父子组件的示例也是相同的:
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; // 获取数据并返回 }, [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}3.class 组件函数非数据流一部分而 Hooks 函数却是对于 React 类组件,函数 props 本身并不是真正的数据流的一部分。很多方法强依赖于可变的(mutable)this 变量,无法通过简单的比较判断函数是否已经变化。
class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; }; render() { return <Child fetchData={this.fetchData} />; }}class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { // 条件永远为 false if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); } } render() {}}上面的子组件 Child 强依赖于 Parent 的 fetchData 函数,但是父组件 fetchData 函数是类实例方法,所以多次渲染时候永远不会变化。而且,即使 Parent 使用 bind 方法重新生成新的 fetchData 方法,Child 也没法通过简单的函数判断是否要重新渲染。
render() { return <Child fetchData={this.fetchData.bind(this, this.state.query)} />; }此时 this.props.fetchData !== prevProps.fetchData 永远是 true。
因此,在类组件中,除了函数外可能依然要配合其他 props 判断组件是否要重新渲染,比如下面的示例通过 query 来判断。
class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; // ... Fetch data and do something ... }; render() { return <Child fetchData={this.fetchData} query={this.state.query} />; }}class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); } } render() {}}总之,即使 Child 只想要一个函数,开发者也必须传递一堆其他数据以便能够 “diff” 。 开发者也无法知道从父级传递的 this.props.fetchData 是否依赖于某种状态,以及该状态是否改变了。
通过 Hooks,useCallback 函数完全可以参与数据流,此时如果函数输入发生变化,则函数本身也发生变化,但如果没有变化,则函数保持不变。 由于 useCallback 提供的粒度,对 props.fetchData 等 props 的更改可以自动向下传播。这一点与 useMemo 非常类似:
function ColorPicker() { // Doesn't break Child's shallow equality prop check // unless the color actually changes. const [color, setColor] = useState('pink'); const style = useMemo(() => ({ color }), [color]); return <Child style={style} />;}值得一提的是,useMemo 在循环中不起作用,因为 Hook 调用不能放在循环内。
function Parent({a, b}) { // Only re-rendered if `a` changes: const child1 = useMemo(() => <Child1 a={a} />, [a]); // Only re-rendered if `b` changes: const child2 = useMemo(() => <Child2 b={b} />, [b]); return ( <> {child1} {child2} </> )}4.React 条件竞争 Race Conditions以下代码的原因是请求可能不按顺序进行。 因此,如果一开始获取 {id: 10},然后切换到 {id: 20},但是 {id: 20} 请求先获得结果。即,较早开始但较晚完成的 {id: 10} 请求将错误地覆盖状态。
class Article extends Component { state = { article: null }; componentDidMount() { this.fetchData(this.props.id); } componentDidUpdate(prevProps) { if (prevProps.id !== this.props.id) { this.fetchData(this.props.id); } } async fetchData(id) { const article = await API.fetchArticle(id); this.setState({article}); }}这就是所谓的条件竞争,在将 async/await 与自上而下的数据流混合在一起的代码中非常常见。本质上是因为当处于异步函数中间时,props 或 state 可能随时变化 。
Effect 并不是解决该问题的灵丹妙药,尽管如果开发者尝试直接将异步函数传递给 Effect 会抛出警告,此时可以在清理函数中为异步方法添加取消。
function Article({id}) { const [article, setArticle] = useState(null); useEffect(() => { let didCancel = false; async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); // 注意:Hooks 要么无返回值要么返回一个清理函数(cleanup function) return () => { didCancel = true; }; }, [id]); // 这里要有依赖数组,否则会一直循环}下面是自定义的数据请求 Hooks 示例:
const useHackerNewsApi = () => { const [data, setData] = useState({ hits: [] }); const [url, setUrl] = useState( 'https://hn.algolia.com/api/v1/search?query=redux', ); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); useEffect(() => { const fetchData = async () => { setIsError(false); setIsLoading(true); try { const result = await axios(url); setData(result.data); } catch (error) { setIsError(true); } setIsLoading(false); }; fetchData(); }, [url]); // url 变化 useEffect 会再次执行 return [{data, isLoading, isError}, setUrl];}接着可以通过下面的方式进行调用:
function App() { const [query, setQuery] = useState('redux'); const [{data, isLoading, isError}, doFetch] = useHackerNewsApi(); return ( <Fragment> <form onSubmit={event => { doFetch(`http://hn.algolia.com/api/v1/search?query=${query}`); event.preventDefault(); }}> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="submit">Search</button> </form> ... </Fragment> );}上面使用了 setIsError, setIsLoading 等众多方法,开发者可以使用 Reducer Hook 组合起来。
一个 Reducer Hook 返回开发者一个状态对象和一个改变状态对象的函数。 该函数执行具有类型和可选 payload 的 action, 所有这些信息都在实际的 reducer 函数中使用,以从先前的状态、操作的可选负载和类型中提取新状态。
下面是使用 useReducer 实现同样的逻辑:
const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, }); useEffect(() => { let didCancel = false; const fetchData = async () => { dispatch({type: 'FETCH_INIT'}); try { const result = await axios(url); if (!didCancel) { dispatch({type: 'FETCH_SUCCESS', payload: result.data}); } } catch (error) { if (!didCancel) { dispatch({type: 'FETCH_FAILURE'}); } } }; fetchData(); return () => { didCancel = true; }; }, [url]); return [state, setUrl];};// reducer 函数const dataFetchReducer = (state, action) => { switch (action.type) { case 'FETCH_INIT': return { ...state, isLoading: true, isError: false }; case 'FETCH_SUCCESS': return { ...state, isLoading: false, isError: false, data: action.payload, }; case 'FETCH_FAILURE': return { ...state, isLoading: false, isError: true, }; default: throw new Error(); }};下面是如何使用自定义 Hooks 的 useDataApi 方法:
const [{data, isLoading, isError}, doFetch] = useDataApi( 'https://hn.algolia.com/api/v1/search?query=redux', {hits: [] }, );5. 尽量避免向下传递 Callback 回调大多数开发者可能不喜欢在组件树的每个级别手动传递回调,尽管这种方法更明确,但是毕竟繁琐。
在大型组件树中,建议通过上下文从 useReducer 传递 dispatch 函数:
const TodosDispatch = React.createContext(null);function TodosApp() { // Note: `dispatch` won't change between re-renders const [todos, dispatch] = useReducer(todosReducer); return ( <TodosDispatch.Provider value={dispatch}> <DeepTree todos={todos} /> </TodosDispatch.Provider> );}TodosApp 内树中的任何子级都可以使用 dispatch 函数将操作传递给 TodosApp:
function DeepChild(props) { // 如果想要执行一个 action,可以从上下文中获取 dispatch const dispatch = useContext(TodosDispatch); function handleClick() { dispatch({type: 'add', text: 'hello'}); } return ( <button onClick={handleClick}>Add todo</button> );}从维护的角度来看,这种方式不仅方便(不需要不断转发回调)又完全避免了回调问题。
请注意,开发者仍然可以选择是否将应用程序 state 作为 props 或 context(对于非常深入的更新更方便)传递。 如果也使用 context 来传递状态,请使用两种不同的上下文类型 , dispatch 上下文永远不会改变,因此读取组件不需要重新渲染,除非也需要应用程序状态。
参考资料https://overreacted.io/a-complete-guide-to-useeffect/
https://www.robinwieruch.de/react-hooks-fetch-data/