React-Redux 技术分享

1,412 阅读10分钟

前言

在上一节 Redux 技术分享 中介绍了 Redux 基本使用及底层源码实现。

现在,我们要在 React 项目中,将 Redux 提供的数据接入到 React 组件中使用,React-Redux 可以完成这件事情。

目前普遍使用 Hook 进行开发,本节将围绕 Hooks 相关 API 来使用和学习 React-Redux(v7.2.8) 及其原理。

与之相关的两篇文章可以翻阅这里:

  1. Redux 技术分享
  2. 从源码角度理解 React.Context

一、基本用法

1、安装:

yarn add redux react-redux

2、看一个加减数 Demo:

// store.js
import { createStore } from 'redux';

const iniState = {
  count: 0
}
function reducer(state = iniState, action) {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "DECREMENT":
      return { ...state, count: state.count - 1 };
    default:
      return state;
  }
}

let store = createStore(reducer);
export default store;

// index.js
import React from 'react';
import ReactDom from 'react-dom';
import { Provider, useDispatch, useSelector } from 'react-redux';
import store from './store';

function App() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>点击 + 1</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>点击 - 1</button>
    </div>
  )
}

ReactDom.render(
  <Provider store={store}>
    <App />
  </Provider>, 
  document.getElementById('root')
);

从 Demo 中我们使用了 React-Redux 提供的三个属性方法:Provider, useDispatch, useSelector,而这三个 API 足以满足在 Hooks 开发下的使用需求。

  • Provider:提供者,类似于 React Context 下的 Provider 对象,它可以接受一个 store 作为 prop,可将 store 中的 value 向下传递;
  • useDispatch:和 Redux dispatch 作用一致,用于派发 action 来更新 store 中的 state;
  • useSelector:用于在函数组件中获取 Redux store 中的 state,在 selecotr 选择的 state 更新后,组件会进行重渲染。

相信上面这个简单的 Demo 大家都能够看明白。下面我们开始从源码入手,理解 React-Redux 的运行机制。

二、源码分析

1、核心方法概览:

import React, { useMemo, useContext, useEffect, useLayoutEffect } from 'react';

// react-redux 内部实现了一套订阅机制,用于订阅 redux store 中的 state 在发生变化后,
// 更新 state 对应的 React 消费组件
import Subscription from './Subscription';

// React Context 对象
const ReactReduxContext = React.createContext(null);

// 提供者:内部会使用 Context 对象返回的一个 Provider React 组件
function Provider({ store, context, children }) {}

// 获取 React Context 对象
function useReduxContext() {}

// 获取 Provider value 提供的 Redux store 对象
function useStore() {}

// 获取 Redux dispatch 方法
function useDispatch() {}

// 被 useSelector 所使用,用于为 useSelector 返回的 state 创建订阅器,来监听状态变化去更新视图
function useSelectorWithStoreAndSubscription(...args) {}

// 默认的一个比较新老状态函数,决定更新视图的规则
const refEquality = (a, b) => a === b;

// 选择器,让函数组件可以拿到 Redux store 中的 state 数据
function useSelector(selector, equalityFn = refEquality) {}

export {
  Provider,
  useStore,
  useDispatch,
  useSelector,
  ReactReduxContext,
}

2、Provider

React-Redux 提供的 Provider 是基于 React Context Provider 封装而成。

它接收一个 store 作为 props(在 Context.Provider 中是 value 作为 props),而这个 store 就是我们熟悉的 Redux store 对象。

ReactDom.render(
  <Provider store={store}>
    <App />
  </Provider>, 
  document.getElementById('root')
);

下面我们来看看 Provider 内部实现:

import React, { useMemo, useEffect } from 'react';

// 1、要创建 React Context 对象
const ReactReduxContext = React.createContext(null);

function Provider({ store, context, children }) {
  // 2、提供一个 contextValue
  const contextValue = useMemo(() => {
    // 2-1、创建一个根订阅器,react-redux 的更新机制借助于「发布订阅」实现
    const subscription = new Subscription(store);
    // 2-2、onStateChange 用于在 state 发生变化后执行的回调
    // 对于根订阅器,在 state 发生变化后,执行 notifyNestedSubs 通知所有子订阅器(每一个 useSelector)
    subscription.onStateChange = subscription.notifyNestedSubs;
    return {
      store,
      subscription,
    }
  }, [store]);

  // 3、开启订阅器的工作
  useEffect(() => {
    const { subscription } = contextValue;
    // 在这里要开启订阅工作。本质是调用 Redux 提供的 store.subscribe 来订阅一个可监听 state 的 listener
    subscription.trySubscribe();

    return () => {
      subscription.tryUnsubscribe(); // 销毁订阅器的监听 store.unsubscribe
      subscription.onStateChange = null;
    }
  }, []);

  const Context = context || ReactReduxContext;

  // 4、返回 Context.Provider
  return <Context.Provider value={contextValue}>{children}</Context.Provider>
}

从上面源码分析 + 注释,可以分为四块内容:

  1. 创建一个 react context 对象;
  2. 创建一个 contextValue,其中包含了 store 和 一个订阅器;
  3. 开启订阅器工作,redux 提供了监听 state 变化的 subscribe,这里本质上是通过 redux store.subscribe(redux 的功能) 监听 state 变化,当 state 发生变化时,react-redux 会处理指定组件的重渲染;(最最最核心实现)
  4. return 返回 context.provider,将 contextValue 向下传递。

有关 Subscription 实例的相关方法会在下面进行讲解。

3、useReduxContext

通过 React Hooks API useContext 来获取 contextValue,即上面 Provider 中提供的具有 storesubscription 属性的对象。

import React, { useContext } from 'react';

const ReactReduxContext = React.createContext(null);

function useReduxContext() {
  const contextValue = useContext(ReactReduxContext);
  return contextValue;
}

4、useStore

上面 useReduxContext 中既然可以拿到 contextValue 对象,自然可以从中获取 Redux store

function useStore() {
  const { store } = useReduxContext();
  return store;
}

5、useDispatch

既然 useStore 可以拿到 Redux store,自然可以从中拿到 store.dispatch 方法:

function useDispatch() {
  const store = useStore();
  return store.dispatch;
}

6、useSelector

useSelector 可以类比为 useContext,用来读取 context.value,即 store.state

它允许传递一个函数(selector)作为参数,内部会将 store.state 作为此函数的参数,用户可以自行根据 store.state 决定组件内要使用的数据。

const count = useSelector(state => state.count);

我们来分析下源码。

const refEquality = (a, b) => a === b;

// equalityFn 是一个比较函数,可自定义比较新老状态,决定更新视图的规则。
function useSelector(selector, equalityFn = refEquality) {
  // 1、获取 store 对象
  const { store, subscription: contextSub } = useReduxContext();
  
  // 2、执行传入的 selector 拿到 selectedState,并且借助订阅机制实现 selectedState 更新组件重渲染
  const selectedState = useSelectorWithStoreAndSubscription(
    selector,
    equalityFn,
    store,
    contextSub
  );
  return selectedState;
}

真正的核心实现是在 useSelectorWithStoreAndSubscription 中。

function useSelectorWithStoreAndSubscription(
  selector,
  equalityFn,
  store,
  contextSub, // 上下文订阅器(应用的根订阅器)
) {
  const [, forceRender] = useReducer((s) => s + 1, 0);

  // 1、为 selector 也创建一个订阅器,并将 contextSub(根订阅器) 作为 parentSub
  const subscription = useMemo(() => new Subscription(store, contextSub), [
    store,
    contextSub,
  ]);

  // 存储变量,避免变量在每次更新都重新创建,所以用到 useRef 来持久化变量
  const latestSelector = useRef(); // 最新的选择器方法
  const latestStoreState = useRef(); // 最新的仓库状态
  const latestSelectedState = useRef(); // 最新的选择器返回的state

  // 2、拿到 store.state
  const storeState = store.getState();
  let selectedState;

  // 3、执行 selector 获取需要的 state 数据
  if (
    selector !== latestSelector.current ||
    storeState !== latestStoreState.current
  ) {
    selectedState = selector(storeState); // 初渲染时执行一次
  } else {
    selectedState = latestSelectedState.current; // 重渲染时读取缓存
  }

  // 每次执行都存储最新的数据
  useLayoutEffect(() => {
    latestSelector.current = selector;
    latestStoreState.current = storeState;
    latestSelectedState.current = selectedState;
  });

  // 4、提供一个子订阅器,当「根订阅器」收到更新通知,会执行子订阅器的 checkForUpdates,
  // 来校验当前组件是否需要被更新
  useLayoutEffect(() => {
    function checkForUpdates() {
      const newStoreState = store.getState();
      // Avoid calling selector multiple times if the store's state has not changed
      // 这里很关键,如果 state 引用地址未改变,不进行重渲染。
      if (newStoreState === latestStoreState.current) {
        return;
      }
  
      // 执行 selector fn,拿到返回的 state
      const newSelectedState = latestSelector.current(newStoreState);
      // 如果 state 前后没有发生变化,取消更新
      if (equalityFn(newSelectedState, latestSelectedState.current)) {
        return;
      }
      // selector state 发生变化,重渲染组件
      latestSelectedState.current = newSelectedState;
      forceRender();
    }

    // 子订阅器的 onStateChange 指向一个更新机制函数,用于触发组件重渲染
    subscription.onStateChange = checkForUpdates;
    subscription.trySubscribe(); // 开启订阅

    return () => subscription.tryUnsubscribe();
  }, [store]);

  return selectedState;
}

从上面源码分析 + 注释,可以分为四块内容:

  1. 使用 useContext 先读取到 contextValue.store 对象;
  2. useSelecotr 创建一个 subscription,它的目的并不是像根订阅器那样去订阅 redux store.state,而是关联「根订阅器」,由根订阅器通知「子订阅器」进行更新;
  3. 拿到 store.state 传入并执行 selector 返回用户需要的 state 数据;
  4. 提供一个 state 变化比较函数,在发生更新时,若 state 前后存在变化,执行 forceRender 进行组件重渲染。

这样,当根订阅器监听到 Redux store state 发生变化,通知这些子订阅器,执行 equalityFn 判断是否要进行组件更新,进而触发组件更新渲染。

这里要明确一下:redux dispatch 被触发后,会执行 reducer 纯函数计算新的 state,即使 state 引用地址没有变化,也会执行「根订阅器」在 store.subscribe 中订阅的回调。

当「根订阅」通知「子订阅」并进入到 checkForUpdates 方法后,会在这里比较一次 state 前后是否发生变化。若未发生变化,不会进行后续组件重渲染逻辑,这也是为什么我们要在 reducer 中进行更新时,返回一个全新 state 对象。

不过,要彻底了解数据变化组件重渲染更新机制,需要深入理解 Subscription 的实现。

三、Subscription

Subscription 是 React-Redux 内部实现的一套 订阅 store.state 更新 机制。这里我们先梳理一下上面已经露面的 subscription 属性和方法:

  1. 实例对象:new Subscription(store)
  2. 开启订阅并监听 store.statesubscription.trySubscribe()
  3. 销毁订阅:subscription.tryUnsubscribe()
  4. 「根订阅」通知「子订阅」:subscription.notifyNestedSubs
  5. state 变化后的处理函数:subscription.onStateChange

接下来我们从源码中以上几个维度进行分析:

1、构造函数

构造函数接收 store 和 「根订阅器」作为参数,当 store.state 发生变化,会执行 handleChangeWrapper 进而执行的是 onStateChange

const nullListeners = { notify() {} };
export default class Subscription {
  constructor(store, parentSub) {
    this.store = store;
    this.parentSub = parentSub;
    this.unsubscribe = null;
    this.listeners = nullListeners;
    this.handleChangeWrapper = this.handleChangeWrapper.bind(this);
  }
  // state 发生变化,执行组件 listener 回调
  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange();
    }
  }
  ...
}

2、进行订阅

通过调用 subscription.trySubscribe() 开启订阅。

// 开启订阅
trySubscribe() {
  if (!this.unsubscribe) {
    this.unsubscribe = this.parentSub
      ? this.parentSub.addNestedSub(this.handleChangeWrapper)
      : this.store.subscribe(this.handleChangeWrapper); // store 监听状态变化
    this.listeners = createListenerCollection();
  }
}
  1. 如果是「根订阅器」,它会通过 redux store.subscribe 监听 state,在发生变化后执行 onStateChange
  2. 如果是「子订阅器」,它会将 onStateChange 关联到「根订阅器」中,这样只需订阅一次 store.state,在 state 发生变化后,统一由「根订阅器」来通知到它们。

其中,订阅器之间进行关联的逻辑 addNestedSub

// 供父订阅实例来添加子级实例的监听函数
addNestedSub(listener) {
  this.trySubscribe(); // 这里可以不关心它,因为只会开启一次订阅器工作
  return this.listeners.subscribe(listener);
}

在上面 useSelector 中我们看到,「子订阅器」的 onStateChange 是一个校验更新函数 checkForUpdates

「根订阅器」的 onStateChange 用来通知执行子订阅器,它的实现在 notifyNestedSubs 中。

notifyNestedSubs() {
  this.listeners.notify();
}

现在,最神秘的莫过于 this.listeners,我们来看看 createListenerCollection 的实现。

这里,会通过 链表 来串联组织每个 listener 监听函数,按链表顺序依次执行每一个 listener。

function createListenerCollection() {
  // 链表指针,管理监听函数的执行
  let first = null;
  let last = null;

  return {
    clear() {
      first = null
      last = null
    },
    
    notify() {
      let listener = first;
      while (listener) {
        listener.callback();
        listener = listener.next;
      }
    },

    subscribe(callback) {
      let isSubscribed = true;

      let listener = (last = {
        callback,
        next: null,
        prev: last,
      })

      // 加入链表队列
      if (listener.prev) {
        listener.prev.next = listener;
      } else {
        first = listener;
      }

      return function unsubscribe() {
        if (!isSubscribed || first === null) return;
        isSubscribed = false;

        // 将 listener 从链表中移除
        if (listener.next) {
          listener.next.prev = listener.prevl;
        } else {
          last = listener.prev;
        }
        // 重新建立链表关系
        if (listener.prev) {
          listener.prev.next = listener.next
        } else {
          first = listener.next
        }
      }
    },
  }
}

这里可以分两步分析:

  1. 「根订阅器」执行 listeners.subscribe(listener) 将子订阅器的 onStateChange 组合成一个链表;
  2. store.state 数据发生更新后,「根订阅器」调用 listeners.notify() 执行链表触发一个个子订阅器 onStateChange

这样,在 redux store.state 发生更新后通知到「根订阅器」,根订阅器再通知每个「子订阅器」执行 onStateChange,进而执行 checkForUpdates,如果组件 selector 的 state 发生了变化,组件将进行重渲染。

3、销毁订阅

做的是数据重置工作。

tryUnsubscribe() {
  if (this.unsubscribe) {
    this.unsubscribe();
    this.unsubscribe = null;
    this.listeners.clear();
    this.listeners = nullListeners;
  }
}

四、思考:useSelector 是如何只在我们想要的时候触发更新的?

你有没有想过这样一个问题:为什么 react-redux 不直接使用 useContext API,而要自己实现一个 useSelector API ?

从源码角度理解 React.Context 一文中我们知道,若想通过 useContext 方式读取最新数据,必须让所在组件进行重渲染,这要求 Provider 必须返回一个全新 value 对象引用。

这存在一个问题:一次更新,会将所有使用了 Provider value 数据的组件都进行重渲染,这会导致出现很大性能问题,这也是 React.context 一大弊端。

react-redux 并没有这个问题,它只会让组件所 selector 的数据在发生更新时进行重渲染。它是如何绕过 context 更新,并实现一套高性能更新策略呢?

  1. 首先,提供给 Context.Provider 的 value 对象地址不会发生变化,这使得子组件中使用了 useSelector -> useContext,但不会因顶层数据而进行重渲染。
  2. 那么 store.state 数据变化组件如何更新呢?其实 react-redux 订阅了 redux store.state 发生更新的动作(new Subscription),然后通知依赖此变量的组件「按需」执行重渲染(subscription.onStateChange = checkForUpdates)。

文末

感谢阅读,本文在编写中如有不足的地方,欢迎读者提出宝贵意见。