当前位置: 首页 > 后端技术 > PHP

不优雅的ReactHooks

时间:2023-03-30 02:31:15 PHP

到2021年底,ReactHooks已经在React生态中大放异彩,席卷了几乎所有的React应用。而且它几乎与功能组件和Fiber架构自然契合。此刻,我们似乎没有理由拒绝它。诚然,Hooks解决了ReactMixins长期存在的问题,但从其各种奇怪的经历来看,我认为现阶段的Hooks并不是一个很好的抽象。红脸太常见了,我们也唱黑脸吧。本文将从“挑剔”~“奇葩”的规则角度,谈谈我眼中的ReactHooks。React官方制定了一些Hooks编写规范来避免bug,但这也正好暴露了它的问题。NamingHooks不是一个普通的函数。我们通常以use开头的名字来区别于其他函数。但相应的,这也打破了函数命名的语义。固定的使用前缀使得Hooks的命名变得困难。你对useGetState之类的命名感到迷惑,无法理解useTitle是怎么回事。相比之下,以_开头的私有成员变量和以$结尾的流则没有类似的麻烦。当然,这只是使用习惯的不同,问题不大。调用时机在使用useState的时候,你有没有想过:虽然每次render()都会调用useState,但是它可以帮我保留State。如果我写了很多,它怎么知道我想要什么?什么是国家?const[name,setName]=useState('xiaoming')console.log('somesentences')const[age,setAge]=useState(18)复制两次代码useState只是参数的不同,没有语义differenceDistinguish(我们只是给返回值赋予语义),从useState的角度来看,React怎么知道我什么时候要name,什么时候要age?看上面的示例代码,为什么第1行useState返回的是字符串name,第3行返回的是数字age?毕竟,看起来我们只是以一种“不起眼”的方式调用了两次useState。答案是“时机”。useState的调用顺序决定了结果,即第一个useState“保存”了name的状态,第二个useState“保存”了age的状态。//在ClassComponent中,state是通过字面量声明和更新的,不存在一致性问题。“时机”决定了这一切(背后的数据结构是链表),这也导致了Hooks对调用时机的严格要求。也就是说,要避免所有的分支结构,不要让Hooks“偶尔消失”。//?典型错误if(some){const[name,setName]=useState('xiaoming')}要求复制代码完全取决于开发者的经验或Lint,但从一般第三方Lib的角度来看,这个需要调用时间的API设计非常罕见,而且非常违反直觉。理想的API封装应该是开发者认知负担最小的。就像封装了一个纯函数add(),不管开发者在什么环境下调用,调用多深,使用什么调用顺序,只要传入的参数满足要求,就可以正常工作,简单和纯。functionadd(a:number,b:number){returna+b}functionouter(){constm=123;setTimeout(()=>{request('xx').then((n)=>{constresult=add(m,n)//直观调用:无环境要求})},1e3)}重复代码可以可以说“React确实没有办法让Hooks不需要环境”,但是我们不能否认这种方法的古怪。类似的情况存在于redux-saga中。开发人员很容易写出如下“直观”的代码,他们“看”不出任何问题。import{call}from'redux-saga/effects'function*fetch(){setTimeout(function*(){constuser=yieldcall(fetchUser)console.log('hi',user)//不会在这里执行},1e3)}在Generator中调用了复制代码yieldcall(),看起来真的很“合理”。但实际上function*需要Generator的执行环境,call也需要redux-saga的执行环境。在双重需求下,示例代码自然无法正常运行。useRef的“披荆斩棘”本来意义上的useRef其实是ClassComponent时代React.createRef()的等价替代品。官方文档中的初始示例代码可以支持这个(如下图,有删减):return();}复制代码但由于其实现的特殊性,常被用于其他用途。在ReactHooks的源码中,useRef只在Mount期间初始化对象,在Update期间返回Mount期间的结果(memoizedState)。这意味着在一个完整的生命周期中,useRef保留的引用永远不会改变。而这个特性让它成为了Hooks闭包的救星。“未定,使用Ref!”(useRef的滥用有很多,本文不再赘述)每个Function的执行都有对应的Scope。对于面向对象来说,这个引用是连接所有Scopes的纽带。Context(当然前提是在同一个Class下)。类Runner{runCount=0run(){console.log('run')this.runCount+=1}xrun(){this.run()this.run()this.run()}output(){this.xrun()//即使是“间接调用”`run`,这里“仍然”可以得到`run`的执行信息console.log(this.runCount)//3}}复制代码在ReactHooks中,每timeRender由当时的State决定,Render完成后刷新Context。优雅的UI渲染,干净利落。但是useRef有些违背了设计者的初衷。useRef可以跨越多个Render生成的Scope。它可以保留已执行的渲染逻辑,但也可以防止渲染的Context被释放。它很强大,但也很邪恶。如果this引用是面向对象中最重要的副作用,那么useRef也是一样的。从这个角度来看,带有useRef的FunctionComponent注定很难实现“功能性”。当使用有缺陷的生命周期构造时,请注意类组件和函数组件之间存在一个很大的“错误”。ClassComponent只被实例化一次,然后只执行render(),而FunctionComponent则不断地执行自己。这就导致FunctionComponent相对于ClassComponent实际上缺少对应的构造函数。当然,如果有办法只执行Function中的某段逻辑,也可以模拟构造函数。//例如使用useRef构造函数useConstructor(callback){constinit=useRef(true)if(init.current){callback()init.current=false}}就代码生命周期而言,constructor不能相似touseEffect,如果实际节点渲染耗时比较长,两者的时间会有很大的差异。也就是说,Class组件和Function组件的生命周期API并不是完全一一对应的,这是一个很容易出错的地方。useEffect的乱七八糟的设计了解了useEffect的基本用法,再加上字面意思(监控副作用),你会误以为它等同于Watcher。useEffect(()=>{//watchchangestoadoSomething4A()},[a])复制代码但很快你会发现不对劲,如果变量a未能触发重新渲染,watch将不会生效。也就是说,其实应该是用来监听State变化的,即useStateEffect。但是,参数deps并没有将输入限制为只有State。如果不是一些特殊的动作,很难不让人觉得是设计缺陷。const[a]=useState(0)const[b]=useState(0)useEffect(()=>{//假设这是`a`的监视器},[a])useEffect(()=>{//假设这是对`b`的监听//其实即使b没有变化,a也没有被监听,但是因为有变化还是执行了},[b,Date.now()])//因为Date.now()每次都是一个新值。对拷贝代码useStateEffect的理解不到位,因为useEffect实际上是负责Mount的监听。您需要使用“空依赖”来区分Mount和Update。useEffect(onMount,[])复制代码单个API支持的功能越多,其设计就越混乱。复杂的功能不仅考验开发者的记忆力,而且难以理解,更容易因误解而导致失败。UseCallback性能问题?在ClassComponent中,我们经常将函数绑定到this上,并保留其唯一引用,以减少不必要的子组件重新渲染。classApp{constructor(){//方法一this.onClick=this.onClick.bind(this)}onClick(){console.log('Iam`onClick`')}//方法二onChange=()=>{}render(){return()}}复制FunctionComponent中的代码,对应的解决方案是useCallback://?有效优化函数App(){constonClick=useCallback(()=>{console.log('Iam`onClick`')},[])return()}//?错误的演示,onClick在每个Render中都是全新的,将thereforere-renderfunctionApp(){//...somestatesconstonClick=()=>{console.log('Iam`onClick`')}return()}复制代码useCallback可以重复多次的函数的引用仍然保留在渲染中,第2行的onClick始终不变,从而避免了子组件的重新渲染。useCallback的源码其实很简单:在Mount期间,只保存回调及其依赖数组。Update期间判断依赖数组是否一致,则返回之前的回调。顺便看看useMemo的实现。其实它和useCallback的区别只是多了一步Invoke:UnlimitedMatryoshka?相对于不使用useCallback带来的性能问题,真正麻烦的是useCallback带来的引用依赖问题。//当你决定引入useCallback来解决重复渲染的问题functionApp(){//请求A需要的参数const[a1,setA1]=useState('')const[a2,setA2]=useState('')//请求B需要的参数const[b1,setB1]=useState('')const[b2,setB2]=useState('')//请求A,并处理返回结果constreqA=useCallback(()=>{requestA(a1,a2)},[a1,a2])//请求A、B,并处理返回结果constreqB=useCallback(()=>{reqA()//`reqA`的引用总是第一个That,requestB(b1,b2)//当`a1`,`a2`改变时,`reqB`中的`reqA`实际上已经过时了。},[b1,b2])//当然,将reqA添加到reqB中,放在依赖数组中不是很好吗?//但是当你调用`reqA`函数时,//你怎么知道它“应该”被添加到依赖数组中?return(<>/>)}复制代码从上面的例子可以看出,当有依赖在useCallback之前,他们的reference维护也变得复杂了。调用函数时要小心。您需要考虑它是否参考了过时的问题。如果有遗漏,没有添加到依赖数组中,就会出现bug。Use-UniversalHooks在百花齐放的时期,诞生了很多工具库。仅ahooks就有62个自定义Hooks。可以说是“什么都能用”~真的有必要封装那么多Hook吗?或者我们真的需要那么多Hooks吗?包装合理?虽然在React文档中,官方也推荐封装自定义Hooks,提高逻辑复用性。但是我觉得还是要看情况,并不是所有的生命周期都需要封装成Hooks。//1.封装前functionApp(){useEffect(()=>{//useEffect参数不能是asyncfunction(async()=>{awaitPromise.all([fetchA(),fetchB()])awaitpostC()})()},[])return(

