阅读 2602

微前端从思考到实践上线(一)

本篇文章来自团队小伙伴 @OnWork 的一次学习分享,希望跟大家分享与探讨。

求积硅步以致千里,勇于探享生活之美。

前言

随着业务量的叠加,项目不再是当初创建的那般小而美,逐渐变得臃肿。特别是在多个业务线共同维护一个巨型应用时、相互间引用带来的影响,不易分清职责,给开发调试以及发版带来不便,也增加了测试同学的工作量。

后端「微服务」普及推广时,「微」的概念也影响到了前端,应运而生的就是「微前端」。

本文简述我们在实际项目中落地踩坑的部分经验。

说干就干,撸起袖子,抄起键盘,默默地在 chrome 输入了「微前端」三个大字。

出现在搜索结果里:iframe / single-spa / qiankun

方案选择

iframe

MDN 中的定义为:HTML 内联框架元素,表示嵌套的 browsing context。它能够将另一个 HTML 页面嵌入到当前页面中。每个嵌入的浏览上下文(embedded browsing context)都有自己的会话历史记录(session history)和 DOM 树。包含嵌入内容的浏览上下文称为父级浏览上下文。顶级浏览上下文(没有父级)通常是由 Window 对象表示的浏览器窗口。

简单来说,iframe 就是一个内联框架,用来在当前 HTML 文档中嵌入另一个文档。而且 iframe 存在 sandbox 的属性,即沙箱属性。

sandbox(又叫沙箱、沙盘)即是一个虚拟系统程序,允许你在沙盘环境中运行浏览器或其他程序,因此运行所产生的变化可以随后删除。它创造了一个类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。 在网络安全中,沙箱指在隔离环境中,用以测试不受信任的文件或应用程序等行为的工具。

——百度百科

“iframe 沙箱属性 sandbox 在 Internet Explorer 9 及更早的版本上不被支持。”

优势

  • 能够原封不动的把嵌入的网页展示出来;
  • 上面提到的沙箱,完全的 css / js 隔离;
  • 并行加载脚本

劣势

  • 冗余 的 css / js 外链请求;
  • 一个项目当中使用了多个 iframe 的话,会出现很多 x 轴 y 轴的滚动条;
  • 对搜索引擎不友好(iframe 一般只有链接没有 innerText 或者 innerHtml 显示);
  • 部分移动设备无法显示 iframe 框架,设备兼容性差;
  • 阻塞主页面的 onload 事件

如果将我们这庞大的应用进行拆分,有 20 个一级菜单,就要嵌入 20 个 iframe,这对加载速度来说简直是致命性问题。那么有没有更好的方案呢?

single-spa

single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。 使用 single-spa 进行前端架构设计可以带来很多好处,例如:

  • 敏捷:独立开发、更高效的部署;
  • 更小单元:更快捷的测试,每次更新不必再去更新整个应用程序;
  • 风险下降:降低错误和回归问题的风险,缩短问题排查周期;
  • CI/CD:更高效的持续集成、持续部署以及持续交付

查看 >>> 官网例子

single-spa

可以看出我们切换菜单的时候,右侧 Elements 面板中的 div 在进行变化,显示对应菜单的内容。

进入 GET STARTED,跟着 create-single-spa 走一个流程。

single-spa

搭建的过程中发现还需要自己摸索各种配置项,那还有没有更好的、更简洁的方案呢?

qiankun

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用的微前端架构系统。

查看 >>> 官网例子 qiankun

目前主流的微前端框架有 single-spa / qiankun 两大阵营,而 qiankun 基于 single-spa,有以下特性:

  • 技术栈无关,任意技术栈的应用均可使用/接入,不论是 React/Vue/Angular/JQuery 还是其他框架;
  • HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单;
  • 样式隔离,确保微应用之间样式互相不干扰;
  • JS 沙箱,确保微应用之间「全局变量/事件」不冲突;
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度;
  • umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统

综上,iframe 是最简单直接的方案,但因其局限性 pass 掉。qiankun / single-spa 的选择上,qiankun 基于 single-spa 并拓展了它,背靠蚂蚁大厂,有更好的生态、更强大社区,排查问题也很方便,入门也相对较简单,配置项相比 single-spa 简洁了许多。

谷歌得差不多了,剩下就要着手落地了,开动脑瓜思考如何去做?

首先我们得看看项目的实际情况,如果我们的项目很小、没多少个功能点就那么几个页面的,那么并不推荐接入坑微前端。如果你的项目目前很庞大,已经有了很多个功能点,很多个页面的,很多复杂的交互的,或者正在规划一个大型的项目的话,那么推荐不妨试试 qiankun 这套微前端框架。

项目背景

痛点需求

一般来说项目到了需要使用微前端架构的话,那么就会有很多个业务模块,以电商系统为例:登录模块、个人中心模块、订单模块、购物车模块、商品模块、销售模块、库存模块、物流模块、系统模块、报表模块等。还有各种类库 lodash、qs、bigNum、dayjs、echarts、stompjs 等等,以及 vue 配套的 UI、utils...

回想一下我们在使用 vue-cli 创建一个项目,然后去 yarn serve / yarn build 是多么的快,然而随着我们这些模块接入、项目的运行、打包是不是越来越慢、部署是不是越来越繁琐,有同感伙伴的握个爪。

那么跑完 qiankun 的例子我们想想怎么接入 qiankun,需要做哪些基础建设的工作?

聚合分析

我们的项目是基于 vue-cli 创建出来的,Vue 的版本是 2.6.x,考虑到各种成本,我们并没有拓展到其他的前端框架。项目里面有 util 工具库、网络通讯 axios 、状态存储 vuex、左侧菜单栏、顶部导航栏、国际化、eslint、外部资源等。看到这些的同时,我们是不是觉得这些放在一个 vue 应用中很常见,几乎看不到有什么需要值得关注的地方,抄起键盘一把梭就行了😏,「编程一时爽,BUG 火葬场」😂。

下面我们将一步一步深入微前端实践(挖坑)之路。

First Question,我们把业务拆分了,要怎么解决这些公共部分的依赖问题,我们不可能每个项目都去拷贝粘贴同样一份工具函数、去做 axios 封装,这不现实、也不推荐。

所以,我们需要把这些公共部分拆分成 npm 包,统一进行维护,并提供使用:

拆分包功能
@xxx/xxx-core用来存放子应用初始化流程以及一些重要的公共方法
@xxx/xxx-ui通用 UI 组件库,基于 element-ui 二次封装的组件
@xxx/xxx-business业务组件库,用于存放跟业务耦合性较大的组件。区别于 UI 组件,强耦合业务逻辑
@xxx/xxx-util工具函数库,类似于 lodash 函数库,基本上其余的几个库都会引入这个 util 库
@xxx/xxx-httpaxios 请求封装,统一管理拦截、响应请求及错误处理
@xxx/xxx-config应用配置相关
@xxx/xxx-mixinsmixin 相关

这种以 npm 拆分工具包的好处,让管控的粒度更精细。util / config 库作为最基本的库,可被其他的库引用,从而形成依赖关系。好了现在一下子有 6 个库,库的数量可能随着业务需求增加而扩充,那么维护这么多 npm 包,是不是有点头大?要怎么便捷的去处理库与库之间的依赖关系、版本号升级以及本地开发调试呢?这是我们的下一个问题。

管理 npm 包

npm 包的管理,推荐使用 lerna 这一工具。

安装 lerna

因为后面需要频繁的使用 lerna 的命令去进行操作,就不使用 npx 命令去进行创建了,而是全局进行安装。

npm install -g lerna
复制代码

lerna 初始化项目

安装完之后新建一个目录下执行

lerna init --independent
复制代码

完成之后会输出

➜  xxx lerna init --independent
lerna notice cli v3.22.1
lerna info Initializing Git repository
lerna info Creating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files
复制代码

independent 标志着使用独立的版本号,而不是统一的版本号(发版到 npmjs.com 相关) 我们得到了一个文件夹 packages,一个 lerna.json 配置文件,一个 package.json 文件。

配置 lerna.json

{
  "npmClient": "yarn",             // 用 yarn 代替 npm
  "useWorkspaces": true,           // 启用工作区
  "packages": [
    "packages/@xxx/*"              // 库路径
  ],
  "version": "independent",
  "command": {
    "publish": {
      "ignoreChanges": [           // 忽略改动的文件
        "**/node_modules/**",
        //...
      ],
      "message": "chore: publish"
    }
  }
}
复制代码

配置 package.json

{
  "name": "xxx",                  // 最外层的这个name可以随意取,不过还是建议语义化一点
  "private": true,                // 必须是true 才能启用 workspaces
  "workspaces": [
    "packages/@xxx/*"             // 库路径和 lerna.json 保持一致
  ],
  "scripts": {
    "cz": "cz",                   // changelog 的命令,和普通的开发业务逻辑不一样 我们需要详细的记录改动点
    "build-core": "lerna run build --scope @xxx/xxx-core", // 核心库打包命令
    "build-http": "lerna run build --scope @xxx/xxx-http", // 网络通讯库打包命令
    "build-util": "lerna run build --scope @xxx/xxx-util", // 工具库打包命令
    "build": "concurrently \"npm run build-core\" \"npm run build-http\" \"npm run build-util\"" // 一起执行命令
  },
  "keywords": [                   // 关键词
    "micro",
    "util",
    //...
  ],
  "license": "ISC",
  "devDependencies": {
    "@commitlint/cli": "^11.0.0",
    "@commitlint/config-conventional": "^11.0.0",
    "commitizen": "^4.2.3",
    "commitlint-config-cz": "^0.13.2",
    "concurrently": "^5.3.0",
    "cz-customizable": "^6.3.0",
    "husky": "^4.3.8",
    "inquirer": "^7.3.3",
    "lerna-changelog": "^1.0.1",
    "shelljs": "^0.8.4"
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-customizable"
    },
    "cz-customizable": {
      "config": ".cz-config.js"
    }
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "version": "0.0.1"
}
复制代码

npm 包目录结构

npm 包目录结构

开发 npm 包

  1. 进入 xxx-util 目录;
  2. 运行 npm init -y
  3. 修改 package.json,修改 name
- "name": "xxx-util",
+ "name": "@xxx/xxx-util", // 发包之后人家安装就是 import xxx from '@xxx/xxx-util' 这个样子
复制代码

可以参考这篇文章 《如何编写自己的库,并发布到npm?》》 来编写和发布自己的包,因为这里主要说的是 lerna 就不再简述 「npm 的包的从 0 到 1 再到 npm 的发布」。

说个小知识点,我们参照上面链接文章里面的内容,只是来进行编写、发布。

那么如果遇到需要调试的地方改怎么办?我们可以使用 npm link 进行链接的方式来进行调试。具体操作:在 packages/@xxx/xxx-util 目录下运行 sudo npm link,这个命令需要权限所以加上sudo

➜  xxx-util (develop) ✗ sudo npm link
/usr/local/lib/node_modules/@xxx/xxx-util -> /Users/xxx/Desktop/micro/xxx/packages/@xxx/xxx-util
复制代码

在进入你项目里面,也就是需要 import xxx from '@xxx/xxx-util' 的项目

➜  xxx-web-main (develop) ✗ npm link @xxx/xxx-util
/Users/xxx/Desktop/micro/xxx-web/xxx-web-main/node_modules/@xxx/xxx-util -> /usr/local/lib/node_modules/@xxx/xxx-util -> /Users/xxx/Desktop/micro/xxx/packages/@xxx/xxx-util
复制代码

我们去使用的时候是不需要加 sudo 的,那么再检查 node_modules/@xxx/xxx-util 的目录结构,如果和你正在开发的目录结构一样,那就是链接成功了,之后在调试代码就方便多了。

lerna 发布 npm 包

lerna publish patch --canary 用于发布 alpha 版本的包, 类似于下面的:

- @xxx/xxx-util => 0.0.30-alpha.34+d78bade
复制代码

如果要发正式版本的话去掉 --canary 就行了,依次将其他包也用相同的步骤发上去。

我们现在可以将我们微前端需要的一些工具类放到 @xxx/xxx-util 里面,例如:browser 相关,类型相关,验证相关,数值相关,日期相关等项目需要的工具函数,都可在这个包里面去维护,当成一个小项目。

统一网络通讯

就像上面提到的一样,我们需要将一些公共的部分,按照功能聚合拆分成 npm 包的形式,方便维护和引用。这里我们以常用的 axios 请求封装为例。

在 @xxx/xxx-http 里面进行封装,那么进入

➜  xxx (develop) ✔ cd packages/@xxx/xxx-http
➜  xxx (develop) mkdir src // 创建 src 文件夹用于存放源码
➜  xxx (develop) mkdir lib // 创建 lib 文件夹用于存放打包之后的文件 打包的东西有很多 我选择的是 "build": "./node_modules/.bin/babel src --out-dir lib --extensions '.ts' --extensions '.js'" 进行打包
➜  xxx (develop) cd src
➜  src (develop) mkdir config // 创建配置文件夹
➜  src (develop) mkdir core // 封装的核心逻辑
➜  src (develop) mkdir utils // 存放导出的文件
➜  src (develop) pwd
/Users/xxx/Desktop/micro/xxx/packages/@xxx/xxx-http/src
➜  config (develop) cd config // 进入配置文件夹
➜  config (develop) touch HttpCode.ts // 创建状态码映射文件
➜  config (develop) touch settings.js // 创建配置
复制代码

在 HttpCode.ts 里面存放默认配置以及一些类型:

export enum HttpCode {
  "e400" = 400,
  "e401" = 401,
  "e403" = 403,
  "e405" = 405,
  "e408" = 408,
  "e500" = 500,
  "e501" = 501,
  "e502" = 502,
  "e503" = 503,
  "e504" = 504,
  "e505" = 505,
}
export class StatusCode {
  static 400 = '请求无效';
  static 401 = '由于长时间未操作,登录已超时,请重新登录';
  static 403 = '拒绝访问';
  static 405 = '未授权';
  static 408 = '请求超时';
  static 500 = '服务器内部错误';
  static 501 = '服务未实现';
  static 502 = '网关错误';
  static 503 = '服务不可用';
  static 504 = '网关超时';
  static 505 = 'HTTP版本不受支持';
}
复制代码

在 settings.js 里面存放一些配置:

export const _httpOptions = {
  baseURL: '',   // api 的 base_url
  retry: 3,
  retryDelay: 1000,
  withCredentials: true,
  headers: {
    "Content-Type": "application/json;charset=UTF-8"
  },
  timeout: 5000, // request timeout
  method: 'post' // 默认请求方法
}

export const _httpType = {
  DELETE: 'delete',
  GET: 'get',
  POST: 'post',
  PUT: 'put',
  PATCH: 'patch'
}
复制代码

进入 utils 目录:

➜  config (develop) ✔ cd .. && cd utils
➜  utils (develop) ✔
➜  utils (develop) ✔ touch axios.js
➜  utils (develop) ✔ touch http.js
复制代码

在 axios.js 里面写入:

import axios from "axios";
import { isObject, isArray } from "@xxx/xxx-util"
import { _httpOptions } from "../config/settings"; // 导入配置项
import { HttpCode, StatusCode } from "../config/HttpCode";
import NProgress from 'nprogress';

// 配置请求拦截器
const _configRequestInterceptor = (instance, reqInterceptSuccess) => {
  instance.interceptors.request.use(config => {
    if (reqInterceptSuccess) {
      const _config = reqInterceptSuccess(config);
      if (!isObject(_config)) {
        throw Error('reqInterceptSuccess必须返回一个config对象.')
      }
      return _config;
    }
    return config;
  }, error => {
    return Promise.reject(error);
  })
}

/**
 * @method 配置响应拦截器
 * @param {Object} instance axios实例
 * @param {Function} respInterceptSuccess 响应拦截器成功回调
 * @param {Function} respInterceptError 响应拦截器失败回调
 * @param {Number} retry 请求失败自动重试次数 默认2
 * @param {Number} retryDelay 请求失败自动重试时间间隔 默认1000ms
 */
const _configResponseInterceptor = (instance, respInterceptSuccess, respInterceptError, retry, retryDelay) => {
  // 自动重试机制
  instance.defaults.retry = retry;
  instance.defaults.retryDelay = retryDelay;
  // 响应拦截器
  instance.interceptors.response.use(
    res => {
      if (respInterceptSuccess) {
        const _res = respInterceptSuccess(res);
        return _res;
      }
      return res;
    },
    err => {
      NProgress.done();
      let config = err.config;
      let errres = err.response;
      let err_type = errres?.status ?? 0;
      // 处理状态码
      err.message = ErrorCodeHandler(err_type);
      // 收集错误信息
      if (!err.message) {
        switch (err_type) {
          case 404:
            err.message = `请求地址出错: ${errres?.config?.url ?? '/'}`;
            break;
          default:
            err.message = "未知异常,请重试";
            break;
        }
      }
      if (!config || !config.retry) return Promise.reject(err);
      config.__retryCount = config.__retryCount || 0;
      if (config.__retryCount >= config.retry) {
        // 自定义重复请求后失败的回调
        if (respInterceptError) {
          const _res = respInterceptError(err);
          if (!isObject(err?.config)) {
            throw Error('respInterceptError')
          }
          return Promise.reject(_res);
        }
        return Promise.reject(err);
      }
      config.__retryCount += 1;
      let backoff = new Promise((resolve) => {
        setTimeout(() => {
          resolve();
        }, config.retryDelay || 1);
      });
      return backoff.then(() => {
        if (config.baseURL) {
          config.url = config.url.replace(config.baseURL, "");
        }
        return instance(config);
      });
    }
  );
}

/**
 * @param {Number} error 错误码
 */
const ErrorCodeHandler = (error) => {
  return StatusCode[HttpCode[`e${error}`]]
}

export default class Axios {
  constructor() {
    this.httpInstance = null;
  }

  /**
   * @method 创建axios实例
   * @param {Object} param 配置项
   * @description retry:Number 请求失败自动重连次数 默认2
   * @description retryDelay:Number 请求失败自动重连时间间隔 默认1000ms
   * @description timeout:Number 请求超时时间 默认5000
   * @description baseURL:String 请求地址前缀 默认''
   * @description expand:Object 其他需要扩展的配置项 other
   * @param {Function} reqInterceptSuccess 请求拦截器成功回调,必须返回一个config对象
   * @param {Function} respInterceptSuccess 响应拦截器成功回调,必须返回一个response对象
   * @param {Function} respInterceptError 响应拦截器失败回调,必须返回一个response对象
   * @returns 返回创建后的axios实例
   */
  static create({
    retry = _httpOptions.retry,
    retryDelay = _httpOptions.retryDelay,
    withCredentials = _httpOptions.withCredentials,
    headers = _httpOptions.headers,
    timeout = _httpOptions.timeout,
    baseURL = _httpOptions.baseURL,
    ...expand
  } = {}, reqInterceptSuccess, respInterceptSuccess, respInterceptError) {
    // 整理配置项
    const _options = {
      baseURL,
      withCredentials,
      headers,
      timeout,
      ...expand
    }
    // 创建axios实例
    const _http = axios.create(_options);
    // 注册请求拦截器
    _configRequestInterceptor(_http, reqInterceptSuccess);
    // 注册响应拦截器
    _configResponseInterceptor(_http, respInterceptSuccess, respInterceptError, retry, retryDelay);
    this.httpInstance = _http;
    return _http;
  }

  /**
   * 通过向 axios 传递相关配置来创建单个请求
   * @param {Object} param
   * @description url:String 请求地址
   * @description method:String 请求方法类型 默认post
   * @description params:Object 即将与请求一起发送的 URL 参数
   * @description data:Object 作为请求主体被发送的数据
   * @description instance:Object 外部传入的axios实例,默认使用内部创建,无特殊需求不得在外部创建多余实例
   * @description expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  static axios({
    url,
    method = _httpOptions.method,
    params,
    data,
    instance,
    ...expand
  } = {}) {
    // 废弃 返回一个新的promise,注意:此promise将把http错误和与create axios时
    // 整理请求参数
    const _options = {
      url,
      method,
      params,
      data,
      ...expand
    }
    // 处理请求并直接返回_http()
    const _http = instance ? instance() : this.httpInstance;
    return _http(_options);
  }

  /**
   * 执行多个请求
   * @param {Array} list axios Promise 对象
   */
  static all(list) {
    if (!isArray(list)) {
      throw Error('必须传入一个数组!');
    }
    return this.httpInstance.all(list)
  }
}
复制代码

在 http.js 里面写入:

import Axios from './axios' // 导入Axios类
import { _httpType } from "../config/settings" // 导入配置项

export default class Http {
  /**
   * @param {Object} axios 外部axios实例 无特殊情况不要使用此参数; 如果传入则表示使用自定义axios实例,后续参数将不会产生作用
   * @param {Object} axiosOptions Axios.create
   * @description retry:Number 请求失败自动重连次数 默认2
   * @description retryDelay:Number 请求失败自动重连时间间隔 默认1000ms
   * @description withCredentials:Boolean 开启请求跨域 默认true
   * @description headers:Object 请求头配置 默认"Content-Type": "application/json;charset=UTF-8"
   * @description timeout:Number 请求超时时间 默认5000
   * @description baseURL:String 请求地址前缀 默认''
   * @description expand:Object 其他需要扩展的配置项
   * @param {Function} reqInterceptSuccess 请求拦截器成功回调
   * @param {Function} respInterceptSuccess 响应拦截器成功回调
   * @param {Function} respInterceptError 响应拦截器失败回调
   */
  constructor({ axios, axiosOptions, reqInterceptSuccess, respInterceptSuccess, respInterceptError } = {}) {
    this.__http__ = axios || Axios.create(axiosOptions, reqInterceptSuccess, respInterceptSuccess, respInterceptError);
  }

  /**
   * get方法请求
   * @param url:String 请求地址
   * @param params:Object 即将与请求一起发送的 URL 参数
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  get(url, params, expand) {
    return Axios.axios({ url, params, ...expand, method: _httpType.GET });
  }

  /**
   * post方法请求
   * @param url:String 请求地址
   * @param data:Object 作为请求主体被发送的数据
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  post(url, data, expand) {
    return Axios.axios({ url, data, ...expand, method: _httpType.POST })
  }

  /**
   * post方法请求,以url形式传参
   * @param url:String 请求地址
   * @param params:Object 即将与请求一起发送的 URL 参数
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  postQuery(url, params, expand) {
    return Axios.axios({ url, params, ...expand, method: _httpType.POST })
  }

  /**
   * 执行多个并发请求
   * @param {Array} list axios Promise 对象
   */
  all(list) {
    return Axios.all(list)
  }

  /**
   * delete方法请求
   * @param url:String 请求地址
   * @param params:Object 即将与请求一起发送的 URL 参数
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  delete(url, params, expand) {
    return Axios.axios({ url, params, ...expand, method: _httpType.DELETE })
  }

  /**
   * delete方法请求,以url形式传参
   * @param url:String 请求地址
   * @param data:Object 作为请求主体被发送的数据
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  deletePayload(url, data, expand) {
    return Axios.axios({ url, data, ...expand, method: _httpType.DELETE })
  }

  /**
   * put方法请求
   * @param url:String 请求地址
   * @param data:Object 作为请求主体被发送的数据
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  put(url, data, expand) {
    return Axios.axios({ url, data, ...expand, method: _httpType.PUT })
  }

  /**
   * put方法请求,以url形式传参
   * @param url:String 请求地址
   * @param params:Object 即将与请求一起发送的 URL 参数
   * @param expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  putQuery(url, params, expand) {
    return Axios.axios({ url, params, ...expand, method: _httpType.PUT })
  }

  /**
   * patch方法请求
   * @param {Object} options
   * @description url:String 请求地址
   * @description data:Object 作为请求主体被发送的数据
   * @description expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */
  patch(url, data, expand) {
    return Axios.axios({ url, data, ...expand, method: _httpType.PATCH })
  }

   /**
   * 通用请求方法
   * @param {Object} options
   * @description url:String 请求地址
   * @description params:Object 即将与请求一起发送的 URL 参数
   * @description data:Object 作为请求主体被发送的数据
   * @description instance:Object 外部传入的axios实例,默认使用内部创建,无特殊需求不得在外部创建多余实例
   * @description expand:Object 扩展对象,其他不常用的axios(options)配置项放在expand字段传入,key值和axios文档一致
   */

  request(options) {
    return Axios.axios(options)
  }
}
复制代码

进入 core:

➜  config (develop) ✔ cd .. && cd core
➜  core (develop) ✔
➜  core (develop) ✔ touch service.js
复制代码

在 service.js 里面写入:

import axios from 'axios';
import Http from '../utils/http';
import { LocalStorage, logout } from '@xxx/xxx-util';
import { Message, } from 'element-ui';
import NProgress from 'nprogress';

// 用于存储目前状态为pending的请求标识信息
const pendingRequest = [];
// VUE_APP_BASE_API_GW 参考 vuecli 文档中模式和环境变量一节
const { VUE_APP_BASE_API_GW, } = process.env;

export const NEED_MANUALLY_HANDLE_CODE_OBJ = { // 白名单
  SHOW_LOG_LINK: 'B18027',
  LOGISTICS_WAREHOUSE: 'B05017',
  LOGISTICS_PICKUPING: 'A05093',
  LOGISTICS_SOME_PICKUPING: 'A05028',
};

// NProgress 配置
NProgress.configure({ showSpinner: false, });

// 配置项
const options = {
  axiosOptions: { baseURL: VUE_APP_BASE_API_GW, },
  reqInterceptSuccess: config => {
    // 开启 progress bar
    NProgress.start();
    const token = LocalStorage.getToken();
    // 头部加入语言参数
    config.headers['x-ca-language'] = LocalStorage.getLanguage() === 'en' ? 'en_US' : 'zh_CN';
    // 加入请求的唯一ID
    config.headers['x-ca-reqid'] = Math.random() + new Date().getTime();
    // 加入请求的时间戳
    config.headers['x-ca-reqtime'] = new Date().getTime();
    if (token) {
      //让每个请求携带token--['Authorization']为自定义key 请根据实际情况自行修改
      config.headers['Authorization'] = 'bearer ' + token;
    }
    // 如果一个项目里有多个不同baseURL的请求,可以改成`${config.method} ${config.baseURL}${config.url}`
    if (config.cancelToken !== false) {
      const requestMark = `${config.method}-${config.url}`;
      // 找当前请求的标识是否存在pendingRequest中,即是否重复请求了
      const markIndex = pendingRequest.findIndex(item => {
          return item.name === requestMark;
      });
      // 存在,即重复了
      if (markIndex > -1) {
          // 取消上个重复的请求
          pendingRequest[markIndex].cancel();
          // 删掉在pendingRequest中的请求标识
          pendingRequest.splice(markIndex, 1);
      }
      //(重新)新建针对这次请求的axios的cancelToken标识
      const CancelToken = axios.CancelToken;
      const source = CancelToken.source();
      config.cancelToken = source.token;
      // 设置自定义配置requestMark项,主要用于响应拦截中
      config.requestMark = requestMark;
      // 记录本次请求的标识
      pendingRequest.push({
          name: requestMark,
          cancel: source.cancel,
      });
    }
    return config;
  },
  respInterceptSuccess: res => {
    //关闭 progress bar
    NProgress.done();
    if(res.config.cancelToken !== false){
      // 根据请求拦截里设置的requestMark配置来寻找对应pendingRequest里对应的请求标识
      const markIndex = pendingRequest.findIndex(item => {
        return item.name === res.config.requestMark;
      });
      // 找到了就删除该标识
      markIndex > -1 && pendingRequest.splice(markIndex, 1);
    }
    const { status, data, } = res;
    const { code, data: dataForm, msg, error_description: errorDescription, } = data
    // 如果是401则跳转到登录页面
    if (status === 401) {
      logout();
      location.reload();
    }
    // 如果请求为非200否者默认统一处理
    if (status !== 200) {
      const message = msg || errorDescription || '未知错误';
      return Promise.reject(new Error(message));
    }
    // B01032 登录超时
    if (code === 'B01032' || code === 'A00998') {
      logout();
      Message.error({
        message: '登录超时,请重新登陆',
        duration: 250,
        onClose: () => {
          location.reload();
        }
      });
      return Promise.reject(new Error('登录超时,请重新登陆'));
    }

    if (code !== '000000') {
        const message = msg || errorDescription || '未知错误';
        // 需要手动在页面处理的逻辑
        if (Object.values(NEED_MANUALLY_HANDLE_CODE_OBJ).includes(code)) {
            return Promise.reject({
                message,
                code,
            });
        }
        Message.error(message);
        return Promise.reject(new Error(message));
    }
    return dataForm;
  },
  respInterceptError: error => {
    NProgress.done();
    if (axios.isCancel(error)) {
        // 手动取消的请求 关闭promise链
        return new Promise(() => ({}));
    }
    return Promise.reject(new Error(error));
  },
};

// 实例化http
const http = new Http(options);

export default http;
复制代码

service 导出了 axios 的实例,我们需要一个文件来承载它,好用于打包

➜  core (develop) ✔ pwd
/Users/xxx/Desktop/micro/xxx/packages/@xxx/xxx-http/src/core
➜  core (develop) ✔ cd .. && touch index.js
复制代码

在 index.js 里面导出这个实例,让其他的项目可以引用

import http from "./core/service"
export default http;
复制代码

同样的,我们使用 lerna 去进行发版就可以了。

到目前为止,我们写好了网络请求库。我们可以根据 qiankun 的 example 来跑一遍了,同时在 example 都 yarn add -D @xxx/xxx-core,测试能不能用。

qiankun 官网提供的简单例子,很明显不能直接拿过来就用,我们需要在其中进行很多的拓展,从登陆到左侧菜单栏,到顶部导航条再到右侧内容渲染。下一篇我们讲讲如何创建主应用/子应用、以及 keep-alive 和 vuex 的整合、如何统一公共依赖包的版本。

因涉及保密协议,文中 @xxx 表示带域的包,类似 @vue,xxx 是同一个英文。

以上便是本次分享的全部内容,希望对你有所帮助 ^_^

喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。


关于我们

我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。

一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。

VANTOP前端团队

文章分类
前端
文章标签