React Hooks实践体会

一、前言

距离React Hook发布已经有一段时间了,笔者在之前也一直在等待机会来尝试一下Hook,这个尝试不是像文档中介绍的可以先在已有项目中的小组件和新组件上尝试,而是尝试用Hook的方式构建整个项目,正好新的存储项目启动了,需要一个新的基于web的B/S管理系统,机会来了。在项目未进入正式开发前的时间里,笔者和小伙伴们对官方的Hook和Dan以及其他优秀开发者的关于Hook的文档和文章都过了至少一遍,当时的感觉就是:之前学的又没用了,新的一套又来了。目前这个项目已经成功搭起来了,主要组件和业务已具规模,UT也对应完成了。是时候写一下对Hook使用后的初步体会了,在这里,笔者不会做太多太深入的Hook API和原理讲解,因为很多其他优秀的文章可以已经讲得足够多了。再者因为虽然重构了项目,但代码组织方式可能还不是最Hook的方式。本文内容大多为笔者认为使用Hook最需要明白的地方。

 

二、怎么替代之前的生命周期方法?

这个问题在笔者粗略地过了一遍Hook的API后自然而然地产生了,因为毕竟大多数关注Hook新特性的开发者们,都是从生命周期的开发方式方式过来的,从 createClass 到ES2015的 class ,再到Hook。很少有人是从Hook出来才使用React的。这也就是说,大家在使用初期,都会首先用生命周期的思维模式来探究Hook的使用,就像我们对英语没熟练使用之前,英文对话都是先在心里准备出中文语句,在心里翻译出英文语句再说出来。笔者已有3年的生命周期方式的开发经验,惯性的思维改变起来最为困难。

笔者在之前使用生命周期的方式开发组件时,使用最多的、对要实现业务最依赖的生命周期是 componentDidMount 、 componentWillReceiveProps 、 shouldComponentUpdate 。

对于 componentDidMount 的替代方式很简单: useEffect(() => {/* code */}, []); ,使用 useEffect hook,依赖给空数组就行,空数组在这里表示有依赖的存在,但依赖实际上又为空,会是这个hook在初次render完成的时候调用一次足矣。如果有需要在组件卸载的生命周期内 componentWillUnmount 干的事情,只需要在 useEffect 内部返回一个函数,并在这个函数内部做这些事情即可。但要记住的时候,考虑到函数的Capture Value的特性,对值的获取等情况与生命周期方法的表现并非完全一致。

对于 componentWillReceiveProps 这个生命周期。首先这里说说笔者自己的历史原因。在React16.3版本以后,生命周期API被大幅修改,16.4又在16.3上改了一把,为了后期的Async Render的出现,原有的 componentWillReceiveProps 被预先重命名为unsafe方法,并引入了 getDerivedStateFromPorps 的静态方法,为了不重构项目,笔者把React和对应打包工具都停留在了16.2和适配16.2的版本。现有的Hook文档也忽略了怎么替代 componentWillReceiveProps 。其实这个生命周期的替代方式最为简单,因为像 useEffect 、 useCallback 、 useMemo 等hook都可以指定依赖,当依赖变化后,回调函数会重新执行,或者返回一个根据依赖产生的新的函数,或者返回一个根据依赖产生的新的值。

对于 shouldComponentUpdate 来说,它和 componentWillReceiveProps 的替换方式其实差不多。说实话,笔者在项目中,至少是在目前跑在PC浏览器的项目中,不太经常使用这个生命周期。因为在目前的业务中,从redux导致的props更新基本都有数据变化进而导致有视图更新的需要,可能从触发父到子的prop更新的时候,会出现不太必要的冲渲染需要,这个时候可能需要这个生命周期对当前和历史状态进行判断。也就是说,如果对于某个组件来说,差不多每次的props变化大概率可能是值真的变了,其实做比较是无意义的,因为比较也需要耗时,特别是数据量较大的情况。最后耗时去比较了,结果还是数据发生了变化,需要冲渲染,那么这是很操蛋的。所有说不能滥用 shouldComponentUpdate ,真的要看业务情况而定,在PC上多几次小范围的无意义的重渲染对性能影响不是很大,但在移动端的影响就很大,所以得看时机情况来决定。