123
)}//--------------------------------------------//2.自定义HooksfunctionApp(){useABC()return(
123
)}functionuseABC(){useEffect(()=>{(async()=>{awaitPromise.all([fetchA(),fetchB()])awaitpostC()})()},[])}//-----------------------------------------------//3.传统封装函数App(){useEffect(()=>{requestABC()},[])return(
123
)}asyncfunctionrequestABC(){awaitPromise.all([fetchA(),fetchB()])awaitpostC()}复制代码上面代码中,将生命周期中的逻辑封装为HookuseABC,反而耦合了生命周期回调,降低了复用性。即使我们的package不包含任何Hooks,调用的时候也只是一层useEffect,并不麻烦,而且这个逻辑也可以用在Hooks以外的地方。如果自定义Hooks中使用useEffect和useState的次数加起来不超过2次,你就真该好好想想这个Hook的必要性了,能不能封装一下。简单来说,Hook要么是“钩住生命周期”,要么是“处理State”,否则就没有必要了。重复调用Hook调用是非常“反直觉”的,会随着重新渲染不断调用,这就需要Hook开发者对这种重复调用有一定的预期。如上例,很容易依赖useEffect对请求进行封装。毕竟可以通过hook生命周期来确定请求不会被重复调用。functionuseFetchUser(userInfo){const[user,setUser]=useState(null)useEffect(()=>{fetch(userInfo).then(setUser)},[])returnuser}复制代码但是,useEffect真的合适吗??如果这个时机是DidMount,那么执行的时机还是比较晚的。毕竟如果渲染结构复杂,层次太深,DidMount会很晚。例如,在ul中呈现2000里:functionApp(){conststart=Date.now()useEffect(()=>{console.log('elapsed:',Date.now()-start,'ms')},[])return(
    {Array.from({length:2e3}).map((_,i)=>({i}))}
)}//output//elapsed:242ms复制代码,使用状态驱动而不是链接到生命周期怎么样?似乎是个好主意,如果状态已更改则重新获取数据似乎是合理的。useEffect(()=>{fetch(userInfo).then(setUser)},[userInfo])//当请求参数发生变化时,重新获取数据复制代码但是第一次执行的时机还是不理想,它还在DidMount。letstart=0letf=falsefunctionApp(){const[id,setId]=useState('123')constrenderStart=Date.now()useEffect(()=>{constnow=Date.now()console.log('elapsedfromstart:',now-start,'ms')console.log('elapsedfromrender:',now-renderStart,'ms')},[id])//这里监听id的变化if(!f){f=truestart=Date.now()setTimeout(()=>{setId('456')},10)}returnnull}//输出//从开始经过:57ms//经过fromrender:57ms//elapsedfromstart:67ms//elapsedfromrender:1ms复制代码这就是为什么说useEffect的设计令人困惑。当您将其用作StateWatcher时,它实际上暗示了“第一次执行是在DidMount”逻辑。从Effect的字面意思来看,这个逻辑就是sideeffect。..状态驱动的封装其实除了调用的时机还有其他的问题:functionApp(){constuser=useFetchUser({//乍一看好像没什么问题name:'zhang',age:20,})return(
{user?.name}
)}复制代码其实重新渲染组件会导致重新计算请求入参->字面值声明的对象每次都是全新的->useFetchUser因此一直在请求->请求改变了Hook内部用户的状态->外部组件重新渲染。这是一个恶性循环!当然可以使用Immutable来解决重复请求同一个参数的问题。useEffect(()=>{//xxxx},[Immutable.Map(userInfo)])复制代码但总的来说,封装Hooks远不止改变你的代码组织那么简单。比如在做数据请求的时候,你可能会走上状态驱动的道路,同时你还要解决状态驱动带来的新麻烦。对于混合?其实Mixin的能力并不是Hooks独有的。我们可以用Decorator来封装一个Mixin机制。换句话说,Hooks不能靠Mixin的能力来克服所有意见。constHelloMixin={componentDidMount(){console.log('Hello,')}}functionmixin(Mixin){returnfunction(constructor){returnclassextendsconstructor{componentDidMount(){Mixin.componentDidMount()super.componentDidMount()}}}}}@mixin(HelloMixin)classTestextendsReact.PureComponent{componentDidMount(){console.log('IamTest')}render(){returnnull}}render()//输出:你好,\n我正在测试复制代码,但是Hooks的组装能力更强,容易嵌套。但是你需要警惕层次更深的Hooks。很可能在你不知道的角落里潜伏着一个隐藏的useEffect。最后,如果您觉得这篇文章对您有点帮助,请点个赞。或者可以加入我的开发交流群:1025263163互相学习,我们会有专业的技术解答。如果您觉得这篇文章对您有用,请给我们的开源项目一个小星星:https://gitee。com/中邦科技非常感谢!PHP学习手册:https://doc.crmeb.com技术交流论坛:https://q.crmeb.com