React Hooks实践体会

一、前言

距离React Hook发布已经有一段时间了,笔者在之前也一直在等待机会来尝试一下Hook,并且是尝试用Hook的方式构建整个项目,正好公司的新的存储项目启动了,需要一个新的B端的web管理平台,机会就来了。笔者公司之前的前端项目都基于笔者之前在React Class组件以生命周期的方式下搭建的工程化框架和业务最佳实践的架子。组内有个小伙伴参加了所在城市在三月底举行前端开发者大会,回来给我说会上讲Hook了,但基本上都是在讲官方文档,并没有讲实际的实践,小伙伴觉得太low了。笔者当时心想在下一个项目里一定要玩把大的,推翻我们之前那两个项目的架子设计,重来一遍,不使用Class组件+生命周期的方式,采用全函数式组件+Hook的方式,仅保留Redux那套体系,以及一些经过长期业务沉淀下来的、可复用的工具函数等东西。要这么做还有一个重要理由就是,在后面一些组件库的大版本更新有很大的可能都会抛弃对老生命周期的支持,在后面进行组件库升级的时候,会被迫升级React,进而会面临大量的业务逻辑修改。而换成Hook以后,可以直接在业务代码中彻底告别生命周期的组件设计方式。毕竟这个项目是长时间支持的,不可能只维持几个月,要为后续的迭代提前考虑。一开始抛弃之前Class组件的开发模式和思维是难以接受的,但今天的不便是为了以后的方便。

在项目未进入正式开发前的两周预研时间里,笔者和组内小伙伴对官方的Hook文档以及其他优秀开发者的关于Hook的文章都过了至少一遍,并写了很多小规模的Demo。当时的感觉就是:之前学的又没用了,完全不同的一种React coding方式又来了,心智模型彻底改变了,代码的表面意思并不能代表它实际运行起来的状态,相比于老的编码方式,你得想得更多想得更深。代码组织方式也从面向对象变成了函数式,还需要理解代数效应等等东西。目前这个项目已经成功搭起来了,工程化工具和UT框架也搭建起来了,预计在初版完成时已是具有上百组件规模的中小型项目了,后面可能会优化一下打包工具,因为编译速度随组件的增多明显慢下来了。所以是时候写一下对Hook使用后的初步体会了,在这里,笔者不会做太多太深入的Hook API和原理讲解,因为很多其他优秀的文章已经讲得足够多了。当初在新技术调研完后,笔者认为上手起项目已经很稳了,哈哈,但实际上还是在开发中犯了不少错误(思维方式的改变不是段时间能完成的)。所以本文内容大多为笔者认为在使用Hook的初期最需要弄明白的地方,废话不多说了,下面就开始正文,文中的例子都基于react-16.9.0-alpha和react-dom-16.9.0-alpha。

 

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

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

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

对于 componentDidMount 的替代方式很简单: useEffect(() => {/* code */}, []); ,使用 useEffect hook,依赖给空数组就行,空数组在这里表示有依赖的存在,但依赖实际上又为空,会让这个hook在初次在组件渲染到屏幕之后执行一次传入的函数。PS:其实 componentDidMount 和 useEffect Hook的触发时机并不一样, useEffect 是被放到一个异步宏任务里,在浏览器JS线程和渲染线程的任务完毕以后(绘制后)再被调用,大多数情况下都是异步执行的。 componentDidMount 和 useLayoutEffect Hook的触发时机是一样的。对这点感兴趣的读者可以找别的文章,深入了解。

对于需要在组件卸载的生命周期内 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的方式来思考组件的设计了。

还需要注意的一点是,对于 useEffect 这个常用的Hook来说,它是异步的,传递给它的回调函数如果在第一次执行或者依赖变化后需要重新执行的话,会被放入 requestIdleCallback 中,会被当做一个宏任务在主线程空闲的时候按优先顺序执行,因此这个回调函数的执行不会占用主线程,会导致界面更新或许会延迟些,但不会造成界面卡顿,在性能上有一定保证。如果你对React的Async Render(Time Slicing)有了解的话,你应该会明白这一点,这也是后期React在视图更新上的非常重要的一点。如果想要和之前类组件时代的生命周期函数有同样的表现,请使用 useLayoutEffect 这个Hook,因为他是同步的,会占用主线程,但能得到最新的、最准确的和当前组件状态最匹配的样式和DOM结构。

 