Hook带来的改变,最重要的应该是在组织一个组件代码的时候,在思维方式上的变化,这也是官方文章中有提到的:"忘记你已经学会的东西",所以我们在熟悉Hook以后,在书写组件逻辑的时候应该不要先考虑生命周期是怎么实现这个业务的,再转成Hook的实现,这样一来,一是还停留在生命周期的方式上,二是即便实现了业务功能,可能也不是很Hook的最优方式。所以,是时候用Hook的方式来思考组件的设计了。

 

三、不要忘记依赖、不要打乱Hook的顺序

先说Hook的顺序,在很多文章中,都有介绍Hook的基本实现或模拟实现原理,笔者这里不再多讲,有兴趣可以自行查看。总结来说就是,Hook实现的时候依赖于调用索引,当某个Hook在某一次渲染时因条件不满足而未能被调用,就会造成调用索引的错位,进而导致结果出错。这是和Hook的实现方式有关的原因,只要记住Hook不能书写在 if 等条件判断语句内部即可。

对于某个hook的依赖来说,一定要记住写,因为函数式组件是没有 componentWillReceive 、 shouldComponentUpdate 生命周期的。任何在重渲染时,一个函数是否需要重新创建、一个值是否需要重新计算,都和依赖有关系,如果依赖变了,就需要计算,没变就不需要计算,以节省重渲染的成本。这里特别需要注意的是函数依赖,因为函数内部可能会使用到 state 和 props 。比如,当你在 useEffect 内部引用了某些 state 和 props ,你可能会很容易的查看到,但是不太容易查看到其内部调用的其他函数是否也用到了 state 和 props 。所以函数的依赖一定不要忘记写。当然官方的CRA工具已经集成了ESlint配置,来帮我们检测某个hook是否存在有遗漏的依赖没有写上。PS. 这里我也推荐大家使用CRA进行项目初始化,并eject出配置文件,这样可以按照我们的业务要求自定义修改配置,然后将一些框架代码通过yeoman打包成generator,这样我们就有了自己的种子项目生成器,当开新项目的时候,可以进行快速的初始化。

 

四、性能优化

在类组件中,我们给一个点击事件指定一个事件的回调函数,并且期望在回调函数中访问到该组件实例,通常采用以下做法:

export default class App extends React {
    constructor (){
        this.onClick = this.onClick.bind(this);
    }

    onClick (){
        console.log('点击了按钮');
    }

    render (){
        return <div>
            <button onClick={this.onClick}>点击</button>
        </div>;  
    }  
}  

我们不在render方法中button组件的 onClick 事件上直接写箭头函数的或者进行 bind 操作的原因是:这两种方式会在 render 方法每次执行的时候都执行一次,要不就是创建一个新的箭头函数或者重新执行一次 bind 方法。但回调函数的内容却从未改变过,因此这些重复的执行均为非必要的,上严格上来讲,存在有性能上的不必要的损耗。鉴于 constructor 只会执行一次,所以把 bind 操作放置于此是十分正确的处理方式。

对于上述例子,使用Hook方式应该如此:

export default function App (){
    const onClick = useCallback(() => {
        console.log('点击了按钮');
    }, []);    

    return <>
        <button onClick={onClick}>点击</button> 
    </>;  
}

如果不用useCallback在每次App重渲染(调用)时, onClick 方法都会被重新创建一次。如果方法内部有依赖,可以将依赖写入 useCallback 的第二个参数的数组中,仅当依赖改变后, onClick  

方法才会被重新创建一次。如果存在有依赖,一定不要忘记依赖,否则这个方法在组件初始化调用以后永远都不会被改变。

对于一些组件内部永远都不会改变,或者仅依赖于某些值而改变的值,可以使用 useMemo 进行优化:

export default function App ({name, age}){
    const name = useMemo(() => <span>name</span>, [name]);
   
    const age = useMemo(() => <span>age</span>, [age]);

    return <>
        我叫{name},今年{age}岁
    </>;
}

如果一个值不可能改变,那么则不需要为期设置具体依赖,传入一个空数组即可。

这样处理后,可以减少重渲染时必须要的工作,也可以避免一个不需要改变的值在组件函数在每次调用时,都被重新创建的问题。

