React hook心得小结

节前遇到项目准备重构,想着趁机把react升级一下,顺便用上react hook,目前还没能全部改完,先分享一下心得体会。

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。 ——《react中文官方文档》

React中组件有两种类型,一种是class,一种是函数,因为函数是JS的一等公民,所以函数组件对于开发者来说有一种天然的亲切感,学习的成本非常的低,只需要在最后return一个react节点或者null就可以了;而class则不然,相比之下使用起来麻烦得多。

但是,由于业务的复杂性,我们需要借助react的状态和生命周期来实现复杂的逻辑,而这两个特性却是class特有的,函数组件无法使用,基于这个原因我们不得不在大部分情况下选择class而不是函数。

而hook的出现打破了这一现状,使得我们可以随心所欲地使用函数组件!

我们先来实现一个简单的点击计数demo作对比:

class ClickButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    this.clickHandler = this.clickHandler.bind(this);
  }
  clickHandler() {
    const { count } = this.state;
    this.setState({
      count: count + 1,
    });
  }
  render() {
    const { count } = this.state;
    return (
      <div>
        <p>You clicked {count} times</p>
        <button onClick={this.clickHandler}>
          Click me
        </button>
      </div>
    );
  }
}

可以看到,使用class组件首先我们需要写一些模板代码,比如extends、constructor、super等,紧接着,我们还需要对自定义方法的this指向进行bind,必要的话,如果你有20个自定义方法,你得bind足20次。。。

这事我忍了得有好几年:(

原本单靠函数组件我们是无法实现这个功能的,但是现在我们有hook,使用useState让函数也能拥有自己的状态:

function ClickButton() {
  const [count, setCount] = useState(0);
  function clickHandler() {
    setCount(count + 1);
  }
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={clickHandler}>
        Click me
      </button>
    </div>
  );
}

没有多余的模板代码,

没有烦人的this,

清爽提神,

每一行代码都用在实现功能上,

怎一个香字了得!

Class组件的生命周期函数非常好理解,上手也很容易,我们只需要简单地看看文档就能掌握个七七八八的了。

但是,简单的东西并不一定就很好用,打个比方,文章详情组件Post接受一个外部属性id,根据id获取文章的详情:

class Post extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      content: '',
    };
    this.getDetail = this.getDetail.bind(this);
  }
  componentDidMount() {
    this.getDetail();
  }
  getDetail() {
    const { id } = this.props;
    const that = this;
    $ajax
      .get(`/post/${id}`)
      .then((res) => {
        that.setState({
          content: res.data,
        });
      })
      .catch(console.error);
  }
  render() {
    const { content } = this.state;
    return (
      <div>{content}</div>
    );
  }
}

这里我们考虑一个问题,如果请求比较耗时,组件已经被销毁,但是请求还没回来,那么等到请求回来的时候,setState就会报错。所以我们需要一个状态mounted来知道组件是否已卸载,如果卸载就不进行setState操作,这时候就还需要用上另外一个生命周期函数componentWillUnmount:

class Post extends React.Component {
  ...
  componentDidMount() {
    this.mounted = true;
    this.getDetail();
  }
  componentWillUnmount() {
    this.mounted = false;
  }
  getDetail() {
    const { id } = this.props;
    const that = this;
    $ajax
      .get(`/post/${id}`)
      .then((res) => {
        if (!that.mounted) {
          return;
        }
        that.setState({
          content: res.data,
        });
      })
      .catch(console.error);
  }
  ...
}

现在还有没有问题?有的,由于依赖外部属性id,所以当id发生变化的时候,就要重新加载新id对应的文章详情,这时候我们需要使用componentDidUpdate来实现:

class Post extends React.Component {
  ...
  componentDidUpdate(prevProps) {
    if (this.props.id !== prevProps.id) {
      this.getDetail();
    }
  }
  ...
}

另外,这里还存在一个竞态问题,当id变化得比较快,快于请求回来的时间的时候,前一个请求还没回来,新的请求又已经发出去,我们需要适当丢弃一些中间已过期的异步回调,避免页面的频繁闪烁和错误渲染(这种情况在一些tab页面就比较常见):

class Post extends React.Component {
  ...
  getDetail() {
    const { id } = this.props;
    const that = this;
    $ajax
      .get(`/post/${id}`)
      .then((res) => {
        if (!that.mounted) {
          return;
        }
        if (id !== that.props.id) {
          return;
        }
        that.setState({
          content: res.data,
        });
      })
      .catch(console.error);
  }
  ...
}

