IRequest - A class-based request library for Typescript/Javascript (一)现有的模式有什么问题

通常,几乎每一个语言都会有第三方的网络库。因为语言的 stdlib 或系统内置的网络库并不是那么好用。Objective-C 有 AFNetworking, Swift 有 Alamofire, c# 有 RestSharp。那在前端的世界里,第三方的网络库就有更多了,jQuery, request, axios, superAgent, Angular HttpClient。

其实我不太懂前端为什么会有这么多的网络库,Angular Http 为了接入 Rxjs 新搞一个可以理解,其他大概都是因为历史原因吧。但总结起来,这些网络库搞到最后,接口大概都是这样的形式:

Request.get/post(url, config).then(response => ...)  

看起来这样的接口已经非常通俗易懂、好用了,不就是发一个网络请求嘛,一行代码传入 url 和 config 即可。那我们为什么要再写一个新的网络库呢?难道是为了 KPI 而去重复造轮子?

现有的网络库有什么问题

我们公司的程序员都是有追求的,他们在项目里使用一款第三方网络库的时候,当然不会在业务代码里直接调用这个网络库。至少都会做一层封装,或者干脆直接封装 XMLHttpRequest or fetch。这样就可以让请求的接口更符合我们的业务代码。

这里‘更符合我们的业务代码’,是指这些二次封装通常需要做以下几件事情:

  • 请求的默认错误处理机制(大部分的请求需要有默认的错误处理,而有的不需要)
  • 公共参数拼接(所有的请求都要带上的 param,比如 timestamp, udid 之类的)
  • baseUrl (给所有请求url加上公共前缀/域名)
  • 打 log (在请求开始,成功或者失败的时候打log)

再封装一层,这当然是好的做法。但不幸的是,封装有的时候并不是那么容易的事。有的同学可能一拍脑袋就直接开始封装了,而对接口的设计和内部的实现并没有考虑太多,这就会出现以下几个比较尴尬的问题:

  • 接口不是那么好用
  • 封装的耦合性比较重,比如把默认错误的处理,公共参数拼接,打 log 这些代码直接怼进了封装的请求函数里
  • 每个项目对请求的封装都不一样(但它们的功能是一样的),如果我们定义了一套良好设计的,可复用性高的,适用于大型复杂项目的 request 接口,那大家都不需要再自行设计

可能上面对问题的描述不是那么清楚,我们直接来看几个例子吧:

不好用:

// 项目A:post Json的请求还需要手动设置 content-type
Ajax.post(URL), {  
    name: this.name,
    phone: this.phone,
    address: this.address
}, { headers: { 'content-type': 'application/json' } }

// 项目B;对于这样的post接口,你只有看了实现才知道怎么用。只填2个参数就会把param当成body. 那我就是想只填param而保持body为空呢,你得把第3个参数填入null
export function post (url, params, data, options) {  
    if (arguments.length === 2) {
        data = params;
        params = null;
    }

    ... do something with params
    ... send reqeust
}

下面的代码在开头做了拼接请求域名和公共参数的事,后面又做了默认错误处理的事,可以看到,这样的代码是很脏的。即使你把这些事抽成函数提出去,你的 request 代码也不‘纯’,因为你的 request 发送代码里,显式地做了拼公共参数,拼请求域名,做错误处理的事,而这些,并不是一个请求封装本身需要关注的事情。后面可以看到 IRequest 的设计是如何把这些逻辑解耦出去的。

export function baseRequest(url, options) {  
    url = joinParams(url, Object.assign({
        _productId: Config.productId,
        UDID: deviceId,
        timestamp: Date.now()
    }, options.params));
    if (url.indexOf('http') !== 0) {
        url = Config.apiHost + url;
    }

    options.url = url;

    let promise = makeRequest(options)

    promise.catch(e => setTimeout(() => {
        if (e.name !== 'UnauthorizedException') {
            let msg = null;
            if (e.name === 'RequestException') {
                if (e.exception && e.exception.data && e.exception.data.message) {
                    wx.showModal({
                        title: '提示',
                        content: e.exception.data.message
                    });
                    return;
                } else if (!e.exception.statusCode) {
                    msg = '网络异常';
                }
            }

            wx.showToast({
                title: '网络错误',
                icon: 'none',
            });
        }
    }));

    return promise;
}

下面的代码展示了同一个公司下不同项目的不同封装,接口五花八门, 花里胡哨,但功能其实都是一样的。如果你同时维护多个项目,看到不同风格发请求的代码会是个很头疼的事情,虽然这并不是很重要。

// 项目 A 的封装
export function get (url, params, options)