对于类组件中使用 shouldComponentUpdate 进行优化的地方,可以使用 React.memo 包裹整个组件,对 props 进行浅比较来判断。

 

针对严格意义上的极致性能优化,笔者有个体会就是:若要对每一个函数组件内的方法或值进行 useCallback 、 useMemo 等操作来进行缓存优化,会出现很多模板式的代码,似乎又回到了被模板代码支配的时代。是否严格执行这种代码书写约束,还是要取决于应用的复杂程度和需要适配的机器,如果是仅需要支持PC端而且界面简单的话,从实践来看,一些模板代码是可以舍弃的,舍弃后也不会造成性能上的问题(用开发者工具Performance测试后的结果)。这一点就像在类组件时代,PC端的项目连 shouldComponentUpdate 都不需要判断,依然能有一个不错的性能一样(是想经过了xx ms的判断了,最后得到的结果是依旧需要更新,那么这就很扯淡的)。况且从应用和某个页面的设计来讲,每一次的更新基本都需要重绘界面,那么确实没有太大的必要去执行 shouldComponentUpdate 这个生命周期。但在移动端为了低端机器的性能就必须判断了,因为DOM的消耗相当于运行JS代码来说实在是太高。总结就是:一切得根据实际的渲染结果来决定,不要过早的进行性能优化,否则不仅没有意义,还会适得其反(浅比较也有成本消耗)。

 

对于怎么实现其他类组件中的功能,比如 ref 、怎么调用子函数组件内部的一个方法等等之类的问题,在官方Hook文档中都有详细的描述,这里就不再做过多讲解了。

 

五、Cpature Value特性

捕获值的这个特性并非函数式组件特有,它是函数特有的一种特性,函数的每一次调用,会产生一个属于那一次调用的作用域,不同的作用域之前不受影响。笔者看过的有关Hook的文档中,大多都引述过这个经典的例子:

function App (){
    const [count, setCount] = useState(0);
    
    function increateCount (){
        setCount(count + 1);
    }
    
    function showCount (){
        setTimeout(() => console.log(`你点击了${count}次`), 3000);
    }
    
    return (
        <div>
            <p>点击了{count}次</p>
            <button onClick={increateCount}>增加点击次数</button>
            <button onClick={showCount}>显示点击次数</button>
        </div>
    );
}

当我们点击了一次"增加点击次数"按钮后,再点击"显示点击次数"按钮,在大约3s后,我们可以看到点击次数会在控制台输上出来,在这之前我们再次点击"增加点击次数"按钮。3s后,我们看到控制台上输出的是1,而我们期望的是2。当你第一次接触Hook的时候看到这个结果,你一定会大吃一惊,WTF?

可以惊,但不要慌,听我细细道来:

1. 当App函数组件初次渲染完后,生成了第一个scope。在这个scope中, count 的值为0。

2. 我们第一次点击"增加点击次数"按钮的时候,调用了 setCount 方法,并将 count 的值加1,触发了重渲染,App组件函数因重渲染的需要而被重新调用,生成了第二个scope。在这个scope中,count为1。页面也更新到最新的状态,显示"点击了1次"。

3. 紧接着我们点击了"显示点击次数"按钮,将调用 showCount 方法,延迟3s后显示 count 的值。请注意这里,我们这次操作是在第二次渲染生成的这个scope(第二个scope)中进行的,而在这个scope中, count 的值为1。

4. 在3s的异步宏任务还未被推进主线程执行之前,我们又再次点击了"增加点击次数"按钮,再次调用了 setCount 方法,并加 count 的值再次加1,又触发了重渲染,App组件函数因重渲染的需要而被重新调用,生成了第三个scope。在这个scope中,count为2。页面也更新到最新的状态,显示"点击了2次"。

5. 3s到了以后,主线程也出于空闲状态,之前压入异步队列的宏任务被推入主线程中执行,重要的地方来了,这个异步任务所处的作用域是属于第二个scope,也就是说它会使用那一次渲染scope的 count 值,也就是1。而不是和界面最新的渲染结果2一样。