一个简单的异步加载数据的组件就需要处理这么多的边界情况,想想如果组件依赖的外部属性有多个的时候、组件内部的异步请求不止一个的时候,这个过程就会显得相当麻烦,这就是生命周期函数存在的一些问题,它很简单,但也很繁琐。

那么hook又如何?

如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。 ——《react中文官方文档》

话虽如此,hook(useEffect)却并不是按照这个方向去设计的,使用hook编程与使用class的思维方式完全不同,所以不要试图简单地将生命周期转换为hook实现,这样你会错过很多hook的优化手段,你应该暂时忘掉生命周期,转而用hook的思维方式重构你的整个组件。

来看看hook版本:

function Post(props) {
  const [content, setContent] = useState('');
  useEffect(() => {
    let didCancel = false;
    $ajax
      .get(`/post/${props.id}`)
      .then((res) => {
        if (didCancel) {
          return;
        }
        setContent(res.data);
      })
      .catch(console.error);
    return () => {
      didCancel = true;
    };
  }, [props.id]);
  return (
    <div>{content}</div>
  );
}

这里的didCancel很有意思,它充分说明了函数组件的内部状态。useEffect的函数A在函数组件初始化的时候就执行一次,之后是否要重新执行需要根据第二参数中声明的依赖是否发生变化来决定,所以在依赖未发生变化的情况下,函数A不会再次执行,这个异步请求不管耗时多久,then中的didCancel还是那个didCancel,不会重新声明赋值;当依赖发生变化时,return的函数B被执行,由于闭包的关系,组件会将函数B被创建那一刻对应的didCancel置为true,所以then中的didCancel也是true,这次回调就会被丢弃——同时别忘了,函数A会被再次执行,新的didCancel诞生,同时新的请求发出。

Class组件的思维方式是维护生命周期,而hook则是处理依赖关系。

此时我们再来对比一下两种组件的异同,首先是代码量的差异,hook重构的组件代码量较之前少,其次是模板代码的减少,还有就是处理异步逻辑更加的安全便捷,同时,相对于class中一个属性或方法要反复穿梭于各个生命周期的情况来说,hook的业务逻辑更加集中。

说到异步,这里分享一个有趣的例子,就是定时器在hook中的使用,假如我要实现一个累加器,每秒自动加1,直觉上会这么写:

function Accumulator() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, [count]);
  return (
    <div>{count}</div>
  );
}

但事实上这是一个错误的实现,我们直觉上会用原来的count加上1,所以对外部的count产生了依赖,于是诚实地把count添加到依赖项中,然后就发现当组件跑起来时,useEffect中的setCount操作直接导致count变化,然后useEffect的函数体又重新执行了一次,定时器被清除,然后重新开始,这里我们发现每次循环都会清除定时器,那setInterval不就没有意义了吗?那么换成setTimeout可以吗?可以,但是由于setCount本身是一个异步操作,导致每一次的延时实际上大于1000ms,这样计时器就“不准”了。

你细品,是不是这个理。

正确的实现应该是这样的:

function Accumulator() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, []);
  return (
    <div>{count}</div>
  );
}

setCount的另一种用法,可以直接读取到最新的state,这样我们就可以把count从依赖中移除了。

Hook的主要优化手段是useMemo和useCallback,前者用于缓存结果,后者用于缓存函数,举个栗子:

function Calcu(props) {
  const { dist } = props;
  let sum = 0;
  for (let i = 0; i <= dist; i++) {
    sum += i;
  }
  return (
    <div>{sum}</div>
  );
}

这里我们分析一下,当dist没有变化的时候,sum的值是否每次都需要重新计算?答案是不需要,我们可以使用useMemo来优化:

function Calcu(props) {
  const { dist } = props;
  const memoizedSum = useMemo(() => {
    let sum = 0;
    for (let i = 0; i <= dist; i++) {
      sum += i;
    }
    return sum;
  }, [dist]);
  return (
    <div>{memoizedSum}</div>
  );
}

而useCallback会更常用一些,如无特殊情况,所有组件内定义的函数都应该使用useCallback包裹:

function ClickButton() {
  const clickHandler = useCallback(() => {
    alert('Aha!');
  }, []);
  return (
    <button onClick={clickHandler}>
      Click me
    </button>
  );
}

如果某个函数A使用了另外一个函数B,你还要把函数B加入到函数A的依赖项当中:

function ClickButton() {
  const [name, setName] = useState('Jack');
  const sayHi = useCallback(() => {
    alert(`Hi, ${name}`);
  }, [name]);
  const clickHandler = useCallback(() => {
    alert('Aha!');
    sayHi();
  }, [sayHi]);
  return (
    <button onClick={clickHandler}>
      Click me
    </button>
  );
}

这是使用hook比较麻烦的地方,你会发现几乎所有组件内的函数都要使用useCallback包裹,并且都要尽可能补全它的依赖项。

因为函数组件都是“快照式”的,没有this的概念,所以无法实现class中this.mounted,如果我们需要一个“穿越时空”的全组件可共享的状态,那就需要用到useRef。

function Posts() {
  const [list, setList] = useState([]);
  const refLoading = useRef(false);
  const refPage = useRef(1);
  useEffect(() => {
    if (refLoading.current) {
      return;
    }
    refLoading.current = true;
    $ajax
      .get({
        url: '/list',
        data: {
          page: refPage.current,
        },
      })
      .then((res) => {
        refLoading.current = false;
        setList(arr => arr.concat(res.data));
        if (res.data) {
          refPage.current += 1;
        }
      })
      .catch((err) => {
        refLoading.current = false;
        console.error(err);
      });
  }, []);
  return (
    <div>
      {list.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

这里的refLoading充当锁的作用,确保上个请求没完成之前,下个请求不会发出,refPage则用于记录当前页码。ref对象在组件的整个生命周期内保持不变。

当state的逻辑比较复杂的时候,使用useReducer比useState更方便,useReducer的用法类似redux,这里不作展开。另外还有useContext用于接收一个context对象,这里也不展开。。。

最后是自定义hook,react的hook是可以自己定义和封装的,事实上useReducer就是由useState封装而来的,自定义的hook必须以“use”这一并不怎么别致的单词开头。

相比普通函数,自定义hook可以使用各种hook,可以更好地实现逻辑的复用。

比如以下这个时钟hook,用来同步获取当前时间,使用的时候直接取数就可以了,不需要自己书写定时器逻辑:

function fix2(n) {
  return n > 9 ? n : `0${n}`;
}
function Clock() {
  const clock = useClock();
  return (
    <div>
      {fix2(clock.hour)}:{fix2(clock.minute)}:{fix2(clock.second)}
    </div>
  );
}

一般情况下,如果多个页面需要一个时钟,我们可能会写一个组件Clock来实现,然后再将组件引入到各个需要的地方,就像:

function PageA() {
  return (
    <div>
      <p>Page A</p>
      <Clock />
    </div>
  );
}

function PageB() {
  return (
    <div>
      <p>Page B</p>
      <Clock />
    </div>
  );
}

但是,如果需求稍有不同,PageB的时钟的字体颜色需要设置五彩斑斓的黑,这时我们只能选择扩展Clock的功能,增加一个color的prop来实现,随后又来了PageCDEFG等等各种定制需求,有些需求其实并不那么通用,Clock被改得五花八门。直到PageX说想要每个数字都显示不同颜色,你心想实在改不动了算了吧还是单独写一个吧!于是copy了一个Clock,把多余的props和无关的逻辑都删除,只保留基础的逻辑,然后再在这基础上继续编写新的逻辑。

有了hook以后,我们就可以把Clock的逻辑抽离出来,这样再实现一个Clock的成本就低了很多,甚至可以直接在页面中使用hook来完成一个Clock。

最后再附上useClock的实现:

function getState() {
  const now = new Date();
  return {
    hour: now.getHours(),
    minute: now.getMinutes(),
    second: now.getSeconds(),
  };
}

function reducer(state, action) {
  switch (action.type) {
    case 'tick':
      return getState();
    default:
      return state;
  }
}

function useClock() {
  const [state, dispatch] = useReducer(reducer, getState());
  useEffect(() => {
    const timer = setInterval(() => {
      dispatch({
        type: 'tick',
      });
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, []);
  return state;
}

总结:

Hook很方便,用着也很舒服,逻辑复用使得编程更加强大且富有想象力,但是hook组件的实现成本要高于class,相比生命周期,用hook的思维来转化需求并不那么顺畅,甚至反直觉。另外,函数组件内大量的函数声明也很违反我们的认知习惯,虽然最后基本都优化成useCallback会感觉好一点,但是频繁用useCallback包裹又让人有种在写模板代码的琐碎感觉。。。

目前项目还没有改造完,因为class转hook的成本比较高,基本上都是逻辑重构与重新优化,但是hook带来的好处是很明显的,代码量的减少与编程体验的提升让重构工作有了不小的动力~

推荐阅读更多精彩内容