Zustand 源码阅读计划(2)- JS 篇 - React 框架适配逻辑

6 天前(已编辑)
/ ,
9

阅读此文章之前,你可能需要首先阅读以下的文章才能更好的理解上下文。

Zustand 源码阅读计划(2)- JS 篇 - React 框架适配逻辑

为什么需要框架适配?

为了提升开发体验,能够更好地利用框架的特性和框架的调试能力

完整代码

'use strict';

var React = require('react');
var vanilla = require('zustand/vanilla');

const identity = (arg) => arg;
function useStore(api, selector = identity) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState())
  );
  React.useDebugValue(slice);
  return slice;
}
const createImpl = (createState) => {
  const api = vanilla.createStore(createState);
  const useBoundStore = (selector) => useStore(api, selector);
  Object.assign(useBoundStore, api);
  return useBoundStore;
};
const create = (createState) => createState ? createImpl(createState) : createImpl;

exports.create = create;
exports.useStore = useStore;

设计思路

现在 React 中想要封装使用一个 ui 无关的功能,一般用什么?答案是用 hook。

所以状态管理也很适合这样的管理模式,我们可以且应该提供一个 hook 供开发者调用。

同时,也不能局限开发者只能在 React 组件 中使用状态管理,我们也希望可以在普通的函数中能够使用状态管理,而之前实现过的框架无关的核心逻辑能为我们实现这个需求。

因此,对 React 框架的适配,我们需要至少做到以下目标:

  1. 提供一个 hook,利用好 React 的特性,让 React 组件能够安全、高效地获取 store 中的值
  2. 提供组件外调用的支持,将 vallina 实现与 hook 合并,支持两种使用方式,减少心智负担

React 组件更新

使用原生的 store,最大的一个问题就是,在 React 的机制下如何更新渲染。我们知道,如果直接使用 let 之类定义的变量,修改后是不会反应在 UI 上的。只有 setState 这类 hook 定义的才能修改后正常渲染。

所以我们应该将 store,使用 setState 之类的 hook 进行处理,以保障正常渲染更新。

总结一下

大前提:在 React 中,数据改变需要刷新组件才能正常渲染。 当前情况:我们实现了原生的 store。 处理方式:我们需要实现一个 hook,监听 store 的变化,并触发 react 的刷新

setStateuseEffect

我们最容易想到的,就是使用 React 中的 setStateuseEffect。在 useEffect 中订阅 store,将获取的值使用 setState 存储,组件便会更新,在卸载的时候取消对 store 的订阅。

封装的 hook 示例如下:

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

const identity = (arg) => arg;
// 这是一个使用 `setState` 和 `useEffect` 的“传统”实现
function useLegacyStore(api, selector = identity) {
  // 1. 使用 useState 创建一个本地 state,用于存储和展示数据
  //    初始值直接从 store 中获取
  //    有用户的选择器就调用选择器,没有就调用默认值 identity 返回全量数据
  const [slice, setSlice] = useState(() => selector(api.getState()));

  useEffect(() => {
    // 2. 在 effect 中订阅 store 的变化
    const unsubscribe = api.subscribe(() => {
      // 4. 当 store 变化时,计算新的数据切片
      const newSlice = selector(api.getState());
      
      // 5. 检查新旧 slice 是否有变化,防止不必要的更新
      //    (这是一个重要的优化,Zustand 内部有类似 Object.is 的比较)
      if (newSlice !== slice) {
        // 6. 用新的 slice 更新本地 state,触发组件重渲染
        setSlice(newSlice);
      }
    });

    // 3. effect 的清理函数:在组件卸载时取消订阅
    return () => {
      unsubscribe();
    };
    
    // 依赖项数组:当 api 或 selector 变化时,需要重新执行 effect (重新订阅)
  }, [api, selector, slice]); // slice 也加入依赖项以在步骤5中获取最新的slice进行比较

  // 7. 返回当前的 state 切片
  return slice;
}

可能有些人会比较迷惑怎么用,我也给大家举个例子。

由上一篇文章的内容,我们知道 vallina.createStore 传入一个函数,能返回一个 api 对象 { setState, getState, getInitialState, subscribe },这些就是一个 store 最核心的功能。

我们的 React Hook,就是使用这些功能来做一层更适用于 React 的封装。

'use strict';

var React = require('react');
var vanilla = require('zustand/vanilla');

const createImpl = (createState) => {
  const api = vanilla.createStore(createState);
  // 使用 setState + useEffect 实现的 useLegacyStore
  const useBoundStore = (selector) => useLegacyStore(api, selector);
  Object.assign(useBoundStore, api);
  return useBoundStore;
};

const create = (createState) => createState ? createImpl(createState) : createImpl;

exports.create = create;
exports.useStore = useStore;

其实就是将完整代码中的 useStore 替换为我们实现的 useLegacyStore

那你应该就疑惑了,这两者的区别是什么呢?为什么不一样呢?那我们就来探索一下。

useSyncExternalStore

鉴于大部分人应该都没见过这个 api(包括我),所以我们先来讲解一下基础知识。

在 React 18 中,更新了并发特性。在并发模式下,React 可以暂停、恢复甚至放弃渲染,以便优先处理更高优先级的任务(如用户输入)。这种中断和恢复的机制,在与不受 React 控制的外部数据源(External Store)交互时,某些数据状态对 UI 有影响的时候,可能会引发内容“撕裂”(渲染不一致)的问题。

场景举例:

  1. React 开始渲染一个组件树,其中多个组件都依赖于同一个外部数据源(例如 Redux store)。
  2. 在渲染过程中,React 暂停了渲染,以响应一个紧急的用户操作。
  3. 在暂停期间,外部数据源的值发生了变化。
  4. React 恢复渲染,但此时,已经渲染的组件显示的是旧的数据,而接下来渲染的组件则显示了新的数据。例如一个禁用状态,刚开始的时候还没禁用,某些按钮能点击,后续渲染获取了禁用状态,后渲染的剩余按钮不能点击。

为了解决这个问题,提出了一个新的 api。既然流程不可控,那就弄一个流程可控的 api 出来。

useSyncExternalStore 的核心使命,就是提供一种同步的方式来读取外部数据源,并确保在整个 React 渲染周期中,所有组件读取到的都是同一个一致的快照(Snapshot),从而从根本上消除“撕裂”(渲染不一致)的问题。

用法:

const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  1. subscribe(callback): 这是一个函数,用于向外部数据源注册一个回调函数 (callback)。当外部数据源发生变化时,必须调用这个 callback 来通知 React 需要重新渲染。此函数还必须返回一个用于清理订阅的函数(unsubscribe)。React 会在组件卸载时或 subscribe 函数本身发生变化时调用这个清理函数。

  2. getSnapshot(): 这是一个函数,它的职责是返回当前外部数据源的一个快照。这个快照应该是不可变的(immutable)。在一次渲染过程中,即使外部数据源发生了变化,getSnapshot 也要保证返回同一个快照,确保渲染的一致性。React 可能会在一次渲染中多次调用 getSnapshot,所以它必须是一个纯函数,且执行速度要快。

  3. getServerSnapshot() (可选): 这是一个可选的函数,仅在服务端渲染(SSR)和混合渲染(Hydration)时使用。它的作用是在服务端生成组件时,获取初始的快照值,确保服务端渲染的内容与客户端首次渲染的内容一致,避免不匹配的警告。

在这里,subscribe(callback) 可能会让很多人还是想不明白如何实现通知更新的。我们来详细讲解一下流程。

export function useSyncExternalStore<Snapshot>(
    subscribe: (onStoreChange: () => void) => () => void,
    getSnapshot: () => Snapshot,
    getServerSnapshot?: () => Snapshot,
): Snapshot;

我们来看一下类型定义,可以看到,传入的 subscribe 函数,要求传入是一个函数,并且返回也是一个函数。这里传入的 subscribeuseSyncExternalStore 这个 hook 内部自行调用。

在组件创建的过程中,useSyncExternalStore 就会生成一个和该组件实例唯一绑定的 onStoreChange,作用就是更新组件,可以等效于 forceUpdate 这种 hook。到了处理组件副作用阶段时,就会调用 subscribe ,将 onStoreChange 传入其中。这样,我们在 Vallina 中实现的 subscribe js const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); }; 参数 listener 的值就是 onStoreChange,将其存入 listeners,待状态发生变化,通知订阅者的时候执行,这样对应的组件就会通过 getSnapshot 去重新获取最新的数据快照,并更新渲染组件。

在组件卸载的时候会执行 subscribe 调用后返回的取消订阅的函数。

分析比较

setStateuseEffect 在 React 17 以及之前是可以的,因为组件渲染是同步的,任何情况下都会正常渲染直到完成。但也并不是说这个方案就完全没问题,由于异步的特性,更新和组件渲染的时机并不能确保按照理想情况来进行。就可能会存在更新在渲染之后,这样会造成组件的多次渲染,影响性能开销。

useSyncExternalStore 是同步的,确保了组件渲染的时候,拿到的都是同一个值,不会出现“撕裂”的情况。

特性useState + useEffectuseSyncExternalStore
并发安全容易出现“撕裂”现象从根本上解决“撕裂”,保证渲染一致性
更新时机更新是异步的,可能导致额外的渲染同步读取,确保在渲染开始时就获取到最新的、一致的数据
性能可能因为异步更新导致不必要的重复渲染更高效,避免了不必要的渲染,因为 React 可以同步地知道状态是否改变
代码简洁性需要手动管理状态和副作用,代码相对冗余提供了更声明式、更简洁的 API 来订阅外部源

开发体验优化

'use strict';

var React = require('react');
var vanilla = require('zustand/vanilla');

const identity = (arg) => arg;
// 2️⃣
function useStore(api, selector = identity) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState())
  );
  // 3️⃣
  React.useDebugValue(slice);
  return slice;
}
const createImpl = (createState) => {
  const api = vanilla.createStore(createState);
  // 1️⃣
  const useBoundStore = (selector) => useStore(api, selector);
  // 4️⃣
  Object.assign(useBoundStore, api);
  return useBoundStore;
};
const create = (createState) => createState ? createImpl(createState) : createImpl;

exports.create = create;
exports.useStore = useStore;

1️⃣ 为什么要创建这个 useBoundStore?我们可以注意到 useStore 是需要传入 api 的,这个值对于一个 store 实例来说,是固定不变的。那么我们就没必要在使用的时候每次都自己传这个值,在一开始的时候就固定传入。所以用 (selector) => useStore(api, selector) 这个形式包裹了一层,这样我们就只需要根据使用情况,是否传入 selector 就可以了。

2️⃣ selector 是什么呢?在使用过程中,我们并不是每次都需要完整的 state,大部分情况下,我们可能只需要其中的一小部分内容。这个时候,我们就可以传入一个选择器,从完整的 state 中,选出我想要的内容。也就是说 selector 是个函数,(state) => any,开发者可以自由决定如何组织返回的部分内容,可以是单个值,计算值,对象或者数组等。

没有传递的默认情况下,就使用 identity,返回完整的 state 内容。

3️⃣ 当使用 React 开发者工具时,它能支持你在查看组件的 Hooks 列表时,能够直接看到 useStore 当前返回的 slice 值,极大地方便了调试。

4️⃣ 除了适配 React 的 hook 形式,将原生的 api 也合并到了 hook 的对象中。这样创建的 store 既能在 React 组件中以 hook 的形式获取值,也可以在 js 或者 ts 中通过原生的方法来使用 store,极大提升了可用范围。

结语

在本篇文章中,我们学习了 zustand 如何对原生代码进行 react 的框架适配。对 原生 -> React 的流程更加熟悉了。

  1. 探究了两种实现封装的方式,并总结了两者的优缺点
  2. 学习了 React 18 提出的 useSyncExternalStore 的背景和意义,以及使用方法
  3. 学习了如何为开发者提供一个良好的开发体验

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...