Перейти к основному содержимому
Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Разделение кода

В крупных веб-приложениях часто возникает необходимость разделить код приложения на несколько JS-бандлов, которые можно загружать по требованию. Эта стратегия, называемая "разделением кода", помогает повысить производительность приложения за счёт уменьшения размера начального JS-бандла, который необходимо загрузить.

Для реализации разделения кода в Redux нам требуется возможность динамически добавлять редюсеры в хранилище. Однако в Redux по сути существует только один корневой редюсер. Этот корневой редюсер обычно создаётся вызовом combineReducers() или аналогичной функции при инициализации приложения. Чтобы динамически добавлять новые редюсеры, нам нужно повторно вызвать эту функцию для пересоздания корневого редюсера. Ниже мы рассмотрим несколько подходов к решению этой задачи и упомянем две библиотеки, предоставляющие такую функциональность.

Базовый принцип

Использование replaceReducer

Хранилище Redux предоставляет функцию replaceReducer, которая заменяет текущий активный корневой редюсер новым. Её вызов заменяет внутреннюю ссылку на функцию-редюсер и диспатчит действие, помогающее новым редюсерам слайсов инициализироваться:

const newRootReducer = combineReducers({
existingSlice: existingSliceReducer,
newSlice: newSliceReducer
})

store.replaceReducer(newRootReducer)

Подходы к внедрению редюсеров

В этом разделе описаны ручные методы внедрения редюсеров.

Определение функции injectReducer

Нам потребуется вызывать store.replaceReducer() из любого места приложения. Поэтому полезно определить переиспользуемую функцию injectReducer(), которая сохраняет ссылки на все существующие редюсеры слайсов и добавляет её в экземпляр хранилища.

import { createStore } from 'redux'

// Define the Reducers that will always be present in the application
const staticReducers = {
users: usersReducer,
posts: postsReducer
}

// Configure the store
export default function configureStore(initialState) {
const store = createStore(createReducer(), initialState)

// Add a dictionary to keep track of the registered async reducers
store.asyncReducers = {}

// Create an inject reducer function
// This function adds the async reducer, and creates a new combined reducer
store.injectReducer = (key, asyncReducer) => {
store.asyncReducers[key] = asyncReducer
store.replaceReducer(createReducer(store.asyncReducers))
}

// Return the modified store
return store
}

function createReducer(asyncReducers) {
return combineReducers({
...staticReducers,
...asyncReducers
})
}

Теперь для добавления нового редюсера достаточно вызвать store.injectReducer.

Использование "менеджера редюсеров"

Другой подход — создание объекта "менеджер редюсеров", который отслеживает все зарегистрированные редюсеры и предоставляет функцию reduce(). Рассмотрим пример:

export function createReducerManager(initialReducers) {
// Create an object which maps keys to reducers
const reducers = { ...initialReducers }

// Create the initial combinedReducer
let combinedReducer = combineReducers(reducers)

// An array which is used to delete state keys when reducers are removed
let keysToRemove = []

return {
getReducerMap: () => reducers,

// The root reducer function exposed by this object
// This will be passed to the store
reduce: (state, action) => {
// If any reducers have been removed, clean up their state first
if (keysToRemove.length > 0) {
state = { ...state }
for (let key of keysToRemove) {
delete state[key]
}
keysToRemove = []
}

// Delegate to the combined reducer
return combinedReducer(state, action)
},

// Adds a new reducer with the specified key
add: (key, reducer) => {
if (!key || reducers[key]) {
return
}

// Add the reducer to the reducer mapping
reducers[key] = reducer

// Generate a new combined reducer
combinedReducer = combineReducers(reducers)
},

// Removes a reducer with the specified key
remove: key => {
if (!key || !reducers[key]) {
return
}

// Remove it from the reducer mapping
delete reducers[key]

// Add the key to the list of keys to clean up
keysToRemove.push(key)

// Generate a new combined reducer
combinedReducer = combineReducers(reducers)
}
}
}

const staticReducers = {
users: usersReducer,
posts: postsReducer
}

export function configureStore(initialState) {
const reducerManager = createReducerManager(staticReducers)

// Create a store with the root reducer function being the one exposed by the manager.
const store = createStore(reducerManager.reduce, initialState)

// Optional: Put the reducer manager on the store so it is easily accessible
store.reducerManager = reducerManager
}

Чтобы добавить новый редюсер, можно вызвать store.reducerManager.add("asyncState", asyncReducer).

Чтобы удалить редюсер, вызовите store.reducerManager.remove("asyncState").

Redux Toolkit

Redux Toolkit 2.0 включает утилиты для упрощения разделения кода с редюсерами и middleware, в том числе с надёжной поддержкой TypeScript (что является распространённой проблемой при ленивой загрузке редюсеров и middleware).

combineSlices

Утилита combineSlices предназначена для упрощённого внедрения редюсеров. Она также заменяет combineReducers, позволяя комбинировать несколько слайсов и редюсеров в один корневой редюсер.

