React Hooks实践体会

1、前言react

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

 

2、怎么替代以前的生命周期方法?数组

这个问题在笔者粗略地过了一遍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上多几回小范围的无心义的重渲染对性能影响不是很大,但在移动端的影响就很大,因此得看时机状况来决定。app

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

 

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

先说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,这样咱们就有了本身的种子项目生成器,当开新项目的时候,能够进行快速的初始化。

 

4、性能优化

在类组件中,咱们给一个点击事件指定一个事件的回调函数,而且指望在回调函数中访问到该组件实例,一般采用如下作法:

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文档中都有详细的描述,这里就再也不作过多讲解了。

 

5、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带来的限制。

 

6、Hook的优点与"坑"

在Hook的官方文档和一些文章中也提到了类组件的一些很差的地方,好比:HOC的多层嵌套,HOC和Render Props也不是太理想的复用代码逻辑,有关状态管理的逻辑代码很难在组件之间复用、一个业务逻辑的实现代码被放到了不一样的生命周期内、ES2015与类有关语法和this指向等困扰初级开发者的问题等都有提到,若是组件时间过多,在构造函数内经过 bind 进行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的方式来抽象逻辑有助于代码的组织质量。

说了优点就来讲说笔者认为最容易掉进去的"坑":

1. 对于 useEffect 的使用,在当内部逻辑代码存在有获取数据或修改涉及到该hook的依赖的时候,必定要小心,譬如你在该hook内部的操做可能会触发重渲染并会改变该hook的某个依赖的值,就会致使死循环的出现,切记要在hook内部加上条件判断来避免死循环的出现。若是某个界面出现明显的卡顿和动画的掉帧等性能问题,那么极可能是这个缘由致使的。能够直接在函数组件内部打log或者使用performance工具进行检测。

2. 使用hook后,代码归类不会像以前class组件时代的同样有语法的强制规划了,什么意思呢?在class组件时代,redux的有关的代码是放到connect里的,state生命是放constructor里的,其余逻辑是放每一个有关的生命周期里的。而在hook的时代,没有这些东西了,一切都直接放在函数组件内部,若是写得混乱,看起来就是一锅粥,因此,制定组件的书写规范和经过注释来归类不一样功能的逻辑显得尤其重要。这有助于后期的维护,也有助于一个团队的书写风格的一致性。

 

7、为啥会推进Hook

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

 

8、单元测试

笔者目前的项目对稳定性要求高,属于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除外)。

 

9、Vue的Hook

尤大对于Vue的hook和React的hook的总结以下:

1. 总体上更符合JavaScript的直觉;

2. 不受调用顺序的限制,能够有条件地被调用;

3. 不会在后续更新时不断产生大量的内联函数而影响引擎优化或是致使GC压力;

4. 不须要老是使用 useCallback 来缓存传给子组件的回调以防止过分更新;

5. 不须要担忧传了错误的依赖数组给useEffect/useMemo/useCallback从而致使回调中使用了过时的值 —— Vue 的依赖追踪是全自动的。

结合以上段落自行体会吧。

 

10、总结

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

相关文章
相关标签/搜索