前言
前端跨端开发一直都是一个热门的话题。因为一份代码可以跑在不同的端内,可以极大的提高人效,且可以快速的将业务在不同的端进行上线,提高产品的端侧覆盖率。与此同时,前端需要面临一个问题,如何在多端的业务场景下提供各端都可复用的代码逻辑,而不是针对不同的端去实现。当然了针对跨端也有很多跨端的框架,比如 taro
可以开发跨端的小程序和 H5
,Electron
和 Tauri
可以通过跨桌面端端应用开发。但不可否认的是即使面对这些丰富的跨端框架下,业务层面仍会面临需要针对多端实现一下特定的业务逻辑,举例来讲,如何实现各端的统一监控和统一打点,不同的端抽象层可能是一样的,但是底层的实现层肯定是不一样的。最简单的,web
和 H5
的上报方式可能是 sendBeacon
、XHR
、Fetch
、new Image
等方式,而各端小程序可能是 wx.request
、 dd.httpRequest
、my.request
等方式,调用者实际是感知这些差异的,都是统一调用 sendLog
进行上报。这就涉及到如何提供抹平端侧差异的代码以支持各端的业务,且业务调用者无需感知端侧的差异,仅是直接调用对应的方法即可。这里我们以为各端提供统一的 Request
为例来描述如何提供多端复用代码。
/*
* 提供抹平端侧差异的 Request 类
* 各端仅需在 app 入口测进行实例化即可使用
* 全局单例 globalConfig 设置 request 实例
* seivice 层通过在 adapter 获取 request 实例进行 api 请求
*/
// 伪代码如下:
// request.ts
class Request {
httpRequest(requestOptions: RequestOption) {}
}
// index.ts
import { Request } from './request';
import { setGlobalConfig } from './config';
setGlobalConfig({ request: new Request() });
// config.ts
let globalConfig: GlobalConfig = {}
const setGlobalConfig = (options: Partial<GlobalConfig>) => {
globalConfig = {...globalConfig, ...options};
};
const getRequest = () => {
return globalConfig.request!;
};
// adapter.ts
import { getRequest } from './config';
const httpRequest = (options: RequestOption) => {
// 这里主要是作数据格式化的操作
return getRequest().httpRequest(options);
};
// service.ts
import { httpRequest } from './adapter';
const getUserInfo = (data: GetUserInfoRequest) => {
return httpRequest({
url: 'xxx/xxx/xx',
data,
});
};
设计模式
这里我们思考几个问题:
Request
类提供给所有的端使用,作为抽象侧如何定义好接口协议?- 各端差异化的
baseRequest
如何实现? request
请求需要的token
如何存取,且需要抹平端侧差异?- 差异化的
baseRequest
和tokenManger
如何注入给Request
类? token
的无感刷新与请求的去重等逻辑如何实现?
组合这些问题我们可以实现一个简单的 Request
类。
interface TokenManager {
async getItem<T>(key: string) {
}
async setItem(key: string, value: string | Record<string, string | number | boolean>) {}
}
class Request {
private _baseRequest; // 这里可以定义 baseRequest 的类型同 axios.request
private _tokenManager; // 需要实现 TokenManager
constructor(baseRequest, tokenManager) {
this._baseRequest = baseRequest;
this._tokenManager = tokenManager;
}
async httpRequest<T>(options: RequestOptions) {
const token = this._tokenManager.getItem<{
accessToken: string;
refershToken: string;
expireTime: string;
}>('token');
// 这里判断 token 是否过期 过期刷新 token 使其有效
// 组装 headers
const header = {}
try {
/*
* 这里需要注意
* 请求去重,针对多个统一请求仅发送一个就可以
* 有些请求需加锁 保证请求顺序
*/
const res = await this._baseRequest();
} catch (e) {
// 判断 e 是否是 token 过期造成的 401 是 需要刷新token请求重试
}
}
}
依赖倒置
针对以上我们的伪代码设计了 Request
类。
这里需要明确一个设计原则。在针对 Request
类的设计中,需要用到 token
管理的类 TokenManger
与基础请求 baseRequest
方法。这里我们做了解耦合,使得 Request
类对这两者的依赖是依赖于抽象接口协议,而不是具体的实现,具体的实现根据不同的端进行代码的实现。这里就有一个设计原则叫依赖倒置。依赖倒置原则(Dependency Inversion Principle,简称DIP)是面向对象设计的基本原则之一,属于 SOLID
设计原则之一,由罗伯特·C·马丁(Robert C. Martin)提出。这个原则的核心思想是“依赖于抽象,而不是依赖于具体”。在传统的软件设计中,高层模块往往依赖于底层模块,也就是说,具体的实现细节(底层模块)决定了模块间的耦合关系。而依赖倒置原则则是要求我们反过来,让底层模块依赖于抽象,高层模块依赖于抽象,这样,具体的实现细节就被“倒置”到了底层,高层模块与底层模块之间的耦合关系是基于抽象的,而不是基于具体实现的。
具体来说,依赖倒置原则包含以下两个方面:
- 抽象不应该依赖于细节,细节应该依赖于抽象。也就是说,我们应该面向接口编程,而不是面向具体实现编程。
- 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。这样可以提高模块的独立性,降低耦合性。
通过遵循依赖倒置原则,我们可以使代码更加模块化,更加的解耦合,更易于测试和维护,因为高层模块对实现细节的依赖被解除了,只要保持接口不变,更换具体实现对高层模块几乎没有影响。
依赖注入
通过依赖倒置原则进行 Request
类的设计,那么如何将互相依赖的类进行耦合关系的关联呢。这里需要有到一个设计模式,叫依赖注入。
依赖注入(Dependency Injection,简称DI)是一种设计模式,它是依赖倒置原则的一种实现方式,主要用于解决组件之间的依赖关系,使得组件之间的耦合度降低,提高代码的可测试性和可维护性。
依赖注入的基本思想是:将一个对象所需的依赖(即其他对象)传递给它,而不是由它自己创建。这样,对象不再负责获取它的依赖,而是由外部(如容器或框架)在运行时注入。这样做的好处是,对象的依赖关系可以被灵活地配置,不需要修改对象的代码。
这里也需要明确依赖和注入这两个概念:
- 依赖:一个对象为了完成它的功能所需要的对象或资源。
- 注入:将依赖传递给对象的方式,而不是由对象自行创建依赖。
依赖注入主要有三种方式:
- 接口注入:通过接口,将依赖对象传递给需要它的类。
- 构造函数注入:在类的构造函数中接收依赖对象。
- 属性注入:通过设置类的公共属性来注入依赖对象。
// 构造函数注入
class Request {
constructor(baseRequest, tokenManager) {
this._baseRequest = baseRequest;
this._tokenManager = tokenManager;
}
}
// 接口注入
class Request {
setBaseRequest(baseRequest) {
this._baseRequest = baseRequest;
}
setTokenManager(tokenManager) {
this._tokenManager = tokenManager;
}
}
// 属性注入
class Request {
set baseRequest(baseRequest) {
this._baseRequest = baseRequest;
}
set tokenManager(tokenManager) {
this._tokenManager = tokenManager;
}
}
优化的几个点
Request
类的设计已经有,下面就可以针对依赖进行实现,以及需要进行业务控制的代码进行实现了,如请求加锁、请求去重、无感刷新、请求重试。
TokenManager 的实现
这里以 web
为例:
class TokenManager {
async setItem(key: string, value: string | Record<string, string | number | boolean>) {
localStorage.setItem(key, JSON.stringify(value));
}
async getItem<T>(key) {
return JSON.parse(localStorage.getItem(key)) as T;
}
}
baseRequest 实现
这里以 web
为例:
// 这里可以直接使用 axios 其它端模拟 axios.request 实现即可
export const webBaseRequest = (options) => {
return axios.request(options);
}
请求加锁
请求加锁在很多业务场景就是为了保持请求的顺序,因此这里可以借助先用的成熟的库来解决这个问题。这里使用 async-mutex
这个库来解决。
请求去重
请求去重在业务场景中针对同一个请求同时发起了好几个,只需发起一个请求接口,其它请求仅返回第一个请求结果即可。这里我们简单实现一下:
type RequestFunc<T> = () => Promise<T>;
class RequestManager {
private pendingRequests: Map<string, Promise<any>> = new Map();
public async makeRequest<T>(key: string, requestFunc: RequestFunc<T>): Promise<T> {
// 如果当前已有请求在进行中,直接返回这个请求的 Promise
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}
// 否则,发起请求并缓存这个请求的 Promise
const requestPromise = requestFunc().finally(() => {
// 请求完成后,从 pendingRequests 中移除
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, requestPromise);
return requestPromise;
}
}
无感刷新
无感刷新主要是针对 token
的操作,需要保证 token
的有效性。这里可以参看我之前的文章【axios】不可不知的axios无感刷新token。
请求重试
请求重试也是为了请求做兜底的,当请求出现指定错误是可以通过一些操作手段是请求正常。这里可以使用 axios-retry
这个库做请求重试。
针对 refreshtoken 的 error
当 refreshtoken
时发生了 error
说明当前的 token
已经完全失效,需要重新登录了。
总结
这里以统一请求(request)为例,通过依赖倒置原则,借助依赖注入模式,实现多端架构下的代码复用。如果各位在实际的业务场景落地中,有更好的实践欢迎留言交流。