React项目实现全局 loading 以及错误提示

13,819 阅读6分钟

前言

  • 在项目中使用 loading,一般是在组件中用一个变量( 如isLoading)来保存请求数据时的 loading 状态,请求 api 前将 isLoading 值设置为 true,请求 api 后再将 isLoading 值设置为 false,从而对实现 loading 状态的控制,如以下代码:
import { Spin, message } from 'antd';
import { Bind } from 'lodash-decorators';
import * as React from 'react';
import * as api from '../../services/api';

class HomePage extends React.Component {
  state = {
    isLoading: false,
    homePageData: {},
  };
  
  async componentDidMount () {
    try {
      this.setState({ isLoading: true }, async () => {
        await this.loadDate();
      });
    } catch (e) {
      message.error(`获取数据失败`);
    }
  }
  
  @Bind()
  async loadDate () {
    const homePageData = await api.getHomeData();
    this.setState({
      homePageData,
      isLoading: false,
    });
  }
  
  render () {
    const { isLoading } = this.state;
    return (
      <Spin spinning={isLoading}>
        <div>hello world</div>
      </Spin>
    );
  }
}

export default HomePage;
  • 然而,对于一个大型项目,如果每请求一个 api 都要写以上类似的代码,显然会使得项目中重复代码过多,不利于项目的维护。因此,下文将介绍全局存储 loading 状态的解决方案。

思路

  • 封装 fetch 请求(传送门👉:react + typescript 项目的定制化过程)及相关数据请求相关的 api
  • 使用 mobx 做状态管理
  • 使用装饰器 @initLoading 来实现 loading 状态的变更和存储

知识储备

  • 本节介绍与之后小节代码实现部分相关的基础知识,如已掌握,可直接跳过🚶🚶🚶。

@Decorator

  • 装饰器(Decorator)主要作用是给一个已有的方法或类扩展一些新的行为,而不是去直接修改方法或类本身,可以简单地理解为是非侵入式的行为修改。
  • 装饰器不仅可以修饰类,还可以修饰类的属性(本文思路)。如下面代码中,装饰器 readonly 用来装饰类的 name 方法。
class Person {
  @readonly
  name() { return `${this.first} ${this.last}` }
}
  • 装饰器函数 readonly 一共可以接受三个参数:
    • 第一个参数 target 是类的原型对象,在这个例子中是 Person.prototype ,装饰器的本意是要“装饰”类的实例,但是这个时候实例还没生成,所以只能去装饰原型(这不同于类的装饰,那种情况时 target 参数指的是类本身)
    • 第二个参数 name 是所要装饰的属性名
    • 第三个参数 descriptor 是该属性的描述对象
function readonly(target, name, descriptor){
  // descriptor对象原来的值如下
  // {
  //   value: specifiedFunction,
  //   enumerable: false,
  //   configurable: true,
  //   writable: true
  // };
  descriptor.writable = false;
  return descriptor;
}

readonly(Person.prototype, 'name', descriptor);
// 类似于
Object.defineProperty(Person.prototype, 'name', descriptor);
  • 上面代码说明,装饰器函数 readonly 会修改属性的描述对象(descriptor),然后被修改的描述对象再用来定义属性。
  • 下面的 @log 装饰器,可以起到输出日志的作用:
class Math {
  @log
  add(a, b) {
    return a + b;
  }
}

function log(target, name, descriptor) {
  var oldValue = descriptor.value;

  descriptor.value = function() {
    console.log(`Calling ${name} with`, arguments);
    return oldValue.apply(this, arguments);
  };

  return descriptor;
}

const math = new Math();

// passed parameters should get logged now
math.add(2, 4);
  • 上面代码说明,装饰器 @log 的作用就是在执行原始的操作之前,执行一次 console.log,从而达到输出日志的目的。

mobx

  • 项目中的状态管理不是使用 redux 而是使用 mobx,原因是 redux 写起来十分繁琐:

    • 如果要写异步方法并处理 side-effects,要用 redux-saga 或者 redux-thunk 来做异步业务逻辑的处理
    • 如果为了提高性能,要引入 immutable 相关的库保证 store 的性能,用 reselect 来做缓存机制
  • redux 的替代品是 mobx,官方文档给出了最佳实践,即用一个 RootStore 关联所有的 Store,解决了跨 Store 调用的问题,同时能对多个模块的数据源进行缓存。

  • 在项目的stores 目录下存放的 index.ts代码如下:

import MemberStore from './member';
import ProjectStore from './project';
import RouterStore from './router';
import UserStore from './user';

class RootStore {
  Router: RouterStore;
  User: UserStore;
  Project: ProjectStore;
  Member: MemberStore;

  constructor () {
    this.Router = new RouterStore(this);
    this.User = new UserStore(this);
    this.Project = new ProjectStore(this, 'project_cache');
    this.Member = new MemberStore(this);
  }
}

export default RootStore;
  • 关于 mobx 的用法可具体查看文档 👉mobx 中文文档,这里不展开介绍。

代码实现

  • 前面提到的对loading 状态控制的相关代码与组件本身的交互逻辑并无关系,如果还有更多类似的操作需要添加重复的代码,这样显然是低效的,维护成本太高。
  • 因此,本文将基于装饰器可以修饰类的属性这个思路创建一个 initLoading 装饰器,用于包装需要对 loading 状态进行保存和变更的类方法。
  • 核心思想是使用 store 控制和存储 loading 状态,具体地:
    • 建立一个 BasicStore类,在里面写 initLoading 装饰器
    • 需要使用全局 loading 状态的不同模块的 Store需要继承 BasicStore类,实现不同 Storeloading 状态的“隔离”处理
    • 使用 @initLoading 装饰器包装需要对 loading 状态进行保存和变更的不同模块 Store 中的方法
    • 组件获取 Store 存储的全局 loading 状态
  • Tips:👆的具体过程结合👇的代码理解效果更佳。

@initLoading 装饰器的实现

  • 在项目的stores 目录下新建 basic.ts 文件,内容如下:
import { action, observable } from 'mobx';

export interface IInitLoadingPropertyDescriptor extends PropertyDescriptor {
  changeLoadingStatus: (loadingType: string, type: boolean) => void;
}

export default class BasicStore {
  @observable storeLoading: any = observable.map({});

  @action
  changeLoadingStatus (loadingType: string, type: boolean): void {
    this.storeLoading.set(loadingType, type);
  }
}

// 暴露 initLoading 方法
export function initLoading (): any {
  return function (
    target: any,
    propertyKey: string,
    descriptor: IInitLoadingPropertyDescriptor,
  ): any {
    const oldValue = descriptor.value;

    descriptor.value = async function (...args: any[]): Promise<any> {
      let res: any;
      this.changeLoadingStatus(propertyKey, true); // 请求前设置loading为true
      try {
        res = await oldValue.apply(this, args);
      } catch (error) {
        // 做一些错误上报之类的处理 
        throw error;
      } finally {
        this.changeLoadingStatus(propertyKey, false); // 请求完成后设置loading为false
      }

      return res;
    };

    return descriptor;
  };
}
  • 从上面代码可以看到,@initLoading 装饰器的作用是将包装方法的属性名 propertyKey 存放在被监测数据 storeLoading 中,请求前设置被包装方法的包装方法 loadingtrue,请求成功/错误时设置被包装方法的包装方法 loadingfalse

Store 继承 BasicStore

  • ProjectStore 为例,如果该模块中有一个 loadProjectList 方法用于拉取项目列表数据,并且该方法需要使用 loading,则项目的stores 目录下的 project.ts 文件的内容如下:
import { action, observable } from 'mobx';
import * as api from '../services/api';
import BasicStore, { initLoading } from './basic';

export default class ProjectStore extends BasicStore {
  @observable projectList: string[] = [];

  @initLoading()
  @action
  async loadProjectList () {
    const res = await api.searchProjectList(); // 拉取 projectList 的 api
    runInAction(() => {
      this.projectList = res.data;
    });
  }
}

组件中使用

  • 假设对 HomePage 组件增加数据加载时的 loading 状态显示:
import { Spin } from 'antd';
import { inject, observer } from 'mobx-react';
import * as React from 'react';
import * as api from '../../services/api';

@inject('store')
@observer
class HomePage extends React.Component {
  render () {
    const { projectList, storeLoading } = this.props.store.ProjectStore;
    return (
      <Spin spinning={storeLoading.get('loadProjectList')}>
        {projectList.length && 
          projectList.map((item: string) => {
            <div key={item}>
              {item}
            </div>;
          })}
      </Spin>
    );
  }
}

export default HomePage;
  • 上面代码用到了 mobx-react@inject@observer 装饰器来包装 HomePage 组件,它们的作用是将 HomePage 转变成响应式组件,并注入 Provider(入口文件中)提供的 store 到该组件的 props 中,因此可通过 this.props.store 获取到不同 Store 模块的数据。
    • @observer 函数/装饰器可以用来将 React 组件转变成响应式组件
    • @inject 装饰器相当于 Provider 的高阶组件,可以用来从 Reactcontext中挑选 store 作为 props 传递给目标组件
  • 最终可通过 this.props.store.ProjectStore.storeLoading.get('loadProjectList') 来获取到 ProjectStore 模块中存放的全局 loading状态。

总结

  • 通过本文介绍的解决方案,有两个好处,请求期间能实现 loading 状态的展示;当有错误时,全局可对错误进行处理(错误上报等)。
  • 合理利用装饰器可以极大的提高开发效率,对一些非逻辑相关的代码进行封装提炼能够帮助我们快速完成重复性的工作,节省时间。

参考资料

  1. ECMAScript 6 入门 | 装饰器
  2. Javascript 装饰器的妙用
  3. typescript | decorators