2
头图

前面说的话

本文主要讲述在项目中遇到的一些业务场景,并提炼出来的解决方案。供小伙伴们参考~

在一个项目中,我们可能会遇到这样子的场景,项目请求的接口如 https://a.com/xxx,由于业务的交集,可能还需要请求第二个域名的接口,如 https://b.com/xxx

针对这种场景,我们可能会想到几个方案:
(注意:由于浏览器同源策略,一个前端工程在打包发布之后,通常我们会把资源放在与后端接口服务同一个域下。所以当有第二个域接口时,就会出现跨域请求导致请求失败。)

  1. 后端处理请求 “第二个域接口”,相当于代理动作。这样子前端就不会有跨域问题,无需做其他事。

存在问题:如果只是单纯的做代理,个人觉得有一种耦合的感觉,方法较为不优雅。

  1. 在前端请求两个不同域的接口。

存在问题:

  • 由于浏览器同源策略,必须会有一个域的接口跨域,后端需要设置允许跨域白名单。
  • 一般来说我们会对请求框架进行封装,类似 request.get('getUser'),我们还会设置一个 “baseURL” 为默认域名,如 https://a.com。这样子 “request” 默认发起的请求都是 https://a.com 下的相关接口。
    那请求域名 https://b.com 相关接口我们该怎样进行封装呢?

针对以上的两个方案分析,我们得出了一个较优的处理方案,请继续往下看:

先看下处理封装后的最终效果

本文 demo 以请求 掘金,思否,简书 的接口来为例。

// ...
const requestMaster = async () => {
  const { err_no, data, err_msg } = await $request.get('user_api/v1/author/recommend');
};
const requestSifou = async () => {
  const { status, data } = await $request.get.sifou('api/live/recommend');
};
const requestJianshu = async () => {
  const { users } = await $request.get.jianshu('users/recommended');
};
// ...

我们封装 $request 作为主要对象,并扩展 .get 方法,sifoujianshu 为其属性作为两个不同域接口的方法,从而实现了我们在一个前端工程中请求多个不同域接口。接下来让我们看看实现的相关代码吧(当前只展示部分核心代码)~

二次封装 axios 的 request 请求插件

这里我们拿 axios 为例,先对它进行一个封装:

// src/plugins/request
import axios from 'axios';
import apiConfig from '@/api.config';
import _merge from 'lodash/merge';
import validator from './validator';
import { App } from 'vue';
export const _request = (config: IAxiosRequestConfig) => {
  config.branch = config.branch || 'master';
  let baseURL = '';
  // 开发模式开启代理
  if (process.env.NODE_ENV === 'development') {
    config.url = `/${config.branch}/${config.url}`;
  } else {
    baseURL = apiConfig(process.env.MY_ENV, config.branch);
  }
  return axios
    .request(
      _merge(
        {
          timeout: 20000,
          headers: {
            'Content-Type': 'application/json',
            token: 'xxx'
          }
        },
        { baseURL },
        config
      )
    )
    .then(res => {
      const data = res.data;
      if (data && res.status === 200) {
        // 开始验证请求成功的业务错误
        validator.start(config.branch!, data, config);
        return data;
      }
      return Promise.reject(new Error('Response Error'));
    })
    .catch(error => {
      // 网络相关的错误,这里可用弹框进行全局提示
      return Promise.reject(error);
    });
};

/**
 * @desc 请求方法类封装
 */
class Request {
  private extends: any;
  // request 要被作为一个插件,需要有 install 方法
  public install: (app: App, ...options: any[]) => any;
  constructor() {
    this.extends = [];
    this.install = () => {};
  }
  extend(extend: any) {
    this.extends.push(extend);
    return this;
  }
  merge() {
    const obj = this.extends.reduce((prev: any, curr: any) => {
      return _merge(prev, curr);
    }, {});
    Object.keys(obj).forEach(key => {
      Object.assign((this as any)[key], obj[key]);
    });
  }
  get(path: string, data: object = {}, config: IAxiosRequestConfig = {}) {
    return _request({
      ...config,
      method: 'GET',
      url: path,
      params: data
    });
  }
  post(path: string, data: object = {}, config: IAxiosRequestConfig = {}) {
    return _request({
      ...config,
      method: 'POST',
      url: path,
      data
    });
  }
}
export default Request;

现在我们来一一解释 “request” 插件

策略模式,不同环境的接口域名配置

import apiConfig from '@/api.config';

// @/api.config
const APIConfig = require('./apiConfig');
const apiConfig = new APIConfig();
apiConfig
  .add('master', {
    test: 'https://api.juejin.cn',
    prod: 'https://prod.api.juejin.cn'
  })
  .add('jianshu', {
    test: 'https://www.jianshu.com',
    prod: 'https://www.prod.jianshu.com'
  })
  .add('sifou', {
    test: 'https://segmentfault.com',
    prod: 'https://prod.segmentfault.com'
  });
module.exports = (myenv, branch) => apiConfig.get(myenv, branch);

使用策略模式添加不同域接口的 测试/正式环境 域名。

策略模式,扩展 $request.get 方法

// src/plugins/request/branchs/jianshu
import { _request } from '../request';
export default {
  get: {
    jianshu(path: string, data: object = {}, config: IAxiosRequestConfig = {}) {
      return _request({
        ...config,
        method: 'GET',
        url: path,
        data,
        branch: 'jianshu',
        // 在 headers 加入 token 之类的凭证
        headers: {
          'my-token': 'jianshu-test'
        }
      });
    }
  },
  post: {
     // ...
  }
};
// src/plugins/request
import { App } from 'vue';
import Request from './request';
import sifou from './branchs/sifou';
import jianshu from './branchs/jianshu';
const request = new Request();
request.extend(sifou).extend(jianshu);
request.merge();
request.install = (app: App, ...options: any[]) => {
  app.config.globalProperties.$request = request;
};
export default request;

通过 Request 类的 extend 方法,我们就可以进行扩展 $request 的 get 方法,实现优雅的调用其他域接口。

策略模式,根据接口返回的 “code” 进行全局弹框错误提示

import validator from './validator';

考虑到不同域接口的出参 “code” 的 key 和 value 都不一致,如掘金的 code 为 err_no,思否的 code 为 status,但是简书却没有设计返回的 code ~

让我们仔细看两段代码(当前只展示部分核心代码):

// src/plugins/request/strategies
import { parseCode, showMsg } from './helper';
import router from '@/router';
import { IStrategieInParams, IStrategieType } from './index.type';
/**
 * @desc 请求成功返回的业务逻辑相关错误处理策略
 */
const strategies: Record<
  IStrategieType,
  (obj: IStrategieInParams) => string | undefined
> = {
  // 业务逻辑异常
  BUSINESS_ERROR({ data, codeKey, codeValue }) {
    const message = '系统异常,请稍后再试';
    data[codeKey] = parseCode(data[codeKey]);
    if (data[codeKey] === codeValue) {
      showMsg(message);
      return message;
    }
  },
  // 没有授权登录
  NOT_AUTH({ data, codeKey, codeValue }) {
    const message = '用户未登录,请先登录';
    data[codeKey] = parseCode(data[codeKey]);
    if (data[codeKey] === codeValue) {
      showMsg(message);
      router.replace({ path: '/login' });
      return message;
    }
  }

  /* ...更多策略... */
};
export default strategies;
// src/plugins/request/validator
import Validator from './validator';
const validator = new Validator();
validator
  .add('master', [
    {
      strategy: 'BUSINESS_ERROR',
      codeKey: 'err_no',
      /* 
        配置 code 错误时值为1,如果返回 1 就会全局弹框显示。
        想要看到效果的话,可以改为 0,仅测试显示全局错误弹框,
       */
      codeValue: 1
    },
    {
      strategy: 'NOT_AUTH',
      codeKey: 'err_no',
      /* 
        配置 code 错误时值为3000,如果返回 3000 就会自动跳转至登录页。
        想要看到效果的话,可以改为 0,仅测试跳转至登录页
       */
      codeValue: 3000
    }
  ])
  .add('sifou', [
    {
      strategy: 'BUSINESS_ERROR',
      codeKey: 'status',
      // 配置 code 错误时值为1
      codeValue: 1
    },
    {
      strategy: 'NOT_AUTH',
      codeKey: 'status',
      codeValue: 3000
    }
  ]);
/* ...更多域相关配置... */
// .add();
export default validator;

因为不同域的接口,可能是不同的后端开发人员开发,所以出参风格不一致是一个很常见的问题,这里采用了策略模式来进行一个灵活的配置。在后端返回业务逻辑错误时,就可以进行 全局性的错误提示 统一跳转至登录页 。整个前端工程达成更好的统一化。

Proxy 代理多个域

本地开发 node 配置代理应该是每个小伙伴的基本操作吧。现在我们在本地开发时,不管后端是否开启跨域,都给每个域加上代理,这步也是为了达成一个统一。目前我们需要代理三个域:

// vue.config.js
// ...
const proxy = {
  '/master': {
    target: apiConfig(MY_ENV, 'master'),
    secure: true,
    changeOrigin: true,
    // 代理的时候路径是有 master 的,因为这样子就可以针对代理,不会代理到其他无用的。但实际请求的接口是不需要 master 的,所以在请求前要把它去掉
    pathRewrite: {
      '^/master': ''
    }
  },
  '/jianshu': {
    target: apiConfig(MY_ENV, 'jianshu'),
    // ...
  },
  '/sifou': {
    target: apiConfig(MY_ENV, 'sifou'),
    // ...
  }
};
// ...

TS 环境下 global.d.ts 声明,让调用更方便

// src/global.d.ts
import { ComponentInternalInstance } from 'vue';
import { AxiosRequestConfig } from 'axios';
declare global {
  interface IAxiosRequestConfig extends AxiosRequestConfig {
    // 标记当前请求的接口域名是什么,默认master,不需要手动控制
    branch?: string;
    // 全局显示 loading,默认false
    loading?: boolean;

    /* ...更多配置... */
  }

  type IRequestMethod = (
    path: string,
    data?: object,
    config?: IAxiosRequestConfig
  ) => any;
  type IRequestMember = IRequestMethod & {
    jianshu: IRequestMethod;
  } & {
    sifou: IRequestMethod;
  };
  interface IRequest {
    get: IRequestMember;
    post: IRequestMember;
  }

  interface IGlobalAPI {
    $request: IRequest;

    /* ...更多其他全局方法... */
  }

  // 全局方法钩子声明
  interface ICurrentInstance extends ComponentInternalInstance {
    appContext: {
      config: { globalProperties: IGlobalAPI };
    };
  }
}

/**
 * 如果你在 Vue3 框架中还留恋 Vue2 Options Api 的写法,需要再新增这段声明
 *
 * @example
 * created(){
 *  this.$request.get();
 *  this.$request.get.sifou();
 *  this.$request.get.jianshu();
 * }
 */
declare module '@vue/runtime-core' {
  export interface ComponentCustomProperties {
    $request: IRequest;
  }
}
export {};

注意

项目正式上线时,除了 master 主要接口,其他分支的不同域接口,服务端需要开启跨域白名单。

总结

本文为一个前端项目请求多个不同域的接口,提供了封装的思路,基础框架为 Vue3+TS
不同的项目业务场景复杂程度不一致,可能还需要更多的封装,针对业务的抽象架构才是不耍流氓的架构。
以上只是阐述了一些核心代码,具体还是要看源码才能更加了解,点我查看源码


前端精
536 声望20 粉丝