Оптимизация bundle size

RTK Query поставляется как часть @reduxjs/toolkit, и его архитектура изначально ориентирована на tree-shaking и ESM-совместимость. Однако итоговый размер bundle в реальных проектах зависит не столько от самой библиотеки, сколько от способа импорта и организации API-слоёв.

Ключевой момент — различие между пакетами:

  • @reduxjs/toolkit/query
  • @reduxjs/toolkit/query/react

Первый содержит только ядро логики: создание API, middleware, кэширование и базовые утилиты. Второй добавляет React-адаптер: хуки, интеграцию с жизненным циклом компонентов, дополнительные обёртки.

Использование react-версии автоматически увеличивает размер сборки, но позволяет использовать useQuery, useMutation и связанные хуки. В проектах без React или с гибридной архитектурой имеет смысл ограничиваться @reduxjs/toolkit/query.


Tree-shaking и условия его срабатывания

Tree-shaking в RTK Query работает только при соблюдении условий сборщика (Webpack, Vite, Rollup):

  • ESM-импорты без CommonJS-обёрток
  • отсутствие динамических require
  • корректная настройка sideEffects: false в зависимости

Redux Toolkit уже поставляется с корректной ESM-структурой, но проблемы возникают на уровне пользовательского кода:

import { createApi } from '@reduxjs/toolkit/query/react'

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

Оптимальный вариант при отсутствии React:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'

Разделение API на независимые слайсы

Один из главных факторов роста bundle — монолитный API-слайс.

Когда в одном createApi описаны десятки эндпоинтов разных доменов (users, auth, orders, analytics), сборщик вынужден включать весь файл целиком, даже если используется только часть.

Разделение API на логические домены снижает объем финального бандла за счёт:

  • более точного tree-shaking
  • возможности code splitting
  • ленивой подгрузки редьюсеров

Пример структуры:

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query({ query: (id) => `user/${id}` })
  })
})

export const orderApi = createApi({
  reducerPath: 'orderApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getOrders: builder.query({ query: () => 'orders' })
  })
})

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


Динамическая инъекция endpoints

RTK Query поддерживает инъекцию эндпоинтов через injectEndpoints. Это один из наиболее эффективных способов уменьшить initial bundle size.

Базовый API определяется отдельно:

export const baseApi = createApi({
  reducerPath: 'baseApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: () => ({})
})

Функциональные модули добавляют эндпоинты только при загрузке соответствующего кода:

const extendedApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getProfile: builder.query({
      query: () => 'profile'
    })
  })
})

Преимущество этого подхода:

  • базовый API остаётся минимальным
  • фичи подгружаются по мере необходимости
  • улучшается code splitting на уровне маршрутов

Lazy loading API-модулей

В связке с динамическими импортами ES Modules RTK Query позволяет переносить загрузку API-логики на уровень маршрутов:

const OrdersPage = React.lazy(() => import('./pages/OrdersPage'))

Внутри модуля OrdersPage импортируется только нужный API:

import { orderApi } from '../services/orderApi'

Такой подход гарантирует, что код RTK Query для конкретного домена не попадёт в initial bundle.

Особенно эффективно в SPA с роутингом:

  • /users загружает userApi
  • /orders загружает orderApi

Минимизация использования React-обёртки

Пакет @reduxjs/toolkit/query/react добавляет значительный объём кода за счёт:

  • генерации React hooks
  • подписок на lifecycle компонентов
  • интеграции с React render cycle

Если архитектура позволяет, можно разделить слой запросов и UI:

import { createApi } from '@reduxjs/toolkit/query'

и затем вручную обрабатывать dispatch:

dispatch(api.endpoints.getUser.initiate(id))

Это снижает bundle size, но увеличивает сложность. Такой подход оправдан:

  • в non-React проектах
  • в middleware-ориентированных архитектурах
  • в сервисных слоях (BFF, orchestration)

Контроль за импортами утилит Redux Toolkit

Частая проблема — неявное подтягивание всего @reduxjs/toolkit через неправильные импорты.

Плохая практика:

import { configureStore } from '@reduxjs/toolkit'

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

Для RTK Query важно избегать:

  • импорта из глубинных CommonJS путей
  • barrel imports, собирающих весь пакет

Правильный подход:

import { createApi } from '@reduxjs/toolkit/query'

или строго React-ориентированный:

import { createApi } from '@reduxjs/toolkit/query/react'

но не смешивать их без необходимости.


Влияние baseQuery на размер бандла

baseQuery часто недооценивается как источник увеличения bundle size.

Использование готовых адаптеров может подтянуть лишние зависимости:

  • fetchBaseQuery — минимальный и предпочтительный вариант
  • кастомные axiosBaseQuery могут добавлять axios в bundle

Пример минимального решения:

import { fetchBaseQuery } from '@reduxjs/toolkit/query'

Альтернативы с axios:

import axios from 'axios'

в большинстве случаев увеличивают итоговый размер бандла без необходимости.


Разделение middleware и store

RTK Query middleware добавляется в store автоматически:

middleware: (getDefaultMiddleware) =>
  getDefaultMiddleware().concat(api.middleware)

При наличии нескольких API слайсов middleware также растёт.

Оптимизация достигается через:

  • объединение API в минимальное количество инстансов
  • избегание дублирования createApi
  • переиспользование baseApi через injectEndpoints

Удаление неиспользуемых endpoints

Каждый endpoint внутри createApi остаётся частью AST модуля, и даже если он не используется в рантайме, он может попасть в bundle.

Оптимизация:

  • разделение API по доменам
  • удаление экспериментальных endpoint’ов из production-сборки
  • использование feature flags на уровне сборки

Пример условной компиляции:

const endpoints = (builder) => {
  const base = {
    getUser: builder.query({ query: (id) => `user/${id}` })
  }

  if (process.env.ENABLE_ADMIN_API) {
    base.getAdmin = builder.query({ query: () => 'admin' })
  }

  return base
}

Side-effects и влияние на tree-shaking

Redux Toolkit корректно помечает пакеты как ESM, но ошибки появляются при неправильной конфигурации bundler’а.

Критически важно:

  • "sideEffects": false в package.json проекта
  • использование ESM-сборки в Vite/Webpack 5+
  • избегание глобальных импортов типа import '@reduxjs/toolkit'

Если side effects не настроены, tree-shaking может быть полностью отключён, и RTK Query будет тянуться целиком.


Оптимизация селекторов и уменьшение косвенного кода

Хотя selectFromResult напрямую не влияет на bundle size, он влияет на то, какие части кода подтягиваются в зависимости от использования.

Избыточные селекторы увеличивают связность модулей:

useGetUserQuery(id, {
  selectFromResult: ({ data }) => ({ user: data?.user })
})

Чем меньше логики внутри API слоя, тем легче сборщику разделять зависимости.


Итоговая структура оптимизированного API слоя

Типовая оптимизированная архитектура выглядит как:

  • baseApi без endpoints
  • feature-модули через injectEndpoints
  • использование fetchBaseQuery
  • разделение по доменам
  • lazy loading через динамические импорты
  • минимальное использование react-обёртки при необходимости

Такой подход обеспечивает предсказуемый tree-shaking, уменьшает initial bundle и позволяет масштабировать API без линейного роста размера сборки.