前言
2015年发布的Redux至今仍是React生态中最常用的状态管理库,2022年4月19日发布的Redux [v4.2.0](https://github.com/reduxjs/redux/releases/tag/v4.2.0)
中正式将createStore
方法标记为“已弃用”(@deprecated
),并推荐用户使用Redux-Toolkit(下面简称为RTK)的configureStore
方法。
此次发布只是增加了额外的
@deprecate
提示,并不影响任何存量的代码,也不会有运行时的错误提示
可能在此之前很多Redux用户并没有了解过RTK(2021年Redux的占有率在45 - 50%,而同时用到RTK的仅有4.5%),而是用了其他基于Redux的封装来简化Redux的配置和使用,比如曾经一度流行的Redux封装Dva,现在周下载量仍有2w6+,还有相当多的存量应用。不过Dva上一次的正式版发布已经是三年多前了,事实上处于不维护状态。
为什么现在Redux要强推RTK和其代表的“现代Redux”?借此次发布的契机,来聊一聊现代Redux的一些演进
经典Redux
Redux是什么?
Redux是Flux架构的一种扩展或者实现,具有同样的单向数据流,最初是2015年Dan Abramov想要在Flux应用上新增热替换和时间旅行功能而开发的,为了让状态修改“可预测”。
function createStore(reducer) {let state;let listeners = [];let currentReducer = reducer;function getState() {return state;}function subscribe(listener) {listeners.push(listener);return function unsubscribe() {const idx = listeners.indexOf(listener);listeners.splice(idx, 1);};}function dispatch(action) {state = currentReducer(state, action);listeners.forEach((listener) => listener());}function replaceReducer(nextReducer) {currentReducer = nextReducer;dispatch({ type: "replace" });return store;}// 初始化各个reducer的状态树dispatch({ type: "init" });const store = { getState, subscribe, dispatch, replaceReducer };return store;
}
- 除去进阶的enhancers等功能,Redux核心就是一个简单的发布订阅模式,当action被dispatch时通知各个listener,只是限制了action必须是普通的对象。
- 为了实现热替换,Redux将Flux Store中的状态和状态更新逻辑(Reducer)分离,更新Reducer时只需要
replaceReducer
,而不会丢失当前状态。
Redux有著名的三个原则
- 单一数据源:全局状态都存放在一个单个Store的对象树。
- 只读的State,需要通过触发一个Action对象来修改,Action描述了要发生的修改。
- 使用纯函数修改State,reducer纯函数每次都会返回新的状态对象。
但这些原则并不是强制性的,比如大的应用可能拆分了多个Store、state可能在其他地方被直接修改、reducers触发了副作用等等。Redux实现本身并没有在Store或者Reducers上做任何检查或者限制,Redux Core被设计成最小API以及高度可扩展。三原则只是描述了Redux范式应该是怎么样的,具体实现和约束完全交给用户实现,因此Redux的生态非常繁荣,各种中间价百花齐放。
- 为了实现可预测的状态修改和时间旅行,引入了immutability和serializability ,这样每次状态对象变化不直接修改状态,而是生成新的对象,因此能够保存旧的状态,通过Redux DevTool就能追踪每一次的Action的带来的修改。
- 因为所有的状态都放在一个Store里,为了方便维护,可以将大的Reducer拆分多个子Reducer,每个子Reducer只管理对应的状态切片(State Slice),最后通过
combineReducers()
组合到一起。 - 因为Reducer都应该是纯函数进行同步操作,为了实现副作用和异步逻辑,社区上出现了如redux-thunk (async/await写法),redux-saga (generator写法,dva内置)等中间件实现。
- 为了方便维护和阅读,Action对象的Type一般是String,并引入了Action Creator(返回Action的函数)
- …
Redux的中间件其实就是改写了Redux Store里的dispatch方法,官方文档:
const logger = (store) => (next) => (action) => {console.log("dispatching", action);let result = next(action);console.log("next state", store.getState());return result;
};
function applyMiddleware(store, middlewares) {middlewares = middlewares.slice()middlewares.reverse()let dispatch = store.dispatchmiddlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))return { ...store, dispatch }
}
React-Redux
以上只是Redux核心库,要用于React还需要引入React-Redux库做UI Binding,通过高阶组件connect
和mapState/mapDispatch
来订阅Redux Store并通知React更新UI。具体来说就是将组件需要的state和dispath注入到props
里,因为性能优化和React API变更等原因,React-Redux的迭代远比Redux核心库频繁。
- v4及之前
connect
在componentDidMount
里订阅Store,每一个父组件和其子组件的connect
都会独立订阅。并且每次对Store的修改总会触发connect
re-render,mapState
等逻辑都是放在render阶段。 - v5.x重写了
connect
的逻辑为自顶向下订阅,因为componentDidMount
的执行顺序是从子组件到父组件,因此之前版本里子组件先订阅到了Redux Store,可能导致和父组件传递的Props不一致的问题。同时将状态派生等逻辑从React render中移除,只有mapState
结果不一样才会触发re-render,因此v5的性能比之前版本提升很多。 - v6发布在React 16.3推出新的
createContext()
API 取代legacy context之后,为了和未来的“Concurrent React”兼容,直接将原本在Store实例内部的State放在了createContext()
的context里,只有Provider
组件真正订阅了Store,其他子组件都依赖context本身来触发re-render(自顶向下)。因为React context需要遍历整个组件树来找到consumer来触发re-render,导致v6在几乎所有场景都比v5慢,越复杂的场景越慢。
Redux多年发展下来积攒了大量的“最佳实践”,几乎很少会只使用Redux Core进行开发,为了实现各种特性都有大量的中间件可以选择。开发一个Redux应用需要的配置和模版代码也就越来越多,比如大家熟悉的经典Redux全家桶:Webpack + React + React-Redux + Redux + Redux DevTools + Saga/Thunk + reselect + actionCreator+normalizr,reducer纯函数的写法在修改深对象和数组时也需要许多额外逻辑。
现代Redux
因为Redux的推广非常成功,以至于几乎和React绑定到一起,大量的用户从接触React起就开始使用Redux,配置和中间件等都照搬的模板,实际上Redux本身的灵活性反而成了普通用户的累赘,使得上手成本非常高。这个阶段许多像Dva的框架都开始尝试提供开箱即用的Redux,只暴露出少量的API 。 同时,
-
TypeScript开始兴起,给Redux Reducer/Store/Action/connect添加类型定义变得麻烦起来,几乎都需要用户自己手写。* React 16.8发布了Hook API,提供了
useContext
和useReudcer
来实现类Redux的状态管理Redux也积极响应变化,先是React-Redux v7使用Hook来重写connect
,并在v7.1提供了useSelector/useDispatch
Hook,Redux Starter Kit开始用TypeScript重写,然后改写了所有文档和实践,推荐用户直接使用Hook API而不是connect
。直到现在,Redux和RTK中会经常提到“现代”这个词,可以简单概况为以下实践: -
不需要手写冗长的Redux模板和配置代码(比如ActionCreator)
-
React-Redux Hook API取代麻烦的
connect
和mapState
-
Reducer中使用Immer来更新Immutable数据
-
更简单的TypeScript集成
-
内置安全检查* Immutability* Serializability
-
按feature组织Redux逻辑
-
抽象异步数据的获取、变更和缓存逻辑
-
包含了必要的中间件
把以上这些最佳实践组合到一起就是如今的RTK,仍然是经典的Redux,仍然有灵活性,但易用且低门槛。
快速上手
安装
因为RTK只是处理Redux的核心逻辑,Store和React之间的通讯还需要React-Redux(useSelecotr, useDispatch
)
gzip大小
- @reduxjs/toolkit:12.7k
- react-redux:4.7k
- jotai: 3.7k
npm install @reduxjs/toolkit react-redux
创建并连接Store
通过configureStore
API配置一个Redux Store,简化了以往繁杂的组合reducer,middleware,devTool, enhancer等流程,默认配置基本上保证了开箱即用。
// store.ts
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({// Root Reducer或者RTK的Slice Reducer组成的Mapreducer: {// TODO },// middleware: [],// 启用Redux DevTools,默认true// devTools: true,
})
其中
middleware
默认情况下在开发环境是[thunk, immutableStateInvariant, serializableStateInvariant]
,在生产环境仅保留thunk
。
通过React-Redux的Provider
组件包裹React App来传递Redux Store
// index.ts
import { createRoot } from 'react-dom/client'
import { store } from './store'
import { Provider } from 'react-redux'
import App from './App'
const root = createRoot(document.getElementById('root'))
root.render(<Provider store={store}><App /></Provider>
)
创建Redux Slice
通过createSlice
API来创建一个“状态切片”,即包含了namespace,initialState,reducers,action的集合体,也是官方推荐的标准Redux写法。
createSlice
封装了slice reducer,selector,immer,action creator等逻辑。
// slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface CounterState {value: number
}
const initialState: CounterState = {value: 0,
}
export const counterSlice = createSlice({name: 'counter',initialState,reducers: {increment(state) {// 内置immer,可以直接更改状态state.value += 1},incrementByAmount(state, action: PayloadAction<number>) {state.value += action.payload},},
})
export const { increment, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
createSlice
的返回值
{name : string,reducer : ReducerFunction,actions : Record<string, ActionCreator>,caseReducers: Record<string, CaseReducer>.getInitialState: () => State
}
然后将返回的reducer
添加到configureStore
中的reducer
字段里
// store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slice'
export const store = configureStore({reducer: {counter: counterReducer},
作为对比,下面是Dva中的一个典型Model例子,两者在约定的写法上没有多少出入。
// 1. Initialize
const app = dva();
// 2. Model
const model = {namespace: 'count',state: 0,reducers: {add(count) { return count + 1 },minus(count) { return count - 1 },reset() { return 0 },},
}
// 3. 绑定
app.model(model);
在React组件中使用State
- 通过
useSelector
来读取Store中的状态 - 通过
useDispatch
来派生状态,这里可以用createSlice
返回的action来简化。
// Counter.ts
import { useSelector, useDispatch } from 'react-redux'
import { increment } from './slice'
// counter就是添加Slice时用的key
const selectCount = (state) => state.counter.value
const Counter() {const count = useSelector(selectCount)const dispatch = useDispatch()// 相当于dispatch({ type: 'counter/increment' })const onInc = () => dispatch(increment())return (<div><button onClick={onInc}>+</button><span>{count}</span></div>)
}
TypeScript支持
这里只简单介绍基础用法,实际情况下TypeScript类型定义会复杂很多,这也是Redux一直以来的痛点,详见官方文档
// store.ts
/// store = configureStore(....)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
为了避免在每次使用useSelector
和useDispatch
都带上这两个类型,可以重新定义这两个Hook
// hook.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
副作用/异步逻辑
因为Redux Store本身只有同步Dispatch Action的能力,reducer也不应该包含任何副作用,副作用通常需要第三方中间件,如redux-thunk (async/await写法),redux-saga (generator写法,dva内置)。
在RTK中默认启用了redux-thunk作为异步逻辑中间件,Thunk在Redux中指返回值为函数的action 生成器。
const usersSlice = createSlice({name: 'users',initialState: { entities: [], loading: 'idle' },reducers: {usersLoading(state, action) {state.loading = 'loading'},usersFetched(state, action) {state.loading = 'idle'state.entities = action.payload}},
})
const { usersLoading, usersFetched } = usersSlice.actions
const fetchUserById = (userId) => async (dispatch, getState) => {dispatch(usersLoading())const response = await userAPI.fetchById(userId)dispatch(usersFetched(response.data))
}
同样的,RTK提供了createAsyncThunk
来简化Thunk Action的定义流程:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// 创建thunk action创建函数
// 包含本身定义的用户逻辑,pending, fulfilled, rejected 4种action
const fetchUserById = createAsyncThunk('users/fetchByIdStatus',async (userId, thunkAPI) => {const response = await userAPI.fetchById(userId)return response.data}
)
const usersSlice = createSlice({name: 'users',initialState: { entities: [], loading: 'idle' },reducers: {},// 在extraReducer中可以定义reducer来响应Slice外部的action// 比如这里的fetchUserById就是外部定义的Thunk ActionextraReducers: (builder) => {builder.addCase(fetchUserById.pending, (state) => {state.loading = 'loading'}).addCase(fetchUserById.fulfilled, (state, action) => {state.entities.push(action.payload)})},
})
// dispatch thunk, 相当于
// 1. dispatch pending
// 2. dispatch 用户逻辑
// 3. dispatch fulfilled 或 rejected
dispatch(fetchUserById(123))
简单实现createSlice
可以看出createSlice
是简化模板代码最多的reducer和action的关键API,其实核心实现也比较的简单: 包含了createAction
和createReducer
两个工具API
这里的简单实现忽略了immer的集成和Action Matcher相关逻辑
createAction
:返回一个action创建器
function createAction(type) {function actionCreator(...args) {return { type, payload: args[0] }}actionCreator.toString = () => `${type}`actionCreator.type = typeactionCreator.match = (action) => action.type === typereturn actionCreator
}
createReducer
:构建action type的映射Map
function createReducer(initialState, actionsMap) {function reducer(state = initialState, action) {const caseReducer = actionsMap[action.type];if (caseReducer) {return caseReducer(state, action)}return previousState}reducer.getInitialState = () => initialState;return reducer
}
createSlice
function getType(slice, actionKey) {return `${slice}/${actionKey}`
}
function createSlice(options) {if (!options.name) {throw new Error('`name` is a required option for createSlice')}const { name, initialState, reducers = {} } = optionsconst reducerNames = Object.keys(reducers)const sliceCaseReducersByName = {}const sliceCaseReducersByType = {}const actionCreators = {}reducerNames.forEach((reducerName) => {const caseReducer = reducers[reducerName]const type = getType(name, reducerName)sliceCaseReducersByName[reducerName] = caseReducersliceCaseReducersByType[type] = caseReduceractionCreators[reducerName] = createAction(type)})function buildReducer() {// 这里还会有extraReducer的处理,这里省略const finalCaseReducers = { ...sliceCaseReducersByType }return createReducer(initialState, finalCaseReducers)}let _reducer;return {name,reducer(state, action) {if (!_reducer) _reducer = buildReducer()return _reducer(state, action)},actions: actionCreators,caseReducers: sliceCaseReducersByName,getInitialState() {if (!_reducer) _reducer = buildReducer()return _reducer.getInitialState()},}
}
更进一步 - RTK Query
在实际的应用场景中,除了一般的客户端状态管理,还常常充斥着异步数据获取与缓存的复杂状态逻辑。尽管RTK中已经提供了createSlice
和createAsyncThunk
来简化异步数据的流程,但仍然有大量类似的逻辑需要开发者处理。
因此基于React社区经验(react-query/useSWR)和RTK本身,Redux官方推出了RTK Query来解决异步数据的问题。
虽然下面的例子都是React的,但和RTK一样,RTK Query的实现也是和具体UI框架无关
快速入门
社区其他实现习惯是将每个API分开定义,每一个都是单独的自定义hook,而在RTK Query需要通过createApi
定义一个API服务,所有的API endpoint都集中在一块,产物是一个API Slice(类似于Redux Slice)
endpoints按用途分为了两类:查询(query)和突变(mutation)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({reducerPath: 'api',baseQuery: fetchBaseQuery({baseUrl: '/'}),endpoints: (builder) => ({// 定义查询getPost: builder.query({query: (id) => `post/${id}`,}),// 定义突变addPost: builder.mutation({query: (body) => ({url: `posts`,method: 'POST',body,}),}),})
})
// 自动生成了每个Endpoint对应的useQuery/useMutation Hook
export const { useGetPostsQuery, useAddPostMutation } = api
export const { endpoints, reducerPath, reducer, middleware } = api
// endpoints也包含hooks
api.endpoints.getPosts.useQuery // useGetPostsQuery
api.endpoints.updatePost.useMutation // useAddPostMutation
其中通过自定义baseQuery
就可以实现全局的请求拦截器。如果需要单独处理某个endPoint
的请求行为,可以通过onQueryStarted
实现,该函数会在请求的整个生命周期(发起/成功/失败)中被调用,或者定义queryFn
来绕过baseQuery
进行查询。
// 函数签名
async function onQueryStarted( arg: QueryArg,{dispatch,getState,extra,requestId,queryFulfilled, // PromisegetCacheEntry,updateCachedData,}: QueryLifecycleApi ): Promise<void>
// 例子
const api = createApi({baseQuery: fetchBaseQuery({baseUrl: '/'}),endpoints: (build) => ({getPost: build.query<Post, number>({query: (id) => `post/${id}`,async onQueryStarted(id, { dispatch, queryFulfilled }) {// 请求开始dispatch('...')try {const { data } = await queryFulfilled// 请求成功dispatch('...')} catch (err) {// 请求失败dispatch('...')}},}),}),
})
API Slice
主要包含了以下部分:
- Redux Reducer:管理缓存数据的reducer
- Redux MiddleWare:管理缓存数据的生命周期和订阅的中间件
- Endpoints:根据用户定义的endpoints生成的Redux相关逻辑,同时也包含Hooks
- Utils: 提供一系列的Action Creator用于手动管理缓存数据
- Hooks:根据endpoints生成的数据获取React Hook
type Api = {// Redux 集成reducerPath: string;reducer: Reducer;middleware: Middleware;// Endpoint 交互endpoints: Record<string, EndpointDefinition>;util: {// ...updateQueryData: UpdateQueryDataThunk;patchQueryData: PatchQueryDataThunk;prefetch: PrefetchThunk;invalidateTags: ActionCreatorWithPayload<Array<TagTypes | FullTagDescription<TagTypes>>,string>;resetApiState: SliceActions["resetApiState"];selectInvalidatedBy: ( state: RootState<Definitions, string, ReducerPath>,tags: ReadonlyArray<TagDescription<TagTypes>> ) => Array<{endpointName: string;originalArgs: any;queryCacheKey: string;}>;// ...};// 自动生成的 React hooks[key in GeneratedReactHooks]: GeneratedReactHooks[key];
};
查询 & 突变
useQuery
用于获取服务端数据,和useSWR
,react-query
,ahooks的useRequest
等请求Hook的用法类似。 主要包含以下特性:
- 轮询
- 缓存
- 依赖刷新
- 聚焦重新请求
- 条件查询(ready/skip)
const {data,error,isLoading,isError,isFetching,refetch // 强制重新请求的函数} = useGetPostQuery(id, {skip: false,pollingInterval: 10_000,refetchOnFocus: true, selectFromResult: undefined, // 根据选择器,只订阅结果的一部分,其他部分改变不会引起重渲染refetchOnReconnect: true,refetchOnMountOrArgChange: true});
因为本质上还是订阅了Redux Store,所以useQuery
的selectFromResult
同样遵循selector
的原则,如果返回的引用发生改变(比如map/filter
等操作)就会使优化失效。 如果需要组件级别来派生数据并正确缓存,则需要每一个组件有一个唯一的reselect
选择器实例,可以通过组合useMemo
和reselect
来实现:
import { createSelector } from '@reduxjs/toolkit'
import { useGetPostsQuery } from '../api/apiSlice'
// useMemo保证该选择器在当前组件的渲染期间引用不变
const selectPostsForUser = useMemo(() => {const emptyArray = [];// 返回记忆化的选择器实例return createSelector([(res) => res.data, (res, name) => name],(data, name) =>data?.filter((post) => post.name.includes(name)) ?? emptyArray);
}, []);
const { filteredPosts } = useGetPostsQuery(undefined, {selectFromResult: (result) => ({...result,filteredPosts: selectPostsForName(result, name)})
})
useMutation
用于发送数据到服务端并更新本地缓存
const [updatePost, result] = useAddPostMutation();
const { isLoading, data } = result;
updatePost({// ...
})
缓存
缓存是这类异步请求库的核心特性,RTK Query将每个查询的endpoint和参数序列化为字符串当作queryCacheKey
,queryCacheKey
相同的查询会共享同一个请求和缓存。当查询的引用计数为0(即没有组件用到),在一定时间后缓存将会被自动清除。除了根据queryCacheKey
进行缓存,RTK Query还可以通过缓存标签中间件实现自动的缓存管理。
比如以下简单的CURD配置,给Post相关的查询标记为'Posts'
,而Post相关的突变使有'Posts'
标签的缓存失效。因此当发生增删改后,对应Post的查询缓存就会失效并自动重新发起请求,标签还可以指定id
来匹配特定查询。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface Tag {type: string;id?: string | number;
}
export const postApi = createApi({reducerPath: 'postsApi',baseQuery: fetchBaseQuery({ baseUrl: '/' }),tagTypes: ['Posts'],endpoints: (build) => ({getPosts: build.query({query: () => 'posts',providesTags: [{ type: 'Posts', id: 'LIST' }],}),getPost: build.query({query: (id) => `post/${id}`,providesTags: (result, error, id) => [{ type: 'Posts', id }],}),addPost: build.mutation({query(body) {return {url: `post`,method: 'POST',body,}},invalidatesTags: [{ type: 'Posts', id: 'LIST' }],}),deletePost: build.mutation({query(id) {return {url: `post/${id}`,method: 'DELETE',}},invalidatesTags: (result, error, id) => [{ type: 'Posts', id }],}),}),
})
标签中间件的部分实现:
// packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts
function invalidateTags( tags: readonly FullTagDescription<string>[],mwApi: SubMiddlewareApi ) {const rootState = mwApi.getState()const state = rootState[reducerPath]const toInvalidate = api.util.selectInvalidatedBy(rootState, tags)context.batch(() => {const valuesArray = Array.from(toInvalidate.values())for (const { queryCacheKey } of valuesArray) {const querySubState = state.queries[queryCacheKey]const subscriptionSubState = state.subscriptions[queryCacheKey]if (querySubState && subscriptionSubState) {if (Object.keys(subscriptionSubState).length === 0) {// 引用计数为0时直接清理缓存mwApi.dispatch(removeQueryResult({queryCacheKey: queryCacheKey as QueryCacheKey,}))} else if (querySubState.status !== QueryStatus.uninitialized) {// 存在引用且已经查询过,重新查询并更新缓存mwApi.dispatch(refetchQuery(querySubState, queryCacheKey))} else {}}}})}
总结
虽然近年来React社区的状态管理方案层出不穷,比如原子式的jotai
和recoil
,基于proxy的Valtio
,但Redux仍然是使用最广泛,影响力最大的,生态最繁荣的一个方案。官方这次力推的RTK和RTK Query通过大量抽象和简化已经基本上做到和其他轻量库类似的开发体验(包括TypeScript类型补全),同时兼具效率和可扩展性(虽然API数量还是不少)。如果已经在用Redux类的状态管理,不妨往前迈一步,尝试下现代Redux开发。