fatcher: 像koa一般使用Fetch

594 阅读4分钟

在 Node 17.5.0 开始,fetch 被 nodejs 加入支持,几个相关的 api 在 18.x 相继被加入到支持列表中。

  • fetch >= 17.5.0
  • FormData >= 17.6.0
  • Headers>= 17.5.0
  • ReadableStream >= 18.0.0
  • Response >= 17.5.0
  • Request >= 17.5.0

在 fetch 没有被支持的话,我们一般会在 node 和浏览器中使用 axios(基于 xhr 或者是 http)来进行请求发送,或者想使用 fetch 的话就会使用 node-fetch。

现在,我们可以在 node 和浏览器中使用相同的请求方式,而且并不需要安装什么依赖,相同的语法,相同的响应,而且天然支持 Promise, 提高了开发效率。

但是 fetch 还是一个比较底层的 api,对响应的处理还需要自己进一步的封装。

fatcher这个库利用中间件把这个功能都封装起来,类 koa 的中间件设计,可以在中间件中实现自定义拦截器,声明式中间件,可以按需使用中间件,使用方式与 fetch 基础请求一致,增强了 fetch 的功能,与日常的使用行为一致。

核心思想

  • 通过中间件的组合,组成类koa的洋葱圈模型,功能均由中间件提供。
  • 核心库只注册/组合中间件,提供上下文以及必需的中间件。
  • 拓展功能通过外部中间件注册使用。
  • 体积最小化,只需注册所要用的功能。

特性

  • 超小体积(小于2KB)
  • 易于拓展(组合中间件)
  • Streams API 支持
  • TypeScript支持
  • 自定义拦截器(自定义中间件)

组合中间件

中间件是一个对象,可以通过函数来返回对象来使用闭包保存上下文数据。

export interface Middleware {
    name: `fatcher-middleware-${string}`;
    use(context: Context, next: MiddlewareNext): Promise<MiddlewareResult> | MiddlewareResult;
}

name为约定的名称fatcher-middleware-xxx

use为中间件的方法

Context 为当前请求的上下文,自上而下地把数据传到下方

Next为调用下个中间件,所以在函数内部基本都需要调用next,如果是进行拦截,则中间件提供者需要自行构造一个 MiddlewareResult , 但是这样做,context无法传递给该中间件以下的所有中间件。

基础使用

简单的发出一个 GET 请求

import { fatcher, isFatcherError } from 'fatcher';

fatcher({
    url: '/foo/bar',
    payload: {
        foo: 'bar',
    },
})
    .then(response => {
        // 响应数据
        console.log(response);
    })
    .catch(error => {
        if (isFatcherError(error)) {
            //处理请求失败的情况
            console.error(error.toJSON());
            return;
        }
        //处理其他错误
        console.error(error);
    });

通过简单配置项配置项,发送 fetch 请求,基础用法与 fetch 用法一致

自动转换 JSON

import { canActivate, Middleware, fatcher } from 'fatcher';

export function json(): Middleware {
    return {
        name: 'fatcher-middleware-json',
        async use(context, next) {
            const result = await next();

            if (canActivate(result.data)) {
                /**
                 * Clone a response to try.
                 */
                const clonedResponse = result.data.clone();

                try {
                    const data = await clonedResponse.json();

                    return Object.assign(result, { data });
                } catch {
                    /**
                     * If transform error.
                     *
                     * Return origin result.
                     */
                }
            }

            return result;
        },
    };
}

fatcher({
    url: '/bar/foo',
    middlewares: [json()],
    payload: {
        bar: 'foo',
    },
})
    .then(res => {
        console.log(res); // JSON 格式
    })
    .catch(err => {
        console.error(error);
    });

FormData 请求体支持

import { Middleware, fatcher } from 'fatcher';

/**
 * A middleware for consuming payload to form data.
 * @returns
 */
export function formData(): Middleware {
    return {
        name: 'fatcher-middleware-form-data',
        async use(context, next) {
            const { requestHeaders: headers, payload = null } = context;

            if (!headers.get('content-type')?.includes('multipart/form-data')) {
                return next();
            }

            let body: FormData | null = null;

            if (payload) {
                if (payload instanceof FormData) {
                    body = payload;
                } else {
                    body = new FormData();

                    for (const key of Object.keys(payload)) {
                        const value = payload[key];

                        if (Array.isArray(value)) {
                            value.forEach(item => body?.append(key, item));
                            continue;
                        }

                        body.append(key, value);
                    }
                }
            }

            headers.delete('content-type');

            return next({
                payload: null,
                body,
            });
        },
    };
}

fatcher({
    url: '/bar/foo',
    middlewares: [formData()],
    payload: {
        bar: 'foo',
    },
    headers: {
        'Content-Type': 'multipart/form-data',
    },
})
    .then(res => {
        console.log(res);
    })
    .catch(err => {
        console.error(error);
    });

并发限制、请求取消、请求超时

  • 我们在一些上传或者下载的操作的时候,或者一些耗时比较长的请求任务时,我们可能会需要取消当前请求。
  • 我们在如果在长时间没有得到响应的请求时,可能会在请求超时的时候进行一些提示或者是取消请求。
  • 在一些场景下,我们上一次的请求还没有返回,而再次发出请求时,如果不取消掉前面的请求,第二个请求先于第一个请求响应的话,则会出现数据不一致的情况发生。
import { Middleware, fatcher } from 'fatcher';
import { AbortReason, AborterOptions, RoadMap } from './interfaces';

const roadMap: RoadMap = {};

/**
 * A middleware for aborting fatcher request.
 * @param options
 * @returns
 */
export function aborter(options: AborterOptions = {}): Middleware {
    const { timeout = 0, onAbort = null, concurrency, groupBy } = options;

    let _timeout = timeout;

    if (isNaN(timeout) || ~~timeout < 0) {
        console.warn('[fatcher-middleware-aborter] Timeout is not a valid number.');
        _timeout = 0;
    }

    return {
        name: 'fatcher-middleware-aborter',
        async use(context, next) {
            const abortController = new AbortController();

            const { abort, signal } = abortController;

            const requestTask = next({ signal });

            const group =
                groupBy?.(context) ??
                `${context.url}_${context.method}_${new URLSearchParams(context.params).toString()}`;

            // Setup road map before response
            if (roadMap[group]?.length && concurrency) {
                // If has other request in group. Abort them.
                roadMap[group].forEach(item => {
                    item.abort('concurrency');
                });
            }

            roadMap[group] ??= [];

            const trigger = (reason: AbortReason) => {
                abort.call(abortController);
                onAbort?.(reason);
            };

            roadMap[group].push({
                abort: trigger,
                timer: _timeout ? setTimeout(() => trigger('timeout'), _timeout) : null,
                signal,
            });

            // Cleanup with abort event triggered.
            signal.addEventListener('abort', () => {
                roadMap[group] = roadMap[group].filter(item => {
                    if (item.signal === signal) {
                        if (item.timer) {
                            clearTimeout(item.timer);
                        }

                        return false;
                    }

                    return true;
                });

                if (!roadMap[group].length) {
                    delete roadMap[group];
                }
            });

            return requestTask;
        },
    };
}

fatcher({
    url: '/bar/foo',
    middlewares: [
        aborter({
            timeout: 10 * 1000, // 10s
            onAbort: () => {
                console.log('Request is Aborted.');
            },
        }),
    ],
})
    .then(res => {
        // Request success in 10s
        console.log(res);
    })
    .catch(err => {
        if (isAbortError(err)) {
            //Run error when request aborted.
            console.error(err);
        }

        // Other errors.
    });

下载进度

在下载的场景中,我们很可能需要展示给用户知道当前的下载进度。但是 fetch 没有提供这样的一个 api 给我们进行获取。

这个功能的核心为在响应头中返回一个Content-Length字段。通过总大小与已获取量的大小则可以模拟出当前的下载进度。

import { Middleware, readStreamByChunk, fatcher } from 'fatcher';
import { ProgressOptions } from './interfaces';

/**
 * A Middleware for getting progress
 * @param options
 * @returns
 */
export function progress(options: ProgressOptions = {}): Middleware {
    const { onDownloadProgress = null, lengthName = 'content-length' } = options;

    return {
        name: 'fatcher-middleware-progress',
        async use(context, next) {
            const result = await next();

            if (!onDownloadProgress) {
                return result;
            }

            const total = ~~(result.headers.get(lengthName) || 0);

            if (total === 0) {
                return result;
            }

            let current = 0;

            const clonedResponse = result.data.clone();

            readStreamByChunk(clonedResponse.body, chunk => {
                current += chunk.length;

                onDownloadProgress?.(current, total);
            });

            return result;
        },
    };
}

fatcher({
    url: '/bar/foo',
    middlewares: [
        progress({
            onDownloadProgress: (current, total) => {
                console.log(current / total);
            },
        }),
    ],
    payload: {
        bar: 'foo',
    },
})
    .then(res => {
        console.log(res);
    })
    .catch(err => {
        console.error(error);
    });

响应缓存

在一些不高频改动的数据中,我们可以在客户端进行对结果的缓存,在有效时间内,每次命中缓存 key 的请求会把响应数据缓存起来,下一次请求的时候,优先返回该数据。

import { Middleware, canActivate, Context, MiddlewareResult, fatcher } from 'fatcher';
import { CacheOptions } from './interfaces';

const cacheMap = new Map<string, MiddlewareResult>();

const timer: Record<string, NodeJS.Timer> = {};

function getClonedData(data: unknown) {
    return canActivate(data) ? data.clone() : data;
}

/**
 * A middleware for cache response result
 * @param options
 */
export function cache(options: CacheOptions): Middleware {
    const { validate = (context: Context) => context.method === 'GET', ttl = 60000, useCache = true } = options;

    return {
        name: 'fatcher-middleware-cache',
        async use(context, next) {
            if (!useCache) {
                return next();
            }

            const cacheKey = `${context.method} ${context.url}`;

            if (cacheMap.has(cacheKey)) {
                const result = cacheMap.get(cacheKey) as MiddlewareResult;

                return {
                    ...result,
                    data: getClonedData(result.data),
                };
            }

            const result = await next();

            if (!validate(context) || ttl <= 0) {
                return result;
            }

            // clone and save;
            const clonedData = getClonedData(result.data);

            cacheMap.set(cacheKey, {
                ...result,
                data: clonedData,
            });

            timer[cacheKey] = setTimeout(() => {
                cacheMap.delete(cacheKey);
                delete timer[cacheKey];
            }, ttl);

            return result;
        },
    };
}

fatcher({
    url: '/bar/foo',
    middlewares: [cache({ ttl: 5 * 60 * 1000 })],
    payload: {
        bar: 'foo',
    },
})
    .then(res => {
        console.log(res);
    })
    .catch(err => {
        console.error(error);
    });

请求拦截器

import { Middleware } from 'fatcher';

interface RequestInterceptorOptions {
    //some options
}

export function requestInterceptor(options: RequestInterceptorOptions): Middleware {
    return {
        name: 'fatcher-middleware-request-interceptor',
        use(context, next) {
            if (!context.url) {
                throw new Error('请求路径不能为空');
            }

            if (context.method !== 'POST') {
                throw new Error('请求方法只能为POST');
            }

            // ...

            //检测通过
            return next();
        },
    };
}

fatcher({
    url: '/foo/bar',
    middlewares: [requestInterceptor(/* options */)],
})
   .then((result) => {
      console.log(result)
   })
   .catch((err) => {
      console.error(err.message) // => 请求方法只能为 POST
   });

响应拦截器

import { Middleware } from 'fatcher';

interface ResponseInterceptorOptions {
    //some options
}

export function responseInterceptor(options: ResponseInterceptorOptions): Middleware {
    return {
        name: 'fatcher-middleware-response-interceptor',
        async use(context, next) {
            const result = await next();

            const json = await result.body.json();

            if(json.status === 50000) {
                return Promise.reject(new Error('请求失败'))
            }
           
            // 顺便消费了response
            // 如果不想在判断时消费当前response 则 result.data.clone 一个相同的response对象进行消费
            return { ...result, data: json }  
        },
    };
}

fatcher({
    url: '/foo/bar',
    middlewares: [responseInterceptor(/* options */)],
})
   .then((result) => {
      console.log(result)
   })
   .catch((err) => {
      console.error(err.message) // => 请求失败
   });

最后

更多不同的场景可以通过组合中间件来实现。

我们只需使用自己所需的功能,进行组合,从而实现体积最小化。

在编写中间件的时候只需要关心当前中间件的逻辑,不需要关心上下文的变化。逻辑分离,也更好的组合起来。

fetch 的使用已经越来越普遍,NodeJs的支持也提上日程了。相信 fetch 是以后的主流。

欢迎大家一起参与项目,一起来完善 fatcher,期待你的参与。

项目地址传送门