当前位置:

精读《怎么用 React Hooks 造轮子》

访客 2024-04-23 854 0

1引言

上周的精读《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存储组件的值,并抛出valueonChange,监听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思路是利用之前说的组件辅助方式,提供一个组件方法集,用解构方式传给组件

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抛出的值”一节的思路,就能轻松理解textpassword是如何作用于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标签类型的设置,监听valueonChange,最终聚合到大的values作为formState返回。读到这里应该发现对ReactHooks的应用都是万变不离其宗的,特别是对组件信息的获取,通过解构方式来做,Hooks内部再做一下聚合,就完成表单组件基本功能了。

实际上一个完整的轮子还需要考虑checkboxradio的兼容,以及校验问题,这些思路大同小异,具体源码可以看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是无法模拟的。

isMounted

很久以前React是提供过这个API的,后来移除了,原因是可以通过componentWillMountcomponentWillUnmount推导。自从有了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,再通过StoreProviderstore注入到子组件的context中,最终通过两个Hooks进行获取与操作:useStoreuseAction

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的ontoggle?**正常方式应该拿不到,所以退而求其次,将useRenderProps拿到的Toggle传给wrapwrap构造RenderProps执行环境拿到ontoggle后,调用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。

封装原本对setState增强的库

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));//immutable3总结

本文列出了ReactHooks的以下几种使用方式以及实现思路:

  • DOM副作用修改/监听。
  • 组件辅助。
  • 做动画。
  • 发请求。
  • 填表单。
  • 模拟生命周期。
  • 存数据。
  • 封装原有库。

欢迎大家的持续补充。

4更多讨论

讨论地址是:精读《怎么用ReactHooks造轮子》·Issue#112·dt-fe/weekly

如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读-帮你筛选靠谱的内容。

发表评论

  • 评论列表
还没有人评论,快来抢沙发吧~