Skip to main content

适配器模式(结构型)

适配器模式 —— 兼容代码就是一把梭

适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。

一、生活中的适配器

如下图:

这种耳机转接器就相当于适配器。

二、适配器的业务场景

1、新方法

有一天,Tim 封装了一个基于 fetch 的 http 方法库 HttpUtils

export default class HttpUtils {
// get 方法
static get(url) {
return new Promise((resolve, reject) => {
// 调用 fetch
fetch(url)
.then((response) => response.json())
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
});
}

// post 方法,data 以 object 形式传入
static post(url, data) {
return new Promise((resolve, reject) => {
// 调用 fetch
fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
// 将 object 类型的数据格式化为合法的 body 参数
body: this.changeData(data),
})
.then((response) => response.json())
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
});
}

// body 请求体的格式化方法
static changeData(obj) {
var prop,
str = "";
var i = 0;
for (prop in obj) {
if (!prop) {
return;
}
if (i == 0) {
str += prop + "=" + obj[prop];
} else {
str += "&" + prop + "=" + obj[prop];
}
i++;
}
return str;
}
}

封装完,现在使用 fetch 发起请求时只需要像下面一样直接调用,而不必再操心繁琐的数据配置和数据格式化:

// 定义目标 url 地址
const URL = "xxxxx";
// 定义 post 入参
const params = {
// ...
};

// 发起 post 请求
const postResponse = (await HttpUtils.post(URL, params)) || {};

// 发起 get 请求
const getResponse = await HttpUtils.get(URL);

2、旧方法

老板看到 Tim 封装的 HttpUtils 库后,想把公司所有业务的网络请求都迁移到这个 HttpUtils 上,而原来公司封装的网络请求库是基于 XMLHttpRequest 的:

function Ajax(type, url, data, success, failed) {
// 创建 ajax 对象
var xhr = null;
if (window.XMLHttpRequest) {
xhr = new XMLHttpRequest();
} else {
xhr = new ActiveXObject("Microsoft.XMLHTTP");
}

// ...

var type = type.toUpperCase();

// 识别请求类型
if (type == "GET") {
if (data) {
xhr.open("GET", url + "?" + data, true); //如果有数据就拼接
}
// 发送 get 请求
xhr.send();
} else if (type == "POST") {
xhr.open("POST", url, true);
// 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// 发送 post 请求
xhr.send(data);
}

// 处理返回数据
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
success(xhr.responseText);
} else {
if (failed) {
failed(xhr.status);
}
}
}
};
}

这个原始的网络请求库使用时是这样调用的:

// 发送 get 请求
Ajax(
"get",
url 地址,
post 入参,
function (data) {
// 成功的回调逻辑
},
function (error) {
// 失败的回调逻辑
}
);

可以看到,Tim 封装的 HttpUtils 跟这个原始网络请求库使用起来不仅接口名不同,入参方式也不一样,手动改起来非常麻烦。

3、使用适配器兼容接口

这就需要用到适配器模式,把老代码迁移到新接口,不用挨个修改每一次的接口调用(如同耳机接口不同,不必挨个去改造耳机),只需要在引入接口时进行一次适配(如同耳机的转接器),便可轻松地适配原有请求库了。适配器如下:

// Ajax 适配器函数,入参与旧接口保持一致
async function AjaxAdapter(type, url, data, success, failed) {
const type = type.toUpperCase();
let result;
try {
// 实际的请求全部由新接口发起
if (type === "GET") {
result = (await HttpUtils.get(url)) || {};
} else if (type === "POST") {
result = (await HttpUtils.post(url, data)) || {};
}
// 假设请求成功对应的状态码是 1
result.statusCode === 1 && success
? success(result)
: failed(result.statusCode);
} catch (error) {
// 捕捉网络错误
if (failed) {
failed(error.statusCode);
}
}
}

// 用适配器适配旧的 Ajax 方法
async function Ajax(type, url, data, success, failed) {
await AjaxAdapter(type, url, data, success, failed);
}

只需要编写一个适配器函数 AjaxAdapter,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了。

三、生产实践 —— Axios 中的适配器

数月后,老板发现了网络请求神库 Axios,于是又想迁移到 Axios——对于心中有适配器的 Tim 来说,就简单多了。

另外,Axios 本身就用到了适配器模式,它的兼容方案值得我们学习和借鉴。Axios 的基本使用如下:

// Make a request for a user with a given ID
axios
.get("/user?ID=12345")
.then(function (response) {
// handle success
console.log(response);
})
.catch(function (error) {
// handle error
console.log(error);
})
.then(function () {
// always executed
});

axios
.post("/user", {
firstName: "Fred",
lastName: "Flintstone",
})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});

axios({
method: "post",
url: "/user/12345",
data: {
firstName: "Fred",
lastName: "Flintstone",
},
});

可以看到,Axios 可以轻松发起各种网络请求,而不用去关心底层的实现细节。

除了简明优雅的 API 之外,Axios 强大的地方还在于,它不仅是个局限于浏览器端的库。在 Node 环境下尝试调用上面的 API,会发现它照样好使 —— Axios 完美地抹平了两种环境下 API 的调用差异,靠的正是对适配器模式的灵活运用。

在 Axios 的核心逻辑中,可以注意到实际上派发请求的是 dispatchRequest 方法。该方法内部主要做了两件事:

  • 数据转换,转换请求体/响应体,可以理解为数据层面的适配;
  • 调用适配器。

调用适配器的逻辑如下:

// 若用户未手动配置适配器,则使用默认的适配器
var adapter = config.adapter || defaults.adapter;

// dispatchRequest 方法的末尾调用的是适配器方法
return adapter(config).then(
function onAdapterResolution(response) {
// 请求成功的回调
throwIfCancellationRequested(config);

// 转换响应体
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);

return response;
},
function onAdapterRejection(reason) {
// 请求失败的回调
if (!isCancel(reason)) {
throwIfCancellationRequested(config);

// 转换响应体
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}

return Promise.reject(reason);
}
);

上面如果用户未手动配置适配器,则使用默认的适配器。手动配置适配器可以自定义处理请求,目的是为了使测试更轻松。

实际开发中使用默认适配器的频率更高。默认适配器在 axios/lib/default.js 里是通过 getDefaultAdapter 方法来获取的:

function getDefaultAdapter() {
var adapter;
// 判断当前是否是 node 环境
if (
typeof process !== "undefined" &&
Object.prototype.toString.call(process) === "[object process]"
) {
// 如果是 node 环境,调用 node 专属的 http 适配器
adapter = require("./adapters/http");
} else if (typeof XMLHttpRequest !== "undefined") {
// 如果是浏览器环境,调用基于 xhr 的适配器
adapter = require("./adapters/xhr");
}
return adapter;
}

其中,http 适配器如下:

module.exports = function httpAdapter(config) {
return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
// 具体逻辑
}
}

xhr 适配器如下:

module.exports = function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// 具体逻辑
}
}
  • 两个适配器的入参都是 config
  • 两个适配器的出参都是一个 Promise

这样通过 Axios 发起跨平台的网络请求,不仅调用的接口名是同一个,连入参、出参的格式都只需要掌握同一套,带来了极佳的用户体验。

一个好的适配器的自我修养 —— 把变化留给自己,把统一留给用户。在此处,所有关于 http 模块、关于 xhr 的实现细节,全部被 Adapter 封装进了自己复杂的底层逻辑里,暴露给用户的都是简单的统一接口,统一入参,统一出参,统一规则。