При инициализации она принимает набор слайсов и карт редюсеров, возвращая экземпляр редюсера с методами для внедрения.

Примечание

"Слайс" для combineSlices обычно создаётся с помощью createSlice, но может быть любым "слайсоподобным" объектом со свойствами reducerPath и reducer (что означает совместимость с экземплярами RTK Query API).

const withUserReducer = rootReducer.inject({
reducerPath: 'user',
reducer: userReducer
})

const withApiReducer = rootReducer.inject(fooApi)

Для простоты объекты вида { reducerPath, reducer } в этой документации будут называться "слайсами".

Слайсы будут подключены по их reducerPath, а элементы из объектов-карт редюсеров — по соответствующим ключам.

const rootReducer = combineSlices(counterSlice, baseApi, {
user: userSlice.reducer,
auth: authSlice.reducer
})
// is like
const rootReducer = combineReducers({
[counterSlice.reducerPath]: counterSlice.reducer,
[baseApi.reducerPath]: baseApi.reducer,
user: userSlice.reducer,
auth: authSlice.reducer
})
Внимание!

Избегайте конфликтов имён — последующие ключи перезапишут предыдущие, и TypeScript не сможет это отследить.

Внедрение слайса

Для внедрения слайса вызовите rootReducer.inject(slice) у экземпляра редюсера, возвращённого из combineSlices. Это подключит слайс по его reducerPath к набору редюсеров и вернёт экземпляр комбинированного редюсера с обновлённой типизацией.

Альтернативно, можно вызвать slice.injectInto(rootReducer), который возвращает экземпляр среза с информацией о его внедрении. Можно использовать оба подхода, так как каждый вызов возвращает полезный результат, а combineSlices позволяет без проблем внедрять один и тот же экземпляр редюсера по одному reducerPath.

const withCounterSlice = rootReducer.inject(counterSlice)
const injectedCounterSlice = counterSlice.injectInto(rootReducer)

Ключевое отличие между стандартным внедрением редюсеров и подходом "мета-редюсера" в combineSlice заключается в том, что replaceReducer никогда не вызывается для combineSlice. Экземпляр редюсера, переданный в хранилище, не изменяется.

Следствием этого является отсутствие диспатча действия при внедрении среза, поэтому состояние внедренного среза не отображается в состоянии хранилища немедленно. Оно появится только после диспатча действия.

Однако, чтобы избежать необходимости обработки потенциально undefined состояния в селекторах, combineSlices предоставляет полезные утилиты для селекторов.

Объявление лениво загружаемых срезов

Для отображения лениво загружаемых срезов в выводимом типе состояния предоставляется хелпер withLazyLoadedSlices. Он позволяет заранее объявить срезы, которые планируется внедрить позже, чтобы они отображались как опциональные в типе состояния.

Чтобы полностью избежать импорта ленивого среза в файл комбинированного редюсера, можно использовать augmentation модулей.

// file: reducer.ts
import { combineSlices } from '@reduxjs/toolkit'
import { staticSlice } from './staticSlice'

export interface LazyLoadedSlices {}

export const rootReducer =
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()

// file: counterSlice.ts
import type { WithSlice } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

interface CounterState {
value: number
}

const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment: state => void state.value++
},
selectors: {
selectValue: state => state.value
}
})

declare module './reducer' {
// WithSlice utility assumes reducer is under slice.reducerPath
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}

// if it's not, just use a normal key
export interface LazyLoadedSlices {
aCounter: CounterState
}
}

const injectedCounterSlice = counterSlice.injectInto(rootReducer)
const injectedACounterSlice = counterSlice.injectInto(rootReducer, {
reducerPath: 'aCounter'
})

Утилиты для селекторов

Помимо inject, экземпляр комбинированного редюсера имеет метод .selector для оборачивания селекторов. Он оборачивает объект состояния в Proxy и предоставляет начальное состояние для редюсеров, которые были внедрены, но ещё не появились в состоянии.

Результат вызова inject типизируется с гарантией, что внедренный срез всегда будет определен при вызове селектора.

const selectCounterValue = (state: RootState) => state.counter?.value // number | undefined

const withCounterSlice = rootReducer.inject(counterSlice)
const selectCounterValue = withCounterSlice.selector(
state => state.counter.value // number - initial state used if not in store
)

"Внедренный" экземпляр среза делает то же самое для срезовых селекторов — предоставляет начальное состояние, если оно отсутствует в переданном состоянии.

const injectedCounterSlice = counterSlice.injectInto(rootReducer)

console.log(counterSlice.selectors.selectValue({})) // runtime error
console.log(injectedCounterSlice.selectors.selectValue({})) // 0

Типичное использование

combineSlices разработан так, чтобы внедрение среза происходило сразу при его необходимости (например, при импорте селектора или действия из загруженного компонента).

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