当你使用类组件来实现这个小功能并进行相同操作的时候,在控制台得到的结果都不同,但是在界面上最终的结果是一致的。在类组件中,我们在是生命周期方法 componentDidMount 、 componentDidUpdate 通过 this.state 去获取状态,得到的一定是其最新的值。这就是最大的不同之处,也是让初学者很困惑,很容易踩入坑中的地方,当然这个坑并不是说函数式组件和Hook设计上的问题,而是我们对其的不了解,进而导致使用上的错误和对结果的误判,进而导致代码出现BUG。

Capture Value这个特性在Hook的编码中一定要记住,并且理解。

如果说想要跳出每个重渲染产生的scope会固化自己的状态和值的特性,可以使用Hook API提供的 useRef hook,让所有的渲染scope中的某个状态,都指向一个统一的值的一个Key(API中采用current)。这个对象是引用传递的,ref的值记录在这个Key中,我们并不直接改变这个对象本身,而是通过修改其的一个Key来修记录的值。让每次重渲染生成的scope都保持对同一个对象的引用,来跳出Cpature Value带来的限制。

 

六、Hook的优势

在Hook的官方文档和一些文章中也提到了类组件的一些不好的地方,比如:HOC的多层嵌套,HOC和Render Props也不是太理想的复用代码逻辑,有关状态管理的逻辑代码很难在组件之间复用、一个业务逻辑的实现代码被放到了不同的生命周期内、ES2015与类有关语法和this指向等困扰初级开发者的问题等都有提到。还有像上一段落中提到的一些问题一样。这些都是需要改革和推动的地方。

这里笔者对HOC的多层嵌套确实觉得很恶心,因为笔者之前的项目就是这样的,一旦进入开发者工具的React Dev Tool的Tab,犹如地狱般的connect、asyncLoad就出现了,你会发现每个和Redux有关的组件都有一个connect,做了代码分割以后,异步加载的组件都有一个asyncLoad(虽然后面可以用原生的 lazy 和 suspense 替代),很多因使用HOC而带来的负面影响,对强迫症患者来说这不可接受,只能不看了之。

而对于类组件生命周期的开发方式来说,一个业务逻辑的实现,需要多个生命周期的配合,也就是逻辑代码会被放到多个生命周期内部,在一个组件比较稍微庞大和复杂以后,维护起来较为困难,有些时候可能会忘记修改某个地方,而采用Hook的方式来实现就比较好,可以完全封装在一个自定hook内部,需要的组件引入这个hook即可,还可以做到逻辑的复用。比如这个简单的需求:在页面渲染完成后监听一个浏览器网络变化的事件,并给出对应提示,在组件卸载后,我们再移除这个监听,通常使用生命周期的实现方式为:

class App (){
    browserOnline () {
        notify('浏览器网络已恢复正常!');  
    }   

    browserOffline () {
        notify('浏览器发生网络异常!');  
    }  

    componentDidMount (){
        window.addEventListener('online', this.browserOnline);
        window.addEventListener('offline', this.browserOffline);
    }  

    componentWillUnmount (){
        window.removeEventListener('online', this.browserOnline);
        window.removeEventListener('offline', this.browserOffline);
    }
}

使用Hook方式实现:

function useNetworkNotification (){
    const browserOnline = () => notify('浏览器网络已恢复正常!');

    const browserOffline = () => notify('浏览器发生网络异常!');

    useEffect(() => {
        window.addEventListener('online', browserOnline);
        window.addEventListener('offline', browserOffline);

        return () => {
            window.removeEventListener('online', browserOnline);
            window.removeEventListener('offline', browserOffline);
        };
    }, []);
}
function App (){
    useNetworkNotification();
}    

function AnotherComp (){
    useNetworkNotification();
}

所以,采用Hook实现的代码不仅管理起来方便(无需将相关的代码散布到不同的生命周期方法内),可以封装成自定义的hook,便于逻辑的在不同组件间复用,组件在使用的时候也不需要关注其内部的实现方式。这仅仅是实现了一个很简单功能的例子,如果项目变得更加复杂和难以维护,通过自定义Hook的方式来抽象逻辑有助于代码的组织质量。

 

七、为啥会推动Hook

