具体源码可以看这里
觉得不错的小伙伴记得给个star⭐️,谢谢支持,
在移动端页面中,列表是一个很常见的功能,接下来手把手教你实现一个下拉刷新,上拉加载,带有搜索功能的列表状态管理器
clean-js 使用方法
在此之前先说明一下这个状态库如何使用
功能:
- 提供presenter的约束,约束视图状态和更新的方式;
- 提供视图devtool(redux-devtool/log)
- 提供适配器,适配react/vue/...
- 提供IOC容器,可以实现依赖注入
- 根据YAPI,swagger2,swagger3等api协议自动生成请求代码
实现:
- 所有的状态类都需要继承基类Presenter,需要在基类写入泛型 IViewState
- 在构造器函数中需要声明默认的state,类型为 IViewState
- 可以通过setState函数来设置state值,从而触发组件渲染
interface IViewState {
loading: boolean;
name: string
}
export class NamePresenter extends Presenter<IViewState> {
constructor() {
super();
this.state = {
loading: false,
name: 'hahaha'
}
}
changeName() {
this.setState(s => {
s.name = 'segmentfault'
}); // api of set model state
}
}
具体在react组件中使用的方式如下
const Name = () => {
const { presenter, state } = usePresenter(NamePresenter);
return (
<div>
name: {state.name}
<button onClick={presenter.changeName}>change name</button>
</div>
);
};
export default Name;
此外还支持依赖注入,context,根据YAPI,swagger2,swagger3等api协议自动生成请求代码等多种功能
定义列表模型
首先安装一下自己写的状态库
npm install @clean-js/presenter @clean-js/react-presenter --save
接着定义列表的模型,通常来说我们需要下面这些属性
- loading: boolean; 加载中的状态
- data: Row[]; 列表数据,请求每一页的数据
- allData: Row[]; 列表数据,缓存所有的数据
- params: Record<any, any>; 请求附带的参数,
- pagination: IPagination; 分页相关的参数
export interface IPagination {
current: number;
pageSize: number;
total: number;
}
interface IViewState<Row, OtherParams> {
loading: boolean;
data: Row[];
allData: Row[];
params: OtherParams;
pagination: IPagination;
}
有了这些属性,在组件中就可以正常的渲染列表了
定义通用方法
回到我们的需求
接下来声明ListPresenter类,给他设置一些通用的方法
ListPresenter类中我们声明了几个方法
- fetchData 用来发起请求,他会接受params和pagination作为参数,并且返回约定后的接口,这个函数需要具体业务来实现
- showLoading/hideLoading 切换loading状态
- loadMore 调用fetchData来发起请求,请求完成后更新data,loading和分页数据
- hasMore 判断是否有下一页
- updateParams 更新请求参数,通常我们列表都会伴随搜索框,筛选框,这之后就可以通过这个方法来更新对应的参数了,需要注意的是,在参数发生变化之后,分页会重置为第一页
- resetParams 顾名思义,用来重置请求参数
- updatePagination 分页参数有关的逻辑,具体可以看下面代码
有了这些方法,我们的列表状态管理就完成了
import { Presenter } from '@clean-js/presenter';
// 三个固定参数
export interface IPagination {
current: number;
pageSize: number;
total: number;
}
interface IViewState<Row, OtherParams> {
loading: boolean;
data: Row[]; // 请求每一页的数据
allData: Row[]; // 缓存所有的数据
params: OtherParams; // 额外请求参数
pagination: IPagination;
}
const defaultState = () => ({
loading: false,
params: {} as Record<any, any>,
pagination: { current:1, pageSize: 10, total: 0 },
data: [],
allData: [],
});
export class ListPresenter<
Row = any,
Params = Record<any, any>
> extends Presenter<IViewState<Row, Params>> {
constructor(
) {
super();
this.state = defaultState();
}
loadingCount = 0
showLoading() {
this.loadingCount += 1
if (this.loadingCount === 0) {
this.setState(s => {
s.loading = true;
});
}
}
hideLoading() {
this.loadingCount -= 1
if (this.loadingCount === 0) {
this.setState(s => {
s.loading = false;
});
}
}
fetchData(
params: Partial<Params> & { current: number; pageSize: number },
): Promise<{ data: Row[]; current: number; pageSize: number; total: number }>{
throw Error('请实现fetchTable');
}
/**
* 加载更多
* @returns
*/
loadMore() {
const params: Partial<Params> = {};
Object.entries(this.state.params || {}).map(([k, v]) => {
if (v !== undefined) {
Object.assign(params, { [k]: v });
}
});
this.showLoading();
return this
.fetchData({
current: this.state.pagination.current + 1,
pageSize: this.state.pagination.pageSize,
...params,
})
.then(res => {
this.setState(s => {
s.pagination.current = res.current;
s.pagination.pageSize = res.pageSize;
s.pagination.total = res.total;
s.data = res.data;
s.allData = [...s.allData, ...res.data];
});
return res;
})
.finally(() => {
this.hideLoading();
});
}
hasMore() {
const { current, pageSize, total } = this.state.pagination;
return current * pageSize < total;
}
/**
* 重置所有参数 刷新请求
*/
refresh() {
this.reset();
return this.loadMore();
}
reset() {
this.setState(defaultState());
}
/**
* 重置data,allData,pagination
*/
resetData() {
this.setState(s => {
s.data = [];
s.allData = [];
s.pagination = defaultState().pagination;
});
}
/**
* 更新每次请求的参数
* @param params
*/
updateParams(params: Partial<Params>) {
const d: Partial<Params> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined) {
Object.assign(d, {
[k]: v,
});
}
});
this.setState(s => {
s.params = {
...s.params,
...d,
};
});
}
/**
* 重置参数
*/
resetParams() {
this.setState(s => {
s.params = {} as Record<any, any>;
});
}
updatePagination(pagination: Partial<IPagination>) {
this.setState(s => {
s.pagination = {
...s.pagination,
...pagination,
};
});
}
}
接着找一个常用的组件库实现view层
在这里我们实现一个最基础的下拉刷新,上拉加载的列表功能
import { PullToRefresh, InfiniteScroll, List } from 'antd-mobile'
const Name = () => {
const { presenter, state } = usePresenter(ListPresenter);
return (
<PullToRefresh
onRefresh={async () => {
await presenter.refresh()
}}
>
<List>
{state.data.map((item, index) => (
<List.Item key={index}>{item}</List.Item>
))}
</List>
<InfiniteScroll
loadMore={() => {
presenter.loadMore()
}}
hasMore={presenter.hasMore()}
/>
</PullToRefresh>
);
};
export default Name;
搜索功能
接下来我们添加一个搜索功能
这里有个小优化,可以用防抖函数避免多次请求
search = debounce(this._search, 1000);
_search(value: string) {
this.updatePagination({current: 1})
this.updateParams({
searchText: value,
});
return this.updateData();
}
至于其他工功能,比如筛选之类的就留给小伙伴们自己去实现啦
hook实现和class的对比
此外我还用hooks实现了一版
function useBaseList(
fetchData: (
params: Partial<ListState['params']> & {
current: number;
pageSize: number;
},
) => Promise<{
data: any[];
current: number;
pageSize: number;
total: number;
}>,
) {
const [state, setState] = useState<ListState>({
loading: false,
data: [],
params: {},
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
});
const showLoading = useCallback(() => {
setState({
...state,
loading: true,
});
}, [state]);
const hideLoading = useCallback(() => {
setState({
...state,
loading: true,
});
}, [state]);
const updateData = useCallback(() => {
const params: Record<any, any> = {};
Object.entries(state.params || {}).forEach(([k, v]) => {
if (v !== undefined) {
Object.assign(params, { [k]: v });
}
});
showLoading();
return fetchData({
current: state.pagination.current || 1,
pageSize: state.pagination.pageSize || 10,
...params,
})
.then((res) => {
setState({
...state,
pagination: {
current: res.current,
pageSize: res.pageSize,
total: res.total,
},
data: res.data,
});
return res;
})
.finally(() => {
hideLoading();
});
}, [fetchData, hideLoading, showLoading, state]);
return {
state,
hideLoading,
showLoading,
updateData,
};
}
function useNormalList(
fetchData: (
params: Partial<ListState['params']> & {
current: number;
pageSize: number;
},
) => Promise<{
data: any[];
current: number;
pageSize: number;
total: number;
}>,
) {
const { state, hideLoading, showLoading, updateData } =
useBaseList(fetchData);
/**
* 上拉加载
* @returns
*/
const loadMore = useCallback(() => {
return updateData();
}, [updateData]);
return {
state,
hideLoading,
showLoading,
updateData,
loadMore,
};
}
大家可以发现,其实hook实现起来和用class一样也是用oop的方式来封装
只不过因为hooks函数的原因,你需要用到useCallback之类的api, 以及要特别注意useEffect依赖数组的依赖项,避免死循环
在hooks出现之前,class components最大的问题就是没法很好的复用逻辑,不过通过clean-js我们也可以实现class抽离出通用的逻辑达到复用的效果
对比一下hooks和clean-js的区别
- 代码风格就看个人喜好了,clean-js偏向于传统的oop,更容易理解阅读;hooks可以用函数实现oop
- hooks和react强绑定,无法在vue或者其他框架使用,clean-js可以在vue中使用
- 使用hooks的时候需要注意用useCallback,useMemo等api缓存,避免重复渲染;
- 其他的就是clean-js还提供额外的功能,如dev-tool,IOC,代码生成等等
为什么还要弄一个clean-js
当时看完《架构整洁之道》就想在前端实现这样的架构,于是实现了下面这些功能,就有了这个库
- 为了视图框架,状态解耦,实现依赖倒置,于是弄了Presenter,不依赖于框架,在react和vue中都能使用
- Presenter 不依赖于具体的service, 于是加入了IOC功能,具体可以看这个例子,table可以注入任意的service。
- 提供视图devtool(redux-devtool/log)便于debug
- 请求代码生成器;根据YAPI,swagger2,swagger3等api协议自动生成请求代码
推荐架构
如下图所示,在前端应用中视图层(View)应该是最低的层次,也是最常变化的地方,它依赖于presenter(提供视图状态和方法),而Presenter依赖更核心的业务逻辑(service);
依赖倒置
依赖倒置原则(Dependency Inversion Principle,DIP)是软件工程中常见的一种设计原则
- 高层模块不应该依赖于低层模块,两者都应该依赖于抽象。
- 抽象不应该依赖于细节,细节应该依赖于抽象。
在上图中,view依赖Presenter,如果要做完全的依赖倒置我们可以声明一个接口,view和presenter分别依赖这个接口来实现view和presenter的解耦,下面给个例子
声明ListPresenter接口
interface ListPresenter {
state: ListState;
onPageChange?(p: Pagination): void;
}
而我们的BaseListPresenter实现这个接口
class BaseListPresenter implements ListPresenter {}
在view依赖的是ListPresenter接口
const Index = () => {
const { presenter, state } = usePresenter<ListPresenter>(BaseListPresenter);
return (
<div>
name: {state.name}
<button onClick={presenter.changeName}>change name</button>
</div>
);
};
这样就可以让view和Presenter完全解耦了
不过一般来说没有这个必要,Presenter这个类本身隐含着接口定义,只要接口定义不改就可以了,哪怕以后要切换到别的视图框架,只需要修改view的代码,依赖Presenter即可
再举个例子,Presenter通常的数据源是HTTP service,如果有一天我们需要从缓存中获取,或者jsbridge获取,这时候就需要修改原来的HTTP service了,如果做了依赖倒置,就可以切换具体的service实现,而无需去修改Presenter
IOC
IOC(控制翻转)是一种设计模式,目的为了更好的解耦,实现依赖倒置,而DI(依赖注入)可以理解为IOC的一种实现方式
比如我有这个服务类NameService,需要在NamePresenter中使用,则需要在NamePresenter实例化
NameService,这样两个类就耦合在一起了,最直观的例子就是在我们写单元测试的时候很难去mock NameService这个服务
export class NameService {
getName() {
// 假设从http请求获取名称
return Promise.resolve('name')
}
}
class NamePresenter {
constructor() {
this.nameService = new NameService()
}
}
如果用IOC实现的话,就不需要在NamePresenter中实例化NameService了
export class NameService {
getName() {
// 假设从http请求获取名称
return Promise.resolve('name')
}
}
class NamePresenter {
constructor(@inject('service') public nameService: NameService) {}
}
这样我们写单元测试的时候,就可以随意切换NameService,比如下面的代码MockService是我们用来mock NameService的服务
it('test', () => {
container.register('service', { useClass: MockService });
const presenter = container.resolve(NamePresenter);
// presenter的nameService就会别切换为MockService
});
在clean-js中也提供了IOC的功能,更加具体的例子可以看这里,这个TablePresenter和我们前面封装的ListPresenter一样,不过在组件运行的过程中我们可以注入具体要用的服务类,来达到在不同页面都使用同一个Presenter的效果
const Page = () => {
const { presenter } = usePresenter<TablePresenter<Row, Params>>(
TablePresenter,
{
registry: [{ token: TableServiceToken, useClass: MyService }],
},
);
return (
<div>
<h1>table state</h1>
<p>{JSON.stringify(presenter.state, null, 4)}</p>
<button
onClick={() => {
presenter.getTable();
}}
>
fetch table
</button>
</div>
);
};
具体源码可以看这里
觉得不错的小伙伴记得给个star⭐️,谢谢支持,
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。