http2
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { getToken } from './auth';
// 扩展 AxiosRequestConfig 以支持自定义属性
export interface RequestOptions {
retry?: number; // 重试次数
retryDelay?: number; // 重试延迟(ms)
cache?: boolean; // 是否开启缓存
cacheTime?: number; // 缓存时间(ms)
cancelPrevious?: boolean; // 是否取消上一次同名请求
keys?: string[]; // 自定义 key 计算参数,传入后根据数组内容计算 key
debounce?: number; // 防抖延迟时间(ms),在指定时间内重复请求只执行最后一次
debounceKey?: string; // 自定义防抖 key,不传则根据请求参数自动生成
}
// 内部使用的 Config 类型(包含 Axios 内部属性 + 自定义属性 + 内部状态)
interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig, RequestOptions {
__retryCount?: number; // 内部记录当前重试次数
__requestId?: number; // 内部记录请求 ID,用于避免竞态条件
}
// 缓存数据结构
interface CacheItem {
data: any;
expire: number;
}
// 存储取消函数和请求 ID
interface PendingItem {
cancel: (reason?: string) => void;
requestId: number;
}
const pendingMap = new Map<string, PendingItem>(); // 存储取消函数
const cacheMap = new Map<string, CacheItem>(); // 存储缓存数据
// 防抖相关数据结构
interface DebounceItem {
timer: ReturnType<typeof setTimeout>;
resolvers: Array<{ resolve: (value: any) => void; reject: (reason: any) => void }>;
}
const debounceMap = new Map<string, DebounceItem>(); // 存储防抖定时器
// 全局请求 ID 计数器
let requestIdCounter = 0;
// 生成唯一的 Request Key
const generateReqKey = (
config: InternalAxiosRequestConfig | AxiosRequestConfig,
keys?: string[],
): string => {
// 如果传入了自定义 keys,根据数组内容计算 key
if (keys && keys.length > 0) {
const { method, url, params, data } = config;
const keyParts: string[] = [method || '', url || ''];
keys.forEach((key) => {
// 优先从 params 中取值,再从 data 中取值
const paramValue = params?.[key];
const dataValue = data?.[key];
const value = paramValue !== undefined ? paramValue : dataValue;
keyParts.push(`${key}:${JSON.stringify(value)}`);
});
return keyParts.join('&');
}
// 默认计算方式
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&');
};
const service: AxiosInstance = axios.create({
// baseURL: '/api',
timeout: 10000,
});
/**
* 请求拦截器
*/
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 强制转换为我们自定义的 Config 类型以便访问自定义属性
const customConfig = config as CustomInternalAxiosRequestConfig;
const requestKey = generateReqKey(customConfig, customConfig.keys);
// === A. 先处理缓存 (仅 GET) ===
// 缓存检查应该在取消逻辑之前,避免不必要的取消
if (customConfig.cache && customConfig.method === 'get') {
const cachedItem = cacheMap.get(requestKey);
if (cachedItem) {
const { data, expire } = cachedItem;
if (expire > Date.now()) {
// 缓存命中,清除可能存在的 pending 状态
pendingMap.delete(requestKey);
// 使用 adapter 模拟响应,阻断实际网络请求
customConfig.adapter = (): Promise<AxiosResponse> => {
return Promise.resolve({
data,
status: 200,
statusText: 'OK',
headers: {},
config: config,
request: {},
});
};
return customConfig;
}
}
}
// === B. 处理竞态和取消 ===
// 默认为 false,需要取消时主动传入 true(避免不同组件实例调用相同 API 时互相取消)
const shouldCancelPrevious = customConfig.cancelPrevious ?? false;
// 生成当前请求的唯一 ID
const currentRequestId = ++requestIdCounter;
customConfig.__requestId = currentRequestId;
// 检查是否有正在发送的相同请求
const existingPending = pendingMap.get(requestKey);
if (shouldCancelPrevious && existingPending) {
if (existingPending.requestId < currentRequestId) {
// 存在更旧的请求,取消它
existingPending.cancel('Canceled due to newer request');
} else if (existingPending.requestId > currentRequestId) {
// 存在更新的请求,当前请求(旧请求)应该自己取消
// 直接抛出取消错误,阻止发送请求
return Promise.reject({
isCanceled: true,
message: 'Canceled because newer request exists',
}) as any;
}
}
// 创建新的 AbortController
const controller = new AbortController();
customConfig.signal = controller.signal;
// 更新 pendingMap(只有当前请求是最新的时候)
if (!existingPending || existingPending.requestId < currentRequestId) {
pendingMap.set(requestKey, {
cancel: (msg) => controller.abort(msg),
requestId: currentRequestId,
});
}
/* 添加 Authorization 头部 */
const token = getToken();
if (token) {
customConfig.headers.Authorization = `Bearer ${token}`;
}
/* akira:额外的处理...(如添加自定义头部等) */
return customConfig;
},
(error: AxiosError) => Promise.reject(error),
);
// --- 响应拦截器 ---
service.interceptors.response.use(
(response: AxiosResponse) => {
const config = response.config as CustomInternalAxiosRequestConfig;
const requestKey = generateReqKey(config, config.keys);
// 只有当 requestId 匹配时才移除,避免删除新请求的取消函数
const pending = pendingMap.get(requestKey);
if (pending && pending.requestId === config.__requestId) {
pendingMap.delete(requestKey);
}
// === C. 写入缓存 (仅 GET) ===
if (config.cache && config.method === 'get') {
cacheMap.set(requestKey, {
data: response.data,
expire: Date.now() + (config.cacheTime || 60000), // 默认 60秒
});
}
/* akira:额外的处理...(如处理业务错误码等) */
// 注意:这里直接返回 response.data,意味着 request 返回的类型不再是 AxiosResponse
return response.data;
},
async (error: AxiosError) => {
const config = error.config as CustomInternalAxiosRequestConfig | undefined;
// 如果没有 config (极少见),直接抛出
if (!config) return Promise.reject(error);
const requestKey = generateReqKey(config, config.keys);
// 只有当 requestId 匹配时才移除,避免删除新请求的取消函数
const pending = pendingMap.get(requestKey);
if (pending && pending.requestId === config.__requestId) {
pendingMap.delete(requestKey);
}
// 如果是取消请求,直接抛出,不重试
if (axios.isCancel(error)) {
// console.log('Request canceled:', error.message);
// 抛出特定的对象以便下游识别
return Promise.reject({ isCanceled: true, message: error.message });
}
// === D. 处理重试 ===
const retryCount = config.retry || 0;
// 初始化内部重试计数器
config.__retryCount = config.__retryCount || 0;
// 检查是否超过重试次数
if (config.__retryCount >= retryCount) {
return Promise.reject(error);
}
// 增加重试计数
config.__retryCount += 1;
// 创建延时 Promise
const backoff = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1000);
});
console.log(`Retrying request... (${config.__retryCount}/${retryCount})`);
// 延时后重新发起请求
await backoff;
return service(config);
},
);
// 联合类型:AxiosRequestConfig 加上我们的自定义属性
export type RequestConfig = AxiosRequestConfig & RequestOptions;
/**
* 核心请求函数
* @param url 请求地址
* @param config 配置项
*/
export function request<T = any>(url: string, config: RequestConfig = {}): Promise<T> {
// 1. 构造完整的配置对象
const finalConfig = {
url,
...config,
// 默认不取消上一次同名请求,需要时主动传入 true
cancelPrevious: config.cancelPrevious ?? false,
};
// 2. 如果配置了防抖,则使用防抖逻辑
if (config.debounce && config.debounce > 0) {
// 优先使用自定义 debounceKey,否则根据 url + method 生成(忽略变化的参数)
const debounceKey = config.debounceKey || `${finalConfig.method || 'get'}&${url}`;
return new Promise<T>((resolve, reject) => {
const existing = debounceMap.get(debounceKey);
if (existing) {
// 清除之前的定时器,但保留之前的 resolvers
clearTimeout(existing.timer);
// 把当前请求的 resolve/reject 加入等待队列
existing.resolvers.push({ resolve, reject });
} else {
// 首次请求,创建新的 resolvers 数组
debounceMap.set(debounceKey, {
timer: null as any,
resolvers: [{ resolve, reject }],
});
}
// 获取最新的 item(可能是刚创建的,也可能是已存在的)
const item = debounceMap.get(debounceKey)!;
// 设置新的定时器,使用最新的 finalConfig
item.timer = setTimeout(() => {
const resolvers = item.resolvers;
debounceMap.delete(debounceKey);
// 发起请求,结果分发给所有等待者
service(finalConfig as AxiosRequestConfig)
.then((data) => {
resolvers.forEach((r) => r.resolve(data));
})
.catch((err) => {
resolvers.forEach((r) => r.reject(err));
});
}, config.debounce);
});
}
return service(finalConfig as AxiosRequestConfig) as Promise<T>;
}
export function get<T = any>(url: string, config: RequestConfig = {}): Promise<T> {
return request<T>(url, { ...config, method: 'get' });
}
export function del<T = any>(url: string, config: RequestConfig = {}): Promise<T> {
return request<T>(url, { ...config, method: 'delete' });
}
export function post<T = any>(url: string, data: any, config: RequestConfig = {}): Promise<T> {
return request<T>(url, { ...config, method: 'post', data });
}
export function put<T = any>(url: string, data: any, config: RequestConfig = {}): Promise<T> {
return request<T>(url, { ...config, method: 'put', data });
}
/**
* 手动取消请求
* @param url 请求地址
* @param config 配置项 (需要包含 method, params 等以生成正确的 key)
*/
export function cancelRequest(url: string, config: RequestConfig = {}): void {
// 构造一个临时的 config 对象用于生成 Key
const tempConfig = { url, ...config };
const key = generateReqKey(tempConfig as AxiosRequestConfig, config.keys);
const pending = pendingMap.get(key);
if (pending) {
pending.cancel('Manual cancel');
pendingMap.delete(key);
}
}clsx & twMerge
结合 clsx 和 twMerge。
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(...inputs));
};formatter
formatTimeToChatTime
时间转换
export const formatTimeToChatTime = (time: Date) => {
const now = dayjs();
const target = dayjs(time);
// 如果传入时间小于60秒,返回刚刚
if (now.diff(target, 'second') < 60) {
return 'common.time.Just now';
}
// 如果时间是今天,展示几时:几分
if (now.isSame(target, 'day')) {
return target.format('HH:mm');
}
// 如果是昨天,展示昨天
if (now.subtract(1, 'day').isSame(target, 'day')) {
return 'common.time.Yesterday';
}
// 如果是前天,展示前天
if (now.subtract(2, 'day').isSame(target, 'day')) {
return 'common.time.The day before yesterday';
}
// 如果是今年,展示某月某日
if (now.isSame(target, 'year')) {
return target.format('MM/DD');
}
// 如果是更久之前,展示某年某月某日
return target.format('YYYY/M/D');
};mergeTableCol
antd-table 列合并
单列合并:
export function mergeTableCol<T extends { rowSpan?: number }>(array: T[] = [], key: keyof T): T[] {
// 先按 key 排序,保证表格相邻
const sorted = sortBy(array, key);
// 按 key 分组
const grouped = groupBy(sorted, key);
const result: T[] = [];
Object.values(grouped).forEach((group) => {
// 只对“超过一项的分组”做合并
if (group.length > 1) {
group.forEach((item, index) => {
if (index === 0) {
item.rowSpan = group.length; // 第一条设置合并行数
} else {
item.rowSpan = 0; // 其余隐藏
}
result.push(item);
});
} else {
// 只有一项,不合并,保持原样
group[0].rowSpan = 1;
result.push(group[0]);
}
});
return result;
}多列合并:
function mergeTableCol<T extends Record<string, any>>(
array: T[] = [],
config: {
[K in keyof T]?: boolean; // 哪些列需要合并
},
): T[] {
const mergeKeys = Object.keys(config).filter((key) => config[key as keyof T]) as (keyof T)[];
if (mergeKeys.length === 0) return [...array];
const result: T[] = array.map((item) => ({
...item,
rowSpanMap: {},
}));
// 为每个需要合并的字段独立计算 rowSpan
mergeKeys.forEach((key) => {
let i = 0;
while (i < result.length) {
let j = i;
// 找连续相同的行
while (j + 1 < result.length && result[j][key] === result[j + 1][key]) {
j++;
}
const groupLength = j - i + 1;
// 设置该列的 rowSpan
for (let k = i; k <= j; k++) {
if (k === i) {
result[k].rowSpanMap![key] = groupLength;
} else {
result[k].rowSpanMap![key] = 0;
}
}
i = j + 1;
}
});
return result;
}buildTree
片平数组构建树结构
import { groupBy } from 'lodash';
interface FlatItem {
[key: string]: any;
}
interface BuildTreeOptions {
/* 主键 */
idKey?: string;
/* 父级主键 */
parentIdKey?: string;
/* 子级 */
childrenKey?: string;
/* 根节点 */
rootParentId?: any;
}
/* 构建树 */
export const buildTree = <T extends FlatItem>(list: T[], options: BuildTreeOptions = {}): T[] => {
const {
idKey = 'id',
parentIdKey = 'parentId',
childrenKey = 'children',
rootParentId = null,
} = options;
const grouped = groupBy(list, parentIdKey);
function build(parentId: any): T[] {
return (grouped[parentId] || []).map((item) => ({
...item,
[childrenKey]: build(item[idKey]),
}));
}
return build(rootParentId);
};function
带返回的函数防抖
常规防抖
function debounce(callback, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
callback(...args);
}, delay);
};
}问题:无法获取到函数的返回值。
原因:callback 在 settimeout 中执行,返回值自然是没办法返回给使用者的。
但实际上,一个函数在一段时间内不一定执行,也不应该拿到期待的返回值。
解决方案
- 回调函数中传递
- promise resolve
回调函数中传递
这种方式后续的操作都需要在回调中处理。
import { debounce } from 'lodash-es';
const test = (callback) => {
console.log('test');
let returnValue = 100;
callback && callback(returnValue);
return returnValue;
};
const debouncedTest = debounce(test, 100);
console.log(
'return',
debouncedTest((value) => {
console.log(value);
}),
);promise resolve
将含有返回值的函数当做异步函数对待,通过 promise 承接返回值。
function debounceWithReturn(callback, delay) {
let timer;
return (...args) => {
return new Promise((resolve, reject) => {
clearTimeout(timer);
timer = setTimeout(() => {
try {
let output = callback(...args);
resolve(output);
} catch (err) {
reject(err);
}
}, delay);
});
};
}
// demo
const func = (val) => {
console.log(val);
return val;
};
const func1 = debounce(func, 1000);
const a = () => {
let t;
t = func1(1);
t = func1(2);
t = func1(3);
console.log(
'akira',
t.then((res) => console.log('then', res)),
);
};
a();tree utils
buildTree
将拍平的数组转换为树状结构
export function buildTree(data, key = 'children') {
const result = [];
const map = {};
data.forEach((value, index) => {
map[value.id] = value;
});
data.forEach((value, index) => {
if (value.pid) {
const parent = map[value.pid];
if (!parent.hasOwnProperty(key)) {
parent[key] = [];
}
parent[key].push(value);
} else {
result.push(value);
}
});
return result;
}filterTree
根据条件过滤树,子项包含符合条件的子项,则返回其以及其父级。
export function filterTree(treeData, filter, childrenKey = 'children') {
const loop = (data) => {
return data
.map((item) => {
const children = item[childrenKey];
const hasMatched = filter(item);
const hasMatchingChildren = children && children.some((child) => loop([child])?.length > 0);
if (hasMatched || hasMatchingChildren) {
return {
...item,
[childrenKey]: children ? loop(children) : undefined,
};
}
return null;
})
.filter((item) => item !== null);
};
return loop(treeData);
}flattenTree
将树结构拍平为扁平数组。
interface FlatNode {
id: string;
parentId: string | null;
childIds: string[];
}
function flattenTree(tree: DataNode[], parentId: string | null = null): FlatNode[] {
const result: FlatNode[] = [];
for (const node of tree) {
const id = String(node.key);
const children = (node.children ?? []) as DataNode[];
const childIds = children.map((c) => String(c.key));
result.push({ id, parentId, childIds });
result.push(...flattenTree(children, id));
}
return result;
}getAllDescendantIds
根据节点 ID 获取所有子节点 ID。
interface FlatNode {
id: string;
parentId: string | null;
childIds: string[];
}
function getAllDescendantIds(nodeId: string, flatMap: Map<string, FlatNode>): string[] {
const node = flatMap.get(nodeId);
if (!node || node.childIds.length === 0) return [];
const descendants: string[] = [];
for (const childId of node.childIds) {
descendants.push(childId);
descendants.push(...getAllDescendantIds(childId, flatMap));
}
return descendants;
}getAllAncestorIds
根据节点 ID 获取所有子节点 ID。
interface FlatNode {
id: string;
parentId: string | null;
childIds: string[];
}
function getAllAncestorIds(nodeId: string, flatMap: Map<string, FlatNode>): string[] {
const node = flatMap.get(nodeId);
if (!node || !node.parentId) return [];
return [node.parentId, ...getAllAncestorIds(node.parentId, flatMap)];
}Object Utils
isEmptyObject
判断对象是否为空,包括嵌套对象。
/** 判断对象是否为空 */
export const isEmptyObject = (obj: any): boolean => {
// 处理 null 和 undefined
if (obj === null || obj === undefined) {
return true;
}
// 处理非对象类型(字符串、数字、布尔值等)
if (typeof obj !== 'object' || Array.isArray(obj)) {
return false;
}
// 获取对象的所有键
const keys = Object.keys(obj);
// 空对象视为空
if (keys.length === 0) {
return true;
}
// 递归检查每个属性
return keys.every((key) => {
const value = obj[key];
// null 或 undefined 视为空
if (value === null || value === undefined) {
return true;
}
// 如果是对象,递归检查
if (typeof value === 'object' && !Array.isArray(value)) {
return isEmptyObject(value);
}
// 其他类型(字符串、数字、布尔值、数组等)视为非空
return false;
});
};vaildate
file-type
/**
* 支持的文件类型枚举
*/
export type SupportedFileType = 'pdf' | 'word' | 'excel' | 'image' | 'zip' | 'ppt';
/**
* 文件类型到MIME类型的映射
*/
const FILE_TYPE_MIME_MAP: Record<SupportedFileType, string[]> = {
pdf: ['application/pdf'],
word: [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
excel: [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
image: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'],
zip: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed'],
ppt: [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
};
/**
* 验证文件类型是否符合要求
* @param file 要验证的文件对象
* @param allowedTypes 允许的文件类型数组,例如 ['pdf'] 或 ['pdf', 'word', 'excel']
* @returns 如果文件类型符合要求返回 true,否则返回 false
*
* @example
* ```ts
* const file = event.target.files[0];
* if (!validateFileType(file, ['pdf'])) {
* message.error('只支持PDF格式的文件!');
* return false;
* }
* ```
*/
export function validateFileType(file: File, allowedTypes: SupportedFileType[]): boolean {
if (!file) {
return false;
}
if (!allowedTypes || allowedTypes.length === 0) {
return false;
}
// 收集所有允许的MIME类型
const allowedMimeTypes: string[] = [];
allowedTypes.forEach((type) => {
const mimeTypes = FILE_TYPE_MIME_MAP[type];
if (mimeTypes) {
allowedMimeTypes.push(...mimeTypes);
}
});
// 检查文件类型是否在允许的列表中
return allowedMimeTypes.includes(file.type);
}