用 React.useContext 来组织 Vue 的状态

507 阅读3分钟

因为想要用 React.useContext 来组织 Vue 的状态,所以我们第一步要先在 Vue 中实现一个 React.useContext

vc-state

Easily to compose scoped state in Vue.js

我们先用这个 React.useContext 的思路来实现这个方案。

  1. useContext 只会读取到最近的一个 Provider 的提供的值
  2. useContext 只能在 Provider 后代中使用
  3. Provider 可以由 props 控制默认值
  4. Provider 可以嵌套使用

Provider 可以由 props 控制默认值

因为想在 createContext 的时候可以通过 hooks 来组织状态,所以实现时参考了 constate 的实现方式,不过与 constate 不一样的是,创建出来的 context 是由初始化 context 和 hooks 组合起来的。

// context.ts
import { createContext } from 'vc-state';

const [ContextProvider, useContext] = createContext((props: { c: string }) => {
     return {
        a: '1',
        c: props.c
     }
}, (initialContext) => {
     return {
        b: ~~initialContext.a
     }
});

// 所以这里创造出来的 context 是 { a: '1', b: 1 }

export { ContextProvider, useContext };

image.png

我们需要在上层使用 ContextProvider

// App.tsx

import { ContextProvider } from 'context.ts' 

export default defineComponent({
    name: 'App',
    setup() {
        return () => (
            <ContextProvider c='test'>
              {/* 后代组件 */}
            </ContextProvider>
        );
    },
});

在后代组件里使用 useContext 获取数据

// Child.tsx

import { useContext } from 'context.ts' 

export default defineComponent({
    name: 'Child',
    setup() {
        const { a, b, c } = useContext();

        return () => (
            <div>
              a: {a}
              b: {b}
              c: {c}
            </div>
        );
    },
});

这样我们就可以通过 useContext 来进行 context 的穿透。

因为我们是使用 hooks 来组织状态的,所以我们可以在定义 context 的时候可以使用响应式的 API

嵌套使用

因为我们使用的是生成组件来外层包裹的方式来实现,所以我们支持嵌套使用。

const ContextProvider: FunctionalComponent<Props> = (props, { slots }) => {
    return h(
        defineComponent({
            name: 'Provider',
            setup() {
                const context = useValue(props);

                const hookContextValues = selectors.reduce((merged, selector) => {
                    return Object.assign({}, merged, selector.call(null, context));
                }, Object.create(null));

                provide(injectionKey, Object.assign({}, context, hookContextValues));

                return () => h(Fragment, slots.default?.());
            },
        })
    );
};

读取最近的值

因为是使用 Vue 提供的 Provide/Inject 来实现上下层级的值传递,所以是遵循 Vue 的规则,只会读取到最近的一个 provide 传递下来的值。

useContext 只能在 Provider 后代中使用

因为 provide 是在 Provider 创建时使用,所以 useContext 必须是要在 Provider 后代才能使用。

例子

我们要用例子来证明一下我们的方案是可行的。

ThemeContextProvider

我们使用一个简单的例子来展示我们的基础功能

CodeSandbox - ThemeContextProvider

import { computed, defineComponent, ref } from 'vue';
import { createContext } from 'vc-state';

type Theme = 'dark' | 'light';

interface ThemeContextProviderProps {
    defaultTheme: Theme;
    lightColor?: string;
    darkColor?: string;
}

// Defined Required Props in useValue function
const [ThemeContextProvider, useThemeContext] = createContext((props: ThemeContextProviderProps) => {
    const theme = ref<Theme>(props.defaultTheme);
    const toggleTheme = () => (theme.value = theme.value === 'dark' ? 'light' : 'dark');
    return { theme, toggleTheme };
});

const Button = defineComponent({
    name: 'Button',
    setup() {
        const { toggleTheme, theme } = useThemeContext();
        return () => {
            return <button onClick={toggleTheme}>to {theme.value === 'dark' ? 'light' : 'dark'}</button>;
        };
    },
});

const Panel = defineComponent({
    name: 'Panel',
    setup() {
        const { theme } = useThemeContext();
        const currentThemeColor = computed(() => (theme.value === 'dark' ? '#000' : '#fff'));
        const oppositeThemeColor = computed(() => (theme.value === 'dark' ? '#fff' : '#000'));

        return () => {
            return (
                <div
                    style={{
                        backgroundColor: currentThemeColor.value,
                        border: `1px ${oppositeThemeColor.value} solid`,
                        width: '300px',
                        height: '300px',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        fontSize: '20px',
                        color: oppositeThemeColor.value,
                    }}
                >
                    <p>I'm in {theme.value} mode</p>
                </div>
            );
        };
    },
});

export default defineComponent({
    name: 'App',
    setup() {
        return () => (
            // defaultTheme is required
            // lightColor and darkColor are optional
            <ThemeContextProvider defaultTheme='light'>
                <Panel />
                <Button />
            </ThemeContextProvider>
        );
    },
});

OverridingProviders

这个例子是展示只能读取最近一个 Provider 的值

CodeSandbox - OverridingProviders

import { computed, defineComponent, ref } from 'vue';
import { createContext } from 'vc-state';

