Понимание типа записи Typescript с помощью React useContext

#javascript #reactjs #typescript #react-context

#javascript #reactjs #typescript #реакция-контекст

Вопрос:

У меня возникли некоторые проблемы с пониманием типа записи в функции генератора, которую я разрабатываю для контекстов React.

Вот моя функция генератора:

 import * as React from 'react'

export type State = Record<string, unknown>
export type Action = { type: string; payload?: unknown }

// eslint-disable-next-line @typescript-eslint/ban-types
export type Actions = Record<string, Function>


export type AppContext = { state: State; actions: Actions }
export type Reducer = (state: State, action: Action) => State

type ProviderProps = Record<string, unknown>
type FullContext = {
    Context: React.Context<AppContext>
    Provider: React.FC<ProviderProps>
}

/**
 * Automates context creation
 *
 * @param {Reducer} reducer
 * @param {Actions} actions
 * @param {State} initialState
 * @returns {Contex, Provider}
 */
export default (
    reducer: Reducer,
    actions: Actions,
    initialState: State,
    init: () => State = (): State => initialState,
): FullContext => {
    const Context = React.createContext<AppContext>({
        state: { ...initialState },
        actions,
    })

    const Provider = ({ children }: { children?: React.ReactNode }) => {
        const [state, dispatch] = React.useReducer(reducer, initialState, init)

        const boundActions: Actions = {}
        for (const key in actions) {
            boundActions[key] = actions[key](dispatch)
        }

        return (
            <Context.Provider value={{ state, actions: { ...boundActions } }}>
                {children}
            </Context.Provider>
        )
    }

    return {
        Context,
        Provider,
    }
}

 

Суть проблемы здесь:

         const boundActions: Actions = {}
        for (const key in actions) {
            boundActions[key] = actions[key](dispatch)
        }
 

Что я пытаюсь сделать, так это иметь функцию, которую я могу вызывать с помощью редуктора и начального состояния для создания поставщика контекста, который я могу использовать в любом месте приложения. В этом случае я пытаюсь сгенерировать AuthContext . Для этого контекста потребуется редуктор, который выглядит следующим образом:

 const authReducer: Reducer = (state: State, action: Action): State => {
    switch (action.type) {
        case 'login':
            return { ...state, isLogged: true, avoidLogin: false }
        case 'logout':
            return { ...state, isLogged: false, avoidLogin: true }
        default:
            return { ...state }
    }
}
 

Довольно простой, но он в основном изменяет некоторые логические значения в объекте.

Теперь этот код действительно работает, но меня беспокоит использование типа функции в объявлении типа действий.

export type Actions = Record<string, Function>

использование unknown также работает, но теперь typescript жалуется, что Object is of type 'unknown'. при выполнении: boundActions[key] = actions[key](dispatch) , в частности, на actions[key] , потому что я вызываю его как функцию.

Действия — это функции, которые принимают функцию (отправку) в качестве аргумента и возвращают State объект.

Вот моя декларация действий для дальнейшей иллюстрации:

 const actions: Actions = {
    login: (dispatch: (action: Action) => void) => () => {
        dispatch({ type: 'login' })
    },
    logout: (dispatch: (action: Action) => void) => () => {
        dispatch({ type: 'logout' })
    },
}
 

Если я консольно войду boundActions в свой генератор, я получу что-то вроде:

 Object {
  "login": [Function anonymous],
  "logout": [Function anonymous],
}
 

Это именно то, что я хочу, поскольку я хочу иметь возможность вызывать эти функции в любом месте моего кода, и это, в свою очередь, вызовет функцию отправки определенного редуктора, изменяющего состояние определенного контекста.

Теперь я довольно новичок в Typescript, но моя интуиция подсказывает мне, что объявление Actions типа должно быть примерно таким:

export type Actions = Record<string, (dispatch:Function) => State>

Проблема с этим заключается в следующем: 1 — это не работает, потому что теперь boundActions скажите что-то вроде:

 Type 'Record<string, unknown>' is not assignable to type '(dispatch: Function) => Record<string, unknown>'.
  Type 'Record<string, unknown>' provides no match for the signature '(dispatch: Function): Record<string, unknown>'.
 

2- Я все еще использую тип функции, который не является безопасным для ввода.

Теперь dispatch принимает действие в качестве параметра export type Action = { type: string; payload?: unknown }

Итак, я предполагаю, что export type Actions = Record<string, (dispatch(arg:Action)) => State>

но это тоже не работает, потому что это недопустимый код typescript.

Я знаю, что этот случай излишне сложен, но я просто хочу лучше понять использование Record в этом конкретном случае, когда запись имеет string ключи и Function значения.

В конце концов, все, что я хочу, это сделать что-то вроде:

 export default function Login(): JSX.Element {
    const {
        actions: { login },
    } = React.useContext(AuthContext)

    return (
        <View>
            <Button text='Entrar' onPress={() => login()} />
        </View>
    )
}
 

Который на самом деле работает, но он небезопасен.

Спасибо.

Ответ №1:

Если вы имеете дело с действиями, которые принимают разные аргументы создателя действия, тогда вы хотите определить свои типы на основе сопоставления этих конкретных действий, а не на неопределенном обобщенном типе.

Поскольку здесь у вас есть только два создателя действий, оба они не принимают никаких аргументов (кроме отправки), тогда их можно описать следующим типом:

 (dispatch: React.Dispatch<Action>) => () => void
 

Таким образом, у вас будет

 type Actions = Record<string, (dispatch: React.Dispatch<Action>) => () => void>
 

Мне все еще не нравится этот тип, потому что он имеет string в качестве ключевого типа вместо ваших конкретных функций действия login и logout . Но теперь, когда мы ввели его лучше, Typescript может начать работать по назначению и выдавать нам полезные ошибки.

Тип boundActions , который вы создаете в своей функции, не должен иметь тот же тип, actions что и тот, который мы передаем в качестве аргумента. Это actions функции, которые принимают Dispatch и возвращают функцию () => void . Это boundActions просто возвращаемая функция () => void .

Итак, мы начинаем видеть некоторые проблемы с вашим дизайном. Не имеет смысла (или передавать проверки типа) использовать несвязанные действия в качестве значения по умолчанию для контекста, поскольку они по своей сути отличаются от их связанных версий.

Эти ошибки были скрыты, когда вы просто использовали Function их как тип, и именно поэтому это опасно!

Вы можете создавать твердые типы для своих AuthContext , не делая ничего «необычного». Но вы хотите создать фабрику, которая поддерживает создание нескольких разных контекстов. Вы можете использовать множество неопределенных типов, таких как Record и any , и у вас не будет никаких ошибок в typescript, но вы также не получите много полезной информации при использовании контекста. Какие свойства находятся в state ? Какие действия я могу вызвать? Если вы хотите создать фабрику, которая знает о конкретном контексте, который она создает, вам нужно использовать более продвинутые функции Typescript, такие как общие и отображенные типы.

Общая настройка

 import React, { Reducer, Dispatch as _Dispatch } from 'react';

// Very generalized action type
export type Action = { type: string; payload?: any }

// We are not using specific action types.  So we override the React Dispatch type with one that's not generic
export type Dispatch = _Dispatch<Action>

// 

// Map from action functions of dispatch to their bound versions
type BoundActions<ActionMap> = {
    [K in keyof ActionMap]: ActionMap[K] extends ((dispatch: Dispatch) => infer A) ? A : never
}

// The type for the context depends on the State and the action creators
export type AppContext<State, ActionMap> = {
    state: State;
    actions: BoundActions<ActionMap>;
}

type FullContext<State, ActionMap> = {
    Context: React.Context<AppContext<State, ActionMap>>;
    Provider: React.FC; // Provider does not take any props -- just children
}

const createAuthContext = <
    // type for the state
    State,
    // type for the action map
    ActionMap extends Record<string, (dispatch: Dispatch) => (...args: any[]) => void>>(
        reducer: Reducer<State, Action>,
        actions: ActionMap,
        initialState: State,
        init: () => State = (): State => initialState,
): FullContext<State, ActionMap> => {
    const Context = React.createContext<AppContext<State, ActionMap>>({
        state: { ...initialState },
        actions: Object.fromEntries(
            Object.keys(actions).map(key => (
                [key, () => console.error("cannot call action outside of a context provider")]
            )
            )) as BoundActions<ActionMap>,
    })

    const Provider = ({ children }: { children?: React.ReactNode }) => {
        const [state, dispatch] = React.useReducer(reducer, initialState, init)

        // this is not the most elegant way to handle object mapping, but it always requires some assertion
        // there is already a bindActionCreators function in Redux that does this.
        const boundActions = {} as Record<string, any>
        for (const key in actions) {
            boundActions[key] = actions[key](dispatch)
        }

        return (
            <Context.Provider value={{ state, actions: boundActions as BoundActions<ActionMap> }}>
                {children}
            </Context.Provider>
        )
    }

    return {
        Context,
        Provider,
    }
}
 

Конкретная реализация

 export type AuthState = {
    isLogged: boolean;
    avoidLogin: boolean;
}

const authReducer = (state: AuthState, action: Action): AuthState => {
    switch (action.type) {
        case 'login':
            return { ...state, isLogged: true, avoidLogin: false }
        case 'logout':
            return { ...state, isLogged: false, avoidLogin: true }
        default:
            return { ...state }
    }
}

// no type = let it be inferred
const actions = {
    login: (dispatch: Dispatch) => () => {
        dispatch({ type: 'login' })
    },
    logout: (dispatch: Dispatch) => () => {
        dispatch({ type: 'logout' })
    },
}

const initialAuthState: AuthState = {
    isLogged: false,
    avoidLogin: true
}

const { Context: AuthContext, Provider } = createAuthContext(authReducer, actions, initialAuthState);

export function Login(): JSX.Element {
    // this is where the magic happens!
    const {
        // can only destructure known actions, will get errors on typos
        actions: { login },
        state
    } = React.useContext(AuthContext)

    // we now know all the properties of the state and their types
    const { isLogged } = state;

    return (
        <View>
            <Button text='Entrar' onPress={() => login()} />
        </View>
    )
}
 

Ссылка на игровую площадку Typescript