封装一个ReactNative列表状态管理,对比hook和class的区别|社区征文

社区征文

本文会讲解如何实现一个React Native的列表状态(在react也是同样可以使用),分别用hooks的方式和class的方式实现,也会讲解依赖倒置的好处

ReactNative

React Native,是一款由Facebook开源的移动应用开发框架,使用JavaScript来开发安卓和IOS应用

环境搭建就跟着官网来就好了 https://reactnative.dev/docs/environment-setup

我们这里选择用Typescript的模板

npx react-native init AwesomeTSProject --template react-native-template-typescript

定义列表模型

首先安装一下自己写的状态库

npm install @clean-js/presenter @clean-js/react-presenter --save

接着定义列表的模型,通常来说我们需要下面这些属性

  • loading: boolean; 加载中的状态
  • data: Row[]; 列表数据,这里是所有的数据集合
  • params: Record<any, any>; 请求附带的参数,
  • pagination: IPagination; 分页相关的参数
export interface ListState<Row = any> {
  loading: boolean;
  data: Row[];
  params: Record<any, any>;
  pagination: IPagination;
}

export interface IPagination {
  current: number;
  pageSize: number;
  total: number;
}

有了这些属性,在组件中就可以正常的渲染列表了

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(protected readonly model: OrderModel) {
    super();
    this.state = {
      loading: false,
      name: '稀土掘金'
    }
  }


  changeName() {
    this.setState(s => {
        s.name = '火山引擎'
    }); // 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协议自动生成请求代码等多种功能

详细内容可以看文档描述

定义通用方法

回到我们的需求 接下来声明BaseListPresenter类,给他设置一些通用的方法

BaseListPresenter类中我们声明了几个方法

  • fetchTable 用来发起请求,他会接受params和pagination作为参数,并且返回约定后的接口,这个函数需要具体业务来实现,这个基类只会声明
  • showLoading/hideLoading 切换loading状态
  • updateData 调用fetchTable来发起请求,请求完成后更新data,loading和分页数据
  • updateParams 更新请求参数,通常我们列表都会伴随搜索框,筛选框,这之后就可以通过这个方法来更新对应的参数了,需要注意的是,在参数发生变化之后,分页会重置为第一页
  • resetParams 顾名思义,用来重置请求参数
  • updatePagination, onPageChange都是和分页参数有关的逻辑,具体可以看下面代码

有了这些方法,我们的列表状态管理就完成了

export interface ListState<Row = any> {
  loading: boolean;
  data: Row[];
  params: Record<any, any>;
  pagination: IPagination;
}

export interface IPagination {
  current: number;
  pageSize: number;
  total: number;
}

export class BaseListPresenter<Row = any, OtherState = any> extends Presenter<
  ListState<Row> & OtherState
> {
  constructor() {
    super();
  }

  fetchTable(
    params: Partial<TableState['params']> & {
      current: number;
      pageSize: number;
    },
  ): Promise<{
    data: any[];
    current: number;
    pageSize: number;
    total: number;
  }> {
    throw Error('请实现fetchTable');
  }

  showLoading() {
    this.setState((s) => {
      s.loading = true;
    });
  }

  hideLoading() {
    this.setState((s) => {
      s.loading = false;
    });
  }
  /**
   * 发请求,更新数据
   * @returns
   */
  updateData() {
    const params: Record<any, any> = {};
    Object.entries(this.state.params || {}).forEach(([k, v]) => {
      if (v !== undefined) {
        Object.assign(params, { [k]: v });
      }
    });
    this.showLoading();

    return this.fetchTable({
      current: this.state.pagination.current || 1,
      pageSize: this.state.pagination.pageSize || 10,
      ...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;
        });
        return res;
      })
      .finally(() => {
        this.hideLoading();
      });
  }
   /**
   * 更新参数,重置当前请求页面为1
   * @param params
   */
  updateParams(params: Partial<TableState['params']>) {
    const d: Partial<TableState['params']> = {};
    Object.entries(params).forEach(([k, v]) => {
      if (v !== undefined) {
        Object.assign(d, {
          [k]: v,
        });
      }
    });

    this.setState((s) => {
      s.params = {
        ...s.params,
        ...d,
      };

      s.pagination.current = 1;
    });
  }
  
  resetParams() {
    this.setState((s) => {
      s.params = {} as Record<any, any>;
    });
  }

    /**
   * 更新参数,如果修改的参数是不是current,重置current为1
   * @param pagination
   */
  updatePagination(pagination: Partial<TableState['pagination']>) {
    this.setState((s) => {
      const currentChange =
        pagination.current && pagination.current !== s.pagination.current;
      s.pagination = {
        ...s.pagination,
        ...pagination,
        current: currentChange ? pagination.current : 1, // current 修改就用current,否则重置为1
      };
    });
  }
  
   onPageChange(p: TablePaginationConfig): void {
    if (p.pageSize !== this.state.pagination.pageSize) {
      this.updatePagination({
        ...p,
        current: 1,
      });
    } else {
      this.updatePagination({
        ...p,
      });
    }

    this.updateData();
  }
}

实现

接下来我们在实际场景中使用 首先来实现一个最基础的下拉刷新,上拉加载的列表

实现一个下拉刷新,上拉加载的列表

下拉刷新,上拉加载是很常用的一个需求,大多数列表都是如此

我们先继承BaseListPresenter这个类,然后实现下面两个方法即可

  • loadMore 上拉加载方法
  • reload 下拉刷新方法
class NormalList extends BaseListPresenter<{
  label: string;
}> {
  constructor() {
    super();
    this.state = {
      loading: false,
      data: [],
      params: {},
      pagination: {
        current: 1,
        pageSize: 10,
        total: 0,
      },
    };
  }

  /**
   * 上拉加载
   * @returns
   */
  async loadMore() {
    this.updatePagination({ current: this.state.current + 1 });
    return this.updateData();
  }

  /**
   * 下拉刷新
   */
  async reload() {
    this.updatePagination({ current: 1 });
    return this.updateData();
  }
}

搜索功能

接下来我们添加一个搜索功能

这里有个小优化,可以用防抖函数避免多次请求


 search = debounce(this._search, 1000);

  _search(value: string) {
    this.updatePagination({current: 1})
    this.updateParams({
      searchText: value,
    });
    return this.updateData();
  }

至于其他工功能,比如筛选之类的就留给小伙伴们自己去实现啦

hook实现和class的对比

此外我还用hooks实现了一版

function useBaseList(
  fetchTable: (
    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 fetchTable({
      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();
      });
  }, [fetchTable, hideLoading, showLoading, state]);

  return {
    state,
    hideLoading,
    showLoading,
    updateData,
  };
}

function useNormalList(
  fetchTable: (
    params: Partial<ListState['params']> & {
      current: number;
      pageSize: number;
    },
  ) => Promise<{
    data: any[];
    current: number;
    pageSize: number;
    total: number;
  }>,
) {
  const { state, hideLoading, showLoading, updateData } =
    useBaseList(fetchTable);

  /**
   * 上拉加载
   * @returns
   */
  const loadMore = useCallback(() => {
    return updateData();
  }, [updateData]);

  return {
    state,
    hideLoading,
    showLoading,
    updateData,
    loadMore,
  };
}

大家可以发现,其实hook实现起来和用class一样也是用oop的方式来封装
只不过因为hooks函数的原因,你需要用到useCallback之类的api

在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);

image.png

依赖倒置

依赖倒置原则(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⭐️,谢谢支持,

311
12
4
3
关于作者
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论