react hooks之自定义hook: usePrevious, 新值旧值对比

2,814 阅读6分钟

前言

今天是2022年01月01日, 在这里先祝大家新年快乐~

作为一个react开发者, 使用react开发项目多年, 见证了react的每一次迭代以及新增的特性, 一开始一直使用的是class形式的写法, 到后来react推出了新的特性: hooks, 给我最大的感受就是可以在函数式组件(function component)中使用state了, 而在这之前, 函数式组件一直都被我用作展示型的组件, 就是接收父组件传递的数据渲染页面的组件, 里面不维护任何的业务逻辑, 或者作为一些通用的组件来使用, 接收不同的children并渲染那些children, 逻辑代码非常少, 更别提state了, 而hooks的出现, 确切的说useState hook的出现让函数式组件也有了自己的state

有了hooks之后使用函数式组件的频率就相对高一些了, 毕竟纯展示型的组件出现的情形不多, 更多的还是需要处理业务逻辑的情形, 但由于函数式组件本身的特性, 业务代码写起来相对class形式的难度要稍微大一些, 很多功能都没有现成的API可以用, 需要我们自己去实现

举个例子

比如我们有这样的一个场景: 页面上有个列表, 点击列表的某个条目, 打开一个模态框, 然后模态框中拿着被点击条目的id去请求条目的详情用以展示, 而在详情请求完毕之前要显示一个loding..., 我们一起来看一下这个场景

class形式

父组件:

import React, { useState } from 'react';
import Modal from './Modal';

const data = [
  {
    id: 1,
    name: '条目1'
  },
  {
    id: 2,
    name: '条目2'
  },
  {
    id: 3,
    name: '条目3'
  }
];

let listItem = {};

const handleRenderItems = (data, clickCb) => (
  data.map(item => {
    const { id, name } = item;

    return (
      <li
        key={id}
        onClick={
          () => {
            listItem = item;
            clickCb();
          }
        }
      >{name}</li>
    );
  })
);

const Demo = () => {
  const [ visible, setVisible ] = useState(false);

  return (
    <div>
      <ul>
        {
          handleRenderItems(
            data,
            () => {
              setVisible(true);
            }
          )
        }
      </ul>
      <Modal
        listItem={listItem}
        visible={visible}
        handleClose={
          () => {
            setVisible(false);
          }
        }
      />
    </div>
  );
};

export default Demo;

页面上有3个条目, 点击任意一个都会打开模态框, 然后给模态框传递被点击的条目listItem, 模态框显示信号量visible, 关闭回调handleClose

模态框组件, 首先是class形式的写法:

import React, { PureComponent } from 'react';

const dataMap = {
  1: {
    id: 111,
    name: '条目1',
    createDate: '去年昨天'
  },
  2: {
    id: 222,
    name: '条目2',
    createDate: '去年今天'
  },
  3: {
    id: 333,
    name: '条目3',
    createDate: '去年明天'
  }
};

const fetchData = id => (
  new Promise(resolve => {
    setTimeout(
      () => {
        resolve(dataMap[id]);
      },
      2000
    );
  })
);

class Modal extends PureComponent {

  state = {
    loading: true,
    data: {}
  }

  componentDidUpdate(prevProps, prevState) {
    const { visible: prevVisible } = prevProps;
    const { visible } = this.props;

    if(!prevVisible) {
      if(visible) {
        this.setState({
          loading: true
        });

        const { listItem } = this.props;
        const { id } = listItem;

        fetchData(id).then(res => {
          this.setState({
            data: res,
            loading: false
          });
        });
      }
    }
  }

  componentWillUnmount() {
    this.setState({
      data: {},
      loading: true
    });
  }

  render() {
    const { visible } = this.props;

    if(!visible) {
      return null;
    }

    const { loading } = this.state;

    if(loading) {
      return <div>loading...</div>;
    }

    const { data } = this.state;
    const { name, createDate } = data;
    const { handleClose } = this.props;

    return (
      <div>
        <div>条目名称:{name}</div>
        <div>条目创建时间:{createDate}</div>
        <button
          onClick={handleClose}
        >关闭</button>
      </div>
    );
  }
}

export default Modal;

这里模态框打开之后请求数据, 为了模拟请求, 我写了一个2秒的延迟, 延迟结束之后返回数据, 取消loading然后渲染页面

接下来我们看一下关键代码:

  componentDidUpdate(prevProps, prevState) {
    const { visible: prevVisible } = prevProps;
    const { visible } = this.props;

    if(!prevVisible) {
      if(visible) {
        this.setState({
          loading: true
        });

        const { listItem } = this.props;
        const { id } = listItem;

        fetchData(id).then(res => {
          this.setState({
            data: res,
            loading: false
          });
        });
      }
    }
  }