三、不要忘记依赖、不要打乱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 等操作来进行缓存优化,会出现很多模板式的代码,似乎又回到了被模板代码支配的时代。

比如我们想要实现对子组件是否重渲染进行严格的控制以节省性能(类似于 shouldComponentUpdate ),按照官方的例子我们得这么做:

function Parent({ a, b }) {
    // Only re-rendered if `a` changes:
    const child1 = useMemo(() => <Child1 a={a} />, [a]);
    // Only re-rendered if `b` changes:
    const child2 = useMemo(() => <Child2 b={b} />, [b]);
    return (
      <>
        {child1}
        {child2}
      </>
    )
}

但实际上我们压根就不会这么做,如果有大量的子组件,那么得有多少行 useMemo 的模板代码出现。

是否严格执行这种代码书写约束,

1. 要根据业务类型来判断,如果业务的实现会频繁地触发重渲染,比如宽度支持resizable拖动,宽度改变要求是实时的,不能做节流或者防抖。或者重渲染的成本确实太高。在这些情况下,做优化是绝对必要的,否则将出现严重的性能问题,这时就需要用空间换时间。对应开销不大的重渲染没有太大的必要做优化,否则会增大内存使用和增加样板代码。

2. 还要取决于应用的复杂程度和需要适配的机器,如果是仅需要支持PC端而且界面简单的话,从实践来看,一些模板代码是可以舍弃的,舍弃后也不会造成性能上的问题(笔者用用开发者工具Performance测试后的结果),对于一个PC项目,把1ms的执行时间优化为0.8ms,有意义吗?这一点就像在类组件时代,非庞大数据的展示的PC端的项目连 shouldComponentUpdate 都不需要判断,依然能有一个不错的性能一样(考虑这个情况,我们在 shouldComponentUpdate 中实现了我们的比较方法,但是经过了xx ms的判断计算后,返回了true,也就是说最后得到的结果是依旧需要更新,那么这就很扯淡的,还不如不对prop和state的前后值进行比较)。况且从应用和某个页面的设计来讲,每一次的更新基本都需要重绘界面,那么确实没有太大的必要去执行 shouldComponentUpdate 这个生命周期。但在移动端为了低端机器的性能就必须判断了,因为DOM的消耗相当于运行JS代码来说实在是太高。

3. 对于 useMemo 的合理使用来说,如果计算函数的执行并不耗时,再加上返回的值并非一个引用类型的值,或者说该计算函数只会执行一次,也就说说没有依赖项,那么此时是没有必要做useMemo的。

对于在项目中是否真的需要将Immutable.js引入进来,也会需要考虑上述情况。

总结就是:性能优化不是天上掉下来的馅饼,优化本身也是有成本开销的。如果通过优化节省到的性能成本填补不了执行优化带来的成本开销,那么优化就没有意义,所以不能过早地做优化。

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

 

PS:

附图两张,做memoized与不做的区别,第一张(测试一)不做,第二张图(测试二)做了:

 

测试的用例是:

1. 一个页面通过tab切换两个列表,每个列表纵向展示6列,横向为15条数据,6 columns,15 rows。

2. tab切换以后两个列表的数据和配置对象都不会改变。仅仅是列表外层wrapper的display属性在block和none之间切换,触发CSS的重绘和回流。

可以看到第一张图(未做优化的测试一)帧数很低,主线程被长期占用来跑react的重渲染流程(未开启concurrent mode,一旦开始就只能一条路走到结束),react fiber的workLoop下密密麻麻的beginWork在遍历节点(prod的代码是被混淆了的,所以方法名和源码不一致)。要知道测试一是production包,测试二还是dev(debug)包,测试一的react基础性能就强得多。

第二张图(测试二)就丝滑了很多,该memoized的都缓存了。workLoop只需跑个流程对比一下有关的dependency,因为都没变,所有不会执行任何重渲染流程,笔者自定义的useTable和使用它的组件之间的配合避免了无意义的重渲染,性能得到了大幅提升。笔者在后面有时间了还会出一个极致性能优化的文章,会和之前调研过的webworker一起讲解,这个极致性能优化是已经在项目落地了。

 

五、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的编码中一定要理解和注意。它本质是JS过时闭包引起的坑,所以说用JS这个语言进行开发的时候,对JS本身特性的深入理解,能帮我们规避很多问题。

