在 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,期待你的参与。