type Theme = 'dark' | 'light';

interface ThemeContextProviderProps {
    defaultTheme: Theme;
    lightColor?: string;
    darkColor?: string;
}

const [ThemeContextProvider, useThemeContext] = createContext((props: ThemeContextProviderProps) => {
    const theme = ref<Theme>(props.defaultTheme);
    const toggleTheme = () => (theme.value = theme.value === 'dark' ? 'light' : 'dark');
    return { theme, toggleTheme };
});

const Button = defineComponent({
    name: 'Button',
    setup() {
        const { toggleTheme, theme } = useThemeContext();
        return () => {
            return <button onClick={toggleTheme}>to {theme.value === 'dark' ? 'light' : 'dark'}</button>;
        };
    },
});

const Panel = defineComponent({
    name: 'Panel',
    setup() {
        const { theme } = useThemeContext();
        const currentThemeColor = computed(() => (theme.value === 'dark' ? '#000' : '#fff'));
        const oppositeThemeColor = computed(() => (theme.value === 'dark' ? '#fff' : '#000'));

        return () => {
            return (
                <div
                    style={{
                        backgroundColor: currentThemeColor.value,
                        border: `1px ${oppositeThemeColor.value} solid`,
                        width: '300px',
                        height: '300px',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        fontSize: '20px',
                        color: oppositeThemeColor.value,
                    }}
                >
                    <p>I'm in {theme.value} mode</p>
                </div>
            );
        };
    },
});

export default defineComponent({
    name: 'App',
    setup() {
        return () => (
            // useContext receives the provided value of the nearest Provider
            <ThemeContextProvider defaultTheme='dark'>
                <ThemeContextProvider defaultTheme='light'>
                    <Panel />
                    <Button />
                </ThemeContextProvider>

                <ThemeContextProvider defaultTheme='dark'>
                    <Panel />
                    <Button />
                </ThemeContextProvider>

                <Panel />
                <Button />
            </ThemeContextProvider>
        );
    },
});

NestedProviders

这个例子是展现嵌套使用的方法

CodeSandbox - NestedProviders

// theme.context.ts

import { createContext } from 'vc-state';
import { ref } from 'vue';

export type Theme = 'dark' | 'light';

const [ThemeContextProvider, useThemeContext] = createContext(() => {
    const theme = ref<Theme>('dark');
    const toggleTheme = () => (theme.value = theme.value === 'dark' ? 'light' : 'dark');
    return { theme, toggleTheme };
});

export { ThemeContextProvider, useThemeContext };

// Panel.tsx
import { defineComponent } from 'vue';
import { useThemeContext } from '../theme.context';
import { PanelThemeContextProvider, usePanelThemeContext } from './panel.context';

const Panel = defineComponent({
    name: 'Panel',
    setup() {
        const { theme } = useThemeContext();
        const { styles } = usePanelThemeContext();

        return () => {
            return <div style={styles.value}>I'm in {theme.value} mode</div>;
        };
    },
});

const PanelWrapper = defineComponent({
    name: 'PanelWrapper',
    setup() {
        return () => (
            <PanelThemeContextProvider>
                <Panel />
            </PanelThemeContextProvider>
        );
    },
});

export { PanelWrapper as Panel };

// panel.context.ts

import { createContext } from 'vc-state';
import { computed } from 'vue';
import { useThemeContext } from '../theme.context';

const [PanelThemeContextProvider, usePanelThemeContext] = createContext(() => {
    const { theme } = useThemeContext();

    const currentThemeColor = computed(() => (theme.value === 'dark' ? '#000' : '#fff'));
    const oppositeThemeColor = computed(() => (theme.value === 'dark' ? '#fff' : '#000'));

    const styles = computed(() => {
        return {
            backgroundColor: currentThemeColor.value,
            border: `1px ${oppositeThemeColor.value} solid`,
            width: '300px',
            height: '300px',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            fontSize: '20px',
            color: oppositeThemeColor.value,
        };
    });

    return {
        styles,
    };
});

export { PanelThemeContextProvider, usePanelThemeContext };

// App.tsx

import { defineComponent } from 'vue';
import { ThemeContextProvider, useThemeContext } from './theme.context';
import { Panel } from './Panel';

const Button = defineComponent({
    name: 'Button',
    setup() {
        const { toggleTheme, theme } = useThemeContext();
        return () => {
            return <button onClick={toggleTheme}>to {theme.value === 'dark' ? 'light' : 'dark'}</button>;
        };
    },
});

export default defineComponent({
    name: 'App',
    setup() {
        return () => (
            <ThemeContextProvider>
                <Panel />
                <Button />
            </ThemeContextProvider>
        );
    },
});

总结

通过上面的三个例子我们可以看出已经完成了 React.useContext 的几个特性,另外的优点是:

  • 可以在定义时使用 hooks 来对状态进行监听
  • 可以很简单的创建一个作用域状态
  • 完全的类型支持
  • 轻量级选手

有兴趣的同学,可以一起探讨和研究一下,也欢迎提供一些思路或者建议。

延伸阅读

  • vc-state -- Easily to compose scoped state in Vue.js