2018 年的 React Conf 上 Dan Abramov 正式对外介绍了React Hook,这是一种让函数组件支持状态和其余 React 特性的全新方式,并被官方解读为这是下一个 5 年 React 与时俱进的开端。从中细品,能够窥见React Hook的重要性。今年 2 月 6 号,React Hook 新特性随 React v16.8.0 版本正式发布,整个上半年 React 社区都在积极努力地拥抱它,学习并解读它。虽然官方声明,React Hook还在快速的发展和更新迭代过程当中,不少Class Component支持的特性,React Hook还并未支持,但这丝绝不影响社区的学习热情。html
React Hook上手很是简单,使用起来也很容易,但相比咱们已经熟悉了 5 年的类组件写法,React Hook仍是有一些理念和思想上的转变。React 团队也给出了使用 Hook 的一些规则和eslint 插件来辅助下降违背规则的几率,但规则并非仅仅让咱们去记忆的,更重要的是要去真正理解设计这些规则的缘由和背景。前端
文章中的代码不少只是伪代码,重点在解读设计思路,所以并不是完整的实现。不少链表的构建和更新逻辑也一并省略了,但并不影响你们了解整个 React Hook 的设计。事实上React Hook的大部分代码都在适配React Fiber架构的理念,这也是源码晦涩难懂的主要缘由。不过不要紧,咱们彻底能够先屏蔽掉React Fiber的存在,去一点点构建纯粹的 React Hook 架构。react
React Hook 的产生主要是为了解决什么问题呢?官方的文档里写的很是清楚,这里只作简单的提炼,不作过多陈述,没读过文档的同窗能够先移步阅读React Hook 简介。ajax
总结一下要解决的痛点问题就是:npm
复杂组件变的难以理解编程
难以理解的 Classredux
this 指针问题。数组
组件预编译技术(组件折叠)会在 class 中遇到优化失效的 case。浏览器
class 不能很好的压缩。缓存
class 在热重载时会出现不稳定的状况。
React 官网有下面这样一段话:
为了解决这些问题,Hook 使你在 == 非 class 的状况下可使用更多的 React 特性 ==。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术
对应第一节所抛出的问题,React Hook 的设计目标即是要解决这些问题,总结起来就如下四点:
无 Class 的复杂性
无生命周期的困扰
优雅地复用
对齐 React Class 组件已经具有的能力
无 Class 的复杂性(去 Class) React 16.8 发布以前,按照是否拥有状态的维护来划分的话,组件的类型主要有两种:
类组件 Class Component: 主要用于须要内部状态,以及包含反作用的复杂的组件
class App extends React.Component{
constructor(props){
super(props);
this.state = {
//...
}
}
//...
}
复制代码
函数组件 Function Component:主要用于纯组件,不包含状态,至关于一个模板函数
function Footer(links){ return ( <footer> <ul> {links.map(({href, title})=>{ return <li><a href={href}>{title}</a></li> })} </ul> </footer> ) } 复制代码
若是设计目标是 == 去 Class== 的话,彷佛选择只能落在改造Function Component,让函数组件拥有Class Component同样的能力上了。
咱们不妨畅想一下最终的支持状态的函数组件代码:
// 计时器 function Counter(){ let state = {count:0} function clickHandler(){ setState({count: state.count+1}) } return ( <div> <span>{count}</span> <button onClick={clickHandler}>increment</button> </div> ) } 复制代码
上述代码使用函数组件定义了一个计数器组件Counter,其中提供了状态state,以及改变状态的setState函数。这些 API 对于Class component来讲无疑是很是熟悉的,但在Function component中却面临着不一样的挑战:
- class 实例能够永久存储实例的状态,而函数不能,上述代码中 Counter 每次执行,state 都会被从新赋值为 0;
- 每个Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能经过全局的 setState 方法,或者其余方法来实现对应。
以上两个问题即是选择改造Function component所须要解决的问题。
在 JS 中,能够存储持久化状态的无非几种方法:
类实例属性
class A(){ constructor(){ this.count = 0; } increment(){ return this.count ++; } } const a = new A(); a.increment(); 复制代码
全局变量
const global = {count:0}; function increment(){ return global.count++; } 复制代码
DOM
const count = 0; const $counter = $('#counter'); $counter.data('count', count); funciton increment(){ const newCount = parseInt($counter.data('count'), 10) + 1; $counter.data('count',newCount); return newCount; } 复制代码
闭包
const Counter = function(){ let count = 0; return { increment: ()=>{ return count ++; } } }() Counter.increment(); 复制代码
其余全局存储:indexDB、LocalStorage 等等 Function component对状态的诉求只是能存取,所以彷佛以上全部方案都是可行的。但做为一个优秀的设计,还须要考虑到如下几点:
使用简单
性能高效
可靠无反作用
方案 2 和 5 显然不符合第三点;方案 3 不管从哪一方面都不会考虑;所以闭包就成为了惟一的选择了。
既然是闭包,那么在使用上就得有所变化,假设咱们预期提供一个名叫useState的函数,该函数可使用闭包来存取组件的 state,还能够提供一个 dispatch 函数来更新 state,并经过初始调用时赋予一个初始值。
function Counter(){ const [count, dispatch] = useState(0) return ( <div> <span>{count}</span> <button onClick={dispatch(count+1)}>increment</button> </div> ) } 复制代码
若是用过 redux 的话,这一幕必定很是眼熟。没错,这不就是一个微缩版的 redux 单向数据流吗?
给定一个初始 state,而后经过 dispatch 一个 action,再经由 reducer 改变 state,再返回新的 state,触发组件从新渲染。
知晓这些,useState的实现就一目了然了:
function useState(initialState){ let state = initialState; function dispatch = (newState, action)=>{ state = newState; } return [state, dispatch] } 复制代码
上面的代码简单明了,但显然仍旧不知足要求。Function Component在初始化、或者状态发生变动后都须要从新执行useState函数,而且还要保障每一次useState被执行时state的状态是最新的。
很显然,咱们须要一个新的数据结构来保存上一次的state和这一次的state,以即可以在初始化流程调用useState和更新流程调用useState能够取到对应的正确值。这个数据结构能够作以下设计,咱们假定这个数据结构叫 Hook:
type Hook = { memoizedState: any, // 上一次完整更新以后的最终状态值 queue: UpdateQueue<any, any> | null, // 更新队列 }; 复制代码
考虑到第一次组件mounting和后续的updating逻辑的差别,咱们定义两个不一样的useState函数的实现,分别叫作mountState和updateState。
function useState(initialState){ if(isMounting){ return mountState(initialState); } if(isUpdateing){ return updateState(initialState); } } // 第一次调用组件的 useState 时实际调用的方法 function mountState(initialState){ let hook = createNewHook(); hook.memoizedState = initalState; return [hook.memoizedState, dispatchAction] } function dispatchAction(action){ // 使用数据结构存储全部的更新行为,以便在 rerender 流程中计算最新的状态值 storeUpdateActions(action); // 执行 fiber 的渲染 scheduleWork(); } // 第一次以后每一次执行 useState 时实际调用的方法 function updateState(initialState){ // 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件 doReducerWork(); return [hook.memoizedState, dispatchAction]; } function createNewHook(){ return { memoizedState: null, baseUpdate: null } } 复制代码
上面的代码基本上反映出咱们的设计思路,但还存在两个核心的问题须要解决:
调用storeUpdateActions后将以什么方式把此次更新行为共享给doReducerWork进行最终状态的计算。
同一个 state,在不一样时间调用mountState和updateState时,如何实现hook对象的共享。
更新逻辑是一个抽象的描述,咱们首先须要根据实际的使用方式考虑清楚一次更新须要包含哪些必要的信息。实际上,在一次事件 handler 函数中,咱们彻底能够屡次调用dispatchAction:
function Count(){ const [count, setCount] = useState(0); const [countTime, setCountTime] = useState(null); function clickHandler(){ // 调用屡次 dispatchAction setCount(1); setCount(2); setCount(3); //... setCountTime(Date.now()) } return ( <div> <div>{count} in {countTime}</div> <button onClick={clickHandler} >update counter</button> </div> ) } 复制代码
在执行对setCount的 3 次调用中,咱们并不但愿 Count 组件会所以被渲染 3 次,而是会按照调用顺序实现最后调用的状态生效。所以若是考虑上述使用场景的话,咱们须要同步执行完clickHandler中全部的dispatchAction后,并将其更新逻辑顺序存储,而后再触发 Fiber 的 re-render 合并渲染。那么屡次对同一个dispatchAction的调用,咱们如何来存储这个逻辑呢?
比较简单的方法就是使用一个队列Queue来存储每一次更新逻辑Update的基本信息:
type Queue{ last: Update, // 最后一次更新逻辑 dispatch: any, lastRenderedState: any // 最后一次渲染组件时的状态 } type Update{ action: any, // 状态值 next: Update // 下一次 Update } 复制代码
这里使用了单向链表结构来存储更新队列,有了这个数据结构以后,咱们再来改动一下代码:
function mountState(initialState){ let hook = createNewHook(); hook.memoizedState = initalState; // 新建一个队列 const queue = (hook.queue = { last: null, dispatch: null, lastRenderedState:null }); // 经过闭包的方式,实现队列在不一样函数中的共享。前提是每次用的 dispatch 函数是同一个 const dispatch = dispatchAction.bind(null, queue); return [hook.memoizedState, dispatch] } function dispatchAction(queue, action){ // 使用数据结构存储全部的更新行为,以便在 rerender 流程中计算最新的状态值 const update = { action, next: null } let last = queue.last; if(last === null){ update.next = update; }else{ // ... 更新循环链表 } // 执行 fiber 的渲染 scheduleWork(); } function updateState(initialState){ // 获取当前正在工做中的 hook const hook = updateWorkInProgressHook(); // 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件 (function doReducerWork(){ let newState = null; do{ // 循环链表,执行每一次更新 }while(...) hook.memoizedState = newState; })(); return [hook.memoizedState, hook.queue.dispatch]; } 复制代码
到这一步,更新逻辑的共享,咱们就已经解决了。
Hook 对象是相对于组件存在的,因此要实现对象在组件内屡次渲染时的共享,只须要找到一个和组件全局惟一对应的全局存储,用来存放全部的 Hook 对象便可。对于一个 React 组件而言,惟一对应的全局存储天然就是 ReactNode,在React 16x 以后,这个对象应该是FiberNode。这里为了简单起见,咱们暂时不研究 Fiber,咱们只须要知道一个组件在内存里有一个惟一表示的对象便可,咱们姑且把他叫作fiberNode:
type FiberNode { memoizedState:any // 用来存放某个组件内全部的 Hook 状态 } 复制代码
如今,摆在咱们面前的问题是,咱们对Function component的指望是什么?咱们但愿的是用Function component的useState来彻底模拟Class component的this.setState吗?若是是,那咱们的设计原则会是:
一个函数组件全局只能调用一次 useState,并将全部的状态存放在一个大 Object 里
若是仅仅如此,那么函数组件已经解决了去 Class的痛点,但咱们并无考虑优雅地复用状态逻辑的诉求。
试想一个状态复用的场景:咱们有多个组件须要监听浏览器窗口的resize事件,以即可以实时地获取clientWidth。在Class component里,咱们要么在全局管理这个反作用,并借助 ContextAPI 来向子组件下发更新;要么就得在用到该功能的组件中重复书写这个逻辑。
resizeHandler(){ this.setState({ width: window.clientWidth, height: window.clientHeight }); } componentDidMount(){ window.addEventListener('resize', this.resizeHandler) } componentWillUnmount(){ window.removeEventListener('resize', this.resizeHandler); } 复制代码
ContextAPI 的方法无疑是不推荐的,这会给维护带来很大的麻烦;ctrl+c ctrl+v就更是无奈之举了。
若是Function component能够为咱们带来一种全新的状态逻辑复用的能力,那无疑会为前端开发在复用性和可维护性上带来更大的想象空间。
所以理想的用法是:
const [firstName, setFirstName] = useState('James'); const [secondName, setSecondName] = useState('Bond'); // 其余非 state 的 Hook,好比提供一种更灵活更优雅的方式来书写反作用 useEffect() 复制代码
综上所述,设计上理应要考虑一个组件对应多个 Hook 的用法。带来的挑战是:
咱们须要在fiberNode上存储全部 Hook 的状态,并确保它们在每一次re-render时均可以获取到最新的正确的状态
要实现上述存储目标,直接想到的方案就是用一个 hashMap 来搞定:
{ '1': hook1, '2': hook2, //... } 复制代码
若是用这种方法来存储,会须要为每一次 hook 的调用生成惟一的 key 标识,这个 key 标识须要在 mount 和 update 时从参数中传入以保证能路由到准确的 hook 对象。
除此方案以外,还可使用 hook.update 采用的单向链表结构来存储,给 hook 结构增长一个 next 属性便可实现:
type Hook = { memoizedState: any, // 上一次完整更新以后的最终状态值 queue: UpdateQueue<any, any> | null, // 更新队列 next: any // 下一个 hook } const fiber = { //... memoizedState: { memoizedState: 'James', queue: { last: { action: 'Smith' }, dispatch: dispatch, lastRenderedState: 'Smith' }, next: { memoizedState: 'Bond', queue: { // ... }, next: null } }, //... } 复制代码
这种方案存在一个问题须要注意:
整个链表是在mount时构造的,因此在update时必需要保证执行顺序才能够路由到正确的 hook。
咱们来粗略对比一下这两种方案的优缺点:
方案 | 优势 | 缺点 |
---|---|---|
hashMap | 查找定位 hook 更加方便对 hook 的使用没有太多规范和条件的限制 | 影响使用体验,须要手动指定 key |
链表 | API 友好简洁,不须要关注 key | 须要有规范来约束使用,以确保能正确路由 |
很显然,hashMap 的缺点是没法忍受的,使用体验和成本都过高了。而链表方案缺点中的规范是能够经过 eslint 等工具来保障的。从这点考虑,链表方案无疑是胜出了,事实上这也正是React团队的选择。
到这里,咱们能够了解到为何 React Hook 的规范里要求:
只能在函数组件的顶部使用,不能再条件语句和循环里使用
function Counter(){ const [count, setCount] = useState(0); if(count >= 1){ const [countTime, setCountTime] = useState(Date.now()); } } // mount 阶段构造的 hook 链为 { memoizedState: { memoizedState: '0', queue: {}, next: null } // 调用 setCount(1) 以后的 update 阶段,则会找不到对应的 hook 对象而出现异常 复制代码
至此,咱们已经基本实现了 React Hooks 去 Class的设计目标,如今用函数组件,咱们也能够经过useState这个 hook 实现状态管理,而且支持在函数组件中调用屡次 hook。
上一节咱们借助闭包、两个单向链表(单次 hook 的 update 链表、组件的 hook 调用链表)、透传 dispatch 函数实现了 React Hook 架构的核心逻辑:如何在函数组件中使用状态。到目前为止,咱们尚未讨论任何关于生命周期的事情,这一部分也是咱们的设计要解决的重点问题。咱们常常会须要在组件渲染以前或者以后去作一些事情,譬如:
在Class component的componentDidMount中发送ajax请求向服务器端拉取数据。
在Class component的componentDidMount和componentDidUnmount中注册和销毁浏览器的事件监听器。
这些场景,咱们一样须要在 React Hook 中予以解决。React 为Class component设计了一大堆生命周期函数:
在实际的项目开发中用的比较频繁的,譬如渲染后期的:componentDidMount、componentDidUpdate、componentWillUnmount;
不多被使用的渲染前期钩子componentWillMount、componentWillUpdate;
一直以来被滥用且有争议的componentWillReceiveProps和最新的getDerivedStateFromProps;
用于性能优化的shouldComponentUpdate;
React 16.3 版本已经明确了将在 17 版本中废弃componentWillMount、componentWillUpdate和componentWillReceiveProps这三个生命周期函数。设计用来取代componentWillReceiveProps的getDerivedStateFromProps也并不被推荐使用。
真正被重度使用的就是渲染后和用于性能优化的几个,在 React hook 以前,咱们习惯于以 render 这种技术名词来划分组件的生命周期阶段,根据名字componentDidMount咱们就能够判断如今组件的 DOM 已经在浏览器中渲染好了,能够执行反作用了。这显然是技术思惟,那么在 React Hook 里,咱们可否抛弃这种思惟方式,让开发者无需去关注渲染这件事儿,只须要知道哪些是反作用,哪些是状态,哪些须要缓存便可呢?
根据这个思路咱们来设计 React Hook 的生命周期解决方案,或许应该是场景化的样子:
// 用来替代 constructor 初始化状态
useState()
// 替代 componentDidMount 和 componentDidUpdate 以及 componentWillUnmount
// 统一称为处理反作用
useEffect()
// 替代 shouldComponent
useMemo()
复制代码
这样设计的好处是开发者再也不须要去理清每个生命周期函数的触发时机,以及在里面处理逻辑会有哪些影响。而是更关注去思考哪些是状态,哪些是反作用,哪些是须要缓存的复杂计算和没必要要的渲染。
effect的全称应该是Side Effect,中文名叫反作用,咱们在前端开发中常见的反作用有:
dom 操做
浏览器事件绑定和取消绑定
发送 HTTP 请求
打印日志
访问系统状态
执行 IO 变动操做
在 React Hook 以前,咱们常常会把这些反作用代码写在componentDidMount、componentDidUpdate和componentWillUnmount里,好比
componentDidMount(){ this.fetchData(this.props.userId).then(data=>{ //... setState }) window.addEventListener('resize', this.onWindowResize); this.counterTimer = setInterval(this.doCount, 1000); } componentDidUpdate(prevProps){ if (this.props.userID !== prevProps.userID) { this.fetchData(this.props.userID); } } componentWillUnmount(){ window.removeEventListener('resize', this.onWindowResize); clearInterval(this.counterTimer); } 复制代码
这种写法存在一些体验的问题:
同一个反作用的建立和清理逻辑分散在多个不一样的地方,这不管是对于新编写代码仍是要阅读维护代码来讲都不是一个上佳的体验。
有些反作用可能要再多个地方写多份。
第一个问题,咱们能够经过 thunk 来解决:将清理操做和新建操做放在一个函数中,清理操做做为一个 thunk 函数被返回,这样咱们只要在实现上保障每次 effect 函数执行以前都会先执行这个 thunk 函数便可:
useEffect(()=>{ // do some effect work return ()=>{ // clean the effect } }) 复制代码
第二个问题,对于函数组件而言,则再简单不过了,咱们彻底能够把部分通用的反作用抽离出来造成一个新的函数,这个函数能够被更多的组件复用。
function useWindowSizeEffect(){ const [size, setSize] = useState({width: null, height: null}); function updateSize(){ setSize({width: window.innerWidth, height: window.innerHeight}); } useEffect(()=>{ window.addEventListener('resize', updateSize); return ()=>{ window.removeEventListener('resize', updateSize); } }) return size; } 复制代码
useEffect 的执行时机 既然是设计用来解决反作用的问题,那么最合适的时机就是组件已经被渲染到真实的 DOM 节点以后。由于只有这样,才能保证全部反作用操做中所须要的资源(dom 资源、系统资源等)是 ready 的。
上面的例子中描述了一个在 mount 和 update 阶段都须要执行相同反作用操做的场景,这样的场景是广泛的,咱们不能假定只有在 mount 时执行一次反作用操做就能知足全部的业务逻辑诉求。因此在 update 阶段,useEffect 仍然要从新执行才能保证知足要求。
这就是 useEffect 的真实机制:
Function Component函数(useState、useEffect、…)每一次调用,其内部的全部 hook 函数都会再次被调用。
这种机制带来了一个显著的问题,就是:
父组件的任何更新都会致使子组件内 Effect 逻辑从新执行,若是 effect 内部存在性能开销较大的逻辑时,可能会对性能和体验形成显著的影响。
React 在PureComponent和底层实现上都有过相似的优化,只要依赖的 state 或者 props 没有发生变化(浅比较),就不执行渲染,以此来达到性能优化的目的。useEffect一样能够借鉴这个思想:
useEffect(effectCreator: Function, deps: Array) // demo const [firstName, setFirstName] = useState('James'); const [count, setCount] = useState(0); useEffect(()=>{ document.title = `${firstName}'s Blog`; }, [firstName]) 复制代码
上面的例子中,只要传入的firstName在先后两次更新中没有发生变化,effectCreator函数就不会执行。也就是说,即使调用屡次setCount(*),组件会重复渲染屡次,但只要 firstName 没有发生变化,effectCreator函数就不会重复执行。
useEffect 的实现和 useState 基本类似,在mount时建立一个 hook 对象,新建一个 effectQueue,以单向链表的方式存储每个 effect,将 effectQueue 绑定在 fiberNode 上,并在完成渲染以后依次执行该队列中存储的 effect 函数。核心的数据结构设计以下:
type Effect{ tag: any, // 用来标识 effect 的类型, create: any, // 反作用函数 destroy: any, // 取消反作用的函数, deps: Array, // 依赖 next: Effect, // 循环链表指针 } type EffectQueue{ lastEffect: Effect } type FiberNode{ memoizedState:any // 用来存放某个组件内全部的 Hook 状态 updateQueue: any } 复制代码
deps 参数的优化逻辑就很简单了:
let componentUpdateQueue = null; function pushEffect(tag, create, deps){ // 构建更新队列 // ... } function useEffect(create, deps){ if(isMount)( mountEffect(create, deps) )else{ updateEffect(create, deps) } } function mountEffect(create, deps){ const hook = createHook(); hook.memoizedState = pushEffect(xxxTag, create, deps); } function updateEffect(create, deps){ const hook = getHook(); if(currentHook!==null){ const prevEffect = currentHook.memoizedState; if(deps!==null){ if(areHookInputsEqual(deps, prevEffect.deps)){ pushEffect(xxxTag, create, deps); return; } } } hook.memoizedState = pushEffect(xxxTag, create, deps); } 复制代码
执行时机至关于componentDidMount和componentDidUpdate,有 return 就至关于加了componentWillUnmount。
主要用来解决代码中的反作用,提供了更优雅的写法。
多个 effect 经过一个单向循环链表来存储,执行顺序是按照书写顺序依次执行。
deps 参数是经过循环浅比较的方式来判断和上一次依赖值是否彻底相同,若是有一个不一样,就从新执行一遍 Effect,若是相同,就跳过本次 Effect 的执行。
每一次组件渲染,都会完整地执行一遍清除、建立 effect。若是有 return 一个清除函数的话。
清除函数会在建立函数以前执行。
在useEffect中咱们使用了一个deps参数来声明 effect 函数对变量的依赖,而后经过areHookInputsEqual函数来比对先后两次的组件渲染时deps的差别,若是浅比较的结果是相同,那么就跳过 effect 函数的执行。
仔细想一想,这不就是生命周期函数shouldComponentUpdate要作的事情吗?何不将该逻辑抽取出来,做为一个通用的 hook 呢,这就是useMemo这个 hook 的原理。
function mountMemo(nextCreate,deps) { const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; } function updateMemo(nextCreate,deps){ const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; // 上一次的缓存结果 const prevState = hook.memoizedState; if (prevState !== null) { if (nextDeps !== null) { const prevDeps = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { return prevState[0]; } } } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; } 复制代码
但 useMemo 和shouldComponentUpdate的区别在于 useMemo 只是一个通用的无反作用的缓存 Hook,并不会影响组件的渲染与否。因此从这点上讲,useMemo 并不能替代shouldComponentUpdate,但这丝绝不影响 useMemo 的价值。useMemo 为咱们提供了一种通用的性能优化方法,对于一些耗性能的计算,咱们能够用 useMemo 来缓存计算结果,只要依赖的参数没有发生变化,就达到了性能优化的目的。
const result = useMemo(()=>{ return doSomeExpensiveWork(a,b); }, [a,b]) 复制代码
那么要完整实现shouldComponentUpdate的效果应该怎么办呢?答案是借助React.memo:
const Button = React.memo((props) => {
// 你的组件
});
复制代码
这至关于使用了 PureComponent。
到目前为止,除了getDerivedStateFromProps,其余经常使用的生命周期方法在 React Hook 中都已经有对应的解决方案了,componentDidCatch官方已经声明正在实现中。这一节的最后,咱们再来看看getDerivedStateFromProps的替代方案。
这个生命周期的做用是根据父组件传入的 props,按需更新到组件的 state 中。虽然不多会用到,但在 React Hook 组件中,仍然能够经过在渲染时调用一次"setState"来实现:
function ScrollView({row}) { let [isScrollingDown, setIsScrollingDown] = useState(false); let [prevRow, setPrevRow] = useState(null); if (row !== prevRow) { // Row 自上次渲染以来发生过改变。更新 isScrollingDown。 setIsScrollingDown(prevRow !== null && row > prevRow); setPrevRow(row); } return `Scrolling down: ${isScrollingDown}`; } 复制代码
若是在渲染过程当中调用了 "setState",组件会取消本次渲染,直接进入下一次渲染。因此这里必定要注意 "setState" 必定要放在条件语句中执行,不然会形成死循环。
使用过早期版本 React 的同窗可能知道Mixins API,这是官方提供的一种比组件更细粒度的逻辑复用能力。在 React 推出基于 ES6 的Class Component的写法后,就被逐渐’抛弃’了。Mixins虽然能够很是方便灵活地解决AOP类的问题,譬如组件的性能日志监控逻辑的复用:
const logMixin = { componentWillMount: function(){ console.log('before mount:', Date.now()); } componentDidMount: function(){ console.log('after mount:', Date.now()) } } var createReactClass = require('create-react-class'); const CompA = createReactClass({ mixins: [logMixin], render: function(){ //... } }) const CompB = createReactClass({ mixins: [logMixin], render: function(){ //... } }) 复制代码
但这种模式自己会带来不少的危害,具体能够参考官方的一片博文:《Mixins Considered Harmful》。
React 官方在 2016 年建议拥抱HOC,也就是使用高阶组件的方式来替代mixins的写法。minxins API 仅能够在create-react-class手动建立组件时才能使用。这基本上宣告了 mixins 这种逻辑复用的方式的终结。
HOC很是强大,React 生态中大量的组件和库都使用了HOC,好比react-redux的connect API:
class MyComp extends Component{ //... } export default connect(MyComp, //...) 复制代码
用HOC实现上面的性能日志打印,代码以下:
function WithOptimizeLog(Comp){ return class extends Component{ constructor(props){ super(props); } componentWillMount(){ console.log('before mount:', Date.now()); } componentDidMount(){ console.log('after mount:', Date.now()); } render(){ return ( <div> <Comp {...props} /> </div> ) } } } // CompA export default WithOptimizeLog(CompA) //CompB export defaultWithOptimizeLog(CompB); 复制代码
HOC虽然强大,但因其自己就是一个组件,仅仅是经过封装了目标组件提供一些上层能力,所以难以免的会带来嵌套地狱的问题。而且由于HOC是一种将可复用逻辑封装在一个 React 组件内部的高阶思惟模式,因此和普通的React组件相比,它就像是一个魔法盒子同样,势必会更难以阅读和理解。
能够确定的是HOC模式是一种被普遍承认的逻辑复用模式,而且在将来很长的一段时间内,这种模式仍将被普遍使用。但随着React Hook架构的推出,HOC模式是否仍然适合用在Function Component中?仍是要寻找一种新的组件复用模式来替代HOC呢?
React 官方团队给出的答案是后者,缘由是在React Hook的设计方案中,借助函数式状态管理以及其余 Hook 能力,逻辑复用的粒度能够实现的更细、更轻量、更天然和直观。毕竟在 Hook 的世界里一切都是函数,而非组件。
来看一个例子:
export default function Article() { const [isLoading, setIsLoading] = useState(false); const [content, setContent] = useState('origin content'); function handleClick() { setIsLoading(true); loadPaper().then(content=>{ setIsLoading(false); setContent(content); }) } return ( <div> <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> <article>{content}</article> </div> ) } 复制代码
上面的代码中展现了一个带有 loading 状态,能够避免在加载结束以前反复点击的按钮。这种组件能够有效地给予用户反馈,而且避免用户因为得不到有效反馈带来的不断尝试形成的性能和逻辑问题。
很显然,loadingButton 的逻辑是很是通用且与业务逻辑无关的,所以彻底能够将其抽离出来成为一个独立的LoadingButton组件:
function LoadingButton(props){ const [isLoading, setIsLoading] = useState(false); function handleClick(){ props.onClick().finally(()=>{ setIsLoading(false); }); } return ( <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> ) } // 使用 function Article(){ const {content, setContent} = useState(''); clickHandler(){ return fetchArticle().then(data=>{ setContent(data); }) } return ( <div> <LoadingButton onClick={this.clickHandler} /> <article>{content}</article> </div> ) } 复制代码
上面这种将某一个通用的 UI 组件单独封装并提取到一个独立的组件中的作法在实际业务开发中很是广泛,这种抽象方式同时将状态逻辑和 UI 组件打包成一个可复用的总体。
很显然,这仍旧是组件复用思惟,并非逻辑复用思惟。试想一下另外一种场景,在点击了 loadingButton 以后,但愿文章的正文也一样展现一个 loading 状态该怎么处理呢?
若是不对 loadingButton 进行抽象的话,天然能够很是方便地复用 isLoading 状态,代码会是这样:
export default function Article() { const [isLoading, setIsLoading] = useState(false); const [content, setContent] = useState('origin content'); function handleClick() { setIsLoading(true); loadArticle().then(content=>{ setIsLoading(false); setContent(content); }) } return ( <div> <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> { isLoading ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> ) } 复制代码
但针对抽象出 LoadingButton 的版本会是什么样的情况呢?
function LoadingButton(props){ const [isLoading, setIsLoading] = useState(false); function handleClick(){ props.onClick().finally(()=>{ setIsLoading(false); }); } return ( <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> ) } // 使用 function Article(){ const {content, setContent} = useState('origin content'); const {isLoading, setIsLoading} = useState(false); clickHandler(){ setIsLoading(true); return fetchArticle().then(data=>{ setContent(data); setIsLoading(false); }) } return ( <div> <LoadingButton onClick={this.clickHandler} /> { isLoading ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> ) } 复制代码
问题并无由于抽象而变的更简单,父组件 Article 仍然要自定一个 isLoading 状态才能够实现上述需求,这显然不够优雅。那么问题的关键是什么呢?
答案是耦合。上述的抽象方案将isLoading状态和button标签耦合在一个组件里了,这种复用的粒度只能总体复用这个组件,而不能单独复用一个状态。解决方案是:
// 提供 loading 状态的抽象 export function useIsLoading(initialValue, callback) { const [isLoading, setIsLoading] = useState(initialValue); function onLoadingChange() { setIsLoading(true); callback && callback().finally(() => { setIsLoading(false); }) } return { value: isLoading, disabled: isLoading, onChange: onLoadingChange, // 适配其余组件 onClick: onLoadingChange, // 适配按钮 } } export default function Article() { const loading = useIsLoading(false, fetch); const [content, setContent] = useState('origin content'); function fetch() { return loadArticle().then(setContent); } return ( <div> <button {...loading}> {loading.value ? 'loading...' : 'refresh'} </button> { loading.value ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> ) } 复制代码
如此便实现了更细粒度的状态逻辑复用,在此基础上,还能够根据实际状况,决定是否要进一步封装 UI 组件。譬如,仍然能够封装一个 LoadingButton:
// 封装按钮 function LoadingButton(props){ const {value, defaultText = '肯定', loadingText='加载中...'} = props; return ( <button {...props}> {value ? loadingText: defaultText} </button> ) } // 封装 loading 动画 function LoadingSpinner(props) { return ( < > { props.value && <img src={spinner} className="spinner" alt="loading" /> } </> ) } // 使用 return ( <div> <LoadingButton {...loading} /> <LoadingSpinner {...loading}/> { loading.value || <article>{content}</article> } </div> ) 复制代码
状态逻辑层面的复用为组件复用带来了一种全新的能力,这彻底有赖于React Hook基于Function的组件设计,一切皆为函数调用。而且,Function Component也并不排斥HOC,你仍然可使用熟悉的方法来提供更高阶的能力,只是如今,你的手中拥有了另一种武器。
在本文撰写的时间点上,仍然有一些Class Component具有的功能是React Hook没有具有的,譬如:生命周期函数componentDidCatch,getSnapshotBeforeUpdate。还有一些第三方库可能还没法兼容 hook,官方给出的说法是:
咱们会尽快补齐
将来可期,咱们只需静静地等待。