前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >换个角度思考 React Hooks

换个角度思考 React Hooks

作者头像
用户1097444
发布2022-06-29 14:56:00
4.7K0
发布2022-06-29 14:56:00
举报
文章被收录于专栏:腾讯IMWeb前端团队

从 Vue 迁移到 React ,不太习惯 React Hooks 的使用?也许换个角度思考 Hooks 出现的意义会对你有所帮助。

1 什么是 Hooks

简而言之, Hooks 是个函数,通过使用 Hooks 可以让函数组件功能更加丰富。

在某些场景下,使用 Hooks 是一个比使用类组件更好的主意。

1.1 Hooks 出现的背景

在 Hooks 出现之前,函数组件对比类组件(class)形式有很多局限,例如:

  1. 不能使用 state、ref 等属性,只能通过函数传参的方式使用 props
  2. 没有生命周期钩子

同时在类组件的使用中,也存在着不少难以解决的问题:

  1. 在复杂组件中,耦合的逻辑代码很难分离 组件化讲究的是分离逻辑与 UI,但是对于平常所写的业务代码,较难做到分离和组合。尤其是在生命周期钩子中,多个不相关的业务代码被迫放在一个生命周期钩子中,需要把相互关联的部分拆封更小的函数。
  2. 监听清理和资源释放问题 当组件要销毁时,很多情况下都需要清除注册的监听事件、释放申请的资源。 事件监听、资源申请需要在 Mount 钩子中申请,当组件销毁时还必须在 Unmount 勾子中进行清理,这样写使得同一资源的生成和销毁逻辑不在一起,因为生命周期被迫划分成两个部分。
  3. 组件间逻辑复用困难 在 React 中实现逻辑复用是比较困难的。虽然有例如 render props、高阶组件等方案,但仍然需要重新组织组件结构,不算真正意义上的复用。抽象复用一个复杂组件更是不小的挑战,大量抽象层代码带来的嵌套地狱会给开发者带来巨大的维护成本。
  4. class 学习成本 与 Vue 的易于上手不同,开发 React 的类组件需要比较扎实的 JavaScript 基础,尤其是关于 this 、闭包、绑定事件处理器等相关概念的理解。

Hooks 的出现,使得上述问题得到了不同程度的解决。

我认为了解 Hooks 出现的背景十分重要。只有知道了为什么要使用 Hooks,知道其所能解决而 class 不能解决的问题时,才能真正理解 Hooks 的思想,真正享受 Hooks 带来的便利,真正优雅地使用 Hooks。

2 Hooks 基础

让我们从最简单的 Hooks 使用开始。

2.1 useState

这里贴上 React 文档中的示例:

代码语言:javascript
复制
import React, { useState } from 'react';

