feat: 增强默认参数系列化器

BREAKING CHANGE :
axios.get('url',{obj:{v1:1,v2:2}}) -> fetch: 'url?obj={"val":1,"v2":2}'
变为
axios.get('url',{obj:{v1:1,v2:2}}) -> fetch: 'url?obj[v1]=1&obj[v2]=2'
pull/41/head
zjx0905 2023-04-02 18:26:24 +08:00
parent 666a9427d3
commit 0cfb3e1ff0
25 changed files with 425 additions and 134 deletions

View File

@ -15,6 +15,13 @@ export function captureError<T = any>(fn: () => void): T {
}
}
export function cleanedStack(error: Error) {
if (error.stack) {
return error.stack.indexOf('at') === error.stack.indexOf('at /');
}
return true;
}
export function noop() {
return;
}

View File

@ -168,13 +168,10 @@ export function getAdapterDefault(): AxiosAdapter | undefined {
}
export function createAdapter(platform: AxiosPlatform): AxiosAdapter {
assert(isPlainObject(platform), 'platform 需要是一个 object');
assert(isFunction(platform.request), 'platform.request 需要是一个 function');
assert(isFunction(platform.upload), 'platform.upload 需要是一个 function');
assert(
isFunction(platform.download),
'platform.download 需要是一个 function',
);
assert(isPlainObject(platform), 'platform 不是一个 object');
assert(isFunction(platform.request), 'platform.request 不是一个 function');
assert(isFunction(platform.upload), 'platform.upload 不是一个 function');
assert(isFunction(platform.download), 'platform.download 不是一个 function');
function adapterDefault(
config: AxiosAdapterRequestConfig,
@ -204,17 +201,14 @@ export function createAdapter(platform: AxiosPlatform): AxiosAdapter {
upload: AxiosAdapterUpload,
baseOptions: AxiosAdapterBaseOptions,
): AxiosAdapterTask | void {
assert(
isPlainObject(baseOptions.data),
'上传文件时 data 需要是一个 object',
);
assert(isPlainObject(baseOptions.data), 'data 不是一个 object');
assert(
isString(baseOptions.data?.fileName),
'上传文件时 data.fileName 需要是一个 string',
'data.fileName 不是一个 string',
);
assert(
isString(baseOptions.data?.filePath),
'上传文件时 data.filePath 需要是一个 string',
'data.filePath 不是一个 string',
);
const { fileName, filePath, fileType, ...formData } =

View File

@ -1,3 +1,4 @@
import { cleanStack } from '../helpers/error';
import { AxiosAdapterTask } from '../adapter';
import { AxiosRequestConfig, AxiosResponse, AxiosResponseError } from './Axios';
@ -34,5 +35,7 @@ export function createError(
request?: AxiosAdapterTask,
response?: AxiosErrorResponse,
): AxiosError {
return new AxiosError(message, config, request, response);
const axiosError = new AxiosError(message, config, request, response);
cleanStack(axiosError);
return axiosError;
}

View File

@ -1,5 +1,5 @@
import { isPlainObject } from '../helpers/isTypes';
import { omit } from '../helpers/omit';
import { ignore } from '../helpers/ignore';
import { AxiosRequestConfig, AxiosRequestHeaders } from './Axios';
export function flattenHeaders(
@ -12,7 +12,7 @@ export function flattenHeaders(
return {
...(config.headers.common ?? {}),
...(config.headers[config.method!.toLowerCase()] ?? {}),
...omit(
...ignore(
config.headers,
'common',
'options',

View File

@ -41,7 +41,7 @@ export function request<TData = unknown>(
config: AxiosRequestConfig,
): Promise<AxiosResponse<TData>> {
return new Promise((resolve, reject) => {
assert(isFunction(config.adapter), 'adapter 需要是一个 function');
assert(isFunction(config.adapter), 'adapter 是一个 function');
const adapterConfig: AxiosAdapterRequestConfig = {
...config,

View File

@ -1,6 +1,6 @@
import { buildURL } from '../helpers/buildURL';
import { combineURL } from '../helpers/combineURL';
import { interpolation, isDynamicURL } from '../helpers/dynamicURL';
import { dynamicURL } from '../helpers/dynamicURL';
import { isAbsoluteURL } from '../helpers/isAbsoluteURL';
import { AxiosRequestConfig } from './Axios';
@ -8,10 +8,7 @@ export function transformURL(config: AxiosRequestConfig): string {
let url = config.url ?? '';
if (!isAbsoluteURL(url)) url = combineURL(config.baseURL ?? '', url);
if (isDynamicURL(url))
url = interpolation(url, Object.assign({}, config.params, config.data));
url = dynamicURL(url, Object.assign({}, config.params, config.data));
url = buildURL(url, config.params, config.paramsSerializer);
return url;

View File

@ -1,65 +1,46 @@
import { isDate, isNull, isPlainObject, isUndefined } from './isTypes';
import { isArray, isDate, isNull, isPlainObject, isUndefined } from './isTypes';
export function buildURL(
url = '',
params?: unknown,
paramsSerializer = paramsSerialization,
params?: AnyObject,
paramsSerializer = defaultSerializer,
): string {
if (!isPlainObject(params)) {
return url;
}
return generateURL(url, paramsSerializer(params));
}
function generateURL(url: string, serializedParams: string): string {
const hashIndex = url.indexOf('#');
if (hashIndex !== -1) {
url = url.slice(0, hashIndex);
}
if (serializedParams === '') {
return url;
const paramsStr = paramsSerializer(params);
if (paramsStr) {
url = `${url}${url.indexOf('?') === -1 ? '?' : '&'}${paramsStr}`;
}
const prefix = url.indexOf('?') === -1 ? '?' : '&';
serializedParams = `${prefix}${serializedParams}`;
return `${url}${serializedParams}`;
return url;
}
function paramsSerialization(params?: AnyObject): string {
if (!isPlainObject(params)) {
return '';
}
function defaultSerializer(params?: AnyObject): string {
if (!isPlainObject(params)) return '';
const parts: string[] = [];
Object.keys(params).forEach((key): void => {
const value = params[key];
function push(key: string, value: string) {
parts.push(`${encode(key)}=${encode(value)}`);
}
if (isNull(value) || isUndefined(value) || value !== value) {
return;
}
if (Array.isArray(value)) {
key += '[]';
}
const values = [].concat(value);
values.forEach((val: any): void => {
for (const [key, val] of Object.entries(params)) {
if (!isNull(val) && !isUndefined(val) && val === val) {
if (isPlainObject(val)) {
val = JSON.stringify(val);
for (const [k, v] of Object.entries(val)) push(`${key}[${k}]`, v);
} else if (isArray<string>(val)) {
const k = `${key}[]`;
for (const v of val) push(k, v);
} else if (isDate(val)) {
val = (val as Date).toISOString();
push(key, val.toISOString());
} else {
push(key, val);
}
parts.push(`${encode(key)}=${encode(val)}`);
});
});
}
}
return parts.join('&');
}

View File

@ -1,4 +1,4 @@
const combineREG = /(^|[^:])\/{2,}/g;
const combineRE = /(^|[^:])\/{2,}/g;
export function combineURL(baseURL: string, url: string): string {
return url ? `${baseURL}/${url}`.replace(combineREG, '$1/') : baseURL;
return url ? `${baseURL}/${url}`.replace(combineRE, '$1/') : baseURL;
}

View File

@ -3,20 +3,18 @@ import { isPlainObject } from './isTypes';
export function deepMerge<T extends AnyObject>(...objs: T[]): T {
const result: AnyObject = {};
objs.forEach((obj: AnyObject) =>
Object.keys(obj).forEach((key) => {
const val = obj[key];
const resultVal = result[key];
if (isPlainObject(resultVal) && isPlainObject(val)) {
result[key] = deepMerge(resultVal, val);
} else if (isPlainObject(val)) {
result[key] = deepMerge(val);
for (const obj of objs) {
for (const [key, val] of Object.entries(obj)) {
if (isPlainObject(val)) {
const rVal = result[key];
result[key] = isPlainObject(rVal)
? deepMerge(rVal, val)
: deepMerge(val);
} else {
result[key] = val;
}
}),
);
}
}
return result as T;
}

View File

@ -1,18 +1,4 @@
import { isPlainObject } from './isTypes';
const dynamicREG = /\/?(:([a-zA-Z_$][\w-$]*))\/??/g;
export function interpolation(url: string, sourceData?: unknown): string {
if (!isPlainObject(sourceData)) {
return url;
}
return url.replace(dynamicREG, ($1, $2, $3) =>
$1.replace($2, sourceData[$3]),
);
}
export function isDynamicURL(url: string): boolean {
dynamicREG.lastIndex = 0;
return dynamicREG.test(url);
const dynamicRE = /:([^/]+)/g;
export function dynamicURL(url: string, data: AnyObject = {}): string {
return url.replace(dynamicRE, (_, $2) => data[$2]);
}

View File

@ -5,5 +5,19 @@ export function assert(condition: boolean, msg: string) {
}
export function throwError(msg: string): void {
throw new Error(`[axios-miniprogram]: ${msg}`);
const error = new Error(`[axios-miniprogram]: ${msg}`);
cleanStack(error);
throw error;
}
export function cleanStack(error: Error) {
if (error.stack) {
const start = error.stack.indexOf('at');
const end = error.stack.indexOf('at /');
if (start !== end) {
const removed = error.stack.slice(start, end);
error.stack = error.stack.replace(removed, '');
}
}
}

8
src/helpers/ignore.ts Normal file
View File

@ -0,0 +1,8 @@
export function ignore<T extends AnyObject, K extends keyof T>(
obj: T,
...keys: K[]
): Omit<T, K> {
const result = { ...obj };
for (const key of keys) delete result[key];
return result;
}

View File

@ -1,4 +1,4 @@
const absoluteREG = /^([a-z][a-z\d+\-.]*:)?\/\//i;
const absoluteRE = /^([a-z][\w-.]*:)\/\//i;
export function isAbsoluteURL(url: string): boolean {
return absoluteREG.test(url);
return absoluteRE.test(url);
}

View File

@ -1,38 +1,36 @@
const _toString = Object.prototype.toString;
export function isArray<T = unknown>(value: any): value is T[] {
return Array.isArray(value);
}
export function isDate(date: any): date is Date {
return _toString.call(date) === '[object Date]';
}
export function isEmptyArray<T = unknown>(value: any): value is [] {
return isArray<T>(value) && value.length === 0;
}
export function isEmptyObject(value: any): value is object {
return isPlainObject(value) && Object.keys(value).length === 0;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function isFunction<T extends Function>(value: any): value is T {
return typeof value === 'function';
}
export function isNull(value: any): value is null {
return value === null;
}
export function isUndefined(value: any): value is undefined {
return typeof value === 'undefined';
}
export function isString(value: any): value is string {
return (
typeof value === 'string' || _toString.call(value) === '[object String]'
);
}
export function isPlainObject(value: any): value is object & AnyObject {
return _toString.call(value) === '[object Object]';
}
export function isString(value: any): value is string {
return typeof value === 'string';
export function isArray<T = unknown>(value: any): value is T[] {
return Array.isArray(value);
}
export function isUndefined(value: any): value is undefined {
return typeof value === 'undefined';
export function isEmptyArray<T = unknown>(value: any): value is [] {
return isArray<T>(value) && value.length === 0;
}
export function isDate(date: any): date is Date {
return _toString.call(date) === '[object Date]';
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function isFunction<T extends Function>(value: any): value is T {
return typeof value === 'function';
}

View File

@ -1,8 +0,0 @@
export function omit<T extends AnyObject, K extends keyof T>(
obj: T,
...keys: K[]
): Omit<T, K> {
const res = { ...obj };
keys.forEach((key: K) => delete res[key]);
return res;
}

View File

@ -9,7 +9,7 @@ import {
import axios from 'src/axios';
import { Cancel, isCancel, CancelToken, isCancelToken } from 'src/core/cancel';
describe('测试 src/helpers/cancel.ts', () => {
describe('src/helpers/cancel.ts', () => {
test('应该支持空参数', () => {
const cancel = new Cancel();

View File

@ -0,0 +1,27 @@
import { describe, test, expect } from 'vitest';
import { cleanedStack } from 'scripts/test.utils';
import { createError } from 'src/core/createError';
describe('src/core/createError.ts', () => {
test('应该支持空参数', () => {
const config = {};
const axiosError = createError('error', config);
expect(axiosError.isAxiosError).toBeTruthy();
expect(axiosError.message).toBe('error');
expect(axiosError.config).toBe(config);
expect(cleanedStack(axiosError)).toBeTruthy();
});
test('应该支持传入更多参数', () => {
const config = {};
const request = {};
const response = {};
const axiosError = createError('error', config, request, response as any);
expect(axiosError.message).toBe('error');
expect(axiosError.config).toBe(config);
expect(axiosError.request).toBe(request);
expect(axiosError.response).toBe(response);
});
});

View File

@ -0,0 +1,63 @@
import { describe, test, expect } from 'vitest';
import { buildURL } from 'src/helpers/buildURL';
describe('src/helpers/buildURL.ts', () => {
test('应该支持空参数', () => {
expect(buildURL('/user')).toBe('/user');
});
test('应该清理哈希值', () => {
expect(buildURL('/user#hash')).toBe('/user');
});
test('应该对参数进行系列化', () => {
expect(
buildURL('/user#hash', {
v1: 1,
v2: undefined,
v3: null,
v4: '4',
v5: NaN,
}),
).toBe('/user?v1=1&v4=4');
expect(
buildURL('/user?v1=1', {
v2: 2,
}),
).toBe('/user?v1=1&v2=2');
});
test('应该对数组进行系列化', () => {
expect(
buildURL('/user', {
arr: [1, 2],
}),
).toBe('/user?arr[]=1&arr[]=2');
});
test('应该对对象进行系列化', () => {
expect(
buildURL('/user', {
obj: {
k1: 1,
k2: 2,
},
}),
).toBe('/user?obj[k1]=1&obj[k2]=2');
});
test('应该对日期进行系列化', () => {
const date = new Date();
expect(buildURL('/user', { date })).toBe(
`/user?date=${date.toISOString()}`,
);
});
test('应该支持自定义序列化器', () => {
expect(buildURL('/user', {}, () => 'v1=1&v2=2')).toBe('/user?v1=1&v2=2');
expect(buildURL('/user?v1=1', {}, () => 'v2=2&v3=3')).toBe(
'/user?v1=1&v2=2&v3=3',
);
});
});

View File

@ -1,7 +1,7 @@
import { describe, test, expect } from 'vitest';
import { combineURL } from 'src/helpers/combineURL';
describe('测试 src/helpers/combineURL.ts', () => {
describe('src/helpers/combineURL.ts', () => {
test('应该直接返回第一个参数', () => {
expect(combineURL('http://api.com', '')).toBe('http://api.com');
expect(combineURL('file://api.com', '')).toBe('file://api.com');

View File

@ -0,0 +1,71 @@
import { describe, test, expect } from 'vitest';
import { deepMerge } from 'src/helpers/deepMerge';
describe('src/helpers/deepMerge.ts', () => {
test('应该支持空参数', () => {
expect(deepMerge()).toEqual({});
});
test('应该直接返回第一个参数', () => {
expect(
deepMerge({
v1: 1,
v2: [1],
v3: { v: 'v3' },
v4: undefined,
v5: null,
v6: 'v6',
}),
).toEqual({
v1: 1,
v2: [1],
v3: { v: 'v3' },
v4: undefined,
v5: null,
v6: 'v6',
});
});
test('应该进行合并', () => {
expect(
deepMerge(
{
v1: 1,
v2: 2,
v3: 3,
},
{
v2: 22,
v3: undefined,
v4: 4,
},
),
).toEqual({
v1: 1,
v2: 22,
v3: undefined,
v4: 4,
});
});
test('应该合并对象里的对象', () => {
expect(
deepMerge(
{
v1: { v: 1 },
v2: { v: 2 },
v3: 3,
},
{
v1: { vv: 11 },
v2: 2,
v3: { v: 3 },
},
),
).toEqual({
v1: { v: 1, vv: 11 },
v2: 2,
v3: { v: 3 },
});
});
});

View File

@ -0,0 +1,22 @@
import { describe, test, expect } from 'vitest';
import { dynamicURL } from 'src/helpers/dynamicURL';
describe('src/helpers/dynamicURL.ts', () => {
test('应该替换关键字', () => {
expect(dynamicURL('http://api.com/user/:id', {})).toBe(
'http://api.com/user/undefined',
);
expect(dynamicURL('http://api.com/user/:id', { id: 1 })).toBe(
'http://api.com/user/1',
);
});
test('应该支持多个关键字', () => {
expect(
dynamicURL('http://api.com/users/name/:name/age/:age/list', {
name: 'my',
age: 18,
}),
).toBe('http://api.com/users/name/my/age/18/list');
});
});

View File

@ -1,13 +1,15 @@
import { describe, test, expect } from 'vitest';
import { assert, throwError } from 'src/helpers/error';
import { captureError, cleanedStack } from 'scripts/test.utils';
import { assert, throwError, cleanStack } from 'src/helpers/error';
describe('测试 src/helpers/error.ts', () => {
describe('src/helpers/error.ts', () => {
test('第一个参数为 true 时应该无事发生', () => {
expect(assert(true, '')).toBeUndefined();
});
test('第一个参数为 false 时应该抛出异常', () => {
expect(() => assert(false, '')).toThrowError();
expect(cleanedStack(captureError(() => assert(false, '')))).toBeTruthy();
});
test('应该抛出异常', () => {
@ -15,5 +17,17 @@ describe('测试 src/helpers/error.ts', () => {
expect(() => throwError('error')).toThrowError(
'[axios-miniprogram]: error',
);
expect(cleanedStack(captureError(() => throwError('error')))).toBeTruthy();
});
test('应该清掉多余的错误栈', () => {
const ce = () => new Error();
const error = ce();
expect(cleanedStack(error)).toBeFalsy();
cleanStack(error);
expect(cleanedStack(error)).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { describe, test, expect } from 'vitest';
import { ignore } from 'src/helpers/ignore';
describe('src/helpers/ignore.ts', () => {
test('不应该改变传入的对象', () => {
expect(
ignore({
v1: 1,
}),
).toEqual({
v1: 1,
});
});
test('应该忽略指定键值', () => {
expect(
ignore(
{
v1: 1,
v2: {},
v3: [],
v4: undefined,
v5: 5,
v6: null,
},
'v1',
'v2',
'v3',
'v4',
),
).toEqual({ v5: 5, v6: null });
});
});

View File

@ -0,0 +1,20 @@
import { describe, test, expect } from 'vitest';
import { isAbsoluteURL } from 'src/helpers/isAbsoluteURL';
describe('src/helpers/isAbsoluteURL.ts', () => {
test('应该不是绝对路径', () => {
expect(isAbsoluteURL('user')).toBeFalsy();
expect(isAbsoluteURL('/user')).toBeFalsy();
expect(isAbsoluteURL('//user')).toBeFalsy();
expect(isAbsoluteURL('://user')).toBeFalsy();
});
test('应该是绝对路径', () => {
expect(isAbsoluteURL('http://user')).toBeTruthy();
expect(isAbsoluteURL('HTTP://user')).toBeTruthy();
expect(isAbsoluteURL('https://user')).toBeTruthy();
expect(isAbsoluteURL('custom://user')).toBeTruthy();
expect(isAbsoluteURL('custom-v1.0://user')).toBeTruthy();
expect(isAbsoluteURL('custom_v1.0://user')).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
import { describe, test, expect } from 'vitest';
import {
isArray,
isDate,
isEmptyArray,
isPlainObject,
isFunction,
isNull,
isUndefined,
isString,
} from 'src/helpers/isTypes';
describe('src/helpers/isTypes.ts', () => {
test('应该能判断是数组', () => {
expect(isArray(new Array(1))).toBeTruthy();
expect(isArray([])).toBeTruthy();
expect(isArray([1])).toBeTruthy();
});
test('应该能判断是空数组', () => {
expect(isEmptyArray([1])).toBeFalsy();
expect(isArray(new Array(0))).toBeTruthy();
expect(isEmptyArray([])).toBeTruthy();
});
test('应该能判断是普通对象', () => {
expect(isPlainObject(new String())).toBeFalsy();
expect(isPlainObject(new Function())).toBeFalsy();
expect(isPlainObject({ v: 1 })).toBeTruthy();
expect(isPlainObject(new Object())).toBeTruthy();
});
test('应该能判断是日期', () => {
expect(isDate({})).toBeFalsy();
expect(isDate(new Date())).toBeTruthy();
});
test('应该能判断是函数', () => {
expect(isFunction(() => null)).toBeTruthy();
expect(
isFunction(function () {
return;
}),
).toBeTruthy();
expect(isFunction(new Function())).toBeTruthy();
});
test('应该能判断是 Null', () => {
expect(isNull(undefined)).toBeFalsy();
expect(isNull(null)).toBeTruthy();
});
test('应该能判断是 Undefined', () => {
expect(isUndefined(null)).toBeFalsy();
expect(isUndefined(undefined)).toBeTruthy();
});
test('应该能判断是字符串', () => {
expect(isString(new String())).toBeTruthy();
expect(isString('')).toBeTruthy();
expect(isString(``)).toBeTruthy();
});
});