前言
React在16.8版本为我们正式带来了Hooks
API。什么是Hooks
?简而言之,就是对函数式组件的一些辅助,让我们不必写class
形式的组件也能使用state和其他一些React特性。按照官网的介绍,Hooks
带来的好处有很多,其中让我感受最深的主要有这几点:
- 函数式组件相比
class
组件通常可以精简不少代码。 - 没有生命周期的束缚后,一些相互关联的逻辑不用被强行分割。比如在
componentDidMount
中设置了定时器,需要在componentWillUnmount
中清除;又或者在componentDidMount
中获取了初始数据,但要记得在componentDidUpdate
中进行更新。这些逻辑由于useEffect
hook的引入而得以写在同一个地方,能有效避免一些常见的bug。 - 有效减少与善变的
this
打交道。
既然Hooks
大法这么好,不赶紧上车试试怎么行呢?于是本人把技术项目的react
和react-dom
升级到了16.8.6版本,并按官方建议,渐进式地在新组件中尝试Hooks
。不得不说,感觉还是很不错的,确实敲少了不少代码,然而有个值得注意的地方,那就是结合React-Redux
的使用。
本文并不是Hooks的基础教程,所以建议读者先大致扫过官方文档的3、4节,对Hooks API有一定了解。
问题
我们先来看一段使用了Hooks的函数式组件结合React-Redux connect
的用法:
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
formData,
queryFormData,
submitFormData,
} = props;
useEffect(() => {
// 请求表单数据
queryFormData(formId);
},
// 指定依赖,防止组件重新渲染时重复请求
[queryFormData, formId]
);
// 处理提交
const handleSubmit = usefieldValues => {
submitFormData(fieldValues);
}
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
)
}
function mapStateToProps(state) {
return {
formData: state.formData
};
}
function mapDispatchToProps(dispatch, ownProps) {
// withRouter传入的prop,用于编程式导航
const { history } = ownProps;
return {
queryFormData(formId) {
return dispatch(queryFormData(formId));
},
submitFormData(fieldValues) {
return dispatch(submitFormData(fieldValues))
.then(res) => {
// 提交成功则重定向到主页
history.push('/home');
};
}
}
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(React.memo(Form));
上面代码描述了一个简单的表单组件,通过mapDispatchToProps
生成的queryFormData
prop请求表单数据,并在useEffect
中诚实地记录了依赖,防止组件re-render时重复请求后台;通过mapDispatchToProps
生成的submitFormData
prop提交表单数据,并在提交成功后使用React-Router提供的history
prop编程式导航回首页;通过mapStateToProps
生成的formData
prop拿到后台返回的数据。看起来似乎妹啥毛病?
其实有毛病。
问题就在于mapDispatchToProps
的第二个参数——ownProps
:
function mapDispatchToProps(dispatch, ownProps) { // **问题在于这个ownProps!!!**
const { history } = ownProps;
...
}
在上面的例子中我们需要使用React-Router的withRouter
传入的history
prop来进行编程式导航,所以使用了mapDispatchToProps
的第二个参数ownProps。然而关于这个参数,React-Redux官网上的这句话也许不是那么的引人注意:
image-20190728144128356
如果我们在声明mapDispatchToProps
时使用了第二个参数(即便声明后没有真的用过这个ownProps
),那么每当connected的组件接收到新的props时,mapDispatchTopProps
都会被调用。这意味着什么呢?由于mapDispatchToProps
被调用时会返回一个全新的对象(上面的queryFormData
、submitFormData
也将会是全新的函数),所以这会导致上面传入到<Form/>中的queryFormData
和submitFormData
prop被隐式地更新,造成useEffect
的依赖检查失效,组件re-render时会重复请求后台数据。
对应的React-Redux源码是这段:
代码语言:javascript复制// selectorFactory.js
...
// 此函数在connected组件接收到new props时会被调用
function handleNewProps() {
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
// 声明mapDispatchToProps时如果使用了第二个参数(ownProps)这里会标记为true
if (mapDispatchToProps.dependsOnOwnProps)
// 重新调用mapDispatchToProps,更新dispatchProps
dispatchProps = mapDispatchToProps(dispatch, ownProps)
// mergeProps的做法其实是:mergedProps = { ...ownProps, ...stateProps, ...dispatchProps }
// 最后传入被connect包裹的组件
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
...
解决方案
1. 最省事
给useEffect的第二个参数传一个空数组:
代码语言:javascript复制function Form(props) {
const {
formId,
queryFormData,
...
} = props;
useEffect(() => {
// 请求表单数据
queryFormData(formId);
},
// 传入空数组,起到类似componentDidMount的效果
[]
);
...
}
这种方式相当于告诉useEffect
,里面要调用的方法没有任何外部依赖——换句话说就是不需要(在依赖更新时)重复执行,所以useEffect
就只会在组件第一次渲染后调用传入的方法,起到类似componentDidMount
的效果。然而,这种方法虽然可行,但却是一种欺骗React的行为(我们明明依赖了来自props的queryFormData
和formId
),很容易埋坑(见React官方的Hooks FAQ)。实际上,如果我们有遵循React官方的建议,给项目装上eslint-plugin-react-hooks
的话,这种写法就会收到eslint的告警。所以从代码质量的角度考虑,尽量不要偷懒采用这种写法。
2. 不使用ownProps参数
把需要用到ownProps的逻辑放在组件内部:
代码语言:javascript复制function Form(props) {
const {
formId
queryFormData,
submitFormData,
history
...
} = props;
useEffect(() => {
queryFormData(formId);
},
// 由于声明mapDispatchToProps时没使用ownProps,所以queryFormData是稳定的
[queryFormData, formId]
);
const handleSubmit = fieldValues => {
submitFormData(fieldValues)
// 把需要用到ownProps的逻辑迁移到组件内定义
.then(res => {
history.push('/home');
});
}
...
}
...
function mapDispatchToProps(dispatch) { // 不再声明ownProps参数
return {
queryFormData(formId) {
return dispatch(queryFormData(formId));
},
submitFormData(fieldValues) {
return dispatch(submitFormData(fieldValues));
}
}
}
...
同样是改动较少的做法,但缺点是把相关联的逻辑强行分割到了两个地方(mapDispatchToProps
和组件里)。同时我们还必须加上注释,提醒以后维护的人不要在mapDispatchToProps
里使用ownProps
参数(实际上如果有瞄过上面的源码,就会发现mapStateToProps
也有类似的顾忌),并不太靠谱。
3. 不使用mapDispatchToProps
如果不给connect
传入mapDispatchToProps
,那么被包裹的组件就会接收到dispatch
prop,从而可以把需要使用dispatch
的逻辑写在组件内部:
...
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
history,
dispatch
...
} = props;
useEffect(() => {
// 在组件内使用dispatch
// 注意这里的queryFormData来自import,而非props,不会变,所以不用写进依赖数组
dispatch(queryFormData(formId))
},
[dispatch, formId]
);
const handleSubmit = fieldValues => {
// 在组件内使用dispatch
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
}
...
}
...
// 不传入mapDispatchToProps
export default withRouter(connect(mapStateToProps, null)(React.memo(Form));
这是个人比较推荐的做法,不必分割相关联的逻辑(这也是hooks的初衷之一),同时把dispatch的相关逻辑写在useEffect里也可以让eslint自动检查依赖,避免出bug。当然带来的另一个问题是,如果需要请求很多条cgi,那把相关逻辑都写在useEffect
里好像会很臃肿?此时我们可以使用useCallback
:
import { actionCreator1 } from "@/data/actionCreator1/action";
import { actionCreator2 } from "@/data/actionCreator2/action";
import { actionCreator3 } from "@/data/actionCreator3/action";
...
function Form(props) {
const {
dep1,
dep2,
dep3,
dispatch
...
} = props;
// 利用useCallback把useEffect要使用的函数抽离到外部
const fetchUrl1() = useCallback(() => {
dispatch(actionCreator1(dep1));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep1]); // useCallback的第二个参数跟useEffect一样,是依赖项
const fetchUrl2() = useCallback(() => {
dispatch(actionCreator2(dep2));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep2]);
const fetchUrl3() = useCallback(() => {
dispatch(actionCreator3(dep3));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep3]);
useEffect(() => {
fetchUrl1();
fetchUrl2();
fetchUrl3();
},
// useEffect的直接依赖变成了useCallback包裹的函数
[fetchUrl1, fetchUrl2, fetchUrl3]
);
// 为了避免子组件发生不必要的re-render,handleSubmit其实也应该用useCallback包裹
const handleSubmit = useCallback(fieldValues => {
// 在组件内使用dispatch
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
});
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
)
}
...
useCallback
会返回被它包裹的函数的memorized版本,只要依赖项不变,memorized的函数就不会更新。利用这一特点我们可以把useEffect
中要调用的逻辑使用useCallback
封装到外部,然后只需要在useEffect
的依赖项里添加memorized的函数,就可以正常运作了。
然而正如前文提到的,mapStateToProps
中的ownProps
参数同样会引起mapStateToProps
的重新调用,产生新的state props:
// 此函数在connected组件接收到new props时会被调用
function handleNewProps() {
// 声明mapStateToProps时如果使用了ownProps参数同样会产生新的stateProps!
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
因此在这种方案中如果useEffect
有依赖这些state props的话还是有可能造成依赖检查失效(比如说state props是引用类型)。
4. 使用React-Redux的hooks APIs(推荐)
既然前面几种方案或多或少都有些坑点,那么不妨尝试一下React Redux在v7.1.0版本为我们带来的官方hooks APIs,下面就展示下基本用法。
主要用到的API:
代码语言:javascript复制import { useSelector, useDispatch } from 'react-redux'
// selector函数的用法和mapStateToProps相似,其返回值会作为useSelector的返回值,但与mapStateToProps不同的是,前者可以返回任何类型的值(而不止是一个对象),此外没有第二个参数ownProps(因为可以在组件内通过闭包拿到)
const result : any = useSelector(selector : Function, equalityFn? : Function)
const dispatch = useDispatch()
使用:
代码语言:javascript复制...
import { useSelector, useDispatch } from "react-redux";
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
history,
dispatch
...
} = props;
const dispatch = useDispatch();
useEffect(() => {
dispatch(queryFormData(formId))
},
[dispatch, formId]
);
const handleSubmit = useCallback(fieldValues => {
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
}, [dispatch, history]);
const formData = useSelector(state => state.formData;);
...
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
);
}
...
// 无需使用connect
export default withRouter(React.memo(Form));
可以看到和上面介绍的"不使用mapDispatchToProps"
的方式很相似,都是通过传入dispatch
,然后把需要使用dispatch
的逻辑定义在组件内部,最大差异是把提取state
的地方从mapStateToProps
变成useSelector
。两者的用法相近,但如果你想后者像前者一样返回一个对象的话要特别注意:
由于useSelector
内部默认是使用===
来判断前后两次selector函数的计算结果是否相同的(如果不相同就会触发组件re-render),那么如果selector函数返回的是对象,那实际上每次useSelector
执行时调用它都会产生一个新对象,这就会造成组件无意义的re-render。要解决这个问题,可以使用reselect等库创建带memoized效果的selector ,或者给useSelector
的第二个参数(比较函数)传入react-redux内置的shallowEqual
:
import { useSelector, shallowEqual } from 'react-redux'
const selector = state => ({
a: state.a,
b: state.b
});
const data = useSelector(selector, shallowEqual);
用Hooks代替Redux?
自从Hooks
出现后,社区上一个比较热门的话题就是用Hooks
手撸一套全局状态管理,一种常见的方式如下:
- 相关
Hooks
:useContext
,useReducer
- 实现:
import { createContext, useContext, useReducer, memo } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_HEADER_COLOR':
return {
...state,
headerColor: 'yellow'
};
case 'UPDATE_CONTENT_COLOR':
return {
...state,
contentColor: 'green'
};
default:
break;
}
}
// 创建一个context
const Store = createContext(null);
// 作为全局state
const initState = {
headerColor: 'red',
contentColor: 'blue'
};
const App = () => {
const [state, dispatch] = useReducer(reducer, initState);
// 在根结点注入全局state和dispatch方法
return (
<Store.Provider value={{ state, dispatch }}>
<Header/>
<Content/>
</Store.Provider>
);
};
const Header = memo(() => {
// 拿到注入的全局state和dispatch
const { state, dispatch } = useContext(Store);
return (
<header
style={{backgroundColor: state.headerColor}}
onClick={() => dispatch('UPDATE_HEADER_COLOR')}
/>
);
});
const Content = memo(() => {
const { state, dispatch } = useContext(Store);
return (
<div
style={{backgroundColor: state.contentColor}}
onClick={() => dispatch('UPDATE_CONTENT_COLOR')}
/>
);
});
上述代码通过context
,把一个全局的state
和派发actions
的dispatch
函数注入到被Provider
包裹的所有子元素中,再配合useReducer,看起来确实是个穷人版的Redux。
然而,上述代码其实有性能隐患:无论我们点击<Header/>还是<Content/>去派发一个action,最终结果是<Header/>和<Content/>都会被重新渲染!因为很显然,它们俩都消费了同一个state(尽管都只消费了state的一部分),所以当这个全局的state被更新后,所有的Consumer自然也会被更新。
但我们不是已经用
memo
包裹组件了吗?
是的,memo
能为我们守住来自props的更新,然而state
是在组件内部通过useContext
这个hook注入的,这么一来就会绕过最外层的memo
。
那么有办法可以避免这种强制更新吗? Dan Abramov大神给我们指了几条明路:
- 拆分Context(推荐)。把全局的State按需求拆分到不同的
context
,那么自然不会相互影响导致无谓的重渲染; - 把组件拆成两个,里层的用
memo
包裹:
const Header = () => {
const { state, dispatch } = useContext(Store);
return memo(<ThemedHeader theme={state.headerColor} dispatch={dispatch} />);
};
const ThemedHeader = memo(({theme, dispatch}) => {
return (
<header
style={{backgroundColor: theme}}
onClick={() => dispatch('UPDATE_HEADER_COLOR')}
/>
);
});
使用useMemo hook。思路其实跟上面的一样,但不用拆成两个组件:
const Header = () => {
const { state, dispatch } = useContext(Store);
return useMemo(
() => (
<header
style={{backgroundColor: state.headerColor}}
onClick={() => dispatch('UPDATE_HEADER_COLOR')}
/>
),
[state.headerColor, dispatch]
);
};
可见,如果使用Context
Hooks
来代替Redux等状态管理工具,那么我们必须花费额外的心思去避免性能问题,然而这些dirty works其实React-Redux等工具已经默默替我们解决了。除此之外,我们还会面临以下问题:
- 需要自行实现combineReducers等辅助功能(如果发现要用到)
- 失去Redux生态的中间件支持
- 失去Redux DevTools等调试工具
- 出了坑不利于求助……
所以,除非是在对状态管理需求很简单的个人或技术项目里,或者纯粹想造轮子练练手,否则个人是不建议放弃Redux等成熟的状态管理方案的,因为性价比不高。
总结
React Hooks给开发者带来了清爽的使用体验,一定程度上提升了键盘的寿命【并不,然而与原有的React-Redux connect
相关APIs结合使用时,需要特别小心ownProps
参数,很容易踩坑,建议尽快升级到v7.1.0版本,使用官方提供的Hooks API。
此外,使用Hooks自建全局状态管理的方式在小项目中固然可行,然而想用在较大型的、正式的业务中,至少还要花费心思解决性能问题,而这个问题正是React-Redux等工具已经花费不少功夫帮我们解决了的,似乎并没有什么充分的理由要抛弃它们。
参考
- React-Redux官方文档
- React官方文档
- Preventing rerenders with React.memo and useContext hook
推荐阅读
- The History and Implementation of React-Redux
- useEffect完整指南