• 使用react新特性Hook对你的组件完成一次性能优化


    一、前言

    随着16.8版本的出现,react又带我们回到了函数式编程,其的出现解决了类组件的不足同时带来了一些新特性;本文主要围绕Hook所提供的新特性来抛砖引玉我们在使用类组件的时候可能从未关注过的性能方面的问题。

    什么是Hook?

    官方文档给出了解释:Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

    二、Hook在二手车-白菜商家版项目中的实践

    作为React的新特性,伴随着稳定版的发布,我们将Hook赋能在了白菜商家版app的hybird h5业务场景中,该项目技术栈采用koa+nextJs+Hook。

    下图展示的是该项目中车辆发起订单页,我们采用了组件化的思想进行开发去提高代码的复用性。
    image.png

    未采取任何优化方案前

    在没有进行组件性能优化之前,我们进行了一个改变车牌号码的操作,导致了父组件和子组件均进行了重新渲染,<借助在组件内打印渲染日志>如下图所示:

    image.png

    这是由于在react当中,父组件状态变更时,父组件进行重新渲染的过程中会导致子组件也进行重新渲染,哪怕是子组件并没有依赖于父组件某个状态,分析页面运行性能:

    image.png

    由上可知:更改子组件状态导致父组件和子组件重新渲染,scripting耗时15ms,rendering耗时2ms

    优化后

    我们采用Hook提供的新特性进行组件的性能优化后,再进行一次改变车牌号码的操作,如下图所示,只有当前子组件和父组件进行了重新渲染

    image.png

    分析页面性能:

    image.png

    相比较没有优化之前,更改子组件状态导致的重新渲染,scripting耗时15ms->10ms(优化近50%),rendering耗时2ms->1ms(优化近50%)

    三、使用Hook重构Class组件

    接下来,我们从0到1,一起体验从使用hook重构一个最基础的class组件,到逐步使用hook提供的新特性进行组件性能优化。

    在16.8以前的版本中,我们可能是通过class这样去实现一个有状态组件:

    点击进入线上代码沙箱,查看demo>init查看效果

    功能实现是一个简单的计数器如下所示:
    image.png

    而现在,我们开始考虑用hook重构它吧

    // HookFather Component
    const HookFather = () => {
      console.log("hook>>father component render");
    
      let [count, setCount] = useState(0);
    
      const handleBtnClick = () => {
          setCount(++count)
      }
    
      return (
        <>
          <div>{count}</div>
          <button onClick={handleBtnClick}>add</button>
          <HookSon />
        </>
      );
    };
    // HookSon Component
    const HookSon = () => {
        console.log("hook>>son component render");
    
        return <div>this is son hook component</div>;
    }
    
    

    现在我们使用hook完成了重构,接下来我们开始逐步完成优化

    首先明确一点,当父组件中使用了多个子组件时,在没有做任何优化前,子组件会伴随父组件的每次更新而重新渲染

    我们点击了两次button,使count的状态由0增加到了2,如下图

    image.png

    从日志中我们可以发现,组件总共渲染了3次,第1次为组件首次渲染,后2次为state状态更新导致的重新渲染,且父子组件都进行了重新渲染。但实际上,子组件并不依赖于父组件中的某个状态,却跟着父组件进行了两次重新渲染,对于子组件来说后2次重新渲染是不必要的。

    四、使用Hook新特性进行组件性能优化

    1. React.memo

    memo其实是16.6提出的,用于支持函数组件也拥有类似于class组件的PureComponent和shouldComponentUpdate的类似解决方案<此处不做详细讲解>
    点击进入线上代码沙箱,查看demo>memo查看效果

    // 改写HookSon
    const HookSon = React.memo(() => {
      console.log("hook>>son component render");
    
      return <div>this is son hook component</div>;
    })
    

    image.png

    效果还是比较明显,当我们更新父组件的时候,会浅比较使用memo包括的子组件前后的props值,如果props每一个属性都一样,就会跳过当前子组件的执行,从而达到减少不必要的渲染

    自定义子组件是否更新

    React.memo(React.Component, (nextProps, prevProps) => {
       // 根据实际使用场景编写逻辑,用于判断是否需要更新子组件
    })
    

    React.memo函数还支持第2个参数,该参数接收一个function,最终返回一个boolean类型值,用于判断子组件是否需要进行本次更新.

    2. React.useCallback

    我们知道在js中,function() {} === function() {}的结果为false,这是因为它们虽然内容一样,但是在内存中却是两块不同空间。

    回到HookFather组件中,该组件由于每次count状态的变更都会导致整个组件重新渲染,也就是说其函数作用域里面的一切都将会重新开始。对于const handleBtnClick = () => { //... }将会前后渲染两次,哪怕其内容相同。那么,对于此次的重复渲染是否能进行避免?

    方法一:将该方法提取到组件外部,使其不受组件内部状态影响

    const handleBtnClick = () => { //... }
    
    const HookFather = () => {
        // ... 
        return (
            <button onClick={handleBtnClick}></button>
        )
    }
    
    

    如上所示,这是一种办法。但如果handleBtnClick依赖组件内部的一些变量,我们又不得不将其写到组件中(虽然有办法可以解决例如使用引用类型传递出参数,但是只会将简单问题复杂化,反而得不偿失)。

    那么,我们能不能去判断,如果前后两次是相同的一个函数就不进行函数的重新定义?

    方法二:使用函数记忆去实现

    1). 什么是函数记忆?

    让函数记住上一次执行后的参数和结果,利用的是闭包原理

    2). 函数记忆的作用?

    用于避免重复的运算耗时

    3). 什么时候使用函数记忆?

    一个函数可能做重复的事情时

    例如,现在我们有一个这样的函数

    function outer() {
      const inner = function() {
          console.log('inner')
      }
      inner()
    }
    outer() 
    outer() // 执行outer函数,inner函数又被定义了一次
    

    对于inner函数,我们是否能通过一个依赖数组来确定什么时候需要重新被定义呢?

    // 实现一个记忆函数的伪代码
    let prevFunction // 记忆函数
    let prevDeps // 依赖项状态
    
    /** 
    * @description 使用记忆函数
    * @param {function} fundamental 需要被记忆的函数
    * @param {array} memo 记忆依赖项
    */
    var memoizer = function(fn, deps) {
     
      if (prevFunction && isEqual(deps, prevDeps)) {
        return prevFunction
      }
        
      prevDeps = deps
      prevFunction = fn
      return prevFunction
    };
    

    通过上述我们了解了记忆函数的原理和使用,Hook帮我们实现了一个useCallback记忆函数,用来帮助我们仅在某个依赖项发生改变时才会更新
    点击进入线上代码沙箱,查看demo>useCallback查看效果

    const HookFather = () => {
      //参数1: 接收一个内联回调函数,其中实现我们需要的功能
      //参数2: 接收一个依赖项数组,只有依赖项发生改变时,该方法才会重新渲染。如果该依赖项为空数组,则表示只会在该组件第一次渲染时被创建,后续该组件状态无论如何变动都不会重新被创建
      const handleBtnClick = useCallback(() => {
          // ...do something
      }, [])
        
      return (
          <button onClick={handleBtnClick}></button>
      )
    }
    

    3.React.useMemo

    我们改造一下原有例子,假设我们的场景中需要在子组件中去接收父组件的这样一个组合状态,在子组件中输出计算器的价格

    const HookFather = () => {
      console.log("hook>>father component render");
    
      let [count, setCount] = useState(0)
      let [price, setPrice] = useState(10)
    
      const handleBtnClick = useCallback(() => {
        setCount(++count)
      }, [count])
    
      // 组合父组件的状态传递给子组件
      const data = {
        name: '计算器',
        price
      }
    
      return (
        <>
          <div>{count}</div>
          <button onClick={handleBtnClick}>add</button>
          <HookSon data={data} />
        </>
      );
    };
    
    const HookSon = memo(({ data }) => {
      console.log("hook>>son component render");
    
      return <div>this is son hook component, show price: {data.price}</div>;
    })
    

    我们去改变计算器的值,导致父组件重新渲染,data数据跟着被重新渲染。由于HookSon使用memo进行浅比较,对于引用类型的值前后两次是不一样的,所以导致子组件也跟着重新渲染了一次

    但由于父组件中的状态变更对子组件并没有实质上改变,如何优化这一次的子组件重新渲染呢?

    方法一:利用memo的第二个参数

    const HookSon = memo(
      ({ data }) => {
        console.log("hook>>son component render");
    
        return <div>this is son hook component, show name: {data.price}</div>;
      },
      // 比较前后状态是否相同,决定本次是否执行更新操作
      (nextProps, prevProps) => (nextProps.data.price === prevProps.data.price)
    )
    

    这是一种办法,适用于子组件所依赖的父组件状态比较简单,但随着子组件的业务复杂化,无法兼顾到所有场景,且会疲于优化

    方法二,利用Hook提供的useMemo

    把创建函数和依赖项数组作为参数传入useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值,这种优化有助于避免在每次渲染时都进行高开销的计算。
    点击进入线上代码沙箱,查看demo>useMemo查看效果

    const HookFather = () => {
      console.log("hook>>father component render");
    
      let [count, setCount] = useState(0);
      let [price, setPrice] = useState(10)
    
      const handleBtnClick = useCallback(() => {
        setCount(++count)
      }, [count])
        
      // useMemo接收2个参数
      // 参数1: 接收一个内联回调函数,其中实现我们需要的功能
      // 参数2: 重新计算依赖项数组
      const data = useMemo(() => ({
        name: '计算器',
        price
      }), [price])
    
      return (
        <>
           <div>{count}</div>
           <button onClick={handleBtnClick}>add</button>
           <HookSon data={data} />
        </>
        );
    };
    
    const HookSon = memo(
      ({ data }) => {
        console.log("hook>>son component render");
            
        return <div>this is son hook component, show name: {data.price}</div>;
      }
    )
    

    五、关于性能优化的实践思考

    1. 并不是所有函数组件都使用memo包裹就是性能优化!

    如果一个子组件过分依赖于父组件的状态,那么对于这个子组件来说使用memo包裹的意义可有可无,但是memo本身计算对比也是需要时间的。那么,如果某个子组件跟随父组件重新渲染的次数比例很大,那额外的memo对比时间就成为了负担,哪怕这个时间非常短。

    2. 不要过度依赖于useMemo

    useMemo本身也是有开销的,因为记忆函数本身是将依赖项数组中的依赖取出来,和上一次记录的值进行对比,如果相等才会节省本次的计算开销,否则就需要重新执行回调,这个过程本身就是消耗一定的内存和计算资源。

    那么,什么时候使用useMemo,思考以下2个问题?

    • 传递给useMemo的函数开销是否大?

    有些业务场景的计算开销会非常大,那么这个时候我们需要去缓存上一次的值,避免每一次父组件重新渲染就进行重新计算;如果开销并不大,那么可能useMemo本身的开销就超过了所节省的时间

    • 计算出来的值类型是否是复杂类型?

    如果返回的是复杂类型(object、array),由于每次重新渲染哪怕值不变都会生成新的引用,导致子组件重新渲染,那么可以使用useMemo;如果在父组件中使用useMemo计算出来的是基本类型的值,则子组件使用memo就可以浅比较避免重新渲染,无需使用useMemo

    放在最后的话:天下没有免费的午餐,没有价值的性能优化将会变成性能恶化!

    六、参考文献:

    1. React官方文档【https://reactjs.org/docs/hello-world.html】
    2. JavaScript高级程序设计【第3版】
    3. 你不知道的JavaScript
  • 相关阅读:
    C#事务相关
    建造者模式
    CUPS/Printer sharing
    vim note write
    linux下神奇的script
    Nginx server之Nginx添加ssl支持
    nginx使用ssl模块配置HTTPS支持
    stardict dict url
    收银台(POSBox) 配置向导
    让 Odoo POS 支持廉价小票打印机
  • 原文地址:https://www.cnblogs.com/fe-linjin/p/12382635.html
Copyright © 2020-2023  润新知