前端开发 / Ajax 封装

Ajax 封装

前言

学会了 Ajax 的请求以及如何处理服务端的响应。这一章节,我们着重来封装一个简单的 Ajax。

前置知识:

  1. 本章节会使用部分 ES6 语法
  2. 本章节使用 Promise

简单需求:

  • 支持 Promise 语法处理结果
  • 支持自定义配置,包括 headers
  • 内置 url、params、 data、headers 处理

1. 构造一个这样的 xhr

function xhr(config) {
    return new Promise((resolve, reject) => {
        const request = new XMLHttpRequest();

        /**
     	* 调用 open 方法
     	*/
        request.open(method, url);

        request.onreadystatechange = function handleLoad() {
            if (request.readyState !== 4) return
            if (request.status === 0) return
            const responseData = request.response
            resolve(responseData)
        }

        request.send(data)
    });
}

首先, 我们的 xhr 函数支持 config 传入, 内部通过 XMLHttpRequest 技术来进行请求的收发, 大致就是上面这样结构的代码,内部的实现我们前面章节都讲过,唯一不同的是,在 onreadystatechange 上,我们挂载的方法最后使用 resolve() 来进行断言,这样做的目的是,后续可以通过 .then() 的方式进行数据操作。

1.1 method 标准化

首先, 用户传进来的 method 可能是大写也可能是小写,我们可以先做一个标准化,对 method 做一个转化,将其变为大写:

method.toUpperCase()

1.2 构建 url

有些同学很奇怪,为什么说构建 url,我们不是通过 config 传入 url 吗?

是的,但是同学你别忘了,我们支持 params!

因此,我们需要把 params 上的参数进行一定格式序列化拼接到 url 后面 ,构成 "url?a=xxx&b=xxx" 的格式。为此,我们需要提供了一个 buildUrl 的函数:

/**
 * 构建 url
 * @param {*} url
 * @param {*} params
 */
function buildUrl(url, params) {
    if (!params || !isPlainObject(params)) return url; // 如果 params 没有传或者不是一个纯对象,直接返回原 url
    let values = [];
    Object.keys(params).forEach(key => {
        // 对 params 中的每一项进行处理
        const val = params[key];
        if (typeof val === undefined || val === null) {
            // 如果当前项的值为 undefined 或者 null,则忽略
            return;
        }
        values.push(`${key}=${val}`); // 将 “key=value”的形式加入到 values 数组中
    });
    let serializedParams = values.join("&"); // 序列化,将 values 数组转化为字符串,格式为 "key=value&key=value"
    if (serializedParams) {
        // 如果有值,则加入到url后面。构成 "url?key=value&key=value" 的形式
        url += (url.indexOf("?") === -1 ? "?" : "&") + serializedParams;
    }
    return url;
}

在这个函数中,我们可以传参 url 和 params。如果传入params 为假值,那我们直接忽略,返回 url 即可。否则,我们需要对 params 中 的每一项目进行序列化,变为 "key=vaue" 这样的形式, 添加到 values 数组中。接着我们通过数组的 .join("&") 的方法,把 values 数组通过 “&” 进行拼接。最后拼接到 url 后面,构成 "url?key=value&key=value" 的形式返回。

这里,我们也涉及到了一个工具函数 isPlainObject,在本章节中好几处都会用到,他的作用是判断该对象是不是一个纯 “{}” 的对象,它的实现如下:

const toString = Object.prototype.toString; // 由于 Object.prototype.toString 在判断类型的时候非常好用,并且用到的次数经常会比较多,我们通常可以这样缓存起来

/**
 * 判断当前 val 是否是一个纯对象
 * @param {*} val
 */
function isPlainObject(val) {
    return toString.call(val) === "[object Object]";
}

1.3 标准化 data

因为 .send() 是无法支持 Json 格式数据的,所以我们需要对 data 做一个序列化处理:

/**
 * 处理 data,因为 send 无法直接接受 json 格式数据,这里我们可以直接序列化之后再传给服务端
 * @param {*} data 
 */
function transformData (data) {
    if (isPlainObject(data)) {
        return JSON.stringify(data)
    }
    return data
}

实现非常简单,如果判断 data 是一个纯对象的话,就加一道 JSON.stringify(data) 的操作进行序列化, 否则直接返回 data 本身。

1.4 设置 headers

对于 headers 的操作,我们会着重对 Content-Type 进行处理,在没有 Content-Type 的时候,我们应该有个默认的支持。因为 headers 属性上是大小写不敏感的,因此我们会对 Content-Type 做一个统一处理:

function transformHeaders (headers) {
    const contentTypeKey = 'Content-Type' // Content-Type 的 key 值常量
    if (isPlainObject(headers)) {
        Object.keys(headers).forEach(key => {
            if (key !== contentTypeKey && key.toUpperCase() === contentTypeKey.toLowerCase()) {
                // 如果 key 的大写和 contentTypeKey 的大写一致,证明是同一个,这时就可以用 contentTypeKey 来替代 key 了
                headers[contentTypeKey] = headers[key]
                delete headers[key]
            }
        })
        if (!headers[contentTypeKey]) {
            // 如果最后发现没有 Content-Type,那我们就设置一个默认的
            headers[contentTypeKey] = 'application/json;charset=utf-8'
        }
    }
}

// 在 function xhr 中
// 设置头部
transformHeaders(headers)
Object.keys(headers).forEach(key => {
    if (!data && key === 'Content-Type') {
        delete headers[key]
        return
    }
    request.setRequestHeader(key, headers[key])
})

transformHeaders 函数对 headers 进行了一定程度的转化,包括为 Content-Type 提供了默认的支持,这里默认为 "application/json;charset=utf-8"。在 xhr 函数中,我们还会对headers的每一项进行判断,如果没有 data ,那我们会删除 Content-Type。同时,我们会调用 setRequestHeader 方法将 headers 属性添加到头部。

1.5 设置响应类型

if (responseType) {
    // 如果设置了响应类型,则为 request 设置 responseType
    request.responseType = responseType;
}

1.6 设置超时时间

if (timeout) {
    // 如果设置超时时间, 则为 request 设置 timeout
    request.timeout = timeout;
}

1.7 处理结果

// 状态变化处理函数
request.onreadystatechange = function handleLoad() {
    if (request.readyState !== 4) return;
    if (request.status === 0) return;
    
    // 获取响应数据
    const responseData =
          request.responseType === "text"
    ? request.responseText
    : request.response;
    if (request.status >= 200 && request.status < 300 || request.status === 304) {
        // 成功则 resolve 响应数组
        resolve(responseData);
    } else {
        // 失败则 reject 错误原因
        reject(new Error(`Request failed with status code ${request.status}`));
    }
};

// 错误处理事件
request.onerror = function hadleError() {
    //reject 错误原因
    reject(new Error('Network Error'))
}

// 超时处理事件
request.ontimeout = function handleTimeout() {
    // reject 错误原因
    reject(new Error(`Timeout of ${timeout} ms exceeded`))
}

处理结果分为几个部分:

  1. 正常处理服务端响应
  2. 请求错误
  3. 请求超时

其中,正常处理服务端响应还要判断状态码,这里判断正确的是 200 至 300 之间状态码,再一个是 304 缓存。此时我们会通过 resolve 断言数据。否则,通过 reject 来断言失败原因。

1.8 xhr 函数

至此,我们会得到这样一个 xhr 函数:

function xhr(config) {
    return new Promise((resolve, reject) => {
        const {
            url,
            method = "get",
            params = {},
            data = null,
            responseType,
            headers,
            timeout
        } = config;
        const request = new XMLHttpRequest();

        /**
     * 调用 open 方法
     * method.toUpperCase() 的作用主要是讲 method 都标准统一为大写字母状态。 比如 'get'.toUpperCase() 会返回 'GET'
     */
        request.open(method.toUpperCase(), buildUrl(url, params));

        if (responseType) {
            // 如果设置了响应类型,则为 request 设置 responseType
            request.responseType = responseType;
        }

        if (timeout) {
            // 如果设置超时时间, 则为 request 设置 timeout
            request.timeout = timeout;
        }

        // 设置头部
        transformHeaders(headers);
        Object.keys(headers).forEach(key => {
            if (!data && key === "Content-Type") {
                delete headers[key];
                return;
            }
            request.setRequestHeader(key, headers[key]);
        });

        request.onreadystatechange = function handleLoad() {
            if (request.readyState !== 4) return;
            if (request.status === 0) return;
            const responseData =
                  request.responseType === "text"
            ? request.responseText
            : request.response;
            if (request.status >= 200 && request.status < 300 || request.status === 304) {
                resolve(responseData);
            } else {
                reject(new Error(`Request failed with status code ${request.status}`));
            }
        };

        request.onerror = function hadleError() {
            reject(new Error("Network Error"));
        };

        request.ontimeout = function handleTimeout() {
            reject(new Error(`Timeout of ${timeout} ms exceeded`));
        };

        request.send(transformData(data));
    });
}

2. 创建 Ajax

有了 xhr ,我们当然希望 Ajax 能够提供一些默认配置。这里的 Ajax 函数不做太过复杂的功能,但我们会简单模拟支持默认 config。

事实上,最后在 Ajax 中,内部调用的就是 xhr 函数。类似这个样子:

function Ajax(config) {
	// code ...

    return xhr(config);
}

2.1 提供默认 config

首先,我们来定义默认配置

// 默认配置
const defaultconf = {
    method: "get",
    timeout: 500,
    headers: {
        Accept: "application/json, text/plain, */*"
    }
};

// 为 headers 上添加一些方法的默认 headers, 暂时挂在 headers[method] 下
["get", "delete", "options", "head"].forEach(method => {
    defaultconf.headers[method] = {};
});

// 为 headers 上添加一些方法的默认 headers, 暂时挂在 headers[method]["put", "post", "patch"].forEach(method => {
    defaultconf.headers[method] = {
        "Content-Type": "application/x-www-form-urlencoded"
    };
});

这里我们提供了默认的配置,包括默认的 method、 timeout、 headers 等,其中,get、 delete、 options、 head 的 headers 默认为空;而 put、 post 和 patch 涉及到 data 传送的会给一个默认的配置: "Content-Type": "application/x-www-form-urlencoded"

2.2 合并配置

const method = config.method || defaultconf.method; // 请求的方法名

// 合并 headers
const headers = Object.assign(
    {},
    defaultconf.headers,
    defaultconf[method],
    config.headers || {}
);

// 合并默认配置和自定义配置,这里简单的进行后者对前者的覆盖
const conf = Object.assign({}, defaultconf, config);

conf.headers = headers; // 配置的 headers 为我们上面合并好的 headers

// 删除 conf 配置中,headers 下默认的方法的headers块
["get", "delete", "options", "head", "put", "post", "patch"].forEach(key => {
    delete conf.headers[key];
});

如上所示,我们会通过方法名获取方法名对应的默认的 headers,并与传入配置 headers 和默认 headers 进行合并。然后我们会合并配置。最后我们不要忘了把合并后的配置中,headers 中方法名对应的配置块删除。

2.3 Ajax 函数

最后,我们会得到这样一个 Ajax:

function Ajax(config) {

    const method = config.method || defaultconf.method;

    const headers = Object.assign(
        {},
        defaultconf.headers,
        defaultconf[method],
        config.headers || {}
    );

    const conf = Object.assign({}, defaultconf, config);

    conf.headers = headers;
    ["get", "delete", "options", "head", "put", "post", "patch"].forEach(key => {
        delete conf.headers[key];
    });

    return xhr(conf);
}

3.简单的示例

3.1 请求的代码块

// 服务端现有接口,进行 post 请求
Ajax({
    method: 'post',
    url: '/simple/post',
    data: {
        a:1,
        b:2
    }
}).then(data => {
    console.log(data)
}).catch(e => {
    console.log('/simple/post', e)
})


// 服务端暂时没有的接口, 进行 post 请求
Ajax({
    method: 'post',
    url: '/test/post',
    data: {
        a:1,
        b:2
    }
}).then(data => {
    console.log(data)
}).catch(e => {
    console.log('/test/post', e)
})

// 服务端现有接口, 进行 get 请求
Ajax({
    url: '/simple/get',
    params: {
        c:1,
        d:2
    }
}).then(data => {
    console.log(data)
}).catch(e => {
    console.log('/simple/get', e)
})

3.2 请求结果

图片描述

图片描述

如图所示,请求正确接口的 Ajax 请求都得到了正确的返回。而访问服务端暂时没有的接口则返回了 404 错误。同时,GET 请求中没有显式提供 method,默认配置也能够及时生效,默认为 GET。

4.小结

本章节到此为止,关于 Ajax 的封装,核心技术使用的依然是 XMLHttpRequest 技术。在自定义 Ajax 中,我们可以提供多种属性和方法来丰富和强壮我们的方法,比方说,我们可以提供 默认配置、Promise 语法支持、错误检测及处理、参数标准化 等等。

本章节的 Ajax 依然是不完美的,有兴趣的同学可以思考一下还能怎样去封装。至少我们还可以提供 request 和 response 的拦截和处理,我们也可以优化 config 合并策略。希望这能够发动同学们的脑洞风暴!