乐趣区

关于前端:前端必备手把手教你实现下拉刷新上拉加载和搜索让列表功能变得更加简单易用

具体源码能够看这里
感觉不错的小伙伴记得给个 star⭐️,谢谢反对,

在挪动端页面中,列表是一个很常见的性能,接下来手把手教你实现一个下拉刷新,上拉加载,带有搜寻性能的列表状态管理器

clean-js 应用办法

在此之前先阐明一下这个状态库如何应用

性能:

  1. 提供 presenter 的束缚,束缚视图状态和更新的形式;
  2. 提供视图 devtool(redux-devtool/log)
  3. 提供适配器,适配 react/vue/…
  4. 提供 IOC 容器,能够实现依赖注入
  5. 依据 YAPI,swagger2,swagger3 等 api 协定主动生成申请代码

实现:

  1. 所有的状态类都须要继承基类 Presenter,须要在基类写入泛型 IViewState
  2. 在结构器函数中须要申明默认的 state,类型为 IViewState
  3. 能够通过 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⭐️,谢谢反对,

退出移动版