React Hooks 笔记

110 阅读24分钟

Hooks API:reactjs.org/docs/hooks-…

Redux 官网:react-redux.js.org/api/hooks#r…

参考文章:time.geekbang.org/column/arti…

前期准备与了解

JSX语法糖本质

从本质上来说,JSX 并不是一个模板语言,可以认为是一个语法糖。

也就是说,不用 JSX 的写法,其实也是能够写 React 的。据两个例子:

  • 用JSX
import React from "react";

function CountLabel({ count }) {
  return <span>{count}</span>;
}

export default function Counter() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        <CountLabel count={count} />
      </button>
    </div>
  );
}
  • 不用JSX
React.createElement(
  "div",
  null,
  React.createElement(
    "button",
    { onClick: function onClick() {
        return setCount(count + 1);
      } },
    React.createElement(CountLabel, { count: count })
  )
);

搭建react环境

用脚手架创建react-hooks-demo项目。

它会提供一个完善的 Webpack 配置,让我们能够立刻开始使用 React、JavaScript 语言的最新特性和 CSS Module 等主流的技术方案。

npx create-react-app react-hooks-demo

cd my-app

npm start

为什么会有React Hooks?

比起 Class 组件,函数组件是更适合去表达 React 组件的执行的,因为它更符合 State => View 这样的一个逻辑关系。

但是因为缺少状态、生命周期等机制,让它一直功能受限。

而现在有了 Hooks,函数组件的力量终于能真正发挥出来了!

在 React 中,Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上,那么当被钩到的数据或事件发生变化时,产生这个目标结果的代码会重新执行,产生更新后的结果。

Hooks优点

  • 逻辑复用

在hooks没有出现时,在 Class 组件的场景下,我们首先需要定义一个高阶组件,做通用逻辑的处理,最后将变化的值作为 props 传给下一个组件,从而实现通用逻辑的复用。

缺点:

  1. 代码难理解,不直观;
  1. 会增加很多额外的组件节点。每一个高阶组件都会多一层节点,这就会给调试带来很大的负担。
  • 有助于关注分离

在 Class 组件中,你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。

而Hooks 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿,把业务逻辑清晰地隔离开,能够让代码更加容易理解和维护。

React 社区曾用一张图直观地展现了对比结果:

image.png

看相关资料的时候,遇到的一些读者提问与作者或网友回答:

Q:需要注意一点,为什么自定义hooks的状态改变可以使调用它的组件的刷新?

A:自定义hook中的useState与调用自定义hook的组件内的useState完全等价,因为react对所有内置包括自定义的hooks都是严格按照代码定义时的顺序“钩入”读取的。

Q:hooks是如何让函数组件具备随状态变化触发组件重新执行返回更新的呢?

A:Hooks 让组件刷新和传统 class 组件触发刷新的机制是完全一样的,都是由 state 或者 props 变化触发。Hooks 中的 useState 和 class 中的 setState,背后是同一套实现。

保存组件及生命周期

useState:

可以让函数组件具有维持状态的能力,在一个函数组件的多次渲染之间,这个 state 是共享的。

useState用法:

  • useState(initialState) 的参数 initialState 是创建 state 的初始值,它可以是任意类型,比如数字、对象、数组等等。
  • useState() 的返回值是一个有着两个元素的数组。第一个数组元素用来读取 state 的值,第二个则是用来设置这个 state 的值。在这里要注意的是,state 的变量是只读的,所以我们必须通过第二个数组元素 setCount 来设置它的值。
  • 如果要创建多个 state,那么就需要多次调用 useState。

注意:setState 这个 API 还有另外一种用法,就是可以接收一个函数作为参数:setSomeState(previousState => {})。这样在这个函数中通过参数就可以直接获取上一次的 state 的值了。

setCount((q) => q - 1

useState存值原则:state 中永远不要保存可以通过计算得到的值。

useEffect:

执行副作用

useEffect用法:

useEffect 可以接收两个参数,第一个为要执行的函数 callback,第二个是可选的依赖项数组 dependencies。

在下面四种时机可以执行一个回调函数产生副作用:

  • 每次 render 后执行:不提供第二个依赖项参数。
useEffect(() => {})
  • 仅第一次 render 后执行:提供一个空数组作为依赖项。
useEffect(() => {}, [])
  • 第一次以及依赖项发生变化后执行:提供依赖项数组。
useEffect(() => {}, [deps])
  • 组件 unmount 后执行:返回一个回调函数。
useEffect() => { return () => {} }, [])

理解hooks的依赖

依赖项便是Hooks 提供的我们监听某个数据变化的能力。

定义依赖项时,我们需要注意以下三点:

  • 依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
  • 依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
  • React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化

hooks使用规则

  • Hooks 只能在函数组件的顶级作用域使用

所谓顶层作用域,就是 Hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。

同时 Hooks 在组件的多次渲染之间,必须按顺序被执行(比如if/else走不通分支,便会导致调用顺序不一样)。

  • Hooks 只能在函数组件或者其它 Hooks 中使用

如果碰到了class组件需要用到hooks实现的逻辑复用,可已利用高阶组件的模式,将 Hooks 封装成高阶组件,从而让类组件使用。

import { useWindowSize } from '../hooks/useWindowSize';

export const withWindowSize = (Comp) => {
  return props => {
    const windowSize = useWindowSize();

    return <Comp windowSize={windowSize} />;
  };
};
const withWindowSize = Component => {
  class WrappedComponent extends React.PureComponent {
    constructor(props) { 
      super(props); 
      this.state = { 
        size: this.getSize() 
      }; 
    }

    ......

    render() { 
      return <Component size={this.state.size} />;
    }
  }

  return WrappedComponent;
}

细节问题处理

useCallback

在react函数组件中,每一次 UI 的变化,都是通过重新执行整个函数来完成的,便会造成事件处理函数被重复定义。会让接收事件处理函数的组件重新渲染。

而useCallback便是用来缓存回调函数的hook(搭配React.memo使用)。

useCallback 的 API:(这里 fn 是定义的回调函数,deps 是依赖的变量数组。只有当某个依赖变量发生变化时,才会重新声明 fn 这个回调函数。)

useCallback(fn, deps)

使用 useCallback 的两种情形:

  • 包装在 React.memo()(或 shouldComponentUpdate )中的组件接受父组件的回调函数作为prop
  • 当函数用作其他hooks的依赖项时 useEffect(...,[callback])

useMemo

缓存计算结果,避免重复计算,造成饮用该变量的自组件重复渲染(搭配React.memo使用)。

useMemo 的 API:(如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。)

useMemo(fn, deps);

useRef

在多次渲染之间共享数据。

useMemo 的 API:(我们可以把 useRef 看作是在函数组件之外创建的一个容器空间。在这个容器上,我们可以通过唯一的 current 属性设置一个值,从而在函数组件的多次渲染之间共享这个值)

useRef(initialValue);

useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的。

除了存储跨渲染的数据之外,useRef 还有一个重要的功能,就是保存某个 DOM 节点的引用。

在 React 中,几乎不需要关心真实的 DOM 节点是如何渲染和修改的。

但是在某些场景中,我们必须要获得真实 DOM 节点的引用,所以结合 React 的 ref 属性和 useRef 这个 Hook,我们就可以获得真实的 DOM 节点,并对这个节点进行操作。

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 属性指向了真实的 input 这个 DOM 节点,从而可以调用 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useContext

React 提供了 Context 这样一个机制,能够让所有在某个组件开始的组件树上创建一个 Context。

这样这个组件树上的所有组件,就都能访问和修改这个 Context 了。

useContext 的 API:

const value = useContext(MyContext);

用 React.createContext API 创建一个 Context,作为组件树的跟组件


const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};
// 创建一个 Theme 的 Context

const ThemeContext = React.createContext(themes.light);
function App() {
  // 整个应用使用 ThemeContext.Provider 作为根组件
  return (
    // 使用 themes.dark 作为当前 Context 
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{
      background: theme.background,
      color: theme.foreground
    }}>
      I am styled by theme context!
    </button>
  );
}

缺点:

  • 会让调试变得困难,因为你很难跟踪某个 Context 的变化究竟是如何产生的。
  • 让组件的复用变得困难,因为一个组件如果使用了某个 Context,它就必须确保被用到的地方一定有这个 Context 的 Provider 在其父组件的路径上。

看相关资料的时候,遇到的一些读者提问与作者或网友回答:

Q:Context 看上去就是一个全局的数据,为什么要设计这样一个复杂的机制,而不是直接用一个全局的变量去保存数据呢?

A:为了能够进行数据的绑定。当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新。

正确理解函数组件的生命周期

在函数组件中要思考的方式永远是:当某个状态发生变化时,我要做什么,而不再是在 Class 组件中的某个生命周期方法中我要做什么。

类组件的生命周期在函数组件中的存在形式

  • 构造函数

构造函数的本质,其实就是:在所以其它代码执行之前的一次性初始化工作。

函数组件基本上没有统一的初始化需要,因为 Hooks 自己会负责自己的初始化。

比如 useState 这个 Hook,接收的参数就是定义的 State 初始值。

而在过去的类组件中,你通常需要在构造函数中直接设置 this.state ,也就是设置某个值来完成初始化。

但类组件中构造函数能做的不只是初始化 State,还可能有其它的逻辑。

这时便可以利用 useRef 这个 Hook,实现一个 useSingleton 这样的一次性执行某段代码的自定义 Hook,代码如下:

import { useRef } from 'react';

function useSingleton(callback) {
  const called = useRef(false); 

  if (called.current) return;

  callBack();

  called.current = true;
}

这时可能会有疑问了,为什么不直接使用useEffect,然后依赖传递空数组,也是可以实现只执行一次的功能。

请记住构造函数的本质是所以其它代码执行之前的一次性初始化工作,而useEffect是首次render之后才执行的副作用。

所以在日常开发中,是无需去将功能映射到传统的生命周期的构造函数的概念,而是要从函数的角度出发,去思考功能如何去实现。

三种生命周期方法

在类组件中,componentDidMount,componentWillUnmount,和 componentDidUpdate三种生命周期方法基本可以统一到函数组件的 useEffect 这个 Hook。

正如 useEffect 的字面含义,它的作用就是触发一个副作用,即在组件每次 render 之后去执行。

博客文案案例:

假设当文章 id 发生变化时,我们不仅需要获取文章,同时还要监听某个事件,这样在有新的评论时获得通知,就能显示新的评论了。

import React, { useEffect } from 'react';
import comments from './comments';

function BlogView({ id }) {
  const handleCommentsChange = useCallback(() => {
    // 处理评论变化的通知
  }, []);

  useEffect(() => {
    // 获取博客内容
    fetchBlog(id);
    // 监听指定 id 的博客文章的评论变化通知
    const listener = comments.addListener(id, handleCommentsChange);
    
    return () => {
      // 当 id 发生变化时,移除之前的监听
      comments.removeListener(listener);
    };
  }, [id, handleCommentsChange])
}

差异点:

  • useEffect(callback) 这个 Hook 接收的 callback,只有在依赖项变化时才被执行。而传统的 componentDidUpdate 则一定会执行。这样来看,Hook 的机制其实更具有语义化,因为过去在 componentDidUpdate 中,我们通常都需要手动判断某个状态是否发生变化,然后再执行特定的逻辑。
  • callback 返回的函数(一般用于清理工作)在下一次依赖项发生变化以及组件销毁之前执行,而传统的 componentWillUnmount 只在组件销毁时才会执行。

其它的生命周期方法

目前 Hooks 还没法实现这些功能。因此如果必须用到,你的组件仍然需要用类组件去实现。

已有应用是否应该迁移到 Hooks?

React 组件的两种写法本身就可以很好地一起工作:

  • 类组件和函数组件可以互相引用
  • Hooks 很容易就能转换成高阶组件,并供类组件使用

我们完全没必要为了迁移而迁移

看相关资料的时候,遇到的一些读者提问与作者或网友回答:

Q:博客文章案例,为什么要把useCallback返回的函数作为useEffect的依赖呢?是要达到什么目的吗?

A:所有在 useEffect 中用到的变量都应该被作为依赖的参数,这样在 useEffect 执行的时候才会执行正确的回调函数。

自定义Hooks

要用好 React Hooks,很重要的一点,就是要能够从 Hooks 的角度去思考问题。要做到这一点其实也不难,就是在遇到一个功能开发的需求时,首先问自己一个问题:这个功能中的哪些逻辑可以抽出来成为独立的 Hooks?

创建自定义 Hooks

Hooks 和普通函数在语义上的区别,就在于函数中有没有用到其它 Hooks。

计数器案例:

在src文件下新家pages文件夹并在其内新建counter文件夹,在counter文件内新增index.js,use-counter.js文件,并在app.js内引入counter组件

import { useState, useCallback }from 'react';
 
export const useCounter = () => {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => setCount(count + 1), [count]);

  const decrement = useCallback(() => setCount(count - 1), [count]);

  const reset = useCallback(() => setCount(0), []);

  return { count, increment, decrement, reset };
}

在组件中使用它

import React from 'react';
import { useCounter } from './user-counter';

export const Counter = () => {
  const { count, increment, decrement, reset } = useCounter();

  return (
    <div>
      <button onClick={decrement}> - </button>
      <p>{count}</p>
      <button onClick={increment}> + </button>
      <button onClick={reset}> reset </button>
    </div>
  );
}

App.js引入

import './App.css';
import { Counter } from './pages/counter'

function App() {
  return (
    <div className="App">
      <Counter />
    </div>
  );
}

export default App;

启动服务打开浏览器,就会发现计数器可以正常使用了

image.png

在上述例子中,把原来在函数组件中实现的逻辑提取了出来,成为一个单独的 Hook,

一方面能让这个逻辑得到重用,另外一方面也能让代码更加语义化,并且易于理解和维护。

并且可以看出自定义Hooks的两个特点:

  • 名字一定是以 use 开头的函数,这样 React 才能够知道这个函数是一个 Hook
  • 函数内部一定调用了其它的 Hooks,可以是内置的 Hooks,也可以是其它自定义 Hooks。这样才能够让组件刷新,或者去产生副作用。

以下是三个典型的案例

封装通用逻辑:useAsync

组件的开发过程中,有一些常用的通用逻辑,可能会因为逻辑重用比较繁琐,而经常在每个组件中去自己实现,造成维护的困难。

但现在有了 Hooks,就可以将更多的通用逻辑通过 Hooks 的形式进行封装,方便被不同的组件重用。

异步请求案例:

新建async文件夹并在该文件夹内新建index.js,use-async.js文件,然后在app.js内引入该组件

import { useState, useCallback } from 'react';

export const useAsync = (asyncFunction) => {

  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(() => {
    setLoading(true);
    setData(null);
    setError(null);

    return asyncFunction()
      .then((response) => {
        setData(response);
        setLoading(false);
      })
      .catch((error) => {
        setError(error);
        setLoading(false);
      });
  }, [asyncFunction]);

  return { execute, loading, data, error };
};

在组件中使用它

import React, { useEffect, useCallback } from "react";
import axios from 'axios'
import { useAsync } from './use-async';

export const Async = () =>{
  const fetch = useCallback( async() => {
    const data = await axios.get('4245352');
    return data;
  }, [])

  const {
    execute: fetchData,
    data,
    loading,
    error,
  } = useAsync(fetch);

  console.log(loading, error, data)

  useEffect(() => {
    fetchData()
  }, [fetchData])
  
  return null
}

App.js引入

import './App.css';
import { Counter, Async } from './pages'

function App() {
  return (
    <div className="App">
      <Counter />
      <Async />
    </div>
  );
}

export default App;

可以在控制台看到,状态被成功改变了

image.png

通过这个例子可以看到,利用了 Hooks 能够管理 React 组件状态的能力,将一个组件中的某一部分状态独立出来,从而实现了通用逻辑的重用。

这种类型的封装为什么不直接写一个工具方法?为什么一定要通过 Hooks 进行封装呢?

因为在普通的工具类中是无法直接修改组件 state 的,那么也就无法在数据改变的时候触发组件的重新渲染。

监听浏览器状态:useScroll

正如 Hooks 的字面意思是“钩子”,它带来的一大好处就是:可以让 React 的组件绑定在任何可能的数据源上。这样当数据源发生变化时,组件能够自动刷新

把这个好处对应到滚动条位置这个场景就是:组件需要绑定到当前滚动条的位置数据上。

返回案例:

新建scroll文件夹并在该文件夹内新建index.js,use-scroll.js文件,然后在app.js内引入该组件

import { useState, useEffect } from 'react';

const getPosition = () => {
  return {
    x: document.documentElement.scrollLeft,
    y: document.documentElement.scrollTop,
  };
};

export const useScroll = () => {
  const [position, setPosition] = useState(getPosition());

  useEffect(() => {
    const handler = () => {
      setPosition(getPosition());
    };

    document.addEventListener("scroll", handler);
    
    return () => {
      document.removeEventListener("scroll", handler);
    };
  }, []);

  return position;
};

在组件中使用它

import React, { useCallback } from 'react';
import { useScroll } from './use-scroll';

export const  Scroll = () => {
  const { y } = useScroll();

  const goTop = useCallback(() => {
    document.documentElement.scrollTop = 0;
  }, []);

  const style = {
    position: "fixed",
    right: "10px",
    bottom: "10px",
  };

  console.log(y)

  if (y > 300) {
    return (
      <button onClick={goTop} style={style}>
        Back to Top
      </button>
    );
  }
  
  return null;
}

App.js引入

import './App.css';
import { Counter, Async, Scroll } from './pages'

function App() {
  return (
    <div className="App">
      <Counter />
      <Async />
      <Scroll />
    </div>
  );
}

export default App;

结果:

image.png

拆分复杂组件

怎么才能让函数组件不会太过冗长呢?做法很简单,就是尽量将相关的逻辑做成独立的 Hooks,然后在函数组中使用这些 Hooks,通过参数传递和返回值让 Hooks 之间完成交互。

业务隔离案例(伪代码)

设想现在有这样一个需求:我们需要展示一个博客文章的列表,并且有一列要显示文章的分类。同时,我们还需要提供表格过滤功能,以便能够只显示某个分类的文章。

如果按照直观的思路去实现,通常都会把逻辑都写在一个组件里

function BlogList() {
  // 获取文章列表...
  // 获取分类列表...
  // 组合文章数据和分类数据...
  // 根据选择的分类过滤文章...
  
  // 渲染 UI ...
}

把 Hooks 就看成普通的函数,能隔离的尽量去做隔离,从而让代码更加模块化,更易于理解和维护。

import React, { useEffect, useCallback, useMemo, useState } from "react";
import { Select, Table } from "antd";
import _ from "lodash";
import useAsync from "./useAsync";

const endpoint = "https://myserver.com/api/";
const useArticles = () => {
  // 使用上面创建的 useAsync 获取文章列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/posts`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);
  // 返回语义化的数据结构
  return {
    articles: data,
    articlesLoading: loading,
    articlesError: error,
  };
};
const useCategories = () => {
  // 使用上面创建的 useAsync 获取分类列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/categories`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);

  // 返回语义化的数据结构
  return {
    categories: data,
    categoriesLoading: loading,
    categoriesError: error,
  };
};
const useCombinedArticles = (articles, categories) => {
  // 将文章数据和分类数据组合到一起
  return useMemo(() => {
    // 如果没有文章或者分类数据则返回 null
    if (!articles || !categories) return null;
    return articles.map((article) => {
      return {
        ...article,
        category: categories.find(
          (c) => String(c.id) === String(article.categoryId),
        ),
      };
    });
  }, [articles, categories]);
};
const useFilteredArticles = (articles, selectedCategory) => {
  // 实现按照分类过滤
  return useMemo(() => {
    if (!articles) return null;
    if (!selectedCategory) return articles;
    return articles.filter((article) => {
      console.log("filter: ", article.categoryId, selectedCategory);
      return String(article?.category?.name) === String(selectedCategory);
    });
  }, [articles, selectedCategory]);
};

const columns = [
  { dataIndex: "title", title: "Title" },
  { dataIndex: ["category", "name"], title: "Category" },
];

export default function BlogList() {
  const [selectedCategory, setSelectedCategory] = useState(null);
  // 获取文章列表
  const { articles, articlesError } = useArticles();
  // 获取分类列表
  const { categories, categoriesError } = useCategories();
  // 组合数据
  const combined = useCombinedArticles(articles, categories);
  // 实现过滤
  const result = useFilteredArticles(combined, selectedCategory);

  // 分类下拉框选项用于过滤
  const options = useMemo(() => {
    const arr = _.uniqBy(categories, (c) => c.name).map((c) => ({
      value: c.name,
      label: c.name,
    }));
    arr.unshift({ value: null, label: "All" });
    return arr;
  }, [categories]);

  // 如果出错,简单返回 Failed
  if (articlesError || categoriesError) return "Failed";

  // 如果没有结果,说明正在加载
  if (!result) return "Loading...";

  return (
    <div>
      <Select
        value={selectedCategory}
        onChange={(value) => setSelectedCategory(value)}
        options={options}
        style={{ width: "200px" }}
        placeholder="Select a category"
      />
      <Table dataSource={result} columns={columns} />
    </div>
  );
}

通过这样的方式,把一个较为复杂的逻辑拆分成一个个独立的 Hook ,不仅隔离了业务逻辑,也让代码在语义上更加明确。

在函数中使用Redux

随着对 React 使用的深入,会发现组件级别的 state,和从上而下传递的 props 这两个状态机制,无法满足复杂功能的需要。

image.png

从这张对比图,我们可以看到 Redux Store 的两个特点:

  • Redux Store 是全局唯一的。即整个应用程序一般只有一个 Store。
  • Redux Store 是树状结构,可以更天然地映射到组件树的结构,虽然不是必须的。

三个基本概念

  • State 即 Store,一般就是一个纯 JavaScript Object。
  • Action 也是一个 Object,用于描述发生的动作。
  • Reducer 则是一个函数,接收 Action 和 State 并作为参数,通过计算得到新的 Store。

image.png

计数器案例:

import { createStore } from 'redux'

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'incremented':
      return { 
        ...state,
        value: state.value + 1 
      }
    case 'decremented':
      return {
        ...state,
        value: state.value - 1 
        }
    default:
      return state
  }
}

const store = createStore(counterReducer)

store.subscribe(() => console.log(store.getState()))

const incrementAction = { type: 'incremented' };
store.dispatch(incrementAction);

const decrementAction = { type: 'decremented' };
store.dispatch(decrementAction)

通过这段代码,我们就用三个步骤完成了一个完整的 Redux 的逻辑:

  • 先创建 Store;
  • 再利用 Action 和 Reducer 修改 Store;
  • 最后利用 subscribe 监听 Store 的变化。

注意:在 Reducer 中,我们每次都必须返回一个新的对象,确保不可变数据(Immutable)的原则。

  • 延展操作符
  • 第三方的库

React中使用Redux

Redux 和 React 建立联系(react-redux库):

  • React 组件能够在依赖的 Store 的数据发生变化时,重新 Render;
  • 在 React 组件中,能够在某些时机去 dispatch 一个 action,从而触发 Store 的更新。

在 react-redux 的实现中,为了确保需要绑定的组件能够访问到全局唯一的 Redux Store,利用了 React 的 Context 机制去存放 Store 的信息。通常我们会将这个 Context 作为整个 React 应用程序的根节点。

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Provider } from 'react-redux';
import store from './store'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

reportWebVitals();

这里使用了 Provider 这样一个组件来作为整个应用程序的根节点,并将 Store 作为属性传给了这个组件,这样所有下层的组件就都能够使用 Redux 了。

然后利用 react-redux 提供的 useSelector 和 useDispatch 这两个 Hooks来使用Redux。

计数器案例:

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

export function Counter() {
  const count = useSelector(state => state.value)

  const dispatch = useDispatch()

  return (
    <div>
      <button
        onClick={() => dispatch({ type: 'counter/incremented' })}
      >+</button>
      <span>{count}</span>
      <button
        onClick={() => dispatch({ type: 'counter/decremented' })}
      >-</button>
    </div>
  )
}

服务启动后,点击加减按钮就会发现,数据被成功更新了

image.png

通过例子,可以看到 React 和 Redux 共同使用时的单向数据流:

image.png

异步Action

在 Redux 的 Store 中,我们不仅维护着业务数据,同时维护着应用程序的状态。

middleware机制:

middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。在这个拦截器中,你可以自由处理获得的 action。无论是把这个 action 直接传递到 reducer,或者构建新的 action 发送到 reducer,都是可以的。

image.png

redux-thunk中间件:

redux-thunk 如果发现接受到的 action 是一个函数,那么就不会传递给 Reducer,而是执行这个函数,并把 dispatch 作为参数传给这个函数,从而在这个函数中你可以自由决定何时,如何发送 Action。

发送请求获取数据案例:

  • 没用异步Action
function DataList() {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }, []);
  
  const data = useSelector(state => state.data);
  const pending = useSelector(state => state.pending);
  const error = useSelector(state => state.error);
  
  if (error) return 'Error.';
  if (pending) return 'Loading...';
  return <Table data={data} />;
}
  • 异步Action

创建 Redux Store 时指定了 redux-thunk 这个中间件

import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import rootReducer from './reducer'

const composedEnhancer = applyMiddleware(thunkMiddleware)
const store = createStore(rootReducer, composedEnhancer)

dispatch action 时就可以 dispatch 一个函数用于来发送请求

function fetchData() {
  return dispatch => {
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }
}
import fetchData from './fetchData';

function DataList() {
  const dispatch = useDispatch();
  dispatch(fetchData());
}

异步 Action 并不是一个具体的概念,可以把它看作是 Redux 的一个使用模式。

它通过组合使用同步 Action ,在没有引入新概念的同时,用一致的方式提供了处理异步逻辑的方案。

状态一致性保证

保证最小化

某些数据如果能从已有的 State 中计算得到,那么就应该始终在用的时候去计算,而不要把计算的结果存到某个 State 中。

这样的话,才能简化状态处理逻辑。

避免中间状态,确保唯一数据源

当原始状态数据来自某个外部数据源,而非 state 或者 props 的时候,冗余状态就没那么明显。

这时候就需要准确定位状态的数据源究竟是什么,并且在开发中确保它始终是唯一的数据源,以此避免定义中间状态。

所以,在任何时候想要定义新状态的时候,都要问自己一下:这个状态有必要吗?是否能通过计算得到?是否只是一个中间状态?只有每次都仔细思考了,才能找到需要定义的最本质的状态。然后围绕这个最本质的状态去思考某个功能具体的实现,从而让 React 的开发更加简洁和高效。

看相关资料的时候,遇到的一些读者提问与作者或网友回答:

Q: 监听 url 变化(这里还得看具体文章了解一下)

A: 通过监听 pushstate、replaceState 等事件,对状态进行同步,但浏览器默认不支持监听 pushState(pushState,replaceState 并不会触发popstate事件),所以需要我们对 history 对象做一层劫持(github.com/streamich/r…

const patchHistoryMethod = (method) => {
  const history = window.history;
  const original = history[method];

  history[method] = function (state) {
    const result = original.apply(this, arguments);
    const event = new Event(method.toLowerCase());

    (event as any).state = state;

    window.dispatchEvent(event);

    return result;
  };
};

if (isBrowser) {
  patchHistoryMethod('pushState');
  patchHistoryMethod('replaceState');
}

异步处理

虽然发请求拿数据有很多种做法,但基本上都会遵循一定的规律。

实现 API Client

在开始实现第一个请求的时候,通常要做的第一件事应该都是创建一个 API Client,之后所有的请求都会通过这个 Client 发出去。(统一对需要连接的服务端做一些通用的配置和处理,比如 Token、URL、错误处理等等)

实现一个 API Client 需要考虑的因素:

  • 一些通用的 Header。
  • 服务器地址的配置。
  • 请求未认证的处理。

示例代码(axios)

import axios from "axios";

const endPoints = {
  test: "https://60b2643d62ab150017ae21de.mockapi.io/",
  prod: "https://prod.myapi.io/",
  staging: "https://staging.myapi.io/"
};

const instance = axios.create({
  baseURL: endPoints.test,
  timeout: 30000,
  headers: { Authorization: "Bear mytoken" }
});

instance.interceptors.response.use(
  (res) => {
    return res;
  },
  (err) => {
    if (err.response.status === 403) {
      document.location = '/login';
    }
    return Promise.reject(err);
  }
);

export default instance;

定义了一个简单的 API Client,之后所有的请求都可以通过 Client 连接到指定的服务器,从而不再需要单独设置 Header,或者处理未授权的请求了。

使用 Hooks 进行异步请求

从 Hooks 角度来说,Get 请求就是一个远程数据源。那么把这个数据源封装成 Hooks 后,使用远程 API 将会非常方便。

只是和本地数据不同的地方在于,它有三个状态,分别是:

  • Data: 指的是请求成功后服务器返回的数据;
  • Error: 请求失败的话,错误信息将放到 Error 状态里;
  • Pending: 请求发出去,在返回之前会处于 Pending 状态;

image.png

文章显示案例:(只显示文章详情)

异步请求Hooks

import { useState, useEffect } from "react";
import apiClient from "../../api-client";

export const useArticle = (id) => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    useEffect(() => {
        setLoading(true);
        setData(null);
        setError(null);
        apiClient
            .get(`/posts/${id}`)
            .then((res) => {
                setLoading(false);
                setData(res.data);
            })
            .catch((err) => {
                setLoading(false);
                setError(err);
            });
    }, [id]);

    return {
        loading,
        error,
        data
    };
};

文章渲染组件

import { useArticle } from "./use-article";

export const ArticleView = ({ id }) => {
    const { data, loading, error } = useArticle(id);

    if (error) return "Failed.";

    if (!data || loading) return "Loading...";

    return (
        <div className="exp-09-article-view">
            <h1>
                {id}. {data.title}
            </h1>
            <p>{data.content}</p>
        </div>
    );
};

启动服务器,可以看到文章内容被返回了

image.png

在项目中,可以把每一个 Get 请求都做成这样一个 Hook。

数据请求和处理逻辑都放到 Hooks 中,从而实现 Model 和 View 的隔离,不仅代码更加模块化,而且更易于测试和维护。

处理并发或串行请求

文章显示的例子中,我们只是简单显示了文章的内容,若还需需要显示作者、作者头像,以及文章的评论列表。

那么,作为一个完整的页面,就需要发送三个请求:

  • 获取文章内容;
  • 获取作者信息,包括名字和头像的地址;
  • 获取文章的评论列表;

用传统的思路去实现,可能会写下这样一段代码:

const [article, comments] = await Promise.all([
  fetchArticle(articleId),
  fetchComments(articleId)
]);

const user = await fetchUser(article.userId);

但是React 函数组件是一个同步的函数,没有办法直接使用 await 这样的同步方法,而是要把请求通过副作用去触发。

这样便会造成业务逻辑比较复杂。

React 的本质,那就是状态驱动 UI。利用这个机制,通过不同的状态组合,来实现异步请求的逻辑。

文章显示案例:

useUser 这个 Hook 上面的 useArticle 这个 Hook,唯一的变化就是在 useEffect 里加入了ID 是否存在的判断

import { useState, useEffect } from "react";
import apiClient from "../../api-client";

export const useUser = (id) => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    useEffect(() => {
        if (!id) return;

        setLoading(true);
        setData(null);
        setError(null);

        apiClient
            .get(`/users/${id}`)
            .then((res) => {
                setLoading(false);
                setData(res.data);
            })
            .catch((err) => {
                setLoading(false);
                setError(err);
            });
    }, [id]);

    return {
        loading,
        error,
        data
    };
};

文章显示组件

import { useArticle } from "./use-article";
import { useUser } from "./use-users";
import { useComments } from "./use-comments";

const CommentList = (data) => {
    console.log(data)
    return <ul>
        <li>{data?.data?.content}</li>
    </ul>
}

export const ArticleView = ({ id }) => {
    const { data: article, loading, error } = useArticle(id);
    const { data: comments } = useComments(id);
    const { data: user } = useUser(article?.userId);

    if (error) return "Failed.";

    if (!article || loading) return "Loading...";

    return (
        <div className="exp-09-article-view">
            <h1>
                {id}. {article.title}
            </h1>
            {user && (
                <div className="user-info">
                    <img src={user.avatar} height="40px" alt="user" />
                    <div>{user.name}</div>
                    <div>{article.createdAt}</div>
                </div>
            )}
            <p>{article.content}</p>
            <CommentList data={comments || []} />
        </div>
    );
};

服务启动后,就可以看到页面正常显示了

image.png

若需要点击某个按钮才去获取数据,可以在 Hook 中将获取数据的方法 return 出去,供外部自由调用。