15

有了 React Hooks 的加持,妈妈再也不用担心函数组件记不住状态

过去,React 中的函数组件都被称为无状态函数式组件(stateless functional component),这是因为函数组件没有办法拥有自己的状态,只能根据 Props 来渲染 UI ,其性质就相当于是类组件中的 render 函数,虽然结构简单明了,但是作用有限。

但自从 React Hooks 横空出世,函数组件也拥有了保存状态的能力,而且也逐渐能够覆盖到类组件的应用场景,因此可以说 React Hooks 就是未来 React 发展的方向。

React Hooks 解决了什么问题

复杂的组件难以分拆

我们知道组件化的思想就是将一个复杂的页面/大组件,按照不同层次,逐渐抽象并拆分成功能更纯粹的小组件,这样一方面可以减少代码耦合,另外一方面也可以更好地复用代码;但实际上,在使用 React 的类组件时,往往难以进一步分拆复杂的组件,这是因为逻辑是有状态的,如果强行分拆,会令代码复杂性急剧上升;如使用 HOC 和 Render Props 等设计模式,这会形成“嵌套地狱”,使我们的代码变得晦涩难懂。

状态逻辑复杂,给单元测试造成障碍

这其实也是上一点的延续:要给一个拥有众多状态逻辑的组件写单元测试,无疑是一件令人崩溃的事情,因为需要编写大量的测试用例来覆盖代码执行路径。

组件生命周期繁复

对于类组件,我们需要在组件提供的生命周期钩子中处理状态的初始化、数据获取、数据更新等操作,处理起来本身逻辑就比较复杂,而且各种“副作用”混在一起也使人头晕目眩,另外还很可能忘记在组件状态变更/组件销毁时消除副作用。

React Hooks 就是来解决以上这些问题的

  • 针对状态逻辑分拆复用难的问题:其实并不是 React Hooks 解决的,函数这一形式本身就具有逻辑简单、易复用等特性。
  • 针对组件生命周期繁复的问题:React Hooks 屏蔽了生命周期这一概念,一切的逻辑都是由状态驱动,或者说由数据驱动的,那么理解、处理起来就简单多了。

利用自定义 Hooks 捆绑封装逻辑与相关 state

我认为 React Hooks 的亮点不在于 React 官方提供的那些 API ,那些 API 只是一些基础的能力;其亮点还是在于自定义 Hooks —— 一种封装复用的设计模式。

例如,一个页面上往往有很多状态,这些状态分别有各自的处理逻辑,如果用类组件的话,这些状态和逻辑都会混在一起,不够直观:

class Com extends React.Component {
    state = {
        a: 1,
        b: 2,
        c: 3,
    }
    
    componentDidMount() {
        handleA()
        handleB()
        handleC()
    }
}

而使用 React Hooks 后,我们可以把状态和逻辑关联起来,分拆成多个自定义 Hooks ,代码结构就会更清晰:

function useA() {
    const [a, setA] = useState(1)
    useEffect(() => {
        handleA()
    }, [])
    
    return a
}

function useB() {
    const [b, setB] = useState(2)
    useEffect(() => {
        handleB()
    }, [])
    
    return b
}

function useC() {
    const [c, setC] = useState(3)
    useEffect(() => {
        handleC()
    }, [])
    
    return c
}

function Com() {
    const a = useA()
    const b = useB()
    const c = useC()
}

我们除了可以利用自定义 Hooks 来拆分业务逻辑外,还可以拆分成复用价值更高的通用逻辑,比如说目前比较流行的 Hooks 库:react-use;另外,React 生态中原来的很多库,也开始提供 Hooks API ,如 react-router

忘记组件生命周期吧

React 提供了大量的组件生命周期钩子,虽然在日常业务开发中,用到的不多,但光是 componentDidUpdate 和 componentWillUnmount 就让人很头痛了,一不留神就忘记处理 props 更新组件销毁需要处理副作用的场景,这不仅会留下肉眼可见的 bug ,还会留下一些内存泄露的隐患。

类 MVVM 框架讲究的是数据驱动,而生命周期这种设计模式,就明显更偏向于传统的事件驱动模型;当我们引入 React Hooks 后,数据驱动的特性能够变得更纯粹。

处理 props 更新

下面我们以一个非常典型的列表页面来举个例子:

class List extends Component {
  state = {
    data: []
  }
  fetchData = (id, authorId) => {
    // 请求接口
  }
  componentDidMount() {
    this.fetchData(this.props.id, this.props.authorId)
    // ...其它不相关的初始化逻辑
  }
  componentDidUpdate(prevProps) {
    if (
      this.props.id !== prevProps.id ||
      this.props.authorId !== prevProps.authorId // 别漏了!
    ) {
      this.fetchData(this.props.id, this.props.authorId)
    }
    
    // ...其它不相关的更新逻辑
  }
  render() {
    // ...
  }
}

上面这段代码有3个问题:

  • 需要同时在两个生命周期里执行几乎相同的逻辑。
  • 在判断是否需要更新数据的时候,容易漏掉依赖的条件。
  • 每个生命周期钩子里,会散落大量不相关的逻辑代码,违反了高内聚的原则,影响阅读代码的连贯性。

如果改成用 React Hooks 来实现,问题就能得到很大程度上的解决了:

function List({ id, authorId }) {
    const [data, SetData] = useState([])
    const fetchData = (id, authorId) => {}
    useEffect(() => {
        fetchData(id, authorId)
    }, [id, authorId])
}

改用 React Hooks 后:

  • 我们不需要考虑生命周期,我们只需要把逻辑依赖的状态都丢进依赖列表里, React 会帮我们判断什么时候该执行的。
  • React 官方提供了 eslint 的插件来检查依赖项列表是否完整。
  • 我们可以使用多个 useEffect ,或者多个自定义 Hooks 来区分开多个无关联的逻辑代码段,保障高内聚特性。

处理副作用

最常见的副作用莫过于绑定 DOM 事件:

class List extends React.Component {
    handleFunc = () => {}
    componentDidMount() {
        window.addEventListener('scroll', this.handleFunc)
    }
    componentWillUnmount() {
        window.removeEventListener('scroll', this.handleFunc)
    }
}

这块也还是会有上述说的,影响高内聚的问题,改成 React Hooks :

function List() {
    useEffect(() => {
        window.addEventListener('scroll', this.handleFunc)
    }, () => {
        window.removeEventListener('scroll', this.handleFunc)
    })
}

而且比较绝的是,除了在组件销毁的时候会触发外,在依赖项变化的时候,也会执行清除上一轮的副作用。

利用 useMemo 做局部性能优化

在使用类组件的时候,我们需要利用 componentShouldUpdate 这个生命周期钩子来判断当前是否需要重新渲染,而改用 React Hooks 后,我们可以利用 useMemo 来判断是否需要重新渲染,达到局部性能优化的效果:

function List(props) => {
  useEffect(() => {
    fetchData(props.id)
  }, [props.id])

  return useMemo(() => (
    // ...
  ), [props.id])
}

在上面这段代码中,我们看到最终渲染的内容是依赖于props.id,那么只要props.id不变,即便其它 props 再怎么办,该组件也不会重新渲染。

依靠 useRef 摆脱闭包

在我们刚开始使用 React Hooks 的时候,经常会遇到这样的场景:在某个事件回调中,需要根据当前状态值来决定下一步执行什么操作;但我们发现事件回调中拿到的总是旧的状态值,而不是最新状态值,这是怎么回事呢?

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

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(count);
    }, 3000);
  };

  return (
    <button onClick={log}>报数</button>
  );
}

/*
    如果我们在三秒内连续点击三次,那么count的值最终会变成 3,而随之而来的输出结果是?
    0
    1
    2
 */

“这是 feature 不是 bug ”,哈哈哈,说是 feature 可能也不太准确,因为这不正是 javascript 闭包的特性吗?当我们每次往setTimeout里传入回调函数时,这个回调函数都会引用下当前函数作用域(此时 count 的值还未被更新),所以在执行的时候打印出来的就会是旧的状态值。

类组件是怎么实现的?

那为啥类组件中,每次都能取到最新的状态值呢?这是因为我们在类组件中取状态值都是从this.state里取的,这相当于是类组件的一个执行上下文,永远都是保持最新的。

借助 useRef 共享修改

通过useRef创建的对象,其值只有一份,而且在所有 Rerender 之间共享

听上去,这 useRef 其实跟 this.state 很相似嘛,都是一个可以一直维持的值,那我们就可以用它来维护我们的状态了:

function Counter() {
  const count = useRef(0);

  const log = () => {
    count.current++;
    setTimeout(() => {
      console.log(count.current);
    }, 3000);
  };

  return (
    <button onClick={log}>报数</button>
  );
}

/*
    3
    3
    3
 */

array_huang
10.5k 声望6.6k 粉丝