
每次新建项目,我都会做同一件事——
打开旧项目,找到 utils 文件夹,把里面那一堆小函数全部复制过来。
不是懒,是因为踩过太多坑之后,我终于搞清楚了一件事:有些代码,永远都会用到。
搜索框的抖动、日期显示的格式、JSON 解析时崩溃的报错、下载文件时莫名失败……这些问题我处理了一遍又一遍,最终决定:不再重复写了,统一放进工具库,每个新项目直接带走。
今天把这 10 个函数全部分享出来,每一个提供 TypeScript + 原生 JS 双版本,完整可粘贴,拿走即用,同时附上真实业务场景。
在写任何业务代码之前,我会先把工具函数库的骨架搭好:
src/
└── utils/
├── debounce.ts / debounce.js
├── formatDate.ts / formatDate.js
├── classNames.ts / classNames.js
├── jsonHelpers.ts / jsonHelpers.js
├── object.ts / object.js
├── clipboard.ts / clipboard.js
├── string.ts / string.js
├── sleep.ts / sleep.js
├── array.ts / array.js
├── download.ts / download.js
└── index.ts / index.js // 统一导出
用得最多的一个,没有之一。搜索框、滚动监听、窗口 resize,任何"用户操作会高频触发"的场景都需要它。
TypeScript 版本
// utils/debounce.ts
exportfunction debounce<T extends (...args: unknown[]) => void>(
func: T,
delay: number = 300
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
returnfunction (...args: Parameters<T>): void {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
timeout = null;
}, delay);
};
}
JavaScript 版本
// utils/debounce.js
exportfunction debounce(func, delay = 300) {
let timeout = null;
returnfunction (...args) {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
timeout = null;
}, delay);
};
}
业务场景
// ① 电商后台:商品搜索,用户停止输入 400ms 后再发请求
// 接口调用量降低 80%,服务器压力大幅减少
const handleProductSearch = debounce((keyword: string) => {
fetchProductList({ keyword, page: 1 });
}, 400);
// ② 数据大屏:监听窗口 resize,停止拖拽后才重绘图表
// 避免 resize 过程中图表一直重新渲染,导致页面卡死
const handleResize = debounce(() => {
chart.resize();
}, 200);
window.addEventListener("resize", handleResize);
// ③ 表单校验:用户输入手机号时实时校验格式
// 停止输入 500ms 后才触发,不要每输入一个数字就校验
const validatePhone = debounce((value: string) => {
const isValid = /^1[3-9]\d{9}$/.test(value);
setPhoneError(isValid ? "" : "请输入正确的手机号");
}, 500);
每个项目都要显示日期,每次都要想格式——这个函数一劳永逸。
TypeScript 版本
// utils/formatDate.ts
type DateFormatOptions = {
locale?: string;
showTime?: boolean;
separator?: string;
};
exportfunction formatDate(
date: string | number | Date,
options: DateFormatOptions = {}
): string {
const { locale = "zh-CN", showTime = false, separator } = options;
const d = newDate(date);
if (isNaN(d.getTime())) return"日期无效";
if (separator) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const base = [y, m, day].join(separator);
if (!showTime) return base;
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return`${base} ${h}:${min}:${s}`;
}
const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
if (showTime) {
dateOpts.hour = "2-digit";
dateOpts.minute = "2-digit";
dateOpts.second = "2-digit";
}
return d.toLocaleDateString(locale, dateOpts);
}
JavaScript 版本
// utils/formatDate.js
exportfunction formatDate(date, options = {}) {
const { locale = "zh-CN", showTime = false, separator } = options;
const d = newDate(date);
if (isNaN(d.getTime())) return"日期无效";
if (separator) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const base = [y, m, day].join(separator);
if (!showTime) return base;
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return`${base} ${h}:${min}:${s}`;
}
const dateOpts = { year: "numeric", month: "short", day: "numeric" };
if (showTime) {
dateOpts.hour = "2-digit";
dateOpts.minute = "2-digit";
dateOpts.second = "2-digit";
}
return d.toLocaleDateString(locale, dateOpts);
}
业务场景
// ① 订单列表:显示下单时间
formatDate("2024-04-22T14:30:00", { showTime: true })
// => "2024年4月22日 14:30:00"
// ② 导出文件名:生成带日期的文件名,避免覆盖
const filename = `订单报表-${formatDate(new Date(), { separator: "-" })}.xlsx`;
// => "订单报表-2024-04-22.xlsx"
// ③ 对接国际化:英文环境切换 locale
formatDate("2024-04-22", { locale: "en-US" })
// => "Apr 22, 2024"
写过 React 的都懂这个痛——动态拼接 className 太丑。
TypeScript 版本
// utils/classNames.ts
type ClassValue = string | number | boolean | undefined | null;
export function classNames(...args: ClassValue[]): string {
return args
.filter((arg): arg is string | number => Boolean(arg))
.map(String)
.join(" ");
}
JavaScript 版本
// utils/classNames.js
export function classNames(...args) {
return args
.filter(Boolean)
.map(String)
.join(" ");
}
业务场景
// ① 按钮状态:根据多个条件组合类名
<button
className={classNames(
"btn",
"btn-md",
isPrimary && "btn-primary",
isLoading && "btn-loading",
isDisabled && "btn-disabled opacity-50 cursor-not-allowed"
)}
disabled={isDisabled}
>
{isLoading ? "提交中..." : "立即提交"}
</button>
// ② 表格行高亮:选中行 + 斑马纹
rows.map((row, index) => (
<tr
key={row.id}
className={classNames(
"table-row",
index % 2 === 0 && "bg-gray-50",
selectedIds.includes(row.id) && "bg-blue-100 font-semibold"
)}
/>
))
// ③ 导航菜单:当前激活项高亮
<NavItem className={classNames("nav-item", isActive && "nav-item--active")} />
JSON.parse 解析失败直接抛异常,整个程序崩了。读 localStorage 时尤其危险——用户的数据可能是任何格式。
TypeScript 版本
// utils/jsonHelpers.ts
exportfunction safeJsonParse<T = unknown>(
str: string | null | undefined,
fallback: T = undefined as unknown as T
): T {
if (str === null || str === undefined || str.trim() === "") {
return fallback;
}
try {
returnJSON.parse(str) as T;
} catch {
console.warn("[safeJsonParse] 解析失败,已返回 fallback:", str);
return fallback;
}
}
// 反向:安全序列化,避免循环引用崩溃
exportfunction safeJsonStringify(value: unknown, fallback = "{}"): string {
try {
returnJSON.stringify(value);
} catch {
return fallback;
}
}
JavaScript 版本
// utils/jsonHelpers.js
exportfunction safeJsonParse(str, fallback = undefined) {
if (str === null || str === undefined || str.trim() === "") {
return fallback;
}
try {
returnJSON.parse(str);
} catch {
console.warn("[safeJsonParse] 解析失败,已返回 fallback:", str);
return fallback;
}
}
exportfunction safeJsonStringify(value, fallback = "{}") {
try {
returnJSON.stringify(value);
} catch {
return fallback;
}
}
业务场景
// ① 读取本地用户配置,解析失败给默认值,不崩溃
interface UserSettings {
theme: "light" | "dark";
language: string;
pageSize: number;
}
const settings = safeJsonParse<UserSettings>(
localStorage.getItem("user_settings"),
{ theme: "light", language: "zh-CN", pageSize: 20 }
);
// ② 处理接口返回的 JSON 字符串字段
// 有些后端会把对象序列化成字符串塞进字段里
const extra = safeJsonParse<Record<string, string>>(order.extraInfo, {});
// ③ 保存前防止循环引用报错(比如含 DOM 引用的对象)
const cache = safeJsonStringify(complexObject);
localStorage.setItem("cache", cache);
检查表单是否有错误、判断数据是否为空,比 Object.keys(obj).length === 0 更安全。
TypeScript 版本
// utils/object.ts
exportfunction isEmpty(
value: Record<string, unknown> | null | undefined | unknown[]
): boolean {
if (value === null || value === undefined) returntrue;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === "object") {
returnObject.keys(value).length === 0;
}
returnfalse;
}
// 深度克隆(简单对象,无循环引用)
exportfunction deepClone<T>(obj: T): T {
returnJSON.parse(JSON.stringify(obj));
}
// 删除对象中值为 undefined / null / '' 的字段(清理请求参数常用)
exportfunction removeEmpty<T extends Record<string, unknown>>(obj: T): Partial<T> {
returnObject.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== "")
) as Partial<T>;
}
JavaScript 版本
// utils/object.js
exportfunction isEmpty(value) {
if (value === null || value === undefined) returntrue;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === "object") {
returnObject.keys(value).length === 0;
}
returnfalse;
}
exportfunction deepClone(obj) {
returnJSON.parse(JSON.stringify(obj));
}
exportfunction removeEmpty(obj) {
returnObject.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== "")
);
}
业务场景
// ① 表单提交前校验:有错误不允许提交
const formErrors = validateForm(formData);
if (isEmpty(formErrors)) {
await submitOrder(formData);
} else {
showErrors(formErrors);
}
// ② 接口请求:清理掉值为空的查询参数,避免传 undefined 给后端
const rawParams = { keyword: searchText, status: selectedStatus, page: currentPage };
const cleanParams = removeEmpty(rawParams);
// keyword 为空时不传这个字段,后端不报参数错误
// ③ 编辑页面:判断是否有未保存的改动
const originalData = deepClone(fetchedFormData); // 保存原始数据副本
const hasUnsavedChanges = JSON.stringify(formData) !== JSON.stringify(originalData);
if (hasUnsavedChanges) {
showLeaveConfirm(); // 离开页面前弹确认框
}
"复制链接""复制代码""复制订单号"——每个产品都会有这个需求。
TypeScript 版本
// utils/clipboard.ts
exportasyncfunction copyToClipboard(text: string): Promise<boolean> {
// 优先使用现代 API
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
returntrue;
} catch {
// fallthrough to legacy
}
}
// 降级兼容:使用 execCommand(已废弃但覆盖面广)
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0;";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
return success;
} catch {
returnfalse;
}
}
JavaScript 版本
// utils/clipboard.js
exportasyncfunction copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
returntrue;
} catch {
// fallthrough to legacy
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0;";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
return success;
} catch {
returnfalse;
}
}
业务场景
// ① 邀请好友:一键复制邀请链接
const handleCopyInvite = async () => {
const link = `https://example.com/register?ref=${userId}`;
const ok = await copyToClipboard(link);
message.success(ok ? "邀请链接已复制!去分享给好友吧 🎉" : "复制失败,请手动复制");
};
// ② 代码展示块:复制代码按钮
const CodeBlock = ({ code }: { code: string }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const ok = await copyToClipboard(code);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000); // 2秒后恢复
}
};
return (
<div className="code-block">
<pre>{code}</pre>
<button onClick={handleCopy}>{copied ? "✅ 已复制" : "复制"}</button>
</div>
);
};
// ③ 订单详情:复制订单号
<span onClick={() => copyToClipboard(order.orderNo).then(ok => ok && toast("已复制"))}>
{order.orderNo} 📋
</span>
🔔 注意:
navigator.clipboard只在 HTTPS 或 localhost 下可用。这个版本做了降级处理,HTTP 环境也能工作。
TypeScript 版本
// utils/string.ts
// 首字母大写
exportfunction capitalize(str: string): string {
if (!str) return"";
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
// 驼峰转中划线(camelCase → kebab-case)
exportfunction camelToKebab(str: string): string {
return str.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "");
}
// 截断长文本,超出显示省略号
exportfunction truncate(str: string, maxLength: number, ellipsis = "..."): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}
// 手机号脱敏:138****8888
exportfunction maskPhone(phone: string): string {
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}
// 金额格式化:12345.6 => ¥12,345.60
exportfunction formatCurrency(
amount: number,
currency = "CNY",
locale = "zh-CN"
): string {
returnnewIntl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(amount);
}
JavaScript 版本
// utils/string.js
exportfunction capitalize(str) {
if (!str) return"";
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
exportfunction camelToKebab(str) {
return str.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "");
}
exportfunction truncate(str, maxLength, ellipsis = "...") {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}
exportfunction maskPhone(phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}
exportfunction formatCurrency(amount, currency = "CNY", locale = "zh-CN") {
returnnewIntl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(amount);
}
业务场景
// ① 状态枚举展示(后端返回小写,前端展示首字母大写)
capitalize("pending") // => "Pending"
capitalize("active") // => "Active"
// ② 订单列表:超长商品名截断显示
truncate("2024年春季新款女士羊绒大衣长款双面呢加厚保暖外套", 20)
// => "2024年春季新款女士羊绒大衣长款双..."
// ③ 用户信息脱敏展示
maskPhone("13812348888") // => "138****8888"
// ④ 价格展示
formatCurrency(12345.6) // => "¥12,345.60"
formatCurrency(99, "USD", "en-US") // => "$99.00"
TypeScript 版本
// utils/sleep.ts
// 基础延时
exportfunction sleep(ms: number): Promise<void> {
returnnewPromise((resolve) => setTimeout(resolve, ms));
}
// 带重试的异步函数包装器
exportasyncfunction withRetry<T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error = newError("Unknown error");
for (let i = 0; i < retries; i++) {
try {
returnawait fn();
} catch (err) {
lastError = err instanceofError ? err : newError(String(err));
if (i < retries - 1) {
console.warn(`[withRetry] 第 ${i + 1} 次失败,${delay}ms 后重试...`);
await sleep(delay * (i + 1)); // 指数退避
}
}
}
throw lastError;
}
JavaScript 版本
// utils/sleep.js
exportfunction sleep(ms) {
returnnewPromise((resolve) => setTimeout(resolve, ms));
}
exportasyncfunction withRetry(fn, retries = 3, delay = 1000) {
let lastError = newError("Unknown error");
for (let i = 0; i < retries; i++) {
try {
returnawait fn();
} catch (err) {
lastError = err instanceofError ? err : newError(String(err));
if (i < retries - 1) {
console.warn(`[withRetry] 第 ${i + 1} 次失败,${delay}ms 后重试...`);
await sleep(delay * (i + 1));
}
}
}
throw lastError;
}
业务场景
// ① 骨架屏最少展示时间,避免加载太快导致的闪烁
setLoading(true);
const [data] = awaitPromise.all([
fetchOrderList(),
sleep(600), // 保证 loading 至少显示 600ms,体验更稳
]);
setLoading(false);
// ② 轮询接口(如支付结果查询)
asyncfunction pollPaymentResult(orderId: string) {
for (let i = 0; i < 10; i++) {
const result = await checkPaymentStatus(orderId);
if (result.status === "paid") return result;
await sleep(2000); // 每隔 2 秒查一次
}
thrownewError("支付结果查询超时");
}
// ③ 不稳定接口自动重试,失败时指数退避
const data = await withRetry(
() => fetchWeatherData(cityCode),
3, // 最多重试 3 次
500 // 首次延迟 500ms,之后 1000ms、1500ms
);
TypeScript 版本
// utils/array.ts
// 数组去重(基本类型)
exportfunction uniqueArray<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
// 按对象字段去重
exportfunction uniqueBy<T>(arr: T[], key: keyof T): T[] {
const seen = new Set();
return arr.filter((item) => {
const val = item[key];
if (seen.has(val)) returnfalse;
seen.add(val);
returntrue;
});
}
// 数组分块(大列表分批处理)
exportfunction chunk<T>(arr: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
// 数组扁平化(兼容性更好)
exportfunction flatDeep<T>(arr: (T | T[])[], depth = 1): T[] {
return arr.flat(depth) as T[];
}
// 按字段排序
exportfunction sortBy<T>(arr: T[], key: keyof T, order: "asc" | "desc" = "asc"): T[] {
return [...arr].sort((a, b) => {
const va = a[key];
const vb = b[key];
if (va < vb) return order === "asc" ? -1 : 1;
if (va > vb) return order === "asc" ? 1 : -1;
return0;
});
}
JavaScript 版本
// utils/array.js
exportfunction uniqueArray(arr) {
return [...new Set(arr)];
}
exportfunction uniqueBy(arr, key) {
const seen = newSet();
return arr.filter((item) => {
const val = item[key];
if (seen.has(val)) returnfalse;
seen.add(val);
returntrue;
});
}
exportfunction chunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
exportfunction sortBy(arr, key, order = "asc") {
return [...arr].sort((a, b) => {
if (a[key] < b[key]) return order === "asc" ? -1 : 1;
if (a[key] > b[key]) return order === "asc" ? 1 : -1;
return0;
});
}
业务场景
// ① 合并多个接口的标签列表,去掉重复 ID
const allTagIds = uniqueArray([...userTags, ...hotTags, ...defaultTags]);
// ② 接口返回可能有重复数据,按 id 字段去重
const uniqueOrders = uniqueBy(orderList, "orderId");
// ③ 批量上传:1000 张图片分批处理,每批 20 张,避免并发太高
const batches = chunk(imageList, 20);
for (const batch of batches) {
awaitPromise.all(batch.map(uploadImage));
await sleep(200); // 批次间稍微等一下
}
// ④ 商品列表按价格升序排列
const sortedProducts = sortBy(productList, "price", "asc");
不用打开新窗口,纯前端触发任意格式文件的下载。
TypeScript 版本
// utils/download.ts
// 通过 URL 下载文件
exportfunction downloadFile(url: string, filename: string): void {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
// 延迟移除,Safari 下立即移除会导致下载中断
setTimeout(() =>document.body.removeChild(a), 100);
}
// 把 Blob 数据下载为文件(适用于前端生成内容,如 CSV)
exportfunction downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
downloadFile(url, filename);
// 释放内存
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// 把字符串内容下载为文件
exportfunction downloadText(
content: string,
filename: string,
mimeType = "text/plain;charset=utf-8"
): void {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
JavaScript 版本
// utils/download.js
exportfunction downloadFile(url, filename) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() =>document.body.removeChild(a), 100);
}
exportfunction downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
downloadFile(url, filename);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
exportfunction downloadText(content, filename, mimeType = "text/plain;charset=utf-8") {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
业务场景
// ① 报表导出:后端返回二进制流,前端触发 xlsx 下载
const handleExport = async () => {
const blob = await exportOrdersAsBlob({ month: "2024-04" });
downloadBlob(blob, `订单报表-2024年4月-${Date.now()}.xlsx`);
};
// ② 前端生成 CSV 并下载(无需后端)
const handleExportCsv = () => {
const headers = ["订单号", "商品名称", "金额", "状态"];
const rows = orderList.map(o => [o.orderId, o.productName, o.amount, o.status]);
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
downloadText(" " + csv, "订单数据.csv", "text/csv;charset=utf-8"); // 解决 Excel 乱码
};
// ③ 下载后端静态资源(如合同 PDF、发票)
<Button onClick={() => downloadFile(contract.url, `合同-${contract.id}.pdf`)}>
下载合同
</Button>
TypeScript
// utils/index.ts
export { debounce } from"./debounce";
export { formatDate } from"./formatDate";
export { classNames } from"./classNames";
export { safeJsonParse, safeJsonStringify } from"./jsonHelpers";
export { isEmpty, deepClone, removeEmpty } from"./object";
export { copyToClipboard } from"./clipboard";
export { capitalize, camelToKebab, truncate, maskPhone, formatCurrency } from"./string";
export { sleep, withRetry } from"./sleep";
export { uniqueArray, uniqueBy, chunk, sortBy } from"./array";
export { downloadFile, downloadBlob, downloadText } from"./download";
JavaScript
// utils/index.js
export { debounce } from"./debounce.js";
export { formatDate } from"./formatDate.js";
export { classNames } from"./classNames.js";
export { safeJsonParse, safeJsonStringify } from"./jsonHelpers.js";
export { isEmpty, deepClone, removeEmpty } from"./object.js";
export { copyToClipboard } from"./clipboard.js";
export { capitalize, camelToKebab, truncate, maskPhone, formatCurrency } from"./string.js";
export { sleep, withRetry } from"./sleep.js";
export { uniqueArray, uniqueBy, chunk, sortBy } from"./array.js";
export { downloadFile, downloadBlob, downloadText } from"./download.js";
之后在任意文件里一行导入:
import { debounce, formatDate, copyToClipboard, maskPhone } from "@/utils";
有了这套工具库,我的新项目启动流程是这样的:
新建项目(Vite / Next.js / CRA)
│
▼
配置 ESLint / Prettier / tsconfig
│
▼
★ 搭建 utils/ 目录,粘贴这套工具函数 ← 这一步从不跳过
│
▼
封装 request(axios/fetch + 拦截器)
│
▼
接入路由 / 状态管理
│
▼
开始写业务组件和页面
以前我总是直接跳到"写业务",结果到后期 JSON.parse 到处抛异常,防抖逻辑三处重写,下载文件每次都现查。现在反过来——工具库先行,之后省的时间是搭建它花费时间的十倍。
这 10 个函数,没有一个是高深的,每一个你都能在 5 分钟内自己写出来。
但「能写」和「每次都写」是两回事。
工程的价值不在于你能解决多难的问题,而在于你有没有把解决过的问题,变成下次不需要再解决的问题。
把这套工具库加进你的 starter kit,下个项目开工的第一天,你会感谢今天做的这件事。