上周的精读《ReactHooks》已经实现了对ReactHooks的基本认知,也许你也看了ReactHooks基本实现剖析(就是数组),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。
这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘ReactHooks的潜力的(造什么轮子)。
首先,站在使用角度,要理解ReactHooks的特点是“非常方便的Connect一切”,所以无论是数据流、Network,或者是定时器都可以监听,有一点RXJS的意味,也就是你可以利用ReactHooks,将React组件打造成:任何事物的变化都是输入源,当这些源变化时会重新触发React组件的render,你只需要挑选组件绑定哪些数据源(use哪些Hooks),然后只管写render函数就行了!
2精读参考了部分ReactHooks组件后,笔者按照功能进行了一些分类。
由于ReactHooks并不是非常复杂,所以就不按照技术实现方式去分类了,毕竟技术总有一天会熟练,而且按照功能分类才有持久的参考价值。
DOM副作用修改/监听做一个网页,总有一些看上去和组件关系不大的麻烦事,比如修改页面标题(切换页面记得改成默认标题)、监听页面大小变化(组件销毁记得取消监听)、断网时提示(一层层装饰器要堆成小山了)。而ReactHooks特别擅长做这些事,造这种轮子,大小皆宜。
由于ReactHooks降低了高阶组件使用成本,那么一套生命周期才能完成的“杂耍”将变得非常简单。
下面举几个例子:
修改页面title效果:在组件里调用useDocumentTitle
函数即可设置页面标题,且切换页面时,页面标题重置为默认标题“前端精读”。
useDocumentTitle("个人中心");
实现:直接用document.title
赋值,不能再简单。在销毁时再次给一个默认标题即可,这个简单的函数可以抽象在项目工具函数里,每个页面组件都需要调用。
functionuseDocumentTitle(title){useEffect(()=>{document.title=title;return()=>(document.title="前端精读");},[title]);}
在线Demo
监听页面大小变化,网络是否断开效果:在组件调用useWindowSize
时,可以拿到页面大小,并且在浏览器缩放时自动触发组件更新。
constwindowSize=useWindowSize();return<div>页面高度:{windowSize.innerWidth}</div>;
实现:和标题思路基本一致,这次从window.innerHeight
等API直接拿到页面宽高即可,注意此时可以用window.addEventListener('resize')
监听页面大小变化,此时调用setValue
将会触发调用自身的UI组件rerender,就是这么简单!
最后注意在销毁时,removeEventListener
注销监听。
functiongetSize(){return{innerHeight:window.innerHeight,innerWidth:window.innerWidth,outerHeight:window.outerHeight,outerWidth:window.outerWidth};}functionuseWindowSize(){let[windowSize,setWindowSize]=useState(getSize());functionhandleResize(){setWindowSize(getSize());}useEffect(()=>{window.addEventListener("resize",handleResize);return()=>{window.removeEventListener("resize",handleResize);};},[]);returnwindowSize;}
在线Demo
动态注入css效果:在页面注入一段class,并且当组件销毁时,移除这个class。
constclassName=useCss({color:"red"});return<divclassName={className}>Text.</div>;
实现:可以看到,Hooks方便的地方是在组件销毁时移除副作用,所以我们可以安心的利用Hooks做一些副作用。注入css自然不必说了,而销毁css只要找到注入的那段引用进行销毁即可,具体可以看这个代码片段。
DOM副作用修改/监听场景有一些现成的库了,从名字上就能看出来用法:document-visibility、network-status、online-status、window-scroll-position、window-size、document-title。
组件辅助Hooks还可以增强组件能力,比如拿到并监听组件运行时宽高等。
获取组件宽高效果:通过调用useComponentSize
拿到某个组件ref实例的宽高,并且在宽高变化时,rerender并拿到最新的宽高。
constref=useRef(null);letcomponentSize=useComponentSize(ref);return(<>{componentSize.width}<textArearef={ref}/></>);
实现:和DOM监听类似,这次换成了利用ResizeObserver
对组件ref进行监听,同时在组件销毁时,销毁监听。
其本质还是监听一些副作用,但通过ref的传递,我们可以对组件粒度进行监听和操作了。
useLayoutEffect(()=>{handleResize();letresizeObserver=newResizeObserver(()=>handleResize());resizeObserver.observe(ref.current);return()=>{resizeObserver.disconnect(ref.current);resizeObserver=null;};},[]);
在线Demo,对应组件component-size。
拿到组件onChange抛出的值效果:通过useInputValue()
拿到Input框当前用户输入的值,而不是手动监听onChange再腾一个otherInputValue
和一个回调函数把这一堆逻辑写在无关的地方。
letname=useInputValue("Jamie");//name={value:'Jamie',onChange:[Function]}return<input{...name}/>;
可以看到,这样不仅没有占用组件自己的state,也不需要手写onChange回调函数进行处理,这些处理都压缩成了一行usehook。
实现:读到这里应该大致可以猜到了,利用useState
存储组件的值,并抛出value
与onChange
,监听onChange
并通过setValue
修改value
,就可以在每次onChange
时触发调用组件的rerender了。
functionuseInputValue(initialValue){let[value,setValue]=useState(initialValue);letonChange=useCallback(function(event){setValue(event.currentTarget.value);},[]);return{value,onChange};}
这里要注意的是,我们对组件增强时,组件的回调一般不需要销毁监听,而且仅需监听一次,这与DOM监听不同,因此大部分场景,我们需要利用useCallback
包裹,并传一个空数组,来保证永远只监听一次,而且不需要在组件销毁时注销这个callback。
在线Demo,对应组件input-value。
做动画利用ReactHooks做动画,一般是拿到一些具有弹性变化的值,我们可以将值赋给进度条之类的组件,这样其进度变化就符合某种动画曲线。
在某个时间段内获取0-1之间的值这个是动画最基本的概念,某个时间内拿到一个线性增长的值。
效果:通过useRaf(t)
拿到t毫秒内不断刷新的0-1之间的数字,期间组件会不断刷新,但刷新频率由requestAnimationFrame控制(不会卡顿UI)。
constvalue=useRaf(1000);
实现:写起来比较冗长,这里简单描述一下。利用requestAnimationFrame
在给定时间内给出0-1之间的值,那每次刷新时,只要判断当前刷新的时间点占总时间的比例是多少,然后做分母,分子是1即可。
在线Demo,对应组件use-raf。
弹性动画效果:通过useSpring
拿到动画值,组件以固定频率刷新,而这个动画值以弹性函数进行增减。
实际调用方式一般是,先通过useState
拿到一个值,再通过动画函数包住这个值,这样组件就会从原本的刷新一次,变成刷新N次,拿到的值也随着动画函数的规则变化,最后这个值会稳定到最终的输入值(如例子中的50
)。
const[target,setTarget]=useState(50);constvalue=useSpring(target);return<divonClick={()=>setTarget(100)}>{value}</div>;
实现:为了实现动画效果,需要依赖rebound
库,它可以实现将一个目标值拆解为符合弹性动画函数过程的功能,那我们需要利用ReactHooks做的就是在第一次接收到目标值是,调用spring.setEndValue
来触发动画事件,并在useEffect
里做一次性监听,再值变时重新setValue
即可。
最神奇的setTarget
联动useSpring
重新计算弹性动画部分,是通过useEffect
第二个参数实现的:
useEffect(()=>{if(spring){spring.setEndValue(targetValue);}},[targetValue]);
也就是当目标值变化后,才会进行新的一轮rerender,所以useSpring
并不需要监听调用处的setTarget
,它只需要监听target
的变化即可,而巧妙利用useEffect
的第二个参数可以事半功倍。
在线Demo
Tween动画明白了弹性动画原理,Tween动画就更简单了。
效果:通过useTween
拿到一个从0变化到1的值,这个值的动画曲线是tween
。可以看到,由于取值范围是固定的,所以我们不需要给初始值了。
constvalue=useTween();
实现:通过useRaf
拿到一个线性增长的值(区间也是0~1),再通过easing
库将其映射到0~1到值即可。这里用到了hook调用hook的联动(通过useRaf
驱动useTween
),还可以在其他地方举一反三。
constfn:Easing=easing[easingName];constt=useRaf(ms,delay);returnfn(t);
发请求利用Hooks,可以将任意请求Promise封装为带有标准状态的对象:loading、error、result。
通用Http封装效果:通过useAsync
将一个Promise拆解为loading、error、result三个对象。
const{loading,error,result}=useAsync(fetchUser,[id]);
实现:在Promise的初期设置loading,结束后设置result,如果出错则设置error,这里可以将请求对象包装成useAsyncState
来处理,这里就不放出来了。
exportfunctionuseAsync(asyncFunction){constasyncState=useAsyncState(options);useEffect(()=>{constpromise=asyncFunction();asyncState.setLoading();promise.then(result=>asyncState.setResult(result);,error=>asyncState.setError(error););},params);}
具体代码可以参考react-async-hook,这个功能建议仅了解原理,具体实现因为有一些边界情况需要考虑,比如组件isMounted后才能相应请求结果。
RequestService业务层一般会抽象一个requestservice
做统一取数的抽象(比如统一url,或者可以统一换socket实现等等)。假如以前比较low的做法是:
asynccomponentDidMount(){//setState:改isLoadingstatetry{constdata=awaitfetchUser()//setState:改isLoading、error、data}catch(error){//setState:改isLoading、error}}
后来把请求放在redux里,通过connect注入的方式会稍微有些改观:
@Connect(...)classAppextendsReact.PureComponent{publiccomponentDidMount(){this.props.fetchUser()}publicrender(){//this.props.userData.isLoading|error|data}}
最后会发现还是Hooks简洁明了:
functionApp(){const{isLoading,error,data}=useFetchUser();}
而useFetchUser
利用上面封装的useAsync
可以很容易编写:
constfetchUser=id=>fetch(`xxx`).then(result=>{if(result.status!==200){thrownewError("badstatus="result.status);}returnresult.json();});functionuseFetchUser(id){constasyncFetchUser=useAsync(fetchUser,id);returnasyncUser;}
填表单ReactHooks特别适合做表单,尤其是antdform如果支持Hooks版,那用起来会方便许多:
functionApp(){const{getFieldDecorator}=useAntdForm();return(<FormonSubmit={this.handleSubmit}className="login-form"><FormItem>{getFieldDecorator("userName",{rules:[{required:true,message:"Pleaseinputyourusername!"}]})(<Inputprefix={<Icontype="user"style={{color:"rgba(0,0,0,.25)"}}/>}placeholder="Username"/>)}</FormItem><FormItem><Buttontype="primary"htmlType="submit"className="login-form-button">Login</Button>Or<ahref="">registernow!</a></FormItem></Form>);}
不过虽然如此,getFieldDecorator
还是基于RenderProps思路的,彻底的Hooks思路是利用之前说的组件辅助方式,提供一个组件方法集,用解构方式传给组件。
效果:通过useFormState
拿到表单值,并且提供一系列组件辅助方法控制组件状态。
const[formState,{text,password}]=useFormState();return(<form><input{...text("username")}required/><input{...password("password")}requiredminLength={8}/></form>);
上面可以通过formState
随时拿到表单值,和一些校验信息,通过password("pwd")
传给input
组件,让这个组件达到受控状态,且输入类型是password
类型,表单key是pwd
。而且可以看到使用的form
是原生标签,这种表单增强是相当解耦的。
实现:仔细观察一下结构,不难发现,我们只要结合组件辅助小节说的“拿到组件onChange抛出的值”一节的思路,就能轻松理解text
、password
是如何作用于input
组件,并拿到其输入状态。
往简单的来说,只要把这些状态Merge起来,通过useReducer
聚合到formState
就可以实现了。
为了简化,我们只考虑对input
的增强,源码仅需30几行:
exportfunctionuseFormState(initialState){const[state,setState]=useReducer(stateReducer,initialState||{});constcreatePropsGetter=type=>(name,ownValue)=>{consthasOwnValue=!!ownValue;consthasValueInState=state[name]!==undefined;functionsetInitialValue(){letvalue="";setState({[name]:value});}constinputProps={name,//给input添加type:textorpasswordgetvalue(){if(!hasValueInState){setInitialValue();//给初始化值}returnhasValueInState?state[name]:"";//赋值},onChange(e){let{value}=e.target;setState({[name]:value});//修改对应Key的值}};returninputProps;};constinputPropsCreators=["text","password"].reduce((methods,type)=>({...methods,[type]:createPropsGetter(type)}),{});return[{values:state},//formStateinputPropsCreators];}
上面30行代码实现了对input
标签类型的设置,监听value
onChange
,最终聚合到大的values
作为formState
返回。读到这里应该发现对ReactHooks的应用都是万变不离其宗的,特别是对组件信息的获取,通过解构方式来做,Hooks内部再做一下聚合,就完成表单组件基本功能了。
实际上一个完整的轮子还需要考虑checkbox
radio
的兼容,以及校验问题,这些思路大同小异,具体源码可以看react-use-form-state。
有的时候React15的API还是挺有用的,利用ReactHooks几乎可以模拟出全套。
componentDidMount效果:通过useMount
拿到mount周期才执行的回调函数。
useMount(()=>{//quitesimilarto`componentDidMount`});
实现:componentDidMount
等价于useEffect
的回调(仅执行一次时),因此直接把回调函数抛出来即可。
useEffect(()=>voidfn(),[]);
componentWillUnmount效果:通过useUnmount
拿到unmount周期才执行的回调函数。
useUnmount(()=>{//quitesimilarto`componentWillUnmount`});
实现:componentWillUnmount
等价于useEffect
的回调函数返回值(仅执行一次时),因此直接把回调函数返回值抛出来即可。
useEffect(()=>fn,[]);
componentDidUpdate效果:通过useUpdate
拿到didUpdate周期才执行的回调函数。
useUpdate(()=>{//quitesimilarto`componentDidUpdate`});
实现:componentDidUpdate
等价于useMount
的逻辑每次执行,除了初始化第一次。因此采用moutingflag(判断初始状态)不加限制参数确保每次rerender都会执行即可。
constmounting=useRef(true);useEffect(()=>{if(mounting.current){mounting.current=false;}else{fn();}});
ForceUpdate效果:这个最有意思了,我希望拿到一个函数update
,每次调用就强制刷新当前组件。
constupdate=useUpdate();
实现:我们知道useState
下标为1的项是用来更新数据的,而且就算数据没有变化,调用了也会刷新组件,所以我们可以把返回一个没有修改数值的setValue
,这样它的功能就仅剩下刷新组件了。
constuseUpdate=()=>useState(0)[1];
对于getSnapshotBeforeUpdate
,getDerivedStateFromError
,componentDidCatch
目前Hooks是无法模拟的。
很久以前React是提供过这个API的,后来移除了,原因是可以通过componentWillMount
和componentWillUnmount
推导。自从有了ReactHooks,支持isMount简直是分分钟的事。
效果:通过useIsMounted
拿到isMounted
状态。
constisMounted=useIsMounted();
实现:看到这里的话,应该已经很熟悉这个套路了,useEffect
第一次调用时赋值为true,组件销毁时返回false,注意这里可以加第二个参数为空数组来优化性能。
const[isMount,setIsMount]=useState(false);useEffect(()=>{if(!isMount){setIsMount(true);}return()=>setIsMount(false);},[]);returnisMount;
在线Demo
存数据全局Store效果:通过createStore
创建一个全局Store,再通过StoreProvider
将store
注入到子组件的context
中,最终通过两个Hooks进行获取与操作:useStore
与useAction
:
conststore=createStore({user:{name:"小明",setName:(state,payload)=>{state.name=payload;}}});constApp=()=>(<StoreProviderstore={store}><YourApp/></StoreProvider>);functionYourApp(){constuserName=useStore(state=>state.user.name);constsetName=userAction(dispatch=>dispatch.user.setName);}
实现:这个例子的实现可以单独拎出一篇文章了,所以笔者从存数据的角度剖析一下StoreProvider
的实现。
对,Hooks并不解决Provider的问题,所以全局状态必须有Provider,但这个Provider可以利用React内置的createContext
简单搞定:
constStoreContext=createContext();constStoreProvider=({children,store})=>(<StoreContext.Providervalue={store}>{children}</StoreContext.Provider>);
剩下就是useStore
怎么取到持久化Store的问题了,这里利用useContext
和刚才创建的Context对象:
conststore=useContext(StoreContext);returnstore;
更多源码可以参考easy-peasy,这个库基于redux编写,提供了一套HooksAPI。
封装原有库是不是ReactHooks出现后,所有的库都要重写一次?当然不是,我们看看其他库如何做改造。
RenderPropstoHooks这里拿react-powerplug举例。
比如有一个renderProps库,希望改造成Hooks的用法:
import{Toggle}from'react-powerplug'functionApp(){return(<Toggleinitial={true}>{({on,toggle})=>(<Checkboxchecked={on}onChange={toggle}/>)}</Toggle>)}↓↓↓↓↓↓import{useToggle}from'react-powerhooks'functionApp(){const[on,toggle]=useToggle()return<Checkboxchecked={on}onChange={toggle}/>}
效果:假如我是react-powerplug
的维护者,怎么样最小成本支持ReactHook?说实话这个没办法一步做到,但可以通过两步实现。
exportfunctionToggle(){//这是Toggle的源码//balabalabala..}constApp=wrap(()=>{//第一步:包wrapconst[on,toggle]=useRenderProps(Toggle);//第二步:包useRenderProps});
实现:首先解释一下为什么要包两层,首先Hooks必须遵循React的规范,我们必须写一个useRenderProps
函数以符合Hooks的格式,**那问题是如何拿到Toggle给render的on
与toggle
?**正常方式应该拿不到,所以退而求其次,将useRenderProps
拿到的Toggle传给wrap
,让wrap
构造RenderProps执行环境拿到on
与toggle
后,调用useRenderProps
内部的setArgs
函数,让const[on,toggle]=useRenderProps(Toggle)
实现曲线救国。
constwrappers=[];//全局存储wrappersexportconstuseRenderProps=(WrapperComponent,wrapperProps)=>{const[args,setArgs]=useState([]);constref=useRef({});if(!ref.current.initialized){wrappers.push({WrapperComponent,wrapperProps,setArgs});}useEffect(()=>{ref.current.initialized=true;},[]);returnargs;//通过下面wrap调用setArgs获取值。};
由于useRenderProps
会先于wrap
执行,所以wrappers会先拿到Toggle,wrap
执行时直接调用wrappers.pop()
即可拿到Toggle对象。然后构造出RenderProps的执行环境即可:
exportconstwrap=FunctionComponent=>props=>{constelement=FunctionComponent(props);constref=useRef({wrapper:wrappers.pop()});//拿到useRenderProps提供的Toggleconst{WrapperComponent,wrapperProps}=ref.current.wrapper;returncreateElement(WrapperComponent,wrapperProps,(...args)=>{//WrapperComponent=>Toggle,这一步是在构造RenderProps执行环境if(!ref.current.processed){ref.current.wrapper.setArgs(args);//拿到on、toggle后,通过setArgs传给上面的args。ref.current.processed=true;}else{ref.current.processed=false;}returnelement;});};
以上实现方案参考react-hooks-render-props,有需求要可以拿过来直接用,不过实现思路可以参考,作者的脑洞挺大。
HookstoRenderProps好吧,如果希望Hooks支持RenderProps,那一定是希望同时支持这两套语法。
效果:一套代码同时支持Hooks和RenderProps。
实现:其实Hooks封装为RenderProps最方便,因此我们使用Hooks写核心的代码,假设我们写一个最简单的Toggle
:
constuseToggle=initialValue=>{const[on,setOn]=useState(initialValue);return{on,toggle:()=>setOn(!on)};};
在线Demo
然后通过render-props
这个库可以轻松封装出RenderProps组件:
constToggle=({initialValue,children,render=children})=>renderProps(render,useToggle(initialValue));
在线Demo
其实renderProps
这个组件的第二个参数,在Class形式React组件时,接收的是this.state
,现在我们改成useToggle
返回的对象,也可以理解为state
,利用Hooks机制驱动Toggle组件rerender,从而让子组件rerender。
Hooks也特别适合封装原本就作用于setState的库,比如immer。
useState
虽然不是setState
,但却可以理解为控制高阶组件的setState
,我们完全可以封装一个自定义的useState
,然后内置对setState
的优化。
比如immer的语法是通过produce
包装,将mutable代码通过Proxy代理为immutable:
constnextState=produce(baseState,draftState=>{draftState.push({todo:"Tweetaboutit"});draftState[1].done=true;});
那这个produce
就可以通过封装一个useImmer
来隐藏掉:
functionuseImmer(initialValue){const[val,updateValue]=React.useState(initialValue);return[val,updater=>{updateValue(produce(updater));}];}
使用方式:
const[value,setValue]=useImmer({a:1});value(obj=>(obj.a=2));//immutable
3总结本文列出了ReactHooks的以下几种使用方式以及实现思路:
- DOM副作用修改/监听。
- 组件辅助。
- 做动画。
- 发请求。
- 填表单。
- 模拟生命周期。
- 存数据。
- 封装原有库。
欢迎大家的持续补充。
4更多讨论讨论地址是:精读《怎么用ReactHooks造轮子》·Issue#112·dt-fe/weekly
如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读-帮你筛选靠谱的内容。