第四章: 使用订阅来共享模块状态

在前面的章节,我们学习了如何使用Context 来实现全局状态。我们可以知道,Context并不适合用于单例模式;它就是设计来避免单例模式,并为不同的子树提供不同的值的。对于一个类单例模式的全局状态来说,使用块级状态会更好,因为单例模式的全局状态只是内存里的一个值。本章的目标在于,是用React 实现 块级状态。这个模式虽然不如Context知名,但却常常被用来与现成的块级状态集成。

我们将学习如何在React把块级状态作为全局状态。为了在React组件使用块级状态,我们将使用订阅机制。

在这一章,我们会讨论这几个主题:

  • 探索块级状态
  • 在React中,把模块状态当作全局状态
  • 添加订阅
  • 使用selector 和 useSubscription

探索块级状态

块级状态是 指 定义在 块级 的变量。此处的 指的是,一个 ES 模块,或者 仅仅是 一个 文件。更简单的说,我们会把定义在函数外的状态,视作 块级 状态。

比如说,我们定义一个 count 变量:

js 复制代码
let count = 0;

假设这个变量被定义在一个模块,那它是一个 块级状态。

通常情况下,在使用 React 时,我们希望拥有一个对象状态。下面定义了一个包含count属性的对象状态:

js 复制代码
let state = {
    count: 0,
}

还可以在state里面添加其他属性,添加对象也是可以的。

然后,我们再定义这个对象的 getter 函数 和 setter 函数:

js 复制代码
export const getState = () => state;

export const setState = (nexState) => {
    state = nextState
}

注意,这两个函数都有 export 关键字,这意味着它们会在模块外被使用。

js 复制代码
export const setState = (nextState) => {
    state = typeof nextState === 'function'
        ? nextState(state) : nextState;
}

你可以用一个函数来更新:

js 复制代码
setState((prevState) => {
    ...prevState,
    count: prevState.count + 1
})

我们可以创建一个函数来生成一个包含状态和一些访问函数的容器,而不是直接定义模块状态。

下面的代码是一个具体实现:

js 复制代码
export const createContainer = (initialState) => {
    let state = initialState;
    const getState = () => state;
    const setState = (nextState) => {
        state = typeof nextState === 'funciton' 
            ? nextState(state) : nextState;
    }
    
    return { getState, setState }
}

然后,你可以这样使用:

js 复制代码
import { createContainer } from '...';

const { getState, setState } = createContainer({
    count: 0
})

目前为止,这个 块级状态 还和 React 没有关系。在下一部分,我们会学习如何 用 React 实现一个 块级状态。

在React中,把模块状态当作全局状态

正如我们在第三章 "使用上下文共享组件状态" 中所讨论的,React 上下文旨在为不同的子树提供不同的值。将 React 上下文用于单例全局状态是一种可行的操作,但这并没有充分发挥上下文的全部功能。

如果你想为整个渲染树设置全局状态,使用块级状态会更好。然而,在React组件中使用 块级状态,我们要自行解决重新渲染问题。

让我们看看这个简单的例子:

js 复制代码
let count = 0;

const Component1 = () => {
    const inc = () => {
        count += 1;
    }
    return (
        <div>{count} <button onClick={inc}> +1</button></div>
    )
}

你会看见 count 的 值 为 0.但随着 按 button 次数的增加,页面并没有发生 重新渲染。

截止至写本书时,React 还只有两个钩子 useState, useReducer 可以 触发 重新渲染。我们需要用其中一个钩子,来为该组件与块级状态 建立 相应式。

我们可以这样调整刚刚的例子:

js 复制代码
let count = 0;

const Component1 = () => {
    const [state, setState] = useState(count);
    const inc = () => {
        count += 1;
        setState(count);
    }
    return (
        <div>{state} <button onClick={inc}> +1</button></div>
    )
}

现在,如果你点击button,count变量会递增,同时重新渲染也得以触发。

让我们再看看这个例子:

js 复制代码
const Component2 = () => {
    const [state, setState] = useState(count);
    const inc = () => {
        count += 2;
        setState(count);
    }
    return (
        <div>{state} <button onClick={inc}> +2</button></div>
    )
}

如果你点击Component1的button,并不会触发Component2组件的重新渲染。只有当你点击Component2组件时,Component2组件会更新最新的块级状态。这是Component1组件与Component2组件之间的不持续性,而我们希望的是Component1组件和Component2组件展示相同的值。这个不持续性,也会发生在有两个Component1组件时。

有一个简单的方法,就是同时触发 Component1组件和Component2组件 的 setState函数。这意味着,setState函数也需要这块级。我们还需要考虑组件的生命周期,并使用useEfffect钩子来修改位于React外的setState函数 集合。

下面这个例子可以大致说明上面的思想,虽然这并不是很可行:

js 复制代码
let count = 0;
const setStateFuncitons =
    new Set<(count: number) => void>();
    
const Compent1 = () => {
    const [state, setState] = useState(count);
    useEffect(() => {
        setStateFunctions.add(setState);
        return () => { setStateFunctions.delete(setState); };
    }, []);
    
    const inc = () => {
        count += 1;
        setStateFunctions.forEach((fn) => {
            fn(count);
        });
    }
    
    return (
        <div>{state} <button onClick={inc}> +1</button></div>
    )
}

注意,我们会在useEffct中设置一个用来清除副作用的清除函数。而在 inc函数中,我们会调用setStateFunctions中所有的setState函数。

同理,Component2组件会被做类似的改造:

js 复制代码
const Compent2 = () => {
    const [state, setState] = useState(count);
    useEffect(() => {
        setStateFunctions.add(setState);
        return () => { setStateFunctions.delete(setState); };
    }, []);
    
    const inc2 = () => {
        count += 2;
        setStateFunctions.forEach((fn) => {
            fn(count);
        });
    }
    
    return (
        <div>{state} <button onClick={inc2}> +2</button></div>
    )
}

如大家所见,这个写法不是很具有实践性。我们要处理很多重复性的代码。

在下一个部分,我们会引入订阅模式,来减少重复性的代码。

添加订阅

现在,我们要学习订阅模式,并学习如何将 块级状态 与 React状态连接在一起。

订阅是获取更新通知的一种方式。常用的订阅会是这样的:

js 复制代码
const unsubscribe = store.subscribe(() => {
    console.log('store is updated');
});

在此,我们假设一个 store 变量有 以 callback 为 参数的 subscribe方法,并返回一个 unsubscribe方法。

我们期望的是,当store内的状态更新时,callback函数被触发,并在控制台展示相关信息。

现在,让我们实现一个具有订阅功能的块级状态:

js 复制代码
type Store<T> = {
    getState: () => T;
    setState: (action: T | (prev: T) => T) => void;
    subsrcibe: (callback: () => void) => () => void;
}

const createStore = <T extends unknown> (
    intialState: T
): Store<T> => {
    let state = intialState;
    const callbacks = new Set(() => void)();
    const setState = (nextState: T | (prev: T) => T) => {
        state =
            typeof nextState === 'function'
                ? (nextState as (prev: T) => T)(state)
                : nextState;
        callbacks.forEach((callback) => callback());
    }
    const subscribe = (callback: () => void) => {
        callbacks.add(callback);
        return () => {
            callbacks.delete(callback)
        }
    }
    return { getState, setState, subscribe }
}

我们可以这样使用 createStor:

js 复制代码
import { createStore } from '...';

const store = createStore({ count: 0 });
console.log(store.getState());
store.setState({ count: 1 });
store.subscribe(...);

之后,是如何在React 使用 store变量。

我们可以定义一个钩子,useStore。这个钩子会 返回一个由store 的状态 和 其 更新函数组成的 元祖:

js 复制代码
const useStore = (store) => {
    const [state, setState] = useState(store.getState());
    useEffect(() => {
        const unsubscribe = store.subscribe(() => {
            setState(store.getState());
        });
        setState(store.getState());  // [1]
        return unsubscribe;
    }. [store]);
    return [state, store.setState];
}

你会注意到 【1】 所在行的 代码。它是用于处理极端情况的。它在useEffect内调用了 setState函数一次。这是因为 useEffect 会延迟执行,而store可能在 useEffect 执行前,已经有了值。

下面这个组件,使用了useStore钩子:

js 复制代码
const Component1 = () => {
    const [state, useState] = useStore(store);
    const inc = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 1
        });
    }
    return (
        <div>
            {state.count} <button onClick={inc}> +1 </button>
        </div>
    )
}

下面是 Component2:

js 复制代码
const Component2 = () => {
    const [state, useState] = useStore(store);
    const inc2 = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 2
        });
    }
    return (
        <div>
            {state.count} <button onClick={inc2}> +2 </button>
        </div>
    )
}

这两个组件的按钮都会更新块级状态,而这个状态又被这两个组件所共享:

js 复制代码
const App = () => (
    <>
        <Component1 />
        <Component2 />
    </>
)

当你运行这个应用时,会发生下图的事情。如果你把 +1 和 +2 按钮各点击一次,你会发现 两个 状态都变为了 3:

在这个部分,我们学习了如何使用订阅来连接 块级状态 和 React组件。

在下一个部分,我们要使用 selector 函数来 使用部分的状态,并学习如何使用 useSubscripition.

使用selector 和 useSubscription

在上一个部分 实现的 useStore 钩子,返回的是 整个状态对象。这意味着,哪怕是很少部分的状态发生变化,系统会通知所有useStore钩子,并导致不必要的重新渲染。

首先,我们开发一个useStoreSelector

我们先用之前的createStore方法:

js 复制代码
const store = createStore({ count1: 0, count2: 0 });

这个store有两个变量: count1count2

useStoreSelectoruseStore大体一样,除了useStoreSelector接收了一个用于锚定状态范围的 selector 函数外。

js 复制代码
const useStoreSelector = <T, S>(
    store: Store<T>,
    selector: (state: T) => S
) => {
    const [state, setState] =
        useState(() => selector(store.getState()));
    useEffect(() => {
        const unsubscribe = store.subscribe(() => {
            setState(selector(store.getState()));
        })
        setState(selector(store.getState()));
        return unsubscribe;
    }, [store, selector]);
    return state;
}

useStoreSelectoruseState钩子,接收的是 selector 函数 选定的值,而不是整个对象。

现在,我们定义个使用了useStoreSelector的组件。useStoreSelector返回的值是一个 数字。为了更新它,我们会 使用 store.setState():

js 复制代码
const Component1 = () => {
    const state = useStoreSelector(
        store,
        useCallback((state) => state.count1, []),
    );
    
    const inc = () => {
        store.setState((prev) => {
            ...prev,
            count1: prev.count1 + 1,
        });
    };
    return (
        <div>
            count1: {state} <button onClick={inc}> +1 </button>
        </div>
    );
}

注意,我们需要使用 useCallback来确保selector 函数的稳定性。否则,selector是作为useEffct中的第二个依赖,Component1组件会在每次重新渲染时重新订阅 store。

我们可以定义Component2组件,用来展示count2。这一次,我们在组件外定义 selector函数。

js 复制代码
const selectCount2 = (
    state: ReturnType<typeof store.getState>
) => state.count2

const Component2 = () => {
    const state = useStoreSelector(store, selectCount2);
    
    const inc = () => {
        store.setState((prev) => {
            ...prev,
            count1: prev.count2 + 1,
        });
    };
    return (
        <div>
            count2: {state} <button onClick={inc}> +1 </button>
        </div>
    );
}

最后,实现 App 组件:

js 复制代码
const App = () => {
    <>
        <Component1 />
        <Component1 />
        <Component2 />
        <Component2 />
    </>
}

下图是运行效果:

前两行是被 Component1所渲染的。如果你点击其中一行的 button,count1会加一,Component1组件会重新渲染。然而,Component2不会重新渲染,因为 count2 并没有变动。

虽然 useStoreSelector钩子运行良好,在生产环境也很稳定,但是在 store 或者 selector 变动时,还是有注意事项等。因为 useEffect 会晚一些之下,所以它可能在再次订阅已经完成时,返回一个已经过时的值。虽然我们可以自行修复这个问题,但是太费时间了。

幸运的是,React团队为我们提供了一个正式的钩子。它就是use-subscription(www.npmjs.com/package/use...%25E3%2580%2582 "https://www.npmjs.com/package/use-subscription)%E3%80%82")

让我们使用useSubscription来定义 useStoreSelector。代码很简单:

js 复制代码
const useStoreSelector = (store, selector) => useSubscription(
    useMemo(() => {
        getCurrentValue: () => selector(store.getState()),
        subscribe: sotre.subscribe,
    }, [store, selector])
);

我们可以避免使用useStoreSelector,而使用 useSubscription:

js 复制代码
const Component1 = () => {
    const state = useSubscription(useMemo(() => ({
        getCurrentValue: () => store.getState().count1,
        subscribe: store.subscribe,
    }), []));
    
    const inc = () => {
        store.setState((prev) => ({
            ...prev,
            count1: prev.count1 + 1,
        }));
    };
    return (
        <div>
            count1: {state} <button onClick={inc}>+1</button>
        </div>
    );
};

在这个场景,useMemo已经被使用了,不再需要使用useCallback

在这个部分,我们学习了使用 selector 函数来 选定 scope,以及更稳定的 useSubscription 来实现。

在本章中,我们学习了如何创建模块状态并将其集成到 React 中。利用这些知识,你可以将模块状态用作 React 的全局状态。订阅在集成中扮演着重要角色,因为它允许在模块状态改变时触发组件的重新渲染。除了基本的订阅实现来使用模块状态在 React 中,还有一个官方包。基本订阅和官方包都适用于生产环境的使用场景。

在下一章中,我们将学习第三种实现全局状态的模式,即将第一种模式和第二种模式结合起来。

相关推荐
庸俗今天不摸鱼几秒前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
QTX187301 分钟前
JavaScript 中的原型链与继承
开发语言·javascript·原型模式
黄毛火烧雪下7 分钟前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox18 分钟前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞20 分钟前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行21 分钟前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_5937581022 分钟前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周25 分钟前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队43 分钟前
Vue自定义指令最佳实践教程
前端·vue.js
Jasmin Tin Wei1 小时前
蓝桥杯 web 学海无涯(axios、ecahrts)版本二
前端·蓝桥杯