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

在前面的章节,我们学习了如何使用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 中,还有一个官方包。基本订阅和官方包都适用于生产环境的使用场景。

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

相关推荐
我命由我123453 分钟前
VSCode - VSCode 放大与缩小代码
前端·ide·windows·vscode·前端框架·编辑器·软件工具
Mintopia11 分钟前
当数字橡皮泥遇上魔法:探秘计算机图形学的细分曲面
前端·javascript·计算机图形学
Mintopia19 分钟前
Three.js 物理引擎:给你的 3D 世界装上 “牛顿之魂”
前端·javascript·three.js
Jeremy_Lee12322 分钟前
grafana 批量视图备份及恢复(含数据源)
前端·网络·grafana
import_random28 分钟前
[python]conda
前端
亲亲小宝宝鸭29 分钟前
写了两个小需求,终于搞清楚了表格合并
前端·vue.js
BUG收容所所长31 分钟前
栈的奇妙世界:从冰棒到算法的华丽转身
前端·javascript·算法
令狐寻欢36 分钟前
JavaScript中常用的设计模式
javascript
xingba39 分钟前
重写IE的showModalDialog模态框以兼容现代浏览器
前端·javascript·google
前端小巷子40 分钟前
Promise 静态方法:轻松处理多个异步任务
前端·面试·promise