refactor: 优化 request 插件 (#130)

* refactor: 优化 request 插件

* fix: errorHandler 的顺序控制问题'
This commit is contained in:
qlin 2022-06-16 11:04:26 +08:00 committed by GitHub
parent c628ec236c
commit 97bfb30f30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 167 additions and 329 deletions

View File

@ -1,6 +1,6 @@
# @fesjs/plugin-request
基于 axios 封装的 request内置防止重复请求、请求节流、错误处理等功能。
基于 axios 封装的 request内置防止重复请求、请求缓存、错误处理等功能。
## 启用方式
@ -9,114 +9,86 @@
```json
{
"dependencies": {
"@fesjs/fes": "^2.0.0",
"@fesjs/plugin-request": "^2.0.0"
"@fesjs/fes": "^3.0.0",
"@fesjs/plugin-request": "^3.0.0"
}
}
```
## 配置
## 运行时配置
### 构建时配置
入口文件的全局配置,具体请求的配置参数会覆盖全局配置,支持 [axios](https://axios-http.com/zh/docs/req_config) 所有的参数。
```js
export default {
import { defineRuntimeConfig } from '@fesjs/fes';
export default defineRuntimeConfig({
request: {
dataField: 'result',
},
};
```
#### dataField
- 类型: `string`
- 默认值: `''`
- 详情:
`dataField` 对应接口中的数据字段。假设接口统一的规范是 `{ code: string, result: any}`,可配置 `dataField: 'result'` 直接获取数据。如果个别接口不符合这个规范,可在第三个参数加上 `dataField: false`
```js
// 构建时配置 dataField: 'result'
import { request } from '@fesjs/fes';
// 假设相应体为: {code: '0', result: {say: 'hello'}}
const result = await request('/path/to/query/');
// {say: 'hello'}
console.log(result);
// 假设相应体为: {code: '0', data: {say: 'hello'}},其中 result 字段换成了 data
const response1 = await request('/special/to/query/', null, { dataField: false });
// {code: '0', data: {say: 'hello'}}
console.log(response1);
// 或者:假设相应体为: {code: '0', data: {say: 'hello'}},其中 result 字段换成了 data
const response2 = await request('/special/to/query/', null, { dataField: 'data' });
// {say: 'hello'}
console.log(response2);
```
### 运行时配置
`app.js` 中进行运行时配置。
```js
export const request = {
// 格式化 response.data (只有 response.data 类型为 object 才会调用)
responseDataAdaptor: (data) => {
data.code = data.code === '200' ? '0' : data.code;
return data;
},
// 关闭 response data 校验(只判断 xhr status
closeResDataCheck: false,
// 请求拦截器
requestInterceptors: [],
// 响应拦截器
responseInterceptors: [],
// 错误处理
// 内部以 reponse.data.code === '0' 判断请求是否成功
// 若使用其他字段判断,可以使用 responseDataAdaptor 对响应数据进行格式
errorHandler: {
11199(response) {
// 特殊 code 处理逻辑
// API 前缀
baseURL: '',
dataHandler(data, response) {
// 处理响应内容异常
if (data.code !== '0') {
if (data.code === '10000') {
FMesseage.error('hello world');
}
if (data.code === '20000') {
FMesseage.error('hello world');
}
throw new Error(response);
}
// 响应数据格式化
return data?.result ? data.result : data;
},
404(error) {},
default(error) {
// 异常统一处理
// http 异常,和插件异常
errorHandler(error) {
if (error.response) {
// 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
console.log(error.response.data);
console.log(error.response.status);
console.log(error.response.headers);
} else if (error.request) {
// 请求已经成功发起,但没有收到响应
// `error.request` 在浏览器中是 XMLHttpRequest 的实例,
// 而在node.js中是 http.ClientRequest 的实例
console.log(error.request);
} else if (error.type) {
// 插件异常
console.log(error.msg);
} else {
// 发送请求时出了点问题
console.log('Error', error.message);
}
console.log(error.config);
},
// 请求拦截器
requestInterceptors: [],
// 响应拦截器
responseInterceptors: [],
// 支持其他 axios 配置
...otherConfigs,
},
// 其他 axios 配置
...otherConfigs,
};
});
```
#### skipErrorHandler
## API
- 类型: `boolean | string | number | array<string | number>`
- 默认值: ``
- 详情:
### request
指定当前请求的某些错误状态不走 `errorHandler`,单独进行处理。如果设置为 `true`,当前请求的错误处理都不走 `errorHandler`
- **类型**:函数
- 示列:
- **详情**:请求后端接口
- **参数**
```js
import { request } from '@fesjs/fes';
- url: 后端接口 url
- data: 参数
- options: 配置支持 [axios](https://axios-http.com/zh/docs/req_config) 所有的参数,和插件扩展参数。
request('/api/login', null, {
skipErrorHandler: '110',
})
.then((res) => {
// do something
})
.catch((err) => {
// 这里处理 code 为 110 的异常
// 此时 errorHandler[110] 函数不会生效,也不会执行 errorHandler.default
});
```
- **返回值**: Promise
### useRequest
request 的封装,返回响应式 `loading``error``data`
## 使用
@ -212,20 +184,3 @@ export default {
},
};
```
## API
### request
- **类型**:函数
- **详情**:请求后端接口
- **参数**
- url: 后端接口 url
- data: 参数
- options:  配置( 支持 axios 所有配置)
- **返回值**: Promise
### useRequest
request 的封装,返回响应式 `loading``error``data`

View File

@ -33,7 +33,7 @@
},
"dependencies": {
"@fesjs/utils": "^3.0.0-beta.0",
"axios": "0.21.1"
"axios": "^1.0.0-alpha.1"
},
"typings": "./types.d.ts"
}

View File

@ -1,41 +1,11 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { name } from '../package.json';
export default (api) => {
api.addRuntimePluginKey(() => 'request');
// 配置
api.describe({
key: 'request',
config: {
schema(joi) {
return joi.object({
dataField: joi
.string()
.pattern(/^[a-zA-Z]*$/)
.allow(''),
base: joi.string().allow(''),
});
},
default: {
base: '',
dataField: '',
},
},
});
const namespace = 'plugin-request';
const absoluteFilePath = `${namespace}/request.js`;
const requestTemplate = readFileSync(join(__dirname, 'template', 'request.js'), 'utf-8');
api.onGenerateFiles(() => {
// 文件写出
const { dataField = '' } = api.config.request;
api.writeTmpFile({
path: absoluteFilePath,
content: requestTemplate.replace('REPLACE_DATA_FIELD', JSON.stringify(dataField)).replace('AXIOS_PATH', 'axios'),
});
});
let generatedOnce = false;
api.onGenerateFiles(() => {
@ -44,7 +14,6 @@ export default (api) => {
api.copyTmpFiles({
namespace,
path: join(__dirname, 'template'),
ignore: ['request.js'],
});
});
@ -58,6 +27,5 @@ export default (api) => {
api.addConfigType(() => ({
source: name,
runtime: ['RequestRuntimeConfig'],
build: ['RequestBuildConfig'],
}));
};

View File

@ -1,6 +1,4 @@
import {
isObject, isString, isURLSearchParams, checkHttpRequestHasBody
} from './helpers';
import { isObject, isString, isURLSearchParams, checkHttpRequestHasBody } from './helpers';
/**
* 缓存实现的功能
* 1. 唯一定位一个请求url, data | params, method
@ -20,7 +18,6 @@ import {
* cacheTime: ''
*/
/**
* 缓存数据结构
* cache: {
@ -41,7 +38,7 @@ const CACHE_KEY_PREFIX = '__FES_REQUEST_CACHE:';
const CACHE_TYPE = {
ram: 'ram',
session: 'sessionStorage',
local: 'localStorage'
local: 'localStorage',
};
const CACHE_DATA_MAP = new Map();
@ -57,19 +54,14 @@ function canCache(data) {
return !data || isObject(data) || isString(data) || Array.isArray(data) || isURLSearchParams(data);
}
function setCacheData({
key,
cacheType = 'ram',
data,
cacheTime = 1000 * 60 * 3
}) {
function setCacheData({ key, cacheType = 'ram', data, cacheTime = 1000 * 60 * 3 }) {
const _key = genInnerKey(key, cacheType);
const currentCacheData = {
cacheType,
data,
cacheTime,
expire: Date.now() + cacheTime
expire: Date.now() + cacheTime,
};
if (cacheType !== CACHE_TYPE.ram) {
const cacheInstance = window[CACHE_TYPE[cacheType]];
@ -150,7 +142,7 @@ function handleCachingQueueSuccess(ctx, config) {
if (queue && queue.length > 0) {
queue.forEach((resolve) => {
resolve({
response: ctx.response
response: ctx.response,
});
});
}
@ -178,7 +170,7 @@ export default async (ctx, next) => {
const cacheData = getCacheData({ key: ctx.key, cacheType: config.cache.cacheType });
if (cacheData) {
ctx.response = {
data: cacheData
data: cacheData,
};
return;
}
@ -200,7 +192,7 @@ export default async (ctx, next) => {
setCacheData({
key: ctx.key,
data: ctx.response.data,
...config.cache
...config.cache,
});
} else {
handleCachingQueueError(ctx, config);

View File

@ -14,9 +14,7 @@ const getQueryString = (data) => {
};
export default async function genRequestKey(ctx, next) {
const {
url, data, params, method
} = ctx.config;
const { url, data, params, method } = ctx.config;
ctx.key = `${url}${getQueryString(data)}${getQueryString(params)}${method}`;

View File

@ -14,7 +14,7 @@ export function typeOf(obj) {
'[object Undefined]': 'undefined',
'[object Null]': 'null',
'[object Object]': 'object',
'[object URLSearchParams]': 'URLSearchParams'
'[object URLSearchParams]': 'URLSearchParams',
};
return map[Object.prototype.toString.call(obj)];
}
@ -43,36 +43,30 @@ export function isURLSearchParams(obj) {
return typeOf(obj) === 'URLSearchParams';
}
// eslint-disable-next-line
export const isUndefined = val => val === undefined;
export const isDefined = val => val != null;
export function checkHttpRequestHasBody(method) {
method = method.toUpperCase();
const HTTP_METHOD = {
GET: {
request_body: false
request_body: false,
},
POST: {
request_body: true
request_body: true,
},
PUT: {
request_body: true
request_body: true,
},
DELETE: {
request_body: true
request_body: true,
},
HEAD: {
request_body: false
request_body: false,
},
OPTIONS: {
request_body: false
request_body: false,
},
PATCH: {
request_body: true
}
request_body: true,
},
};
return HTTP_METHOD[method].request_body;
}

View File

@ -20,11 +20,11 @@ function handleRepeatRequest(ctx) {
queue.forEach((resolve) => {
if (ctx.error) {
resolve({
error: ctx.error
error: ctx.error,
});
} else {
resolve({
response: ctx.response
response: ctx.response,
});
}
});
@ -47,7 +47,7 @@ export default async (ctx, next) => {
ctx.error = {
type: 'REPEAT',
msg: '重复请求',
config: ctx.config
config: ctx.config,
};
return;
}

View File

@ -1,16 +1,13 @@
import axios from 'AXIOS_PATH';
import axios from 'axios';
import { ApplyPluginsType, plugin } from '@fesjs/fes';
import { ref } from 'vue';
import scheduler from './scheduler';
import { checkHttpRequestHasBody, isFunction } from './helpers';
import setDataField from './setDataField';
import paramsProcess from './paramsProcess';
import genRequestKey from './genRequestKey';
import preventRepeatReq from './preventRepeatReq';
import cacheControl from './cacheControl';
import resDataAdaptor from './resDataAdaptor';
import resErrorProcess from './resErrorProcess';
function addInterceptors(instance, interceptors, type = 'request') {
interceptors.forEach((fn) => {
@ -41,10 +38,10 @@ async function axiosMiddleware(context, next) {
function getRequestInstance() {
const {
responseDataAdaptor,
dataHandler,
errorHandler,
requestInterceptors = [],
responseInterceptors = [],
errorHandler,
...otherConfigs
} = plugin.applyPlugins({
key: 'request',
@ -65,23 +62,14 @@ function getRequestInstance() {
addResponseInterceptors(instance, responseInterceptors);
// 洋葱模型内部应该这是对数据的处理,避免有副作用调用
scheduler
.use(paramsProcess)
.use(genRequestKey)
.use(cacheControl)
.use(preventRepeatReq)
.use(axiosMiddleware)
.use(resDataAdaptor)
.use(resErrorProcess)
.use(setDataField);
scheduler.use(paramsProcess).use(genRequestKey).use(cacheControl).use(preventRepeatReq).use(axiosMiddleware);
return {
context: {
errorHandler,
dataHandler: dataHandler || ((data) => data),
instance,
defaultConfig,
dataField: REPLACE_DATA_FIELD, // eslint-disable-line
responseDataAdaptor,
errorHandler,
},
request: scheduler.compose(),
};
@ -110,52 +98,12 @@ function createContext(userConfig) {
};
}
function getResponseCode(response) {
if (response) {
if (response._rawData) return response._rawData.code;
if (response.data) return response.data.code;
}
return null;
}
function skipErrorHandlerToObj(skipErrorHandler = []) {
if (!Array.isArray(skipErrorHandler)) {
skipErrorHandler = [skipErrorHandler];
}
return skipErrorHandler.reduce((acc, cur) => {
acc[cur] = true;
return acc;
}, {});
}
function getErrorKey(error, response) {
const resCode = getResponseCode(response);
if (resCode) return resCode;
if (error.type) return error.type;
return error.response?.status;
}
function isSkipErrorHandler(config, errorKey) {
// 跳过所有错误类型处理
if (config.skipErrorHandler === true) return true;
const skipObj = skipErrorHandlerToObj(config.skipErrorHandler);
return skipObj[errorKey];
}
function handleRequestError({ errorHandler = {}, error, response, config }) {
const errorKey = getErrorKey(error, response);
if (!isSkipErrorHandler(config, errorKey)) {
if (isFunction(errorHandler[errorKey])) {
errorHandler[errorKey](error, response);
} else if (isFunction(errorHandler.default)) {
errorHandler.default(error, response);
}
}
function getCustomerHandler(ctx, options = {}) {
const { dataHandler, errorHandler } = ctx;
return {
dataHandler: options.dataHandler || dataHandler,
errorHandler: options.errorHandler || errorHandler,
};
}
export const request = (url, data, options = {}) => {
@ -169,12 +117,13 @@ export const request = (url, data, options = {}) => {
}
const userConfig = userConfigHandler(url, data, options);
const context = createContext(userConfig);
const { dataHandler, errorHandler } = getCustomerHandler(context, options);
return currentRequestInstance.request(context).then(async () => {
if (!context.error) {
return context.config.useResponse ? context.response : context.response.data;
return dataHandler(context.response.data, context.response);
}
await handleRequestError(context);
errorHandler && errorHandler(context.error);
return Promise.reject(context.error);
});
};

View File

@ -1,17 +0,0 @@
import { isFunction, isObject, isString } from './helpers';
export default async ({ response, responseDataAdaptor }, next) => {
// 如果 data 是 blob 并且 content-type 是 application/json自动进行数据处理
if (response && response.data instanceof Blob && response.headers['content-type'].startsWith('application/json') && response.data.type === 'application/json') {
const rawData = response.data;
try {
response.data = JSON.parse(await response.data.text());
} catch {
response.data = rawData;
}
}
if (isFunction(responseDataAdaptor) && response && (isObject(response.data) || isString(response.data))) {
response.data = responseDataAdaptor(response.data);
}
await next();
};

View File

@ -1,21 +0,0 @@
import { isObject } from './helpers';
// 错误处理等副作用网上提
export default async (ctx, next) => {
const {
response,
config
} = ctx;
if (!config.closeResDataCheck && response && isObject(response.data)) {
const code = response.data.code;
if (code !== '0') {
// 尽量保持内部 error 结构和 http 异常的 error 结构一致
ctx.error = {
...response,
response
};
}
}
await next();
};

View File

@ -1,4 +1,3 @@
class Scheduler {
constructor() {
this.middlewares = [];

View File

@ -1,11 +0,0 @@
import { isObject } from './helpers';
// FEATURE: 后续支持 a.b.c
export default async (ctx, next) => {
const dataField = ctx.config.dataField ?? ctx.dataField;
if (!ctx.error && ctx.response && isObject(ctx.response.data) && dataField) {
ctx.response._rawData = ctx.response.data;
ctx.response.data = ctx.response.data[dataField];
}
await next();
};

View File

@ -1,11 +1,5 @@
import { AxiosRequestConfig, AxiosResponse } from 'axios';
export interface RequestBuildConfig {
request: {
dataField: string
}
}
type RequestInterceptor = (value: AxiosRequestConfig) => AxiosRequestConfig | [(value: AxiosRequestConfig) => AxiosRequestConfig, (error: any) => any];
type ResponseInterceptor = (value: AxiosResponse) => AxiosResponse | [(value: AxiosResponse) => AxiosResponse, (error: any) => any];
@ -17,7 +11,7 @@ export interface RequestRuntimeConfig {
closeResDataCheck?: boolean;
requestInterceptors?: RequestInterceptor[];
responseInterceptors?: ResponseInterceptor[];
errorHandler: {
errorHandler?: {
[key: string]: (error: { response: AxiosResponse } | AxiosResponse) => void;
};
} & AxiosRequestConfig;

View File

@ -10,9 +10,6 @@ export default defineBuildConfig({
}
},
publicPath: '/',
request: {
dataField: 'result'
},
html: {
title: '拉夫德鲁'
},

View File

@ -2,18 +2,38 @@ import { defineRuntimeConfig } from '@fesjs/fes';
export default defineRuntimeConfig({
request: {
errorHandler: {
111() {
console.log('root:111');
},
500() {
console.log('500 error');
},
default(error) {
console.log(error);
const msg = error?.data?.msg || error?.msg;
console.log(msg);
},
baseURL: '/ras-mas',
dataHandler(data) {
if (data?.code !== '0') {
if (data.code === '10000') {
console.log('code', data.code);
}
if (data?.code === '20000') {
console.log('code', data.code);
}
throw new Error(data);
}
return data.result ? data.result : data;
},
errorHandler(error) {
if (error.response) {
// 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
console.log(error.response.data);
console.log(error.response.status);
console.log(error.response.headers);
} else if (error.request) {
// 请求已经成功发起,但没有收到响应
// `error.request` 在浏览器中是 XMLHttpRequest 的实例,
// 而在node.js中是 http.ClientRequest 的实例
console.log(error.request);
} else if (error.type) {
// 插件异常
console.log(error.msg);
} else {
// 发送请求时出了点问题
console.log('Error', error.message);
}
console.log(error.config);
},
},
patchRoutes: () => {

View File

@ -7,7 +7,7 @@
</template>
<script>
import { ref } from 'vue';
import { request, defineRouteMeta, useRoute } from '@fesjs/fes';
import { request, defineRouteMeta } from '@fesjs/fes';
import HelloWorld from '@/components/helloWorld.vue';
defineRouteMeta({
@ -26,15 +26,15 @@ export default {
const clickIcon = () => {
console.log('click icon');
};
console.log(process.env.NODE_ENV, process.env.HELLO);
console.log(useRoute());
const get = () => {
request('/api', null, {
skipErrorHandler: ['500'],
}).catch((err) => {
console.log('skip error', err);
});
request('/api', null, {})
.then((data) => {
console.log(data);
})
.catch((err) => {
console.log('error', err);
});
};
get(1);

View File

@ -3333,12 +3333,14 @@ aws4@^1.8.0:
resolved "https://registry.npmmirror.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@0.21.1:
version "0.21.1"
resolved "https://registry.npmmirror.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
axios@^1.0.0-alpha.1:
version "1.0.0-alpha.1"
resolved "https://registry.npmmirror.com/axios/-/axios-1.0.0-alpha.1.tgz#ce69c17ca7605d01787ca754dd906e6fccdf71ee"
integrity sha512-p+meG161943WT+K7sJYquHR46xxi/z0tk7vnSmEf/LrfEAyiP+0uTMMYk1OEo1IRF18oGRhnFxN1y8fLcXaTMw==
dependencies:
follow-redirects "^1.10.0"
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
babel-jest@^27.0.6, babel-jest@^27.5.1:
version "27.5.1"
@ -5662,11 +5664,16 @@ flatted@^3.1.0:
resolved "https://registry.npmmirror.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3"
integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
follow-redirects@^1.0.0:
version "1.14.9"
resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7"
integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==
follow-redirects@^1.15.0:
version "1.15.1"
resolved "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==
forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.npmmirror.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@ -5681,6 +5688,15 @@ form-data@^3.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.npmmirror.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
form-data@~2.3.2:
version "2.3.3"
resolved "https://registry.npmmirror.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@ -8923,6 +8939,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"