这里我们使用了componentDidUpdate(prevProps, prevState)这个API, 这个生命周期方法会在每次渲染结束之后执行, 其中里面的这两个参数: prevProps prevState就是渲染之前, 或者说上一次渲染时候的propsstate, 因此我们在里面可以和当前的propsstate进行比较, 然后处理一些逻辑, 同时需要注意的是, 在componentDidUpdate中使用setState方法的时候应该先使用一些条件判断, 不然后会出现死循环: setStaterendercomponentDidUpdatesetStaterendercomponentDidUpdate...

可以看到, 对比的逻辑我们直接写到componentDidUpdate里即可, 那如果函数式组件中遇到这个情况如何处理呢?

函数式组件

父组件保持原样, 修改模态框组件如下:

import React, { useState, useEffect } from 'react';

let reserveValue = null;

const handleReserveValue = v => {
  reserveValue = v;
};

const handlePrevious = v => {
  useEffect(
    () => {
      handleReserveValue(v);
    }
  );

  return reserveValue;
};

const dataMap = {
  1: {
    id: 111,
    name: '条目1',
    createDate: '去年昨天'
  },
  2: {
    id: 222,
    name: '条目2',
    createDate: '去年今天'
  },
  3: {
    id: 333,
    name: '条目3',
    createDate: '去年明天'
  }
};

const fetchData = id => (
  new Promise(resolve => {
    setTimeout(
      () => {
        resolve(dataMap[id]);
      },
      2000
    );
  })
);

const Modal = props => {
  const [ loading, setLoading ] = useState(true);
  const [ data, setData ] = useState({});

  const { listItem, visible, handleClose } = props;
  const { id } = listItem;
  const prevVisible = handlePrevious(visible);

  useEffect(
    () => {
      if(!prevVisible) {
        if(visible) {
          setLoading(true);
          fetchData(id).then(res => {
            setData(res);
            setLoading(false);
          });
        }
      }

      return () => {
        setData({});
        setLoading(true);
      };
    },
    [ visible ]
  );

  if(!visible) {
    return null;
  }

  if(loading) {
    return <div>loading...</div>;
  }
  const { name, createDate } = data;

  return (
    <div>
      <div>条目名称:{name}</div>
      <div>条目创建时间:{createDate}</div>
      <button
        onClick={handleClose}
      >关闭</button>
    </div>
  );

};

export default Modal;

关键代码如下, 保存上一次渲染的值:

let reserveValue = null;

const handleReserveValue = v => {
  reserveValue = v;
};

const handlePrevious = v => {
  useEffect(
    () => {
      handleReserveValue(v);
    }
  );

  return reserveValue;
};

对比之前的值和当前值:

useEffect(
  () => {
    if(!prevVisible) {
      if(visible) {
        setLoading(true);
        fetchData(id).then(res => {
          setData(res);
          setLoading(false);
        });
      }
    }

    return () => {
      setData({});
      setLoading(true);
    };
  },
  [ visible ]
);

如果有对useEffect这个方法不是太了解的小伙伴可以看看我之前的这篇文章: react hooks之useEffect

这里的重点在于保存上一次的值, 由于useEffect的特性, 我们可以实现一个自定义hook: handlePrevious, 接收一个值, 将其保存下来, 然后每次渲染之后都可以访问到

有了这个自定义hook, 那么我们就可以实现新值和旧值的对比, 然后处理一些逻辑了

usePrevious

后来在好奇心的驱使下, 我查找了一些资料, 发现官方有一个推荐的写法, 详情可以查看官方的这篇文档: How to get the previous props or state?

这里面官方推荐了一个写法:

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

不得不说官方文档确实高明, 它巧妙的利用了useRef这个API的特点:

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

useRef 返回一个可变的 ref 对象,其 current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

使得我们的自定义hook能保存每次渲染之后的值可供使用, 这样就完美完成了值的存储, 后面就可以用来和新的值进行比对使用了

同时文档中还提到将来官方可能会新增一个叫usePrevioushook来给开发者使用, 那这样我们就不用自己实现了

好的, 这次新值旧值的对比, 自定义hook usePrevious就和大家聊到这了, 有任何问题欢迎在评论区探讨, 最后, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需

参考文献:

  1. componentDidUpdate
  2. useRef
  3. How to compare oldValues and newValues on React Hooks useEffect?
  4. How to get the previous props or state?