#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>
)
}