美文网首页
实战+源码 带你快速掌握React Hooks

实战+源码 带你快速掌握React Hooks

作者: 这个前端不太冷 | 来源:发表于2021-12-06 21:05 被阅读0次
    React Hooks

    大纲

    • 😁 函数式编程
      • 🏆 什么是纯函数
      • 🏆 什么是副作用(Effect)
      • 🏆 为什么要使用纯函数
    • 😁 React函数组件和类组件的区别
    • 😁 为什么会出现React Hooks
      • 🏆 前言
        -🏆 React 组件设计理论
      • 🏆 React Hooks解决了什么问题
      • 🏆 常用的hooks
    • 😁 USESTATE + USEEFFECT:初来乍到
      • 🏆 理解函数式组件的运行过程
      • 🏆 useState: 使用浅析
      • 🏆 useEffect: 使用浅析
    • 😁 useState + useEffect:渐入佳境
      • 🏆 深入 useState 的本质
      • 🏆 深入 useEffect 的本质
    • 😁 React Hooks源码解析-剖析useState的执行过程
      • 🏆 React Fiber
      • 🏆 从memoizedState分析为什么必须在函数组件顶部作用域调用Hooks API(重点)
      • 🏆 React hooks的调度逻辑
      • 🏆 useState hook如何更新数据
      • 🏆 mount 阶段:mountState
      • 🏆 update 阶段:updateState
    • 😁 React Hooks源码解析-剖析useEffect的执行过程
      • 🏆 mount 阶段:mountEffect
      • 🏆 update 阶段:updateEffect
    • 😁 回顾与总结
      • 🏆 为什么是链表
    • 😁 站在巨人肩上

    函数式编程

    函数式编程

    关于纯函数的知识点可以看我之前写的文章前端基础—带你理解什么是函数式编程


    React函数组件和类组件的区别

    使用上的区别
    区别点 函数组件 类组件
    生命周期
    this
    state
    改变state React.Hooks : useState this.setState()
    性能 高(不用实例化) 低(需要实例化)
    其他区别
    1.编程方法和设计理念不同

    严格地说,类组件和函数组件是有差异的。不同的写法,代表了不同的编程方法论:
    以类组件的写法为例:

    import React from 'react'
    
    class Welcome extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          count: 0
        }
      }
      componentDidMount() {
        alert(this.state.count);
      }
      componentDidUpdate() {
        alert(this.state.count);
      }
      addcount = () => {
        let newCount = this.state.count;
        this.setState({
          count: newCount +=1
        });
      }
      render() {
        return <h1>{this.props.name}</h1>
      }
    }
    
    export default Welcome
    

    类(class)是数据和逻辑的封装。 也就是说,组件的状态和操作方法是封装在一起的。如果选择了类的写法,就应该把相关的数据和操作,都写在同一个 class 里面。

    image

    函数一般来说,只应该做一件事,就是返回一个值。 如果你有多个操作,每个操作应该写成一个单独的函数。而且,数据的状态应该与操作方法分离。根据这种理念,React 的函数组件只应该做一件事情:返回组件的 HTML 代码,而没有其他的功能。

    image

    以函数组件为例。

    function Welcome(props) {
       return <h1>Hello, {props.name}</h1>;
    }
    
    export default Welcome
    

    这个函数只做一件事,就是根据输入的参数,返回组件的 HTML 代码。这种只进行单纯的数据计算(换算)的函数,在函数式编程里面称为 "纯函数"(pure function)。

    2.组件内部state存在差异

    虽然React hooks让函数组件也有了 state,但是 函数组件 state 和 类组件 state 还是有一些差异:

    • 函数组件 state 的粒度更细,类组件 state 过于无脑。
    • 函数组件 state 保存的是快照,类组件 state 保存的是最新值。
    • 要修改的state为引用类型的情况下,类组件 state 不需要传入新的引用,而 function state 必须保证是个新的引用。
    2.1快照(闭包) vs 最新值(引用)

    先抛出这么一个问题:在 3s 内频繁点击3次按钮,下面代码的执行表现是什么?

    class CounterClass extends React.Component {
      constructor(props) {
          super(props);
          this.state = {
            count: 0
          }
        }
        handleAddCount = () => {
            setTimeout(() => {
                setState({count: this.state.count + 1});
            }, 3000);
        };
    
        render() {
            return (
                <div>
                    <p>He clicked {this.state.count} times</p>
                    <button onClick={this.handleAddCount.bind(this)}>
                      Show count
                    </button>
                </div>
            );
        }
    

    如果是这段代码呢?它又会是什么表现?

    function CounterFunction() {
        const [count, setCount] = useState(0);
        const handleAddCount = () => {
            setTimeout(() => {
                setCount(count + 1);
            }, 3000);
        };
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={handleAddCount}>Show count</button>
            </div>
        );
    }
    

    如果你能成功答对,那么恭喜你,你已经掌握了 接下来要讲的useState 的用法。在第一个例子中,连续点击3次,页面上的数字会从0增长到3。而第二个例子中,连续点击3次,页面上的数字只会从0增长到1。

    这个是为什么呢?其实这主要是引用和闭包的区别。

    类组件里面可以通过 this.state 引用到 count,所以每次 setTimeout 的时候都能通过引用拿到上一次的最新 count,所以点击多少次最后就加了多少。

    在 函数组件 里面每次更新都是重新执行当前函数,也就是说 setTimeout 里面读取到的 count 是通过闭包获取的,而这个 count 实际上只是初始值,并不是上次执行完成后的最新值,所以最后只加了1次。

    2.2快照和引用的转换

    如果我想让函数组件也是从0加到3,那么该怎么来解决呢?聪明的你一定会想到,如果模仿类组件里面的 this.state,我们用一个引用来保存 count 不就好了吗?没错,这样是可以解决,只是这个引用该怎么写呢?我在 state 里面设置一个对象好不好?就像下面这样:

    const [state, setState] = useState({ count: 0 })
    
    

    答案是不行,因为即使 state 是个对象,但每次更新的时候,要传一个新的引用进去,这样的引用依然是没有意义。

    setState({ count: count + 1})
    

    想要解决这个问题,那就涉及到另一个新的 Hook 方法 —— useRef。useRef 是一个对象,它拥有一个 current 属性,并且不管函数组件执行多少次,而 useRef 返回的对象永远都是原来那一个。

    function CounterFunction() {
        const [count, setCount] = useState(0);
        const ref = useRef(0);
        const handleAddCount = () => {
            setTimeout(() => {
                setCount(ref.current + 1);
            }, 3000);
        };
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={handleAddCount}>Show count</button>
            </div>
        );
    }
    
    image

    useRef 有下面这几个特点:

    1. useRef 是一个只能用于函数组件的方法。
    2. useRef 是除字符串 ref、函数 refcreateRef 之外的第四种获取 ref 的方法。
    3. useRef 在渲染周期内永远不会变,因此可以用来引用某些数据。
    4. 修改 ref.current 不会引发组件重新渲染。

    为什么会出现React Hooks

    Why React Hooks.png
    1.前言

    React Hooks 是 React 16.8 引入的新特性,允许我们在不使用 Class 的前提下使用 state 和其他特性。React Hooks 要解决的问题是状态共享,是继 render-props 和 higher-order components 之后的第三种状态逻辑复用方案,不会产生 JSX 嵌套地狱问题。

    2.React 组件设计理论

    React以一种全新的编程范式定义了前端开发约束,它为视图开发带来了一种全新的心智模型:

    • React认为,UI视图是数据的一种视觉映射,即UI = F(DATA),这里的F需要负责对输入数据进行加工、并对数据的变更做出响应
    • 公式里的F在React里抽象成组件,React是以组件(Component-Based)为粒度编排应用的,组件是代码复用的最小单元
    • 在设计上,React采用props属性来接收外部的数据,使用state属性来管理组件自身产生的数据(状态),而为了实现(运行时)对数据变更做出响应需要,React采用基于类(Class)的组件设计
    • 除此之外,React认为组件是有生命周期的,因此开创性地将生命周期的概念引入到了组件设计,从组件的create到destory提供了一系列的API供开发者使用

    这就是React组件设计的理论基础

    3.React Hooks能解决什么问题

    React 一直在解决一个问题,如何实现分离业务逻辑代码,实现组件内部相关业务逻辑的复用。

    一般情况下,我们都是通过组件和自上而下传递的数据流将我们页面上的大型UI组织成为独立的小型UI,实现组件的重用。但是我们经常遇到很难侵入一个复杂的组件中实现重用,因为组件的逻辑是有状态的,无法提取到函数组件当中。当我们在组件中连接外部的数据源,然后希望在组件中执行更多其他的操作的时候,我们就会把组件搞得特别糟糕:

    • 问题1:难以共享组件中的与状态相关的逻辑,容易产生很多巨大的组件。

    对于共享组件中的与状态相关的逻辑,React团队给出过许多的方案,早期使用CreateClass + Mixins,在使用Class Component取代CreateClass之后又设计了Render Props和Higher Order Component。但是都没有很好的解决这个问题。反而让组件体积变得更加臃肿。组件的可读性进一步降低。
    HOC使用(老生常谈)的问题:
    (1)嵌套地狱,每一次HOC调用都会产生一个组件实例
    (2)可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是HOC
    (3)包裹太多层级之后,可能会带来props属性的覆盖问题
    Render Props:
    (1)数据流向更直观了,子孙组件可以很明确地看到数据来源
    (2)但本质上Render Props是基于闭包实现的,大量地用于组件的复用将不可避免地引入了callback hell问题
    (2)丢失了组件的上下文,因此没有this.props属性,不能像HOC那样访问this.props.children

    • 问题2: 我们构建React类组件的方式与组件的生命周期是耦合的。这一鸿沟顺理成章的迫使整个组件中散布着业务相关的逻辑。产生了不可避免的代码冗余。比如在不同的生命周期中处理组件的状态,或者在不同生命周期中执行setTimeOut和clearTimeOut等等。在下面的示例中,我们可以清楚地了解到这一点。我们需要2个生命周期中(componentDidMount、componentDidUpdate)来完成相同的任务——使repos与任何props.id同步。。
    class ReposGrid extends React.Component {
      constructor (props) {
        super(props)
    
        this.state = {
          repos: [],
          loading: true
        }
    
        this.updateRepos = this.updateRepos.bind(this)
      }
      componentDidMount () {
        this.updateRepos(this.props.id)
      }
      componentDidUpdate (prevProps) {
        if (prevProps.id !== this.props.id) {
          this.updateRepos(this.props.id)
        }
      }
      updateRepos (id) {
        this.setState({ loading: true })
    
        fetchRepos(id)
          .then((repos) => this.setState({
            repos,
            loading: false
          }))
      }
      render() {
        if (this.state.loading === true) {
          return <Loading />
        }
    
        return (
          <ul>
            {this.state.repos.map(({ name, handle, stars, url }) => (
              <li key={name}>
                <ul>
                  <li><a href={url}>{name}</a></li>
                  <li>@{handle}</li>
                  <li>{stars} stars</li>
                </ul>
              </li>
            ))}
          </ul>
        )
      }
    }
    
    • 问题3:令人头疼的 this 管理,容易引入难以追踪的 Bug。

    React Hooks的出现很好的解决了这些问题:

    针对问题1:

    前面我们提到过,React难以共享组件中的与状态相关的逻辑,这导致了像高阶组件或渲染道具这样过于复杂的模式。Hooks对此有自己的解决方案---创建我们自己的自定义Hook,自定义Hook以use开头。如下这个useRepos hook将接受我们想要获取的Repos的id,并返回一个数组,其中第一项为loading状态,第二项为repos状态。

    function useRepos (id) {
      const [ repos, setRepos ] = useState([])
      const [ loading, setLoading ] = useState(true)
    
      useEffect(() => {
        setLoading(true);
        fetchRepos(id)
          .then((repos) => {
            setRepos(repos);
            setLoading(false);
          });
      }, [id]);
      return [loading, repos];
    }
    

    这样任何与获取repos相关的逻辑都可以在这个自定义Hook中抽象。现在,不管我们在哪个组件中,每当我们需要有关repos的数据时,我们都可以使用useRepos自定义Hook。

    function ReposGrid ({ id }) {
      const [ loading, repos ] = useRepos(id);
      ...
    }
    
    function Profile ({ user }) {
      const [ loading, repos ] = useRepos(user.id);
      ...
    }
    
    针对问题2和问题3:

    当使用ReactHooks时,我们需要忘记所知道的关于通俗的React生命周期方法以及这种思维方式的所有东西。我们已经看到了考虑组件的生命周期时产生的问题-“这(指生命周期)顺理成章的迫使整个组件中散布着相关的逻辑。”相反,考虑一下同步。想想我们曾经用到生命周期事件的时候。不管是设置组件的初始状态、获取数据、更新DOM等等,最终目标总是同步。通常,把React land之外的东西(API请求、DOM等)与Reactland之内的(组件状态)同步,反之亦然。当我们考虑同步而不是生命周期事件时,它允许我们将相关的逻辑块组合在一起。为此,Reaction给了我们另一个叫做useEffect的Hook。
    很肯定地说useEffect使我们能在function组件中执行副作用操作。它有两个参数,一个函数和一个可选数组。函数定义要运行的副作用,(可选的)数组定义何时“重新同步”(或重新运行)effect。

    React.useEffect(() => {
      document.title = `Hello, ${username}`
    }, [username]);
    

    在上面的代码中,传递给useEffect的函数将在用户名发生更改时运行。因此,将文档的标题与Hello, ${username}解析出的内容同步。
    现在,我们如何使用代码中的useEffect Hook来同步repos和fetchRepos API请求?

    function ReposGrid ({ id }) {
      const [ repos, setRepos ] = React.useState([])
      const [ loading, setLoading ] = React.useState(true)
    
      React.useEffect(() => {
        setLoading(true)
        fetchRepos(id)
          .then((repos) => {
            setRepos(repos)
            setLoading(false)
          })
      }, [id])
      if (loading === true) {
        return <Loading />
      }
      return (
        <ul>
          {repos.map(({ name, handle, stars, url }) => (
            <li key={name}>
              <ul>
                <li><a href={url}>{name}</a></li>
                <li>@{handle}</li>
                <li>{stars} stars</li>
              </ul>
            </li>
          ))}
        </ul>
      )
    }
    

    相当巧妙,对吧?我们已经成功地摆脱了React.Component, constructor, super, this,更重要的是,我们的业务逻辑不再在整个组件生命周期中散布。

    常用的hooks

    React Hooks常用钩子有如下5种:

    • useState() 状态钩子
    • useContext() 共享状态钩子
    • useReducer(). Action 钩子
    • useCallback. function 钩子
    • useEffect() 副作用钩子

    使用hooks 我们会发现没有了继承,渲染逻辑,生命周期等, 代码看起来更加的轻便简洁了。
    React 约定,钩子一律使用 use 前缀命名 (自定义钩子都命名为:useXXXX)
    关于常用hooks结束可以看
    React Hooks 常用钩子及基本原理
    聊聊useCallback
    详解 React useCallback & useMemo

    USESTATE + USEEFFECT:初来乍到

    首先,让我们从最最最常用的两个 Hooks 说起:useStateuseEffect 。很有可能,你在平时的学习和开发中已经接触并使用过了(当然如果你刚开始学也没关系啦)。不过在此之前,我们先熟悉一下 React 函数式组件的运行过程。

    1.理解函数式组件的运行过程

    我们知道,Hooks 只能用于 React 函数式组件。因此理解函数式组件的运行过程对掌握 Hooks 中许多重要的特性很关键,请看下图:

    image

    可以看到,函数式组件严格遵循 UI = render(data) 的模式。当我们第一次调用组件函数时,触发初次渲染;然后随着 props 的改变,便会重新调用该组件函数,触发重渲染

    你也许会纳闷,动画里面为啥要并排画三个一样的组件呢?因为我想通过这种方式直观地阐述函数式组件的一个重要思想:

    每一次渲染都是完全独立的。

    后面我们将沿用这样的风格,并一步步地介绍 Hook 在函数式组件中扮演怎样的角色。

    2.useState 使用浅析

    首先我们来简单地了解一下 useState 钩子的使用,官方文档介绍的使用方法如下:

    const [state, setState] = useState(initialValue);
    
    

    其中 state 就是一个状态变量,setState 是一个用于修改状态的 Setter 函数,而 initialValue 则是状态的初始值。

    光看代码可能有点抽象,请看下面的动画:

    image

    与之前的纯函数式组件相比,我们引入了 useState 这个钩子,瞬间就打破了之前 UI = render(data) 的安静画面——函数组件居然可以从组件之外把状态和修改状态的函数“钩”过来!并且仔细看上面的动画,通过调用 Setter 函数,居然还可以直接触发组件的重渲染

    提示

    你也许注意到了所有的“钩子”都指向了一个绿色的问号,我们会在下面详细地分析那是什么,现在就暂时把它看作是组件之外可以访问的一个“神秘领域”。

    结合上面的动画,我们可以得出一个重要的推论:每次渲染具有独立的状态值(毕竟每次渲染都是完全独立的嘛)。也就是说,每个函数中的 state 变量只是一个简单的常量,每次渲染时从钩子中获取到的常量,并没有附着数据绑定之类的神奇魔法。

    这也就是老生常谈的 Capture Value 特性。可以看下面这段经典的计数器代码(来自 Dan 的这篇精彩的文章):

    function Counter() {
      const [count, setCount] = useState(0);
    
      function handleAlertClick() {
        setTimeout(() => {
          alert('You clicked on: ' + count);
        }, 3000);
      }
    
      return (
        <div>
          <p>You clicked {count} times</p>
          <button onClick={() => setCount(count + 1)}>
            Click me
          </button>
          <button onClick={handleAlertClick}>
            Show alert
          </button>
        </div>
      );
    }
    
    

    实现了上面这个计数器后(也可以直接通过这个 Sandbox 进行体验),按如下步骤操作:1)点击 Click me 按钮,把数字增加到 3;2)点击 Show alert 按钮;3)在 setTimeout 触发之前点击 Click me,把数字增加到 5。

    image

    结果是 Alert 显示 3!

    如果你觉得这个结果很正常,恭喜你已经理解了 Capture Value 的思想!如果你觉得匪夷所思嘛……来简单解释一下:

    • 每次渲染相互独立,因此每次渲染时组件中的状态、事件处理函数等等都是独立的,或者说只属于所在的那一次渲染
    • 我们在 count 为 3 的时候触发了 handleAlertClick 函数,这个函数所记住的 count 也为 3
    • 三秒种后,刚才函数的 setTimeout 结束,输出当时记住的结果:3

    这道理就像,你翻开十年前的日记本,虽然是现在翻开的,但记录的仍然是十年前的时光。或者说,日记本 Capture 了那一段美好的回忆。

    3.useEffect 使用浅析

    你可能已经听说 useEffect 类似类组件中的生命周期方法。但是在开始学习 useEffect 之前,建议你暂时忘记生命周期模型,毕竟函数组件和类组件是不同的世界。官方文档介绍 useEffect 的使用方法如下:

    useEffect(effectFn, deps)
    
    

    effectFn 是一个执行某些可能具有副作用的 Effect 函数(例如数据获取、设置/销毁定时器等),它可以返回一个清理函数(Cleanup),例如大家所熟悉的 setIntervalclearInterval

    useEffect(() => {
      const intervalId = setInterval(doSomething(), 1000);
      return () => clearInterval(intervalId);
    });
    
    

    可以看到,我们在 Effect 函数体内通过 setInterval 启动了一个定时器,随后又返回了一个 Cleanup 函数,用于销毁刚刚创建的定时器。

    OK,听上去还是很抽象,再来看看下面的动画吧:

    image

    动画中有以下需要注意的点:

    • 每个 Effect 必然在渲染之后执行,因此不会阻塞渲染,提高了性能
    • 在运行每个 Effect 之前,运行前一次渲染的 Effect Cleanup 函数(如果有的话)
    • 当组件销毁时,运行最后一次 Effect 的 Cleanup 函数

    提示

    将 Effect 推迟到渲染完成之后执行是出于性能的考虑,如果你想在渲染之前执行某些逻辑(不惜牺牲渲染性能),那么可使用 useLayoutEffect 钩子,使用方法与 useEffect 完全一致,只是执行的时机不同。

    再来看看 useEffect 的第二个参数:deps (依赖数组)。从上面的演示动画中可以看出,React 会在每次渲染后都运行 Effect。而依赖数组就是用来控制是否应该触发 Effect,从而能够减少不必要的计算,从而优化了性能。具体而言,只要依赖数组中的每一项与上一次渲染相比都没有改变,那么就跳过本次 Effect 的执行。

    仔细一想,我们发现 useEffect 钩子与之前类组件的生命周期相比,有两个显著的特点:

    • 将初次渲染(componentDidMount)、重渲染(componentDidUpdate)和销毁(componentDidUnmount)三个阶段的逻辑用一个统一的 API 去解决
    • 把相关的逻辑都放到一个 Effect 里面(例如 setIntervalclearInterval),更突出逻辑的内聚性

    在最极端的情况下,我们可以指定 deps 为空数组 [] ,这样可以确保 Effect 只会在组件初次渲染后执行。实际效果动画如下:

    image

    可以看到,后面的所有重渲染都不会触发 Effect 的执行;在组件销毁时,运行 Effect Cleanup 函数。

    注意

    如果你熟悉 React 的重渲染机制,那么应该可以猜到 deps 数组在判断元素是否发生改变时同样也使用了 Object.is 进行比较。因此一个隐患便是,当 deps 中某一元素为非原始类型时(例如函数、对象等),每次渲染都会发生改变,从而失去了 deps 本身的意义(条件式地触发 Effect)。我们会在接下来讲解如何规避这个困境。


    useState + useEffect:渐入佳境

    在上一步骤中,我们在 App 组件中定义了一个 State 和 Effect,但是实际应用不可能这么简单,一般都需要多个 State 和 Effect,这时候又该怎么去理解和使用呢?

    1.深入 useState 的本质

    在上一节的动画中,我们看到每一次渲染组件时,我们都能通过一个神奇的钩子把状态”钩“过来,不过这些钩子从何而来我们打了一个问号。现在,是时候解开谜团了。

    注意

    以下动画演示并不完全对应 React Hooks 的源码实现,但是它能很好地帮助你理解其工作原理。当然,也能帮助你去啃真正的源码。

    我们先来看看当组件初次渲染(挂载)时,情况到底是什么样的:

    image

    注意以下要点:

    1. 在初次渲染时,我们通过 useState 定义了多个状态;
    2. 每调用一次 useState ,都会在组件之外生成一条 Hook 记录,同时包括状态值(用 useState 给定的初始值初始化)和修改状态的 Setter 函数;
    3. 多次调用 useState 生成的 Hook 记录形成了一条链表
    4. 触发 onClick 回调函数,调用 setS2 函数修改 s2 的状态,不仅修改了 Hook 记录中的状态值,还即将触发重渲染

    OK,重渲染的时候到了,动画如下:

    image

    可以看到,在初次渲染结束之后、重渲染之前,Hook 记录链表依然存在。当我们逐个调用 useState 的时候,useState 便返回了 Hook 链表中存储的状态,以及修改状态的 Setter。

    提示

    当你充分理解上面两个动画之后,其实就能理解为什么这个 Hook 叫 useState 而不是 createState 了——之所以叫 use ,是因为没有的时候才创建(初次渲染的时候),有的时候就直接读取(重渲染的时候)。

    通过以上的分析,我们不难发现 useState 在设计方面的精巧(摘自张立理:对 React Hooks 的一些思考):

    • 状态和修改状态的 Setter 函数两两配对,并且后者一定影响前者,前者只被后者影响,作为一个整体它们完全不受外界的影响
    • 鼓励细粒度和扁平化的状态定义和控制,对于代码行为的可预测性和可测试性大有帮助
    • 除了 useState (和其他钩子),函数组件依然是实现渲染逻辑的“纯”组件,对状态的管理被 Hooks 所封装了起来

    深入 useEffect 的本质

    在对 useState 进行一波深挖之后,我们再来揭开 useEffect 神秘的面纱。实际上,你可能已经猜到了——同样是通过一个链表记录所有的 Hook,请看下面的演示:

    image

    注意其中一些细节:

    1. useStateuseEffect 在每次调用时都被添加到 Hook 链表中;
    2. useEffect 还会额外地在一个队列中添加一个等待执行的 Effect 函数;
    3. 在渲染完成后,依次调用 Effect 队列中的每一个 Effect 函数。

    至此,上一节的动画中那两个“问号”的身世也就揭晓了——只不过是链表罢了!回过头来,我们想起来 React 官方文档 Rules of Hooks 中强调过一点:

    Only call hooks at the top level. 只在最顶层使用 Hook。

    具体地说,不要在循环、嵌套、条件语句中使用 Hook——因为这些动态的语句很有可能会导致每次执行组件函数时调用 Hook 的顺序不能完全一致,导致 Hook 链表记录的数据失效。具体的场景就不画动画啦,自行脑补吧~

    React Hooks源码解析-剖析useState的执行过程

    1.React Fiber

    关于React Fiber详细解释,可以看我之前写的一篇文章
    由浅入深快速掌握React Fiber

    我们本节只需了解这2个点即可:

    • React现在的渲染都是由Fiber来调度
    • Fiber调度过程中的两个阶段(以Render为界)

    Fiber是比线程还细的控制粒度,是React 16中的新特性,旨在对渲染过程做更精细的调整。

    产生原因:

    (1)Fiber之前的reconciler(被称为Stack reconciler)自顶向下的递归mount/update,无法中断(持续占用主线程),这样主线程上的布局、动画等周期性任务以及交互响应就无法立即得到处理,影响体验

    (2)渲染过程中没有优先级可言

    React Fiber的调度方式:

    • 把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。

    • React Fiber把更新过程碎片化,执行过程如下面的图所示,每执行完一段更新过程,就把控制权交还给React负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。

    • 维护每一个分片的数据结构,就是Fiber。

    • 有了分片之后,更新过程的调用栈如下图所示,中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。让线程处理别的事情


      image.png

    Fiber的调度过程分为以下两个阶段:

    render/reconciliation阶段 — 里面的所有生命周期函数都可能被中断、执行多次

    • componentWillMount

    • componentWillReceiveProps

    • shouldComponentUpdate

    • componentWillUpdate

    Commit阶段 — 不能被打断,只会执行一次

    • componentDidMount

    • componentDidUpdate

    • compoenntWillunmount

    Fiber的增量更新需要更多的上下文信息,之前的vDOM tree显然难以满足,所以扩展出了fiber tree(Current 树),更新过程就是根据输入数据以及现有的fiber tree构造出新的fiber tree(workInProgress 树)。

    Current 树和 workInProgress 树

    React渲染Hook是从renderWithHooks函数开始的:

    function renderWithHooks<Props, SecondArg>(
      current: Fiber | null,
      workInProgress: Fiber,
      Component: (p: Props, arg: SecondArg) => any,
      props: Props,
      secondArg: SecondArg,
      nextRenderLanes: Lanes,
    ): any {
      renderLanes = nextRenderLanes;
      currentlyRenderingFiber = workInProgress;
        ...
      workInProgress.memoizedState = null;
      workInProgress.updateQueue = null;
      workInProgress.lanes = NoLanes;
      ...
    }
    
    • 从renderWithHooks函数可以看出:在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,当 React 开始处理更新时,会在内存中再次构建一棵Fiber树,称为workInProgress Fiber树,它反映了要刷新到屏幕的未来状态。current Fiber树中的Fiber节点被称为current fiber。workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,它们通过alternate属性连接。

    • React应用的根节点通过current指针在不同Fiber树的rootFiber间切换来实现Fiber树的切换。当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。由于有两颗fiber树,实现了异步中断时,更新状态的保存,中断回来以后可以拿到之前的状态。并且两者状态可以复用,节约了从头构建的时间

    • React所有的 work 都是在 workInProgress 树的 Fibler 节点上进行的。当 React 遍历 current 树时,它为每个Fiber节点创建一个替代节点。这些节点构成了 workInProgress 树。一旦处理完所有 update 并完成所有相关 work,React 将把 workInProgress 树刷新到屏幕。一旦在屏幕上渲染 workInProgress 树之后,workInProgress 树将替换 原有current 树成为新的current 树。

    hooks挂载在workInProgress树的 Fibler 节点上的memoizedState属性下,Fiber节点结构如下:

    Fiber节点
    FiberNode { // fiber结构
        stateNode: new ClickCounter,
        type: ClickCounter, // 判断标签类型是原生或react   
        alternate: null, // 指向current fiber节点
        key: null, 节点key
        updateQueue: null, // state 更新,回调以及 DOM 更新的队列。
        memoizedState:  any, // 类组件存储最新的state,函数组件存储hooks列表
        tag: 1, // 判断组件的类型是函数组件还是类组件
        child,
        return,
        sibling,
      ...
    }
    
    • stateNode
      保存对类组件实例,DOM 节点或与 fiber 节点关联的其他 React 元素类型的引用。一般来说,此属性用于保存与 fiber 关联的 local state。

    • type
      定义与此 fiber 关联的函数或类。对于类组件,它指向构造函数,对于 DOM 元素,它指定 HTML 标记。
      我把这个字段理解为 fiber 节点与哪些元素相关。

    • tag
      定义 fiber节点类型,在 reconciliation 算法中使用它来确定按函数组件还是类组件完成接下来的工作。

    • updateQueue
      state 更新,回调以及 DOM 更新的队列。

    • memoizedState
      用于创建输出的 fiber 的state。处理更新时,它反映了当前渲染在屏幕上内容的 state。

    memoziedState这个字段很重要,是组件更新的唯一依据。在class组件里,它就是this.state的结构,调用this.setState的时候,其实就是修改了它的数据,数据改变了组件就会重新执行。
    也就是说,即使是class组件,也不会主动调用任何生命周期函数,而是在memoziedState改变后,组件重新执行,在执行的过程中才会经过这些周期。
    所以,这就解释了函数式组件为什么可以通过让hooks(useState)返回的方法改变状态来触发组件的更新,实际上就是修改了对应fiber节点的memoziedState。

    • memoizedProps
      在上一次渲染期间用于创建输出的 fiber 的 props 。

    • pendingProps
      在 React element 的新数据中更新并且需要应用于子组件或 DOM 元素的 props。(子组件或者 DOM 中将要改变的 props)

    • key
      唯一标识符,当具有一组 children 的时候,用来帮助 React 找出哪些项已更改,已添加或已从列表中删除。与这里所说的React的 “列表和key” 功能有关


    2.从memoizedState分析为什么必须在函数组件顶部作用域调用Hooks API(重点)

    4. useState hook如何更新数据

    本节主要概念:

    • useState是如何被引入以及调用的
    • useState为什么能触发组件更新

    所有的Hooks在React.js中被引入,挂载在React对象中

    // React.js
    import {
      useCallback,
      useContext,
      useEffect,
      useImperativeHandle,
      useDebugValue,
      useLayoutEffect,
      useMemo,
      useReducer,
      useRef,
      useState,
    } from './ReactHooks';
    

    我们进入ReactHooks.js来看看,发现useState的实现竟然异常简单,只有短短两行

    // ReactHooks.js
    export function useState<S>(initialState: (() => S) | S) {
      const dispatcher = resolveDispatcher();
      return dispatcher.useState(initialState);
    }
    

    看来重点都在这个dispatcher上,dispatcher通过resolveDispatcher()来获取,这个函数同样也很简单,只是将ReactCurrentDispatcher.current的值赋给了dispatcher

    // ReactHooks.js
    function resolveDispatcher() {
      const dispatcher = ReactCurrentDispatcher.current;
      return dispatcher;
    }
    

    这个dispatcher对象包含了所有的官方内置hook。

    dispatcher:{
      readContext: ƒ (context, observedBits),
      useCallback: ƒ (callback, deps),
      useContext: ƒ (context, observedBits),
      useEffect: ƒ (create, deps),
      useImperativeHandle: ƒ (ref, create, deps),
      useLayoutEffect: ƒ (create, deps),
      useMemo: ƒ (create, deps),
      useReducer: ƒ (reducer, initialArg, init),
      useRef: ƒ (initialValue),
      useState: ƒ (initialState),
      useDebugValue: ƒ (value, formatterFn),
      useResponder: ƒ (responder, props),
      useDeferredValue: ƒ (value, config),
      useTransition: ƒ (config)
    }
    

    先暂时不管这个dispatcher是怎么来的,我们接着看下useState的内部逻辑。

    与类组件中setState能够触发更新的类似,useState是组件能够触发更新的关键原因,这一点我们可以从useState执行过程和源码来分析:

    useState(hooks)的具体执行过程如下:


    image.png
    • updateContainer → … → beginWork
    • beginWork中会根据当前要执行更新的fiber的tag来判断组件是函数组件还是类组件,分别执行不同的逻辑,在函数式组件,执行了updateFunctionComponent来渲染组件。函数组件的更新分组件初次渲染组件更新2种:
    1.3组件初次渲染

    我们从Fiber调度的开始:ReactFiberBeginwork来谈起

    之前已经说过,React有能力区分不同的组件,所以它会给不同的组件类型打上不同的tag, 详见shared/ReactWorkTags.js所以在beginWork的函数中,就可以根据workInProgess(就是个Fiber节点)上的tag值来走不同的方法来加载或者更新组件。,如下:

    // ReactFiberBeginWork.js
    function beginWork(
      current: Fiber | null,
      workInProgress: Fiber,
      renderExpirationTime: ExpirationTime,
    ): Fiber | null {
      /** 省略与本文无关的部分 **/
     
      // 根据不同的组件类型走不同的方法
      switch (workInProgress.tag) {
        // 不确定组件
        case IndeterminateComponent: {
          const elementType = workInProgress.elementType;
          // 加载初始组件
          return mountIndeterminateComponent(
            current,
            workInProgress,
            elementType,
            renderExpirationTime,
          );
        }
        // 函数组件
        case FunctionComponent: {
          const Component = workInProgress.type;
          const unresolvedProps = workInProgress.pendingProps;
          const resolvedProps =
            workInProgress.elementType === Component
              ? unresolvedProps
              : resolveDefaultProps(Component, unresolvedProps);
          // 更新函数组件
          return updateFunctionComponent(
            current,
            workInProgress,
            Component,
            resolvedProps,
            renderExpirationTime,
          );
        }
        // 类组件
        case ClassComponent {
          /** 细节略 **/
          }
      }
    

    在updateFunctionComponent中,对hooks的处理如下:

    nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderExpirationTime,
    );
    

    所以,React Hooks 的渲染核心是renderWithHooks,在renderWithHooks函数中,初始化了Dispatcher。

    export function renderWithHooks < Props, SecondArg > (current: Fiber | null, workInProgress: Fiber, 
                 Component: (p: Props, arg: SecondArg) = >any, props: Props, secondArg: SecondArg, nextRenderLanes: Lanes, ) : any {
    
        // 若Fiber为空,则认为是首次加载
        ReactCurrentDispatcher.current =
          current === null || current.memoizedState === null
            ? HooksDispatcherOnMount
            : HooksDispatcherOnUpdate;
    
        // 挂载时的Dispatcher
        const HooksDispatcherOnMount: Dispatcher = {
            readContext,
            // ...
            useCallback: mountCallback,
            useContext: readContext,
            useEffect: mountEffect,
            useMemo: mountMemo,
    
            useState: mountState,
            // ...
        };
    
        // 更新时的Dispatcher
        const HooksDispatcherOnUpdate: Dispatcher = {
            readContext,
            // ...
            useCallback: updateCallback,
            useContext: readContext,
            useEffect: updateEffect,
            useMemo: updateMemo,
            useRef: updateRef,
            useState: updateState,
            // ....
        };
    }
    
    • 在renderWithHooks中,会先根据fiber的memoizedState是否为null,来判断是否已经初始化。因为memoizedState在函数式组件中是存放hooks的。是则mount,否则update(判断是否执行过,没有则挂载,有则更新)
    • 在mount(挂载)时,函数式组件执行,ReactCurrentDispatcher.current为HooksDispatcherOnMount,被调用,会初始化hooks链表、initialState、dispatch函数,并返回。这里就完成了useState的初始化,后续函数式组件继续执行,完成渲染返回。(首次渲染过程)
    • 在update(更新)时,函数式组件执行,ReactCurrentDispatcher.current为HooksDispatcherOnUpdate,被调用,updateWorkInProgressHook用于获取当前work的Hook。然后根据numberOfReRenders 是否大于0来判断是否处理re-render状态:是的话,执行renderPhaseUpdates,获取第一个update,然后循环执行,获取新的state,直到下一个update为null;否的话,获取update链表的第一个update,进行循环,判断update的优先级是否需要更新,对于优先级高的进行更新。(更新过程)
    • 结果返回当前状态和修改状态的方法
      以挂载为例,生成一个hook对象(mountState),并对hook对象进行初始化(mountWorkInProgressHook),具体如下:
    function mountState < S > (initialState: (() = >S) | S, ) : [S, Dispatch < BasicStateAction < S >> ] {
        // 创建一个新的hook对象,并返回当前workInProgressHook
        const hook = mountWorkInProgressHook();
        if (typeof initialState === 'function') {
            initialState = initialState();
        }
        hook.memoizedState = hook.baseState = initialState; // 第二步:获取初始值并初始化hook对象
        const queue = hook.queue = { // 新建一个队列  
            // 保存 update 对象
            pending: null,
            // 保存dispatchAction.bind()的值
            dispatch: null,
            // 一次新的dispatch触发前最新的reducer
            // useState 保存固定函数: 可以理解为一个react 内置的reducer
            // (state, action) => { return typeof action === 'function' ? action(state) : action }
            lastRenderedReducer: reducer
            // 一次新的dispatch触发前最新的state
            lastRenderedState: (initialState: any),
        }
        // 绑定当前 fiber 和 queue.
        const dispatch: Dispatch < BasicStateAction < S > ,
        >=(queue.dispatch = (dispatchAction.bind(null, currentlyRenderingFiber, queue, ) : any));
        // 返回当前状态和修改状态的方法
        return [hook.memoizedState, dispatch];
    }
    

    首先来看hook对象是如何生成:

    function mountWorkInProgressHook() {
    
        // 初始化的hook对象
        var hook = {
            memoizedState: null,
            // 存储更新后的state值
            baseState: null,
            // 存储更新前的state
            baseQueue, // 更新函数
            queue: null,
            // 存储多次的更新行为
            next: null // 指向下一次useState的hook对象
        };
    
        // workInProgressHook是一个全局变量,表示当前正在处理的hook
        // 如果workInProgressHook链表为null就将新建的hook对象赋值给它,如果不为null,那么就加在链表尾部。
        if (workInProgressHook === null) {
            currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
        } else {
            workInProgressHook = workInProgressHook.next = hook;
        }
    
        return workInProgressHook;
    }
    

    初始化完成后,怎样对stateA值进行更新的呢?实际上就是通过dispatchAction方法进行更新的,如下:

    // currentlyRenderingFiber$1是一个全局变量,表示当前正在渲染的FiberNode
    var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
    

    dispatchAction逻辑如下:

    function dispatchAction(fiber, queue, action) {
        // ...
        // 1. 创建update对象
        // 该对象保存的是调度优先级/state/reducer以及用户调用dispatch/setState 时传入的action
        const update: Update < S,
        A > ={
            lane,
            action,
            eagerReducer: null,
            eagerState: null,
            next: (null: any),
        };
        // 2. 将update更新到queue.pending中,最后的update.next 指向第一个update对象,形成一个闭环。
        const pending = queue.pending;
        if (pending === null) {
            // This is the first update. Create a circular list.
            update.next = update;
        } else {
            update.next = pending.next;
            pending.next = update;
        }
        queue.pending = update;
    }
    

    简单理解:

    初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中,共享同一个 memoizedState,共享同一个顺序。
    更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。

    相关文章

      网友评论

          本文标题:实战+源码 带你快速掌握React Hooks

          本文链接:https://www.haomeiwen.com/subject/uktnxrtx.html