// file: reducer.ts
import { combineSlices } from '@reduxjs/toolkit'
import { staticSlice } from './staticSlice'

export interface LazyLoadedSlices {}

export const rootReducer =
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()

// file: store.ts
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

export const store = configureStore({ reducer: rootReducer })

// file: counterSlice.ts
import type { WithSlice } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import { rootReducer } from './reducer'

const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => void state.value++
},
selectors: {
selectValue: state => state.value
}
})

export const { increment } = counterSlice.actions

declare module './reducer' {
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}
}

const injectedCounterSlice = counterSlice.injectInto(rootReducer)

export const { selectValue } = injectedCounterSlice.selectors

// file: Counter.tsx
// by importing from counterSlice we guarantee
// the injection happens before this component is defined
import { increment, selectValue } from './counterSlice'
import { useAppDispatch, useAppSelector } from './hooks'

export default function Counter() {
const dispatch = usAppDispatch()
const value = useAppSelector(selectValue)
return (
<>
<p>{value}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
</>
)
}

// file: App.tsx
import { Provider } from 'react-redux'
import { store } from './store'

// lazily importing the component means that the code
// doesn't actually get pulled in and executed until the component is rendered.
// this means that the inject call only happens once Counter renders
const Counter = React.lazy(() => import('./Counter'))

function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
)
}

createDynamicMiddleware

Утилита createDynamicMiddleware создает "мета-мидлвар", позволяющий внедрять мидлвары после инициализации хранилища.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const dynamicMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
})

dynamicMiddleware.addMiddleware(logger)

addMiddleware

addMiddleware добавляет экземпляр мидлвары в цепочку, обрабатываемую динамическим мидлваром. Мидлвары применяются в порядке внедрения и хранятся по ссылке на функцию (одна и та же мидлвара применяется только один раз независимо от количества внедрений).

Примечание

Важно помнить, что все внедренные мидлвары содержатся внутри исходного экземпляра динамического мидлвара.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const dynamicMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
})

dynamicMiddleware.addMiddleware(logger)

// middleware chain is now [thunk, logger]

Для большего контроля над порядком можно использовать несколько экземпляров.

import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
import logger from 'redux-logger'
import reducer from './reducer'

const beforeMiddleware = createDynamicMiddleware()
const afterMiddleware = createDynamicMiddleware()

const store = configureStore({
reducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware()
.prepend(beforeMiddleware.middleware)
.concat(afterMiddleware.middleware)
})

beforeMiddleware.addMiddleware(logger)
afterMiddleware.addMiddleware(logger)

// middleware chain is now [logger, thunk, logger]

withMiddleware

withMiddleware — это создатель действия, который при диспатче добавляет указанные мидлвары и возвращает предварительно типизированную версию dispatch с добавленными расширениями.

const listenerDispatch = store.dispatch(
withMiddleware(listenerMiddleware.middleware)
)

const unsubscribe = listenerDispatch(addListener({ actionCreator, effect }))
// ^? () => void

Это особенно полезно в контексте без React. При работе с React предпочтительнее использовать интеграцию для React.

Интеграция с React

При импорте из точки входа @reduxjs/toolkit/react, экземпляр динамического middleware будет содержать несколько дополнительных методов.

createDispatchWithMiddlewareHook

Этот метод вызывает addMiddleware и возвращает версию хука useDispatch, типизированную с учётом внедрённого middleware.

import { createDynamicMiddleware } from '@reduxjs/toolkit/react'

const dynamicMiddleware = createDynamicMiddleware()

const useListenerDispatch = dynamicMiddleware.createDispatchWithMiddlewareHook(
listenerMiddleware.middleware
)

function Component() {
const dispatch = useListenerDispatch()

useEffect(() => {
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
return unsubscribe
}, [dispatch])
}
Внимание!

Middleware внедряется в момент вызова createDispatchWithMiddlewareHook, не при вызове хука useDispatch.

createDispatchWithMiddlewareHookFactory

Этот метод принимает экземпляр React-контекста и создаёт экземпляр createDispatchWithMiddlewareHook, использующий этот контекст (см. Предоставление кастомного контекста).

import { createContext } from 'react'
import { createDynamicMiddleware } from '@reduxjs/toolkit/react'
import type { ReactReduxContextValue } from 'react-redux'

const context = createContext<ReactReduxContextValue | null>(null)

const dynamicMiddleware = createDynamicMiddleware()

const createDispatchWithMiddlewareHook =
dynamicMiddleware.createDispatchWithMiddlewareHookFactory(context)

const useListenerDispatch = createDispatchWithMiddlewareHook(
listenerMiddleware.middleware
)

function Component() {
const dispatch = useListenerDispatch()

useEffect(() => {
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
return unsubscribe
}, [dispatch])
}

Сторонние библиотеки и фреймворки

Существует несколько хороших внешних библиотек, которые могут автоматизировать описанную функциональность: