项目地址

项目源码在这github,欢迎clone下来配合食用

项目地图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ tree -I js-sdk
.
|-- Loading.tsx             # 按需加载的Loading 页
|-- app.ts                  # 重载dva的逻辑
|-- components
|   `-- TagMgt              
|       |-- TagExec.tsx     # 新增、编辑的弹窗执行者
|       `-- index.tsx       # 标签管理
|-- global.less             # 全局样式
|-- http                    
|   |-- host.ts             # 多host控制
|   |-- index.ts            # http模块 axios的二次封装 
|   `-- proxyCfg.ts         # 参照webpack proxy定的url转发
|-- layouts
|   `-- index.tsx           # 布局主文件
|-- models                  # VM层 dva是对redux redux-saga的封装,把controller和model放一起统一管理
|   |-- global.ts           # 全局
|   |-- record.ts           # 记录
|   |-- tag.ts              # 标签
|   `-- user.ts             # 用户
|-- pages
|   |-- record              
|   |   `-- index.tsx       # 记录页
|   |-- statistic
|   |   `-- index.tsx       # 统计页
|   `-- user
|       `-- index.tsx       # 用户页
|-- theme
|   `-- index.ts            # 全局主题常量
`-- utils                   # 通用工具
    `-- type.ts             

使用的是umi的架构体系,官方文档已经写的非常通俗易懂了,本文就不再赘述。
本文主要讲http模块executer组件的设计思路和使用。

Http,为业务的二次封装

进入src/http/index.ts文件,首先我们先看入参:

1
2
3
4
5
6
7
8
9
// 接收一个 url 和 RequestConfig
function request(url: string, options: BizOptions = {}): AxiosPromise<any>
// BizOptions 是对 AxiosRequestConfig的拓展
interface BizOptions extends AxiosRequestConfig {
  errCatch?: boolean; // 是否捕获错误
  silence?: Silence; // 是否通知
  reAuth?: boolean; // 是否重新登录
  coressPorxy?: boolean; // 是否走代理
}

这四个开关分别对应四种业务行为,稍后会分别讲解; 我们接着往下看代码先遇到coressPorxy

多host多环境下的url分发机制

1
const proxyUrl = coressPorxy ? proxy(url, proxyCfg) : url;

我们看src/http/proxyCfg.ts

1
2
3
4
5
6
7
8
import { mainHost } from './host';

export default {
  '/main/': {
    target: mainHost,
    pathRewrite: { '^/main/': '/' },
  },
};

是不是发现和webpack里的proxy一模一样;举个例子:main/v1/rencord这条url会被匹配到mainHost去, 并且根据pathRewirte规则重写成mainHost/v1/record。 这样的好处是:假设我们要调多个后端,方便维护和区分。我们扩展下会更好理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { mainHost, subHost, thirdHost } from './host';

export default {
  '/main/': {
    target: mainHost,
    pathRewrite: { '^/main/': '/' },
  },
  '/sub/' {
      target: subHost,
      pathRewrite: { '^/sub/': '/v2' },
  },
  '/3th/' {
      target: thirdHost,
      pathRewrite: { '^/3/': '/' },
  },
};

const url = "/main/v1/tags"     // 匹配mainHost, 转换为 mainHost/v1/tags
const url2 = "/sub/v1/user"     // 匹配subHost, 转换为 sub/v2/v1/tags
const url3 = "/3th/v1/record"   // 匹配thirdHost, 转换为 thirdHost/th/v1/tags

我们在来看src/http/host.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export const mainHost = () => {
    // 通过运行时NODE_ENV变量区分url
    switch (process.env.NODE_ENV) {
        case "production":
            return "https://api.furan.xyz/time-mgt";
    default:
        return "http://localhost:8000";
  }
};

如代码所示mainHost在生产环境返回https://api.furan.xyz/time-mgt, 其余环境返回http://localhost:8000。 如果我们有多个host,并且分dev,test,prod多个环境,只需要在打包时传入对应参入即可区分,省的改来改去。

然后回到src/http/index.ts, 看request的返回:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  return axios(proxyUrl, {
    timeout: 10000,
    headers: {
      Authorization: `${localStorage.getItem("token")}`,
      ...headers,
    },
    ...restOptions, // axios request options
  })
    // 业务pipe
    .then(bizHandler)
    // 请求成功pipe
    .then(successHandler)
    // 请求失败pipe
    .catch(errorHandler);

这里有三条管道,对不同响应做了通用处理,主要是践行aop理念。

bizHandler 业务处理器

先上代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  // 主要业务处理
  function bizHandler(response: AxiosResponse) {
    if (response?.data?.ok) {
      return response;
    }

    const bizError = new BizError("biz error");
    bizError.response = response;
    throw bizError;
  }

后端响应体的是这样的json
成功:

1
2
3
4
{
    "ok": true, 
    "data": data
}

失败:

1
2
3
4
{
    "ok": false, 
    "errMsg": "string"
}

所以它的作用主要是先判断业务是否成功。成功交给successHandler,失败则交给errorHandler。可以对具体业务细节进行横向扩展。

successHandler 全局成功处理

1
2
3
4
5
6
7
8
  //全局成功处理
  function successHandler(response: AxiosResponse) {
    const { data } = response;
    if (!(silence === true || silence === "success")) {
      message.success({ content: data?.message || "操作成功" });
    }
    return data;
  }

这个handler的行为就很简单了,根据silence参数决定是否做全局通知,然后提取responsebody并返回。

errorHandler 全局错误处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function errorHandler(error: BizError): any {
    const { response, message: eMsg } = error;
    // reAuth标记是用来防止连续401的熔断处理

    if (response?.status === 401) {
      return reAuth ? reAuthorization() : message.warning({
        content: "请先登录",
        onClose: () => {
          localStorage.clear();
          location.replace(`${window.routerBase}/user/`);
        },
      });
    }
    // silence标记为true 则不显示消息
    if (!(silence === true || silence === "fail")) {
      const timeoutMsg = eMsg.match("timeout") && "连接超时, 请检查网络。";
      const netErrMsg = eMsg.match("Network Error") && "网络错误,请检查网络。";

      message.error({
        content:
          // 超时
          timeoutMsg ||
          netErrMsg ||
          // 后端业务错误
          response?.data?.errMsg ||
          // 错误码错误
          codeMessage[response?.status as number] ||
          "未知错误",
      });
    }
    // 阻止throw
    if (errCatch) {
      return response;
    }

    throw error;
  }

这里我们先处理401,根据reAuth这个开关,会执行静默登录或是跳转登录页,这个reAuthorization()我们后面在讲;
然后根据silence,决定是否发起通知;
最后根据errCatch,决定这个错误是抛出,还是内部消化。

reAuthorization 重新授权

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  function reAuthorization() {
    return RESTful
      .get("/uc/oauth2/refresh", {
        reAuth: false,
        silence: "success",
        params: { token: localStorage.getStorage("refresh_token") },
      })
      .then((resp: AxiosResponse) => {
        localStorage.setItem("token", resp.data.token);
        localStorage.setItem("refresh_token", resp.data.refresh_token);
        return request(url, { ...options, reAuth: false });
      });
  }

这个也很好理解,用旧的jwt换一个新的,然后重新请求原接口。

封装RESTful和graphQL的别名方法

剩下的就是一些alias了,主要两块:

  1. RESTful
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

const restful = ["get", "post", "delete", "put", "patch", "head", "options"];
// 注入别名
export const RESTful = restful.reduce((
  acc: { [k: string]: Function },
  method,
) => ({
  ...acc,
  [method]: (url: string, options?: BizOptions) =>
    request(url, {
      method: method as Method,
      ...options,
    }),
}), {});

这样就可以用诸如RESTful.get()RESTful.post() 这些别名方法。 2. Graphql

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const graphql = ["query", "mutation"];
export const Graphql = graphql.reduce(
  (acc: { [k: string]: Function }, method) => ({
    ...acc,
    [method]: (
      url: string,
      options?: BizOptions,
    ) => ((...query: any[]) =>
      RESTful.post(url, {
        data: {
          query: `${method} {${
            query[0].reduce(
              (acc: string, cur: string, idx: number) =>
                acc + cur + (query[idx + 1] || ""),
              "",
            )
          }}`,
        },
        ...options,
      })),
  }),
  {},
);

现在主流GraphQL还是使用json格式作来传参,所以做了层封装。别名方法仅graphQL.query()graphQL.mutation()

Executer 命令模式的践行

命令模式是一种设计模式,大意是把一部分行为封装成一个Executor(执行者),并且在需要时通过Executor.exec()执行; 起到了抽象和延时执行的效果。 其实React也好Vue也好都是组合模式的践行。 所以在React里使用Exec必然违背了设计理念,那么为何还要使用呢?
要回答这个问题,我们不妨先对比一下他们的核心不同点——行为方式上

两种驱动模式的对比

特点 优点 不足
组合模式 状态驱动 状态改变,视图自动改变 依赖视图层,依赖文档树
命令模式 事件驱动 何时何地都可以使用,包括ws返回的回调事件里 不依赖dom流,意味着context, props, store的改变,视图层都不会改变

可以看出各有优劣,命令模式也只是在弹窗这样的场景才适用。当然你也可以融合两种模式,但是复杂度会很高,违背了KISS原则。

实现

老样子先贴上源码,然后一步一步的解读

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export default class ComponentExecuter {
  static queueMap = new Map();
  static DestroyAll() {
    ComponentExecuter.queueMap.forEach((execter) => execter.Destroy());
  };

  protected comp: React.ReactElement;
  protected el: HTMLDivElement;
  protected id: string;

  constructor(Component: React.ReactElement | React.FC | React.ComponentClass) {
    this.comp = React.isValidElement(Component)
      ? (
        Component
      )
      : (
        <Component />
      );

    this.el = document.createElement("div");
    const id = `execter-${uuid(6, 16)}`;
    this.el.setAttribute("id", id);
    this.id = id;
    document.body.appendChild(this.el); // add the text node to the newly created div.

    ComponentExecuter.queueMap.set(id, this);
  }

  Execute() {
    ReactDom.render(this.comp, this.el);
    return this;
  }

  Update<P>(props?: Partial<P> & Attributes, ...children: ReactNode[]) {
    this.comp = React.cloneElement(this.comp, props, ...children);
    return this;
  }

  Destroy() {
    window.requestIdleCallback(() => {
      unmountComponentAtNode(this.el);
      this?.el.remove();
      ComponentExecuter.queueMap.delete(this.id);
    })
  }
}

静态方法和静态成员

1
2
3
4
5
static queueMap = new Map(); // 每个实例都会注册在这个map里
// 析构所有执行者
static DestroyAll() { 
  ComponentExecuter.queueMap.forEach((execter) => execter.Destroy());
};

构造函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  constructor(Component: React.ReactElement | React.FC | React.ComponentClass) {
    this.comp = React.isValidElement(Component) // 先实例化组件
      ? (
        Component
      )
      : (
        <Component />
      );

    this.el = document.createElement("div"); //创建一个div
    const id = `execter-${uuid(6, 16)}`; // 生成uuid
    this.el.setAttribute("id", id); 
    this.id = id;
    document.body.appendChild(this.el); // 挂在到dom树上

    ComponentExecuter.queueMap.set(id, this); // 注册到队列里,方便管理
  }

成员方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
  // 把Virtual dom渲染到dom树上
  Execute() {
    ReactDom.render(this.comp, this.el);
    return this;
  }

  // 更新组件的props
  Update<P>(props?: Partial<P> & Attributes, ...children: ReactNode[]) {
    this.comp = React.cloneElement(this.comp, props, ...children);
    return this;
  }

  // 析构函数
  Destroy() {
    window.requestIdleCallback(() => {
      unmountComponentAtNode(this.el);
      this?.el.remove();
      ComponentExecuter.queueMap.delete(this.id);
    })
  }

应用

先打开src/components/TagMgt/TagExec.tsx,你会发现整个组件的写法和普通组件并无区别。 只是在最后返回了一个工厂方法,用于创建该实例。

1
2
3
4
5
export default (options?: TagProp) => {
  return new TagExec(
    <TagModForm {...options} />,
  );
};

然后打开src/components/TagMgt/index.tsx, 我们看如何使用

1
2
3
4
5
6
7
  const tagExec = TagExec();
  const dispatch = useDispatch();
  useLayoutEffect(() => {
    return () => {
      tagExec.Destroy();
    };
  });

首先还是初始化实例,然后在析构生命周期里析构掉执行者。然后我们找到

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 打开新建弹窗
function openCreateExec() {
  tagExec.Update({
    modalProps: {
      visible: true,
      title: "新建标签",
    },
    onOk: create,
    onCancel: closeExec,
  }).Execute();
}

这个事件里直接更新了Executerprops,并且调用Execute()方法执行渲染。
这个窗口的标题(title)是"新建标签", 可见性(visible)是true
点击确定(onOk)时执行create方法,点击取消(onCancel)时执行closeExec方法。
除此之外,我们无需在再维护一个组件和其他状态。想关闭也只需要在任意地方执行以下代码即可。

1
2
3
4
5
tagExec.Update({
    modalProps: {
        visible: false,
    }
}).Execute()

其他篇章

前言
前端篇
deno后端篇【废弃】
go后端篇
部署篇
后语