rd-model v0.1.0
rd-model
rd-model 是一个为了简化 redux 在 react 应用中使用的工具库。
核心功能在于将 reducer 切片进行统一封装,将某个 reducer 切片的初始值定义、动作派发函数、reducer 函数、数据加载,状态订阅等汇总在一起。让你在 react 中使用 redux 就像是在定义和使用一个数据模型一样简单。
安装和初始化
pnpm install rd-model
需要在 react 根组件上包裹 ReduxProvider 组件,给 ReduxProvider 传入一个 store 对象来完成初始化。
import ReactDom from 'react-dom';
import { ReduxProvider, initStore } from 'rd-model';
import App from './app';
const { store, updateReducer } = initStore({});
ReactDom.render(
<ReduxProvider store={store}>
<App />
</ReduxProvider>,
document.getElementById('root')
);
基础示例
1. 定义模型
import {
initStore,
createModel,
PayloadAction,
} from 'rd-model';
const { store, updateReducer } = initStore({});
const countModel = createModel({
statePaths: ['count_demo'],
initialState: {
count: 0,
},
reducers: {
add: (state, action: PayloadAction<number>) => {
state.count += action.payload;
},
minus: (state, action: PayloadAction<number>) => {
state.count -= action.payload;
},
}
});
// 将切片合并到 RootReducer 上
updateReducer({ count_demo: countModel.reducer });
2. 使用模型
这里用基于 jest 的单元测试用例举例
// 1. model.getState
expect(countModel.getState()).toBe(store.getState().count_demo); // pass:
// 2. model.actions
countModel.actions.add(2); // 触发 count_demo/add 动作
expect(countModel.getState().count).toBe(2); // pass
countModel.actions.minus(4); // 触发 count_demo/minus 动作
expect(countModel.getState().count).toBe(-2); // pass
// 3. model.useModel
const wrapper = ({ children }) => (
<ReduxProvider store={store}>
{children}
</ReduxProvider>
);
const { result, waitForNextUpdate } = renderHook(
() => countModel.useModel((state) => state.count),
{ wrapper },
)
countModel.actions.add(2)
await waitForNextUpdate();
expect(result.current).toBe(0); // pass
基础API
useStore
在函数组件中获取 redux 的 store 对象
const useStore: () => Store<any, AnyAction>
useDispatch
在函数组件中获取 redux 的 dispatch 方法。
const useDispatch: () => Dispatch<AnyAction>
useSelector
在函数组件中订阅 redux 的状态数据。
type Selector<S = unknown, P = unknown> = (state: S) => P;
type UseSelectorOptions<P> = {
/** 同步订阅,redux 值一更新就马上执行组件的 update 操作,默认为 false */
sync?: boolean;
/** 对比方法: 默认浅对比 */
eq?: IsEqual<P>;
/** 是否与 React.Suspense 配合使用 */
withSuspense?: boolean | FunctionLike<[P], boolean>;
}
function useSelector<S = DefaultRootState, P = any>(
selector: Selector<S, P>,
options?: UseSelectorOptions<P>
): P
配置对象说明:
- sync: 是否立即更新组件状态。默认是否,状态变化时,组件的状态是异步更新的。
- eq: 新旧状态的对比方法,默认使用浅对比函数。
- withSuspense: 一个判断函数,判断所选状态是否为加载中,如果是,则抛出一个 promise,直到状态数据加载完成,promise 才会被 resolve 掉。如果传入值 true,则是使用内部自带的判断函数。
核心 API
createModel
function createModel(ModelOptions):Model;
作为 rd-model 的一个核心函数,它接受初始值、reducers方法对象、持久化配置对象和异步数据获取方法对象等作为参数,并返回 Model 对象,对象内包含动作派发、状态订阅、数据加载、reducer 等方法。
type ModelOptions<STATE> {
/**
* 当前 reducer 切片的合并路径
*/
statePaths: string[];
/**
* 初始值
*/
initialState: STATE;
/**
* 数据的更新方法
* 作用:生成 reducer 与 actionDispatcher
*/
reducers: { [key: string]: (state, action:PayloadAction<any>) => void };
/**
* 异步数据的获取方法。
* 作用:网络防抖、自动管理 pending 状态
*/
fetch?: { [key in keyof STATE]?: (...args: unknown[]) => Promise<STATE[key]> };
/**
* 持久化缓存相关配置
*/
cacheKey?: string;
cacheStorage?: 'session'|'local'|Storage;
cacheVersion?: string;
/**
* 订阅当前切片外的动作
*/
extraReducers?: Record<string, ReducerCase<STATE>> | FunctionType<[Builder<STATE>], void>;
}
PS: 为了实现良好的类型提醒,ModelOptions
的 TS 类型比较复杂,这里为了容易阅读,对其进行了简化。
statePaths
一个字符串数组,表示当前 reducer 切片的合并路径,它可以帮助 model 对象准确地订阅和修改 redux 中的数据。但是它同时要求你在以同样的路径对 model 的 reducer 方法进行嵌套合并。
// 一级嵌套
const testModel = createModel({statePaths: ['test']});
const rootReducer = combineReducers({
...otherReducer,
test: testModel.reducer
});
// 多级嵌套
const testFooModel = createModel({statePaths: ['test', 'foo']});
const testBarModel = createModel({statePaths: ['test', 'bar']});
const rootReducer = combineReducers({
...otherReducer,
test: combineReducers({
foo: testFooModel.reducer,
bar: testBarModel.reducer,
})
});
initialState
reducer 切片的初始值,因为 rd-model 内部使用了 immer 来实现数据不可变,所以,数据模型的初始值必须是一个引用值,而不是简单值。
如果你的 reducer 切片数据仅仅只是一个
number
,string
等简单值,那大可不必使用一个数据模型来管理它。
reducers
一个 key-value
对象, 类型定义如下:
type CaseReducers = {
[key: string]: (state, action:PayloadAction<any>) => void
};
这实际上是对 reducer 函数 switch-case
写法的升级,每个 key 对应的方法代表对当前切片数据的一种修改。
这些方法会使用 immer 进行包装,所以你可以在函数内直接对 state 进行赋值修改,无需像以往一样通过拓展语法去返回一个新的对象。
rd-model 可以由该对象计算出最终的 reducer 方法,以及生成对应的 actionDispatcher 方法。
你需要对 action 参数通过
PayloadAction
指定类型来帮助 rd-model 进行类型推导。
fetch
指定模型对象中某个字段数据的获取方法(异步)。如果数据模型中某个字段的值是通过非分页请求获取的,那么你可以通过 fetch 字段进行配置。
/** redux 定义 **/
type BusinessData = {/*...*/}
const dataModel = createModel({
initialState: {
// 其他字段 。。。
businessData: null as BusinessData,
},
// 其他配置 。。。
fetch: {
// 定义 businessData 的获取方法
businessData: fetchBusinessData, // as (id: string) => Promise<BusinessData>
}
});
/** 业务组件 **/
import { isPending } from 'rd-model';
function BusinessComponent(props) {
useEffect(() => {
// 直接调用 fetch.businessData
// rd-model 内部会自动维护加载状态的更新
dataModal.fetch.businessData(props.id);
}, [props.id]);
// 状态数据订阅
const businessData = dataModal.useModel(state => state.businessData);
// 通过 isPending 直接判定是否处于加载中状态。
if (isPending(businessData) || !businessData) {
return <Loading />
}
return <Display data={businessData} />
}
持久化缓存
当你不希望某些 Redux 数据随着页面刷新就丢失时,你就可以将它们下沉到 localStorage
或 sessionStorage
中,以达到持久化的目的。createModel
提供了三个用于持久化缓存的配置字段:
cacheStorage?: 'session'|'local'|Storage;
:配置存储对象。cacheKey?: string;
: 存储用的 key 值,需要维护其唯一性。cacheVersion?: string;
:缓存的版本号,一般用于避免代码版本的升级导致数据结构的冲突。
extraReducers
订阅当前切片外的动作。
const dataModel = createModel({
initialState,
// other config ...
extraReducers: {
"root-reset": (state, action) => {
return initialState;
}
}
});
Model
Model 对象包含对当前 reducer 切片状态数据的所有订阅和更新方法。它拥有完善的类型提醒,使用也非常简单。
type Selector<S, s> = (state: S) => s;
type UseModelOption = {
withSuspense?: boolean | ((subState: any) => boolean);
eq?: (a, b) => boolean;
};
type Model<STATE> = {
/** 获取切片状态数据 */
getState: () => STATE;
/** 组件内订阅切片状态数据 */
useModel: <T>(selector: Selector<STATE, T>, options?: UseModelOption) => T;
/** actions dispatchers */
actions: Record<string, Function>;
/** data fetchers */
fetch: Record<string, PromiseFn>;
/** pure reducer functions */
reducer: Reducer<STATE>;
}
getState
获取当前 reducer 切片的状态数据。
useModel
在函数组件内订阅当前 reducer 切片的状态数据。在所订阅的状态发生变化时更新当前组件。默认使用浅对比判断状态是否变化。
useModel
与 useSelector
方法的用法是一致的。区别在于它们订阅的数据范围不同,useModel
是当前切片状态,useSelector
是全局状态。
const dataModel = createModel({
initialState: {
// other...
businessData: null as BusinessData,
},
// other config ...
fetch: {
businessData: fetchBusinessData, // () => Promise<BusinessData>
}
});
function Children() {
// 订阅切片状态数据
const businessData = dataModel.useModel(
state => state.businessData,
{ withSuspense: true }
);
return (<Display data={businessData} />)
}
function Parent() {
useLayoutEffect(() => {
dataModel.fetch.businessData()
}, []);
return (
<Suspense fellback={<Loading/>}>
<Children />
</Suspense>
)
}
当用 model.fetch.xxx
去获取异步数据时,withSuspense
配置能减少不少判定逻辑。
因为使用 withSuspense
配置后,当数据处于加载中状态时它会抛出一个 Promise 异常,这个 Promise 会等待加载中状态结束时进行 resolve。 配合 React.Suspense
就可以像获取同步数据一样获取异步数据。
actions
动作派发方法集合对象,调用这些方法就可以直接派发动作,不需要额外调用 bindActionCreators
。该对象由 reduces 配置推导而出:
// reducers 定义
type Reducers = {
setNum: (state, action: PayloadAction<number>) => void;
reset: (state) => void;
};
// 推导出
type Actions = {
setNum: (p: number) => void;
reset: () => void;
}
fetch
状态数据加载方法集合对象,拥有与 fetch
配置对象一样的类型签名,用于获取异步数据。它除了会自动维护数据的加载状态外,还处理了数据竞争的问题。
数据竞争说明: 用户不断变更筛选条件,导致发起多次筛选请求,但这些请求最终都是作用于同一个数据,这个时候,数据的最终结果会变得不可控,网络抖动会导致请求的返回顺序与发起顺序不一致,最终导致界面会展示最慢返回的请求数据,这与用户的期待是不一致的。
虽然使用截流函数能减少此类问题的概率,但是并不能完全杜绝出现的可能性。
reducer
一个纯的 reducer 函数,由 reducers 配置生成,为了让 model 对象能够正常使用,需要将它合并到正确的位置上,详见 statePaths
配置。