手寫一個mini版本的React狀態管理工具

語言: CN / TW / HK

手寫一個mini版本的React狀態管理工具

目前在React中,有很多各式各樣的狀態管理工具,如:

每一個狀態管理工具都有著不盡相同的API和使用方式,而且都有一定的學習成本,而且這些狀態管理工具也有一定的複雜度,並沒有做到極致的簡單。在開發者的眼中,只有用起來比較簡單,那麼才會有更多的人去使用它,Vue不就是因為使用簡單,上手快,而流行的嗎?

有時候我們只需要一個全域性的狀態,防治一些狀態和更改狀態的函式就足夠了,這樣也達到了最簡化原則。

下面讓我們一起來實現一個最簡單的狀態管理工具吧。

這個狀態管理工具的核心就使用到了Context API,在瞭解本文之前務必先了解並熟悉使用這個API的用法。

首先我們來看這個狀態管理工具是如何使用的。假設有一個計數器狀態,然後我們通過二個方法分別去修改計數器,也就是做加法和減法,換句話說我們需要用到一個計數器狀態,二個方法來修改這個狀態。在React函式元件中,我們使用useState方法來初始化一個狀態,因此,我們可以很容易的寫出如下程式碼:

import { useState } from 'react'
const useCounter = (initialCount = 0) => {
    const [count,setCount] = useState(initialCount);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);
    return {
        count,
        increment,
        decrement
    }
}
export default useCounter;

現在,讓我們建立一個元件來使用這個useCounter鉤子函式,如下:

import React from 'react'
import useCounter from './useCounter'

const Counter = () => {
    const { count,increment,decrement } = useCounter();
    return (
        <div className="counter">
            { count }
            <button type="button" onClick={increment}>add</button>
            <button type="button" onClick={decrement}>plus</button>
        </div>
    )
}

然後在根元件App當中使用,如下:

import React from 'react'
const App = () => {
    return (
        <div className="App">
            <Counter />
            <Counter />
        </div>
    )
}

這樣,一個計數器元件就大功告成了,可是真的只是這樣嗎?

首先,我們應該知道計數器元件的狀態應該是一致的,也就是說我們的計數器元件應該是共享同一個狀態,那麼如何共享同一個狀態?這時候就需要Context出場了。將以上的元件改造一下,我們將狀態放在根元件App當中初始化,並且傳到子元件中去。先修改App根元件的程式碼如下:

新建一個CounterContext.ts檔案,程式碼如下:

const CounterContext = createContext();
export default CounterContext;
import React,{ createContext } from 'react'
import CounterContext from './CounterContext'

const App = () => {
    const { count,increment,decrement } = useCounter();
    return (
        <div className="App">
            <CounterContext.Provider value={{count,increment,decrement}}>
                <Counter />
                <Counter />
            </CounterContext.Provider>
        </div>
    )
}

然後在Counter元件程式碼我們也修改如下:

import React,{ useContext } from 'react'
import CounterContext from './CounterContext'

const Counter = () => {
    const { count,increment,decrement } = useContext(CounterContext);
    return (
        <div className="counter">
            { count }
            <button type="button" onClick={increment}>add</button>
            <button type="button" onClick={decrement}>plus</button>
        </div>
    )
}

如此一來,我們就可以共享count狀態,無論是在多深的子元件當中使用都沒有問題,但是這並沒有結束,讓我們繼續。

雖然這樣使用解決了共享狀態的問題,可是我們發現,我們在使用的時候還要額外的傳入一個context名,所以我們需要包裝一下,到最後,我們只需要像如下這樣使用:

const Counter = createModel(useCounter);
export default Counter;
const { Provider,useModel } = Counter;

然後我們的App元件就應該是這樣:

import React,{ createContext } from 'react'
import counter from './Counter'

const App = () => {
    const { Provider } = counter;
    return (
        <div className="App">
            <Provider>
                <Counter />
                <Counter />
            </Provider>
        </div>
    )
}

繼續修改我們的Counter元件,如下:

import React,{ useContext } from 'react'
import counter from './Counter'

const Counter = () => {
    const { count,increment,decrement } = counter.useModel();
    return (
        <div className="counter">
            { count }
            <button type="button" onClick={increment}>add</button>
            <button type="button" onClick={decrement}>plus</button>
        </div>
    )
}

通過以上程式碼的展示,其實我們也就明白了,我們無非是將useContext和createContext內建到我們封裝的Model裡面去了。

接下來我們就來揭開這個狀態管理工具的神祕面紗,首先要用到React相關的API,所以我們需要匯入進來。如下:

// 匯入型別
import type { ReactNode,ComponentType } from 'react';
import { createContext,useContext } from 'react';

接下來定義一個唯一標識,用於確定傳入的Context,並且這個用來確定使用者使用Context時是正確使用的。

const EMPTY:unique symbol = Symbol();

接下來我們要定義Provider的型別。如下:

export interface ModelProviderProps<State = void> {
    initialState?: State
    children: ReactNode
}

以上我們定義了context的狀態型別,是一個泛型,引數就是狀態的型別,預設初始化為undefined型別,並且定義了一個children的型別,因為Provider的子節點是一個React節點,所以也就定義成ReactNode型別。

然後就是我們的Model型別,如下:

export interface Model<Value,State = void> {
    Provider: ComponentType<ModelProviderProps<State>>
    useModel: () => Value
}

這個也很好理解,因為Model暴露了兩個東西,第一個是Provider,第二個就是useContext,只是換了一個名字而已,定義這兩個的型別就夠了。

接下來就是我們的核心函式createModel函式的實現,我們一步一步來,首先當然是定義這個函式,注意型別,如下:

export const createModel = <Value,State = void>(useHook:(initialState?:State) => Value): Model<Value,State> => {
    //核心程式碼
}

以上函式難以理解的應該是型別的定義,我們createModel函式傳入一個hook函式,hook函式傳入一個狀態作為引數,然後返回值就是我們定義好的Model泛型,引數為型別就是我們定義好的這個函式的泛型。

接下來,我們要做的自然是建立一個context,如下:

//建立一個context
const context = createContext<Value | typeof EMPTY>(EMPTY);

然後我們要建立一個Provider函式,本質上也是一個React元件,如下:

const Provider = (props:ModelProviderProps<State>) => {
    // 這裡使用ModelProvider主要是不能和定義的函式名起衝突
    const { Provider:ModelProvider } = context;
    const { initialState,children } = props;
    const value = useHook(initialState);
    return (
        <ModelProvider value={value}>{children}</ModelProvider>
    )
}

這裡也很好理解,實際上就是通過父元件拿到初始狀態和子節點,從context中拿到Provider元件,然後返回即可,注意我們的value是通過傳入的自定義hook函式包裝後的值。

第三步,我們就需要定義一個hook函式拿到這個自定義的Context,如下:

const useModel = ():Value => {
    const value = useContext(context);
    // 這裡確定一下使用者是否正確使用Provider
    if(value === EMPTY){
        //丟擲異常,使用者並沒有用Provider包裹元件
        throw new Error('Component must be wrapped with <Container.Provider>');
    }
    // 返回context
    return value;
}

這個函式的實現也很好理解,就是獲取context,判斷context是否正確使用,然後返回。

最後我們在這個函式內部返回這2個東西,即返回Provider和useModel兩個函式。如下:

return { Provider,useModel }

把以上程式碼全部合併起來,createModel函式就大功告成啦。

最後,我們把所有程式碼合併起來,這個狀態管理工具也就完成了。

// 匯入型別
import type { ReactNode,ComponentType } from 'react';
import { createContext,useContext } from 'react';
const EMPTY:unique symbol = Symbol();
export interface ModelProviderProps<State = void> {
    initialState?: State
    children: ReactNode
}
export interface Model<Value,State = void> {
    Provider: ComponentType<ModelProviderProps<State>>
    useModel: () => Value
}
export const createModel = <Value,State = void>(useHook:(initialState?:State) => Value): Model<Value,State> => {
    //建立一個context
    const context = createContext<Value | typeof EMPTY>(EMPTY);
    // 定義Provider函式
    const Provider = (props:ModelProviderProps<State>) => {
        const { Provider:ModelProvider } = context;
        const { initialState,children } = props;
        const value = useHook(initialState);
        return (
            <ModelProvider value={value}>{children}</ModelProvider>
        )
    }
    // 定義useModel函式
    const useModel = ():Value => {
        const value = useContext(context);
        // 這裡確定一下使用者是否正確使用Provider
        if(value === EMPTY){
            //丟擲異常,使用者並沒有用Provider包裹元件
            throw new Error('Component must be wrapped with <Container.Provider>');
        }
        // 返回context
        return value;
    }
    return { Provider,useModel };
}

更近一步,我們再匯出一個useModel函式,如下:

export const useModel = <Value,State = void>(model:Model<Value,State>):Value => {
    return model.useModel();
}

到目前為止,我們的整個狀態管理工具就完成啦,使用起來也很簡單,很多輕量的共享狀態專案當中我們也就再也不需要使用像Redux這樣的比較複雜的狀態管理工具了。

當然這個想法也並不是我本人想的,文末已註明來源,本文對原始碼做了一遍分析。

原始碼地址

PS: 本文原始碼來自unstated-next。