笔者认为上个段落中提到的函数式组件配合Hook相较于类组件配合生命周期方法是存在有一定优势的。再者,React团队最开始发布Hook的时候,其实是顶着很大的压力的,因为这对于开发者来说实在就是以前的白学了,除了底层某些思想不变外,上层API全部变完。笔者最开始了解Hook后,最直接感受就是这东西是不是在给后面的Async Render填坑用的,为啥会这么说呢?因为React的这种更新机制就是全部树做Diff然后更新patch。而Vue是依赖收集方式的,数据变化后,哪些地方需要更新是明确的,所以更新是精准的。React的这种设计机制,就导致更新的成本很高,即便有虚拟树,但是一旦应用很庞大以后,遍历新旧虚拟树做Diff也是很耗时的,并且没有Async Render前,一旦开启协调,就只能一条路走到底,代码又不能控制JS引擎的函数调用栈,在主线程长时间运行脚本又不归还控制权,会阻塞线程造成界面友好度下降,特别是当应用运行在移动端设备等性能不太强的计算机上时效果特别显著。而基于Fiber的链表式树结构可以模拟出函数调用栈,并能够由代码控制工作的开始和暂停,可以有效解决上述问题,但它会破坏原本完整的生命周期方式,因为一个协调任务的执行,可能会放在不同的线程空闲时间内去完成,进而导致一个生命周期可能会被调用多次,导致实际运行的结果并不像代码书写的那样,这也是在16.3及以后版本将某些生命周期重命名为unsafe的原因。生命周期基本废掉了,虽然后来引入了一些静态方法用来解决一些问题,但存在感太低了,基本都属于过度阶段的产物。生命周期废了,就需要有东西来替代,并支持Async Render的实现,Hook这种模式就是一个不错的选择。当然这可能并不全面,或者说的不绝对正确,但笔者认为是有这个原因的。

 

八、单元测试

笔者目前的项目对稳定性要求高,属于LTS类型,不像创业型的互联网项目,可能上线几个月就下了,所以UT是必须的。笔者给新项目的模块写单元测试的时候,比较完好的支持Hook的Enzyme3.10版本在8天前才发布:(。从目前测试的体验来看,相对于类组件时代确实有进步。在类组件时代,除了生命周期外,其他的一切基本都靠HOC来完成,这就造成了我们在测试的时候,必须套上HOC,而当测试组件业务逻辑的时候,又必须扒开之前套上的HOC,找到里面的真实组件,再进行各种模拟和打桩操作。而函数式组件是没有这个问题的,有Hook加持后,一切都是扁平化的,总之就是比之前好测了。有一点稍微麻烦点的就是:

1. 涉及到会触发重渲染,会执行useEffect 和 useState 的操作,需要放入 react-dom/test-utils 的act 方法内,并且还需要注意源代码是同步还是异步执行,并且在 act 方法执行后,需要执行wrapper的 update 来更新wrapper。遇到这类问题不难解决,到React、Enzyme的Github上搜对应issue即可。

2. 测试中,Capture Value的特性也会存在,所以有些之前缓存的东西,并不是最新的:(。

当然类组件时代也有好处,就是能够访问instance,但对于函数组件来说,无法从函数外面访问函数作用域内的东西(useImperativeHandle除外)。

 

九、总结

就像官方团队的文章中写道的一样:“如果你太不能够接受Hook,我们还是能够理解的,但请你至少不要去喷它,可以适当宣传一下。”。我们还是可以大胆尝试一下Hook的,至少现在2019年年中的时候,因为在这个时间点,一切有关Hook的支持和文档应该都比去年年底甚至是年初的时候更加完善了,虽然可能还不是太完全,但至少官方还在继续摸索,社区也很活跃,造轮子的人也很多。之前也有消息说Vue3.0大版本也会出Hook(Vue最黑暗的一天=.=),哈哈,又是一片腥风血雨。对于有开发经验的人来说入门还算简单,但彻底地掌握这种思想方式和正确地、高水平地运用并总结一套最佳实践的编码方式,还是需要时间和项目实践的。但对于新人来说,无疑提高了入门的门槛,并且很难解释清楚为啥放着好理解的生命周期方式不用,而采用晦涩的函数式方式,所以,对于新人来说,还是建议先尝试16.2版本。

 

转载于:https://www.cnblogs.com/rock-roll/p/11002093.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值