export function remove (url, params, options)

export function post (url, params, data, options)

export let postJSON = function (url, params, data, options);

// 项目 B 的封装
/**
 * 统一的ajax数据加载
 *
 * @example `Ajax(url, { success: function(data) {}, unsuccess: function(o) {}, errorHandler: function(){}});`
 * @param {string} url
 * @param {Object} config
 * @param {Function} config.success 成功回调
 * @param {Function} config.unsuccess 错误处理,针对特殊情况处理
 * @param {Function} config.errorHandler 错误处理,优先已默认的错误处理,如果返回true,则不进行其他处理
 * @returns {Object} jQuery Ajax Object
 */
export function Ajax (url, config);

Ajax.get = function() {  
    return _verb('GET', arguments);
};

Ajax.post = function() {  
    return _verb('POST', arguments);
};

// 项目 C 的封装
export class Ajax {  
    public static async get(
        url: USVString,
        params?: JQuery.PlainObject,
        config: RequestConfig = {}
    )

    public static post(
        url: USVString,
        params?: JQuery.PlainObject,
        config: RequestConfig = {}
    )

    public static delete(
        url: USVString,
        params?: JQuery.PlainObject,
        config: RequestConfig = {}
    )
}

除了上面说到的三个问题外,Request.get/post() 这样基于函数调用传入 url 和config 的模式还有本身的缺陷:

  1. 随着项目越搞越复杂,或者说服务端对接口的设计不符合你的预期。你不好对请求做更深入的定制化。比如你给所有的错误请求(非2xx)的打了 log 用于监控,但服务端对有的请求的设计是返回 404 代表暂时没资源而并非错误。而你打 log 的逻辑把它当成了错误,怎么办,给 request 的 config 加一个字段 needLog? 这显然是一个糟糕的设计。

  2. 各个请求之间可能是存在依赖关系的,其实这就是一个天然的可以用继承树来建模的场景,虽然树的深度不会太深,最多2、3层吧。一个例子是,我们的服务端对所有的分页 list 请求返回的 JSON 格式都是一样的,包含 startCursor, endCursor, 具体的数组数据 data[]。对于这样的模式,用面向对象的思维去思考你很快就能想到搞一个 BaseListRequest,里面处理pageSize,startCursor, endCursor,判断是某能请求下一页数据等等。然后具体的 list 请求直接继承 BaseListRequest 就完事了。

interface PageObject<T> {  
    startCursor: string
    endCursor: string
    list: T[]
}

class CommonListRequest<T> extends Request<PageObject<T>> {  
    private _resultStartCursor?: string
    private _resultEndCursor?: string
    private _resultList?: T[]

    get resultStartCursor() {
        return this._resultStartCursor
    }

    get resultEndCursor() {
        return this._resultEndCursor
    }

    get resultList() {
        return this._resultList
    }

    get hasMoreData() {
        return this.responseObject!.list.length > 0 && this.resultEndCursor !== '0'
    }

    constructor(public startCursor: string) {
        super()
    }

    requestArgument() {
        return {
            startCursor: this.startCursor,
            pageSize: this.pageSize()
        }
    }

    requestDidSuccess() {
        this._resultStartCursor = this.responseObject!.startCursor
        this._resultEndCursor = this.responseObject!.endCursor
        this._resultList = this.responseObject!.list
    }

    pageSize() {
        return 20
    }
}

class LessonsListRequest extends CommonListRequest<Lesson> {  
    requestUrl() {
        return 'lessons'
    }
}

class StudentsListRequest extends CommonListRequest<Student> {  
    requestUrl() {
        return 'students'
    }

    pageSize() {
        return 5
    }
}

let lessonsRequest = await new LessonListRequest('0').startUnwrap()  
const lessons = lessonsRequest.resultList  
if (lessonsRequest.hasMoreData) {  
    lessonsRequest = await new LessonListRequest(lessonsRequest.resultEndCursor).start()
    // do something with next page lessons
}

可能第一次看到上面这种基于 class 的 request 会感觉很复杂,但其实只要你了解了这样设计的目的,你会发现这种模式非常自然而简单。就像 Observable 之于 Promise 一样,Promise 固然简单好用,但它远没有 Observable 强大。而你只要理解了 Rxjs 的思想,就会发现 Observable 也很简单好用。当然这个比喻夸张了点,IRequest 远没有 Rxjs 那么复杂难学啦,可能稍微花个10几20分钟学习就足够了。

这是你没有用过的船新版本,只需体验5分钟,你就会爱上这款 Request Library。

好了,下一篇文章将会详细介绍 IRequset 的设计、实现和使用。