如果说想要跳出每个重渲染产生的scope会固化自己的状态和值的特性,可以使用Hook API提供的 useRef hook,让所有的渲染scope中的某个状态,都指向一个统一的值的一个Key(API中采用current)。这个对象是引用传递的,ref的值记录在这个Key中,我们并不直接改变这个对象本身,而是通过修改其的一个Key来修记录的值。让每次重渲染生成的scope都保持对同一个对象的引用,来跳出Cpature Value带来的限制。这也解释了为什么ref的值是用一个对象包裹起来的,是想如果没有包裹,也就是说我们访问ref不需要通过ref的current属性,那么万一我们ref的是一个基础类型,比如一个字符串,由于基础类型都是值的传递而不是引用传递,会导致在一边修改后,另外一边没法感知的情况发生,而对象是引用传递的,自然不会有这个问题。

这里还有一个经典的例子:

function App (){
    const [count, setCount] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            setCount(count + 1);
        }, 1000);

        return () => clearInterval(id);
    }, []);

    return <h1>{count}</h1>;
}

 <h> 标签中的数字将永远为1,如果你已经理解透彻Capture Value特性,那么你会快反应过来,如果没有,你会觉得很懵。

 

六、Hook的优势与"坑"

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

这里笔者对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的复用并不是停留在将某些常用的逻辑方法代码抽成一个公共方法,而是可以将之前散落在类组件中各个生命周期中的用于实现某个业务的逻辑代码合并在一起封装成一个自定义的Hook,其他地方随用随调。

笔者的项目存在着很多套路差不多的CRUD界面,在使用Hook提取并封装某些常用业务逻辑后,在这些界面的开发商似乎已经不需要手写太多代码了,哈哈。对于一些逻辑稍微复杂的界面,Hook的方式绝对是比类组件的方式高雅得多,尝试以后才是深有体会的,真TM香。

 

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

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

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

3. 在和函数组件搭配使用的时候,请一定注意最终程序运行的结果可能并不是代码所表像的那样,有时候必须使用Hook来memorized一些东西,比如setInterval、debounce等函数。你的代码也有极大的可能会出现很多无意义的重渲染,这取决于你对最基本的那个几个Hook的理解程度。这点在日常开发中,需要细细地品味。

还有一点比较坑的是:在某些业务情况下,用Hook的方式来实现会比类组件的方式复杂些。。。

 

七、Hook的依赖规则

笔者还在实践中,在hook的依赖上,还遇到一个有趣的问题,就是eslint-plugin-react-hooks的exhaustive-deps规则似乎有点"不对",看下面这几个例子:

    // 模拟一个props对象,他有三个属性,其中:hide的类型为function, data的类型为对象, visible的类型为布尔值

    // 例子1 不会有警告
    useEffect(() => {
        console.log(props.hide);
    }, [props.hide]);

    // 例子2 不会有警告
    useEffect(() => {
        setData(props.data);
    }, [props.data]);

    // 例子3 不会有警告
    useEffect(() => {
        setVisible(props.visible);
    }, [props.visible]);

    // 例子4 有警告:
    // React Hook useEffect has a missing dependency: 'props'. Either include it or remove the dependency array.
    // However, 'props' will change when *any* prop changes, so the preferred fix is to destructure the 'props'
    // object outside of the useEffect call and refer to those specific props inside useEffect react-hooks/exhaustive-deps
    useEffect(() => {
        props.hide();
    }, [props.hide]);

    // 例子5 按照react-hooks/exhaustive-deps的警告建议的最后一句修改一下,在useEffect调用的外部事先对props进行解构,把hide方法先提前拿出来再在hook中使用,不会有告警。
    const {hide} = props;
    useEffect(() => {
        hide();
    }, [hide]);

    // 例子6 或者不按建议的做法,这样修改,也不会有告警
    useEffect(() => {
        props.hide();
    }, [props]);

 

从这几个例子来分析:

props在每次函数组件被重新调用以后,都会变掉,因为props这个对象每次会被传入一个新的进函数,引用是不同的。但是对于props内部的属性来说,只要父组件没有改变传入值,它们是不会变的,无论这个属性的值是函数、对象之类的引用类型还是布尔值、字符串之类的非引用类型。因此在上述例子中,例子6没出告警显得很诡异,这种依赖是毫无意义的,和不写useEffect没区别,因为props每次都会变。