function Example() {
  // 声明一个 "count" 的 state 变量
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useState 就是一个 Hooks,以前的函数组件是无状态的,但是有了 Hooks 后我们可以在函数中通过 useState 来获取 state 属性(count)以及修改 state 属性的方法(setCount)。

整个 Hooks 运作过程:

  1. 函数组件 Example 第一次执行函数时 useState 进行初始化,其传入的参数 0 就是 count 的初始值;
  2. 返回的 VDOM 中使用到了 count 属性,其值为 0
  3. 通过点击按钮,触发 setCount 函数,传入修改 count的值,然后重新执行函数(就像类组件中重新执行 render 函数一样);
  4. 第二次及以后执行函数时,依旧通过 useState 来获取 count 及修改 count 的方法 setCount,只不过不会执行 count的初始化,而是使用其上一次 setCount 传入的值。

从使用最简单的 Hooks 我们可以知道。

  • 存储 “状态” 不再使用一个 state 属性。 以往都是把所有状态全部放到 state 属性中,而现在有了 Hooks 我们可以按照需求通过调用多个 useState 来创建多个 state ,这更有助于分离和修改变量。 const [count, setCount] = useState(0); const [visible, setVisible] = useState(false); const [dataList, setDataList] = useState([]);
  • setCount 传入的参数是直接覆盖,而 setState 执行的是对象的合并处理。

总之 useState 使用简单,它为函数组件带来了使用 state 的能力。

2.2 useEffect

在 Hooks 出现之前函数组件是不能访问生命周期钩子的,所以提供了 useEffect Hooks 来解决钩子问题,以往的所有生命周期钩子都被合并成了 useEffect,并且其解决了之前所提的关于生命周期钩子的问题。

2.2.1 实现生命周期钩子组合

先举一个关于 class 生命周期钩子问题的例子,这里贴上 React 文档的示例:

代码语言:javascript
复制
// Count 计数组件
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `你点击了 ${this.state.count} 次`;
  }
  componentDidUpdate() {
    document.title = `你点击了 ${this.state.count} 次`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

可以看到当我们在第一次组件挂载(初始化)后以及之后每次更新都需要该操作,一个是初始化一个是更新后,这种情况在平时经常会遇到,有时候遇到初始化问题,就避免不了会写两次,哪怕是抽离成单独的函数,也必须要在两个地方调用,当这种写法多了起来后将会变得冗余且容易出 bug 。

useEffect 是怎么解决的?一个简单示例:

代码语言:javascript
复制
import React, { useState, useEffect } from 'react';

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

  // 效果如同 componentDidMount 和 componentDidUpdate:
  useEffect(() => {
    // 更新 title
    document.title = `你点击了 ${count} 次`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

它把两个生命周期钩子合并在了一起。

整个 Hooks 过程:

  1. Example 组件第一次执行时,返回 VDOM,渲染;
  2. 渲染后从上至下按顺序执行 useEffect
  3. Example 组件更新后,返回 VDOM,渲染;
  4. 渲染后从上至下按顺序执行 useEffect

可以看到无论是初始化渲染还是更新渲染,useEffect 总是会确保在组件渲染完毕后再执行,这就相当于组合了初始化和更新渲染时的生命周期钩子。并且由于闭包的特性,useEffect 可以访问到函数组件中的各种属性和方法。

useEffect 里面可以进行 “副作用” 操作,例如:

  1. 更变 DOM(调用 setCount)
  2. 发送网络请求
  3. 挂载监听

不应该把 “副作用” 操作放到函数组件主体中,就像不应该把 “副作用” 操作放到 render 函数中一样,否则很可能会导致函数执行死循环或资源浪费等问题。

2.2.2 实现销毁钩子

这就完了吗?没有,对于组件来说,有些其内部是有订阅外部数据源的,这些订阅的 “副作用” 如果在组件卸载时没有进行清除,将会容易导致内存泄漏。React 类组件中还有个非常重要的生命周期钩子 componentWillUnmount,其在组件将要销毁时执行。

下面演示类组件是如何清除订阅的:

代码语言:javascript
复制
// 一个订阅好友的在线状态的组件
class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }
  
 // 初始化:订阅好友在线状态
  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange,
    );
  }

  // 更新:好友订阅更改
  componentDidUpdate(prevProps) {
    // 如果 id 相同则忽略
    if (prevProps.friend.id === this.props.friend.id) {
      return;
    }
    // 否则清除订阅并添加新的订阅
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange,
    );
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange,
    );
  }

  // 销毁:清除好友订阅
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange,
    );
  }
  
  // 订阅方法
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline,
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}

可以看到,一个好友状态订阅使用了三个生命周期钩子。

那么使用 useEffect 该如何实现?

代码语言:javascript
复制
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    
    // 清除好友订阅
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

useEffect 把好友订阅相关的逻辑代码组合到了一起,而不像类组件那样把同一类型的逻辑代码按照生命周期来划分。

其中 return 的函数是在 useEffect 再次执行前或是组件要销毁时执行,由于闭包,useEffect 中的返回函数可以很容易地获取对象并清除订阅。

整个 Hooks 过程:

  1. 初始化函数组件 FriendStatus,挂载 VDOM;
  2. 按顺序执行 useEffect 中传入的函数;
  3. 更新:函数 FriendStatus 重新执行,重新挂载 VDOM;
  4. 执行上一次 useEffect 传入函数的返回值:清除好友订阅的函数;
  5. 执行本次 useEffect 中传入的函数。
2.2.3 实现不同逻辑分离

刚才讲的都是在一个场景下使用 Hooks 。

现在将计数组件和好友在线状态组件结合并作对比。

代码语言:javascript
复制
class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    document.title = `你点击了 ${count} 次`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate() {
    document.title = `你点击了 ${count} 次`;
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  
  componentDidUpdate(prevProps) {
    // 如果 id 相同则忽略
    if (prevProps.friend.id === this.props.friend.id) {
      return;
    }
    // 否则清除订阅并添加新的订阅
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange,
    );
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange,
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

可以很明显地感受到,在多个生命周期钩子中,计数和好友订阅等逻辑代码都混合在了同一个函数中。

接下来看看 useEffect 是怎么做的:

代码语言:javascript
复制
function FriendStatusWithCounter(props) {
  // 计数相关代码
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `你点击了 ${count} 次`;
  });

  // 好友订阅相关代码
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  // ...
}

useEffect 可以像使用多个 useState 那样,把组件的逻辑代码进行分离和组合,更有利于组件的开发和维护。

2.2.4 跳过 useEffect

有些时候并没有必要每次在函数组件重新执行时执行 useEffect,这个时候就需要用到 useEffect 的第二个参数了。

第二个参数传入一个数组,数组元素是要监听的变量,当函数再次执行时,数组中只要有一个元素与上次函数执行时传入的数组元素不同,那么则执行 useEffect 传入的函数,否则不执行。

给个示例会更好理解:

代码语言:javascript
复制
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    
    // 清除好友订阅
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
    
    // 加入 props.friend.id 作为依赖,当 id 改变时才会执行该次 useEffect
  }, [props.friend.id]);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

useEffect 加入 id 的依赖,只有当 id 改变时,才会再次清除、添加订阅,而不必每次函数重新执行时都会清除并添加订阅。

需要注意的是,对于传入的对象类型,React 只是比较引用是否改变,而不会判断对象的属性是否改变,所以建议依赖数组中传入的变量都采用基本类型。

3 真正的 Hooks

刚才只是 Hooks 的简单使用,但是会使用并不能代表着真正理解到了 Hooks 的思想。

从类组件到函数组件不仅仅是使用 Hooks 的区别,更重要的是开发时根本上思维模式的变化

让我们换个角度思考。

3.1 useEffect —— 远不止生命周期

很多人认为 useEffect 只是生命周期钩子的更好替代品,这是不完全正确的。

试想一下这样的场景:一个图表组件 Chart 需要接收大量的数据然后对其进行大量计算处理(getDataWithinRange())并做展示。

类组件:

代码语言:javascript
复制
// 大量计算处理
function getDataWithinRange() {
  //...
}

class Chart extends Component {
  state = {
    data: null,
  }
  componentDidMount() {
    const newData = getDataWithinRange(this.props.dateRange)
    this.setState({data: newData})
  }
  componentDidUpdate(prevProps) {
    if (prevProps.dateRange != this.props.dateRange) {
      const newData = getDataWithinRange(this.props.dateRange)
      this.setState({data: newData})
    }
  }
  render() {
    return (
      <svg className="Chart" />
    )
  }
}

当使用生命周期钩子时,我们需要手动去判断哪些数据(dataRange)发生了变化,然后更新到对应的数据(data)。

而在 Hooks 的使用中,我们只需关注哪些值(dataRange)需要进行同步

使用 useEffect 的函数组件:

代码语言:javascript
复制
const Chart = ({ dateRange }) => {
  const [data, setData] = useState()
  
  useEffect(() => {
    const newData = getDataWithinRange(dateRange)
    setData(newData)
  }, [dateRange])
  
  return (
    <svg className="Chart" />
  )
}

useEffect 可以让你有更简单的想法实现保持变量同步

不过这还不够简单,我们可以再看下一个例子。

3.2 强大的 useMemo

事实上,刚才 Hooks 中的例子还是有些类组件的思维模式,显得有些复杂了。

  1. 使用 useEffect 进行数据的处理;
  2. 存储变量到 state
  3. 在 JSX 中引用 state

有没有发现中间多了个 state 的环节?

我们不需要使用 state ,那是类组件的开发模式,因为在类组件中,render 函数和生命周期钩子并不是在同一个函数作用域下执行,所以需要 state 进行中间的存储,同时执行的 setStaterender 函数再次执行,借此获取最新的 state

而在函数式组件中我们有时根本不会需要用到 state 这样的状态存储,我们仅仅是想使用

所以我们可以把刚才的图表例子写成这样:

代码语言:javascript
复制
const Chart = ({ dateRange }) => {
  
  const data = useMemo(() => (
    getDataWithinRange(dateRange)
  ), [dateRange])
  
  return (
    <svg className="Chart" />
  )
}

useMemo 会返回一个“记忆化”的结果,执行当前传入的函数并返回结果值给声明的变量,且当依赖没变化时返回上一次计算的值。

为什么可以这样写?

因为函数组件中 render 和生命周期钩子在同一个函数作用域中,这也就意味着不再需要 state 作中间数据桥梁,我们可以直接在函数执行时获取到处理的数据,然后在 return 的 JSX 中使用,不必需要每次使用属性都要在 state 中声明和创建了,不再需要重新渲染执行一次函数(setData)了,所以我们去除掉了 useState。这样,我就减少了一个 state 的声明以及一次重新渲染

我们把变量定义在函数里面,而不是定义在 state 中,这是类组件由于其结构和作用域上与函数组件相比的不足,是函数组件的优越性。

当然,如果 getDataWithinRange 函数开销不大的话,这样写也是可以的:

代码语言:javascript
复制
const Chart = ({ dateRange }) => {
  const newData = getDataWithinRange(dateRange)
  return (
    <svg className="Chart" />
  )
}

函数上下文中进行数据的处理和使用,是类结构组件所难以实现的。

如果还没有体会到 Hooks 所带来的变化,那么下面的例子可能会令你有所领悟。

3.3 多个数据依赖

上一个例子我们只要处理一个数据就可以了,这次我们尝试处理多条数据,并且数据间有依赖关系。

需求如下:

  1. 需要对传入的 dataRange 进行处理得到 data
  2. margins 改变后需要更新 dimensions
  3. data 改变后需要更新 scales

类组件:

代码语言:javascript
复制
class Chart extends Component {
  state = {
    data: null,
    dimensions: null,
    xScale: null,
    yScale: null,
  }
  componentDidMount() {
    const newData = getDataWithinRange(this.props.dateRange)
    this.setState({data: newData})
    this.setState({dimensions: getDimensions()})
    this.setState({xScale: getXScale()})
    this.setState({yScale: getYScale()})
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevProps.dateRange != this.props.dateRange) {
      const newData = getDataWithinRange(this.props.dateRange)
      this.setState({data: newData})
    }
    if (prevProps.margins != this.props.margins) {
      this.setState({dimensions: getDimensions()})
    }
    if (prevState.data != this.state.data) {
      this.setState({xScale: getXScale()})
      this.setState({yScale: getYScale()})
    }
  }
  render() {
    return (
      <svg className="Chart" />
    )
  }
}

函数组件:

代码语言:javascript
复制
const Chart = ({ dateRange, margins }) => {
  const data = useMemo(() => (
    getDataWithinRange(dateRange)
  ), [dateRange])
  const dimensions = useMemo(getDimensions, [margins])
  const xScale = useMemo(getXScale, [data])
  const yScale = useMemo(getYScale, [data])
  return (
    <svg className="Chart" />
  )
}

为什么代码那么少?因为在 Hooks 中我们依旧只需关注哪些值(data、dimensions、xScale、yScale)需要同步即可。

而观察类组件的代码,我们可以发现其使用了大量的陈述性代码,例如判断是否相等,同时还使用了 state 作为数据的存储和使用,所以产生了很多 setState 代码以及增加了多次重新渲染。

3.4 解放 State

还是刚才 3.3 的例子,不过把需求稍微改了一下:让 scales 依赖于 dimensions

看看类组件是如何做到的:

代码语言:javascript
复制
class Chart extends Component {
  state = {
    data: null,
    dimensions: null,
    xScale: null,
    yScale: null,
  }
  componentDidMount() {
    const newData = getDataWithinRange(this.props.dateRange)
    this.setState({data: newData})
    this.setState({dimensions: getDimensions()})
    this.setState({xScale: getXScale()})
    this.setState({yScale: getYScale()})
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevProps.dateRange != this.props.dateRange) {
      const newData = getDataWithinRange(this.props.dateRange)
      this.setState({data: newData})
    }
    if (prevProps.margins != this.props.margins) {
      this.setState({dimensions: getDimensions()})
    }
    if (
      prevState.data != this.state.data
      || prevState.dimensions != this.state.dimensions
    ) {
      this.setState({xScale: getXScale()})
      this.setState({yScale: getYScale()})
    }
  }
  render() {
    return (
      <svg className="Chart" />
    )
  }
}

由于依赖关系发生了变化,所以需要重新进行判断,并且由于多个依赖关系,判断的条件也变得更加复杂了,代码的可读性也大幅降低。

接着看 Hooks 是如何做到的:

代码语言:javascript
复制
const Chart = ({ dateRange, margins }) => {
  const data = useMemo(() => (
    getDataWithinRange(dateRange)
  ), [dateRange])
  const dimensions = useMemo(getDimensions, [margins])
  const xScale = useMemo(getXScale, [data, dimensions])
  const yScale = useMemo(getYScale, [data, dimensions])
  return (
    <svg className="Chart" />
  )
}

使用 Hooks 所以不用再去关心谁是 props 谁是 state,不用关心该如何存储变量,存储什么变量等问题,也不必去关心如何进行判断的依赖关系。在 Hooks 开发中,我们把这些琐碎的负担都清除了,只需关注要同步的变量。

所以当数据关系复杂起来的时候,类组件的这种写法显得比较笨重,使用 Hooks 的优势也就体现出来了。

再回顾一下之前一步步走过来的示例,可以看到 Hooks 帮我们精简了非常多的代码。

代码越短并不意味着可读性越好,但是更加精简、轻巧的组件,更容易让我们把关注点放在更有用的逻辑上,而不是把精力消耗在判断依赖的冗余编码中。

4 参考文章

紧追技术前沿,深挖专业领域

扫码关注我们吧!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-06-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯IMWeb前端团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 什么是 Hooks
    • 1.1 Hooks 出现的背景
    • 2 Hooks 基础
      • 2.1 useState
        • 2.2 useEffect
          • 2.2.1 实现生命周期钩子组合
          • 2.2.2 实现销毁钩子
          • 2.2.3 实现不同逻辑分离
          • 2.2.4 跳过 useEffect
          • 3.1 useEffect —— 远不止生命周期
          • 3.2 强大的 useMemo
          • 3.3 多个数据依赖
          • 3.4 解放 State
      • 3 真正的 Hooks
      • 4 参考文章
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档