而有些例子中出现了告警更让人困惑,比如例子2、3、4,为什么单只有4会有告警;再比如例子4、5,为什么5就没告警,本质上不是一样的吗?而且5的做法(也就是官方检查器所推荐的做法)会导致多余的变量声明以及对组件本部变量名或函数名的占用。

总之就是:如果props的一个属性是一个函数,那么情况就有点不同了:你如果在hook中仅仅使用了它,也会需要将整个props放进依赖数组中。

笔者最终不得不在代码中采取例子5的做法,虽然这个选择并不是心甘情愿的,但也好于例子6的方式。

后来笔者去看了下eslsint-plugin-hooks的源码,看到了这行注释,终于明白了为什么会这样要求:

// 文件路径:node_modules/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js

// 从Line1403起:

// `props.foo()` marks `props` as a dependency because it has
// a `this` value. This warning can be confusing.
// So if we're going to show it, append a clarification.

在这句注释以后的代码,就是加上了所谓的澄清文字,也就是我们在警告中看见的最后一句:"However, 'props' will change xxx, so the preferred fix is to xxx"。因此官方也认为这个告警如果只指出缺少props依赖,是不准确的,需要再加上其他澄清文字,但是个这些澄清文章也不能准确说明为啥缺少props依赖,光看警告也会一头雾水。所以直到看了源码中这句注释,特别是注释中的"this",才明白了为什么。

我们做这样一个模拟:

let props = {
    data: 123,
    hide: function () {
        console.log(this);
    }
};

props.hide()

// => {data: 123, hide: ƒ}

let {hide} = props;

hide();

// => Window {postMessage: ƒ, blur: ƒ, ...}

可以看出,如果如果我们在某个hook中调用了props的hide方法,并在hide这个函数内部用到了this,那么按照JavaScript的this指向的原理,只要hide的值是一个普通的使用function声明的函数,而不是箭头函数或者通过bind改变了函数的this指向等情况,this始终指向的是props这个对象本身,谁调用就指向谁。因此我们可以在hide方法内部访问props的其他属性!那么如果我只在hook的依赖里加入了props.hide,这显然是不对的:在hide方法中如果使用到了props的其他属性,并且它们出现了改变,而依赖中只有hide方法这么一个props的属性,hook就会认为依赖的数据没有发生变化,因此内部方法不用重新执行,这样依赖那么就会出现Bug。所以要么我直接将整个props放入hook依赖中,要么就先将props中所有值为函数的属性提前解构出来,通过解构,可以让这些函数内部的this将指向window对象,进而在他们的内部将不能再访问到props对象。

但是这个插件没有再做更深层次的代码语法分析来检测这个被调用的函数内部究竟有没有真的使用到了this,而是用一刀切的做法:如果props的一个值为函数的属性在某个hook中被调用,那么整个props必须被加入到这个hook的依赖中,其他类型的属性除外。

这也就是解释了上面那几个例子中为啥有的情况会出现告警,而有的不会出告警的原因:

例子1:虽然hook中出现了hide函数,但是通过AST进行分析发现hide函数在该hook中并没有被调用,只是打印出他的函数结构,因此不需要考虑hide函数内部是否有使用到this(props)的情况,hook的依赖仅需要props.hide即可。

例子2,3:data和visible是值为非函数的属性,所以不会出现使用到this(props)的问题。

例子4:这个例子就正中这个插件的下怀,在hook中调用了hide函数,但依赖却只有props.hide,因此果断报warning。

例子5:通过解构赋值以后,将hide从props对象中单独拿出来,用一个新的变量(函数)hide来存储这个方法的引用,并且将这个方法内部的this指向到window对象上,也就不会出现在hide内部会引用到props对象的情况了,所以仅将新的hide函数放入hook的是被检测规则所允许的,因此没有告警。

例子6:这个例子也正中这个插件的下怀,如果在某个hook中调用了hide方法,又没有事先从props中解构hide出来,那么就必须将整个props放进该hook的依赖中,这样就不会出现告警。

 

经过以上这么一分析,笔者最初选择例子5的做法,在现在看来还是最明智的。对于这么一个小东西来说,要研究彻底,也是得花不少时间的。

官方文档中有提到,当后面编译器优化后,能够做到在编译的时候自动查找依赖,并添加依赖数组,到那个时候就不需要在业务代码中手动写依赖了。

就目前的情况来看,通过lint来做检查是很蠢的,因为它只能应用一套已经写死了的规则,但这也是当下的无奈之举。

 

八、接入Redux

笔者这个项目存在着大量的state需要管理,单纯使用useReducer来代替redux,从多方面因素来看,这并不靠谱,所以笔者决定继续使用redux,也就面临着怎么在Hook接入Redux的问题。这里当然是找现成的轮子来做啦。

我们在使用类组件的时候,通过react-redux的connect方法创建一个HOC包住我们的业务组件,来让业务组件有操作redux的能力。但是当我们使用函数组件以后,connect方法就没法再继续使用了,react-redux目前也暂未出Hook的API,所以只能另寻他路了。后面找到了一个替代品,出自facebook孵化器项目的,叫redux-react-hook,提供了以Hook的方式来接入redux,看源码发现,其实就是以Hook的方式实现了以前connect干的活,参考:传送门。源码里的关键部分就是差异比较、缓存、触发更新,感兴趣的的同学可以看下。说不定后面react-redux的Hook API就是在照着这个弄的。

如果说后期react-redux的Hook API出来了,在那之后我个人还是建议接着使用redux-react-hook。因为react-redux毕竟还是要兼顾类组件的,所以和connect有关的配合类组件使用的代码一定是存在的,如果我们只用Hook API的话,库的size肯定是没有redux-react-hook那么精简的,虽然有webpack tree-shake,不见得能shake掉,就算能,在编译阶段也需要浪费额外的时间,没有正面的意义。

PS:后面社区里一定会针对hook孵化出一些相较于redux来说更为轻量的状态管理工具,择其中合适者而选之,也是可以的。

 

九、Hook出现的背景

笔者认为第六段落中提到的函数式组件配合Hook相较于类组件配合生命周期方法是存在有一定优势的,且能够做到逻辑层级的复用,而不仅仅是组件层级的,可复用的最小单位更加细化。这一点可能是从编码上优化而考虑的。

Dan之前也说到了,类组件中涉及到的this指向的问题也是困扰初学者的一个大难题,Hook将解决这个问题。笔者能体会Dan的用心良苦,但是以笔者的观点来看,对于那些连this指向都弄不清楚的前端开发应该先学补充一下基础知识。

再者,React团队最开始发布Hook的时候,应该是顶着压力的,因为这对于开发者来说意味着以前的白学了,上层API全部变完。笔者最开始了解Hook后,发现以前类组件时期的东西基本都废了,最直接感受就是这东西是不是在给React后面的Async Render填坑用的,为啥会这么说呢?

因为React16之前版本的这种更新机制就是全部树做Diff然后更新patch,更新并不是精准的,就是不知道哪些数据变了,也不知道这些数据变了,对应的哪些地方需要跟着变。一旦组件树异常复杂,协调过程中主线程将被持续占用造成阻塞。有兴趣的同学模拟循环创建上万个DOM,然后触发更新,从开发者工具Performance里面进行检测,会发现主线程阻塞的时间很长,如果有输入的组件,输入后完全界面没反应,在卡顿结束以后才会出现。需要更新的组件结构越复杂,界面卡顿时间就会越长,用户在界面的行为会长期得不到响应,对CSS3动画来说也是致命的。

这就导致更新的成本很高,即便有虚拟DOM树,但是一旦应用很庞大以后,对庞大的虚拟DOM树进行协调会变得非常耗时。而且由于传统的DOM树是普通的节点不是Fiber那种类似于链表节点的节点,在没有异步Async Render前,这种普通的节点树一旦开启协调的过程,就只能一条路走到底而没法暂停,因为我们不能在代码层面上控制JS引擎的函数调用栈,在主线程上长时间运行脚本又不归还控制权,会阻塞线程造成掉帧而导致界面卡顿,特别是当应用运行在移动端设备等性能不太好的设备上时。因此这种函数栈式协调并不适合所有应用场景,在某些情况下会造成很差的用户体验。

而React16以后,react-dom基于Fiber架构的单链表式的结构可以模拟出函数调用栈,进行任务调度的Scheduler模块内部也是以链表的方式来组织任务的,这样就能够由代码控制协调过程的开始、暂停以及继续,就相当于用代码控制函数调用栈。在开始执行协调过程后,在一帧时间之内还有时间做别的事情就做,没有的话就挂起并归还线程控制权,也能够对任务优先级进行排序,比如按照这个顺序:响应用户的操作、更新在视窗内的元素、更新在是窗外的元素等。并能能够在主线程再次空闲的时候继续之前任务,我们称之为Time-Slicing时间切片和Suspense暂停。这种合作式调度的方式我们就称之为Fiber协调(纤程)一个Fiber Node就是React组件实例,也是一个虚拟的堆栈帧,同时也是一个工作单元,保存了有关的上下文信息(包括存放了与Hook有关的信息的memorizedState属性,Hook是为函数是组建服务的,函数式组件没有this的概念,但与该组件相对应的Fiber节点是有的,因此可以通过useState等在纯函数中执行有副作用的操作来获取state等信息)。

通过这个架构可以实现异步渲染,可以一定程度上解决函数栈式协调的问题(不保证),但它会破坏原本完整的生命周期方式。因为一个协调任务的执行,可能会放在不同的线程空闲时间内去完成,进而导致一个生命周期函数可能会被调用多次,生命周期函数将变得不稳定,其实际运行的结果可能并不像代码书写的那样。同时每次更新都不一定能走到最后的提交阶段,因为新的更新可能在上次更新完成后但还没提交前就已经又出现了,上一个更新就会被抛弃,也会导致生命周期的一些方法被重复调用。Fiber在16版本正式引入,在16.3及以后版本中,基于上述原因一些生命周期函数已被加上了unsafe前缀,并提供了一些新增的静态方法供类组件使用。

虽然后续更新的小版本(16.3、16.4)引入了一些静态方法用来解决一些问题,比如用 getDerivedStateFromProps 静态方法替代 componentWillMount 和 componentWillReceiveProps ,笔者也没有选择升级到这些版本,因为涉及到unsafe的生命周期都要重构,升级这些版本在当时看来工作量太大,所以直接从16.2跳跃至16.9的。

从笔者看来,当 componentWillMount 等生命周期都被弃用以后,生命周期的开发方式在事实上就已经死掉了,见在后期,生命周期的方式在将来将被彻底抛弃(虽然目前Hook还不能完全地覆盖类组件提供的一些静态方法和生命周期)。现在之所以还保留着并且还添加加了新的静态方法,是因为大量的开发者和老项目还在使用这种方式,版本升级不能太陡,必须向下兼容。所以Hook绝对才是未来的主流。当然笔者的这些说法可能并不全面,或者说的不绝对正确,但笔者认为这些原因或多或少是存在的。

 

十、单元测试

笔者目前的项目对稳定性要求高,属于需要以年为单位进行长期维护的那种类型。所以对于笔者当前的项目来说,UT是必需的。笔者给新项目的模块写单元测试的时候,比较完好的支持Hook的Enzyme3.10版本在8天前才发布,运气真好。

从目前测试的体验来看,相对于类组件时代确实有进步。在类组件时代,除了生命周期外,其他的一切基本都靠HOC来完成,这就造成了我们在测试的时候,必须套上HOC,而当测试组件业务逻辑的时候,又必须扒开之前套上的HOC,找到里面的真实组件,再进行各种模拟和打桩操作。而函数式组件是没有这个问题的,有Hook加持后,一切都是扁平化的,总之就是比之前好测了。有一点稍微麻烦点的就是:

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

2. 测试中,Capture Value的特性也会存在,所以有些之前缓存的东西,并不是最新的,所以必须勤快地update。

 

十一、Vue的Hook

尤大对于Vue的hook和React的hook的对比中,传送门,总结的Vue Hook优势如下:

1. 整体上更符合JavaScript的直觉;(还未尝试过Vue的Hook所以没体会过这一点,但是使用React Hook时,但就Capture Valu这个特性来说,你绝对不能从表面上轻信你写的代码,你直觉认为逻辑是对的,最后可能是错的,心智模型带来的负担重=.=)

2. 不受调用顺序的限制,可以有条件地被调用;(React Hook受调用顺序限制,绝对不能错位,Hook不能写在逻辑判断中)

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

针对这点不得不说一下:

函数组件配合Hook,内联函数确实多,笔者用开发者工具的Performance工具测过类组件和相同该业务的函数式组件+Hook,虽然官方说的浏览器创建闭包函数的速度很快,但GC压力真的大。抓一个火焰图,在一个较复杂的组件上干点切换tab的操作:

 

GC很频繁,出现以后帧数都会降低,特备是Major GC,影响很大。笔者这个组件的测试,Minor GC大致需要0.8ms-2ms之间,Major GC最长耗时有7ms。

当然,这也与笔者这个组件的优化程度有关,如果能尽量减少无意义渲染,进而控制内联函数被无意义地重复创建的次数,GC次数势必会减少。这个压力其实是由内联的Hook函数带来的,无Hook的函数组件理论上和普通的Class组件应该没性能上区别。PS:其实Class组件也会有这样的问题,比如HOC层数太多的场景。

函数式组件这套方式,就像调用一个纯函数一样(不纯的东西交给Hook),调用后产生一个作用域,并开辟对应的内容空间存储该作用域下的变量,函数返回结束后该作用域会被销毁,该作用域下的变量在作用域销毁后就没用了,如果没有被作用域外的东西引用,就需要在下一次GC的时候被回收。这相对于Class组件而言,额外的开销会多出很多,因为Class组件这套,所有的东西都是承载在一个对象上的,都是在这对象上做操作,每次更新组件,这个对象、对象的属性和方法都是不会被销毁的,即不会出现因框架原因的频繁的开辟和回收内存空间。

4. 不需要总是使用 useCallback 来缓存传给子组件的回调以防止过度更新;(React Hook中,如果运行你的应用的客户端性能差,比如移动端,为了性能你确实需要这么做,所有的方法和事件回调基本都会被套在useCallback中,但这会导致有很多公式化的代码出现,useMemo也需要经常用到。总之就是用空间换时间。)

5. 不需要担心传了错误的依赖数组给useEffect/useMemo/useCallback从而导致回调中使用了过期的值 —— Vue 的依赖追踪是全自动的。(React Hook必须保证正确,否则会出错,ESlint也会强制检查每个Hook的依赖是否正确,并且还会遇到笔者在段落七中提到的那个this指向的问题)

当你用React Hook真实开发过项目后,再看看尤大总结的这几点,每一点都打到了使用React Hook的痛处上,Vue3的Hook确实是值得期待的。只不过笔者可能近期都会在React技术栈上了,毕竟这种需要长期维护的前端项目,不能说随意地因为想实践新技术就对项目进行重构,做大的改变是需要有正确的时机做支持的。而且笔者对TypeScript不太感兴趣。笔者再预估一下:长期以来培训机构里面的web前端速成套餐都是靠Vue来支撑的,后面Vue3上Hook+TypeScript,门槛一下大幅提高,光Hook的理解和TypeScript的泛型设计都得多讲多少时间。看来后面web前端的培训班都得涨价了。

 

 十二、总结

通过使用Hook,相对于生命周期的类组件来说,我们能少写代码就能实现同样的业务功能,那意味着在使用Hook的时候,React为我们做了更多的事情,我们需要考虑的东西就不能仅仅停留在表面的代码上。

业务逻辑的设计、性能优化的方式这两点是Hook函数组件和Class组件之间最大的区别,从Class组件过渡多来,这两点必须克服

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

 

对React Hook本身而言,以下文章可能有助于你理解它:

https://blog.logrocket.com/rethinking-hooks-memoization/?from=singlemessage&isappinstalled=0

https://blog.logrocket.com/how-to-get-previous-props-state-with-react-hooks/

https://zhuanlan.zhihu.com/p/56975681

https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/

https://overreacted.io/zh-hans/react-as-a-ui-runtime/

https://www.robinwieruch.de/react-hooks-fetch-data

https://github.com/dt-fe/weekly/blob/master/80.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%80%8E%E4%B9%88%E7%94%A8%20React%20Hooks%20%E9%80%A0%E8%BD%AE%E5%AD%90%E3%80%8B.md

 

当然你还需要了解React Time Slicing 和 Fiber这些新特性和架构,因为它们和Hook都属于v16+的新东西。

https://github.com/acdlite/react-fiber-architecture

https://medium.com/react-in-depth/the-how-and-why-on-reacts-usage-of-linked-list-in-fiber-67f1014d0eb7

https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/

 

对于面向组件开发而言,原生的Web Components可能才是最好的归宿。再好的外围作品,也难避免官方逼死同人的情况。

posted @ 2019-06-26 08:53  james·von  阅读(10413)  评论(2编辑  收藏  举报