这已经是这个系列第四篇了,前面几篇把常见的防抖节流、深拷贝、数组去重、日期格式化这些“老演员”都聊了一遍。很多朋友留言说希望来点“进阶但又不至于太冷门”的工具函数,所以这一篇我特意挑了几个平时在业务代码里出现频率高、但很少有人系统讲清楚的小类目:跨上下文的类型判断、URL 查询参数处理、树形结构转换、对象路径操作,以及两个跟性能优化有关的调度工具。这些函数本身不复杂,难的是把边界情况和隐藏的坑处理干净。这篇文章就把它们一次说透,每个函数都给出可直接拿去用的实现,也会解释为什么这样写、在什么场景下用最合适。
1. 类型判断与副本保护
类型判断这个点,从 JavaScript 入门开始就一直绕不开,typeof、instanceof、Object.prototype.toString 三个方法各有用武之地,但也各有各的局限性。在真实业务里,最让人头疼的一个场景是跨上下文判断。所谓跨上下文,就是代码运行在 iframe、Web Worker、浏览器扩展等环境时,全局对象不同,instanceof Array 可能会失灵。下面这一段就是来解决这些问题的。
1.1 跨上下文的严格类型判断
很多前端新手容易被 typeof 迷惑,以为它能判断所有类型。实际上 typeof [] 返回的是 "object",typeof null 也是 "object",这就是误判的重灾区。而 instanceof 在数组、正则这些内置类型上,一旦跨 iframe 就容易失效,因为原型链指向的不是当前窗口的原型对象。
我自己在项目里会用一个相对稳健的 toType 函数,通过 Object.prototype.toString 拿到内部属性 [[Class]],再做字符串解析。具体做法是:
javascript复制function toType(value) {
const str = Object.prototype.toString.call(value);
return str.slice(8, -1).toLowerCase();
}
// 使用示例
toType([]); // "array"
toType(null); // "null"
toType(new Date()); // "date"
toType(/abc/); // "regexp"
toType(Symbol()); // "symbol"
这段代码的核心,是 Object.prototype.toString 会对任意对象返回形如 "[object Array]" 的字符串,这里面的 Array 就是类型标签。之所以用 call 而不是直接调用,是为了防止对象自身重写了 toString 方法。确定核心类型之后,后续的 isPlainObject、isPromise、isError 都能基于它来封装:
javascript复制const isArray = Array.isArray;
const isPlainObject = (value) => {
if (toType(value) !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
};
const isPromise = (value) => value instanceof Promise || (toType(value) === 'promise' && typeof value.then === 'function');
isPlainObject 这里有一个细节,就是通过 Object.getPrototypeOf 排除掉那些 Object.create(null) 创建的无原型对象和类实例。很多深拷贝库判断“普通对象”用的就是这一套逻辑,只是具体实现略有差异。
1.2 限制对象被意外修改
跨团队协作或者复用第三方组件时,经常遇到的问题是我们把对象传给对方的函数,对方直接改了原对象。尤其在配置类对象、白名单映射表这种“只读”场景下,意外污染很让人头疼。Object.freeze 可以解决浅层冻结,但对嵌套对象无能为力。所以我在工具库里会放一个 deepFreeze:
javascript复制function deepFreeze(obj, seen = new WeakSet()) {
if (obj === null || typeof obj !== 'object' || seen.has(obj)) return obj;
seen.add(obj);
Object.freeze(obj);
for (const key of Object.getOwnPropertyNames(obj)) {
deepFreeze(obj[key], seen);
}
return obj;
}
WeakSet 的存在是为了处理循环引用。没有它,遇到循环对象会直接爆栈。这里每次冻结前先检查 seen,如果已经处理过就跳过。还有一个判断顺序的问题,尽量把 Object.freeze 放在递归之前,因为一旦对象被冻结就不能再往它的属性上赋值了,但是读取属性再递归是没有问题的。
日常开发里,我通常只在两类场景用 deepFreeze:一类是全局常量配置,比如路由表、状态机的映射关系;另一类是给第三方插件传入的配置对象。平时业务数据没必要深冻结,白白消耗性能。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. URL 与查询参数的那些“小陷阱”
URL 操作在前端里出现频率极高,但直接用原生 location.search 去处理查询参数会踩很多坑。最典型的是 URLSearchParams 在处理 + 号时会把空格解析成 +,而实际业务里经常会有人手动把空格改写成 +。另外,URLSearchParams 对参数重复、数组参数、中文编码这些场景处理得都不够顺手。这里整理一组自己常用的 URL 工具函数。
2.1 解析查询参数为结构化对象
一个健壮的查询参数解析函数,要处理好三个问题:参数值可能包含 = 号、参数可能重复出现、还可能带 ? 前缀。下面这个解析函数在实践中非常顺手:
javascript复制function parseQuery(search, options = {}) {
const { arrKey = [], autoDecode = true } = options;
const query = (search || '').replace(/^\?/, '');
if (!query) return {};
const result = {};
for (const item of query.split('&')) {
if (!item) continue;
let [key, value = ''] = item.split('=');
if (autoDecode) {
try {
key = decodeURIComponent(key);
value = decodeURIComponent(value);
} catch (e) {
// 某些场景 value 里的 % 不是合法的编码序列,忽略掉就好
}
}
const isOmit = !key || key.includes('[]');
if (isOmit) {
const realKey = key.replace('[]', '');
(result[realKey] ||= []).push(value);
continue;
}
if (result[key] !== undefined || arrKey.includes(key)) {
result[key] = Array.isArray(result[key]) ? result[key] : [result[key]];
result[key].push(value);
} else {
result[key] = value;
}
}
return result;
}
这里值得逐条讲清楚:
- 先去掉开头的
?,避免调用方传location.search和传location.href时逻辑不一致。 - 用
split('=')只分割第一个等号,保证 value 里如果还有等号也不会被误裁。 decodeURIComponent需要包一层 try/catch。因为某些搜索引擎过来的链接,参数里可能带着未编码的%,直接解码会抛URIError,这会导致整个页面白屏。- 关于
key.includes('[]'),这部分是一个约定,很多后端接口希望前端把数组参数写成tag[]=a&tag[]=b,解析的时候我们希望它的 key 是tag,值是一个数组。
2.2 对象序列化为查询字符串
相比解析,序列化看起来简单,但“坑”也不少。比如空值怎么处理、数组怎么拼接、是否编码、要不要保留 null 表示空。我封装的 stringifyQuery 长这样:
javascript复制function stringifyQuery(params, options = {}) {
const { skipEmpty = true, encode = true } = options;
const parts = [];
for (const key of Object.keys(params)) {
const value = params[key];
if (value === undefined || value === null || value === '') {
if (skipEmpty) continue;
parts.push(`${key}=`);
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null || item === '') {
if (skipEmpty) continue;
parts.push(`${encodeKey(key)}[]=`);
} else {
parts.push(`${encodeKey(key)}[]=${encodeValue(item)}`);
}
}
} else {
parts.push(`${encodeKey(key)}=${encodeValue(value)}`);
}
}
return parts.join('&');
}
function encodeKey(key) {
// key 里一般不会有空格,但为了安全还是处理一下
key = key.replace(/\[\]$/, '');
return encodeURIComponent(key);
}
function encodeValue(value) {
const str = String(value);
return encodeURIComponent(str);
}
skipEmpty 参数的默认值是 true,对应很多接口“不传就不带”的习惯。如果你需要让后端明确区分 id= 和没有 id 这两个状态,可以把 skipEmpty 设为 false。这里还有一个经验:数组参数统一输出成 key[]=value 的格式。很多后端框架解析这种格式最省心,如果只拼成 key=value1&key=value2,部分后端只会取到最后一个值。
2.3 一个场景化的 URL 拼接工具
实际业务中,我们更多时候是拿到一个完整 URL,想追加一组参数。直接用字符串拼接容易出问题,比如原 URL 已经带了 query,直接 + '&' 会拼坏。我的做法是封装一个 buildUrl:
javascript复制function buildUrl(baseUrl, params = {}, options = {}) {
const urlObj = new URL(baseUrl, window.location.origin);
const queryObj = parseQuery(urlObj.search);
Object.assign(queryObj, params);
// 合并之后重新序列化,需要注意的是原 URL 里可能已有数组格式
urlObj.search = stringifyQuery(queryObj, options);
return urlObj.toString();
}
new URL 的好处有两个:拿到底层解析能力(比如自动处理端口、协议),也能处理相对路径。第二个参数传 window.location.origin,可以保证相对路径也能被拼接成功。这套工具组合在后台管理系统中用得非常顺手,列表页翻页时拼参数、筛选时重置参数,基本都靠这几个函数维护。
3. 树形结构与对象路径操作
前端和后端接口对接时,树形数据结构非常常见——菜单权限、组织架构、评论回复、类目层级,都是典型的树。然而业务组件需要的往往是扁平数组,比如下拉选择器、穿梭框、级联选择器,不同组件对数据格式的要求还互相冲突。所以专门写两个工具函数处理树和数组之间的转换,几乎是刚需。
3.1 树转数组(DFS 扁平化)
我的方案是深度优先遍历(DFS),把树的每个节点平铺出来,同时保留 parentId 和 depth。这样无论是做搜索过滤、还是做面包屑回显,都有了足够的上下文信息。
javascript复制function flattenTree(tree, options = {}) {
const { childrenKey = 'children', parentKey = 'parentId', depthKey = 'depth' } = options;
const result = [];
const walk = (nodes, parent = null, depth = 0) => {
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
const newNode = { ...node, [parentKey]: parent, [depthKey]: depth };
// 很多人会把 children 也留在节点里,为了避免循环引用,把它独立出来
const children = newNode[childrenKey];
delete newNode[childrenKey];
result.push(newNode);
walk(children, node.id, depth + 1);
}
};
walk(tree);
return result;
}
这里有几个关键决定值得解释。第一,通过扩展运算符浅拷贝节点,原树不会因为 delete 而改变形状;第二,children 属性被单独取出后再删掉,不会同步影响原对象;第三,保留 parentId 和 depth,是为了后续处理时不用重新遍历。如果某个节点没有 id,可能导致 parentId 失效,这个函数并不负责生成 id,需要在前置逻辑中补齐。
3.2 数组转树(一次遍历 + 索引)
反过来,后端有时会返回平铺数组,我们需要在前端把它拼成树。最常见、复杂度也最低的做法是使用一个 Map 索引,只需一次遍历。实现如下:
javascript复制function listToTree(list, options = {}) {
const { idKey = 'id', parentKey = 'parentId', childrenKey = 'children', rootValue = null } = options;
const map = new Map();
const roots = [];
list.forEach((item) => {
map.set(item[idKey], { ...item, [childrenKey]: [] });
});
list.forEach((item) => {
const current = map.get(item[idKey]);
const parent = map.get(item[parentKey]);
if (parent && item[parentKey] !== rootValue) {
parent[childrenKey].push(current);
} else {
roots.push(current);
}
});
return roots;
}
这个实现里的核心优势是不需要递归,不担心数据顺序。哪怕父节点在数组中排在子节点之后,第二次遍历时因为 Map 索引已经全部建立,依然能找到对应父节点。如果某些节点找不到父节点,会被默认当成根节点放入 roots,这种容错在数据不完整时非常有用。需要注意的一点是,Map 的 key 如果统一按字符串 id 处理会更稳妥,数字和字符串混用很容易导致查询失败。
3.3 通过路径安全读写对象
路径访问的需求常见于复杂配置的读取,比如 res.data.list[0].name 这种链路,如果 res.data 或 res.data.list 不存在,直接读会抛错。我常用的 getByPath 和 setByPath 如下:
javascript复制function getByPath(obj, path, defaultValue) {
const keys = Array.isArray(path) ? path : path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
let current = obj;
for (const key of keys) {
if (current == null) return defaultValue;
current = current[key];
}
return current === undefined ? defaultValue : current;
}
function setByPath(obj, path, value) {
const keys = Array.isArray(path) ? path : path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] == null) {
current[key] = /^\d+$/.test(keys[i + 1]) ? [] : {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
return obj;
}
path.replace(/\[(\w+)\]/g, '.$1') 这段是把 list[0] 转成 list.0,对数组下标也能统一处理。读取的时候,current == null 这个判断用了宽松比较,同时把 null 和 undefined 两种情况都覆盖了。写入的时候,会根据下一层 key 是否为数字自动决定创建数组还是对象,这样做相对智能,不用调用方提前声明每一层的类型。这个工具函数在处理接口返回的深层嵌套数据、或者表单配置联动时非常好用。
4. 格式化输出:文件大小与数字精度
格式化输出类工具函数是“看起来简单,写起来到处是边角料”的典型。以文件大小格式化为例,1.5MB 还是 1536KB,不同标准下结果完全不同。如果不统一标准,前端展示和后端日志对不上是很常见的。数字精度问题也一样,0.1 + 0.2 的经典问题在格式化时最容易爆发。
4.1 文件大小格式化:不止是除 1024
文件大小本质上就是一个字节数,但展示端希望它更人性化。我写的是:
javascript复制function formatSize(bytes, options = {}) {
const { decimals = 2, base = 1024, units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] } = options;
if (!Number.isFinite(bytes) || bytes < 0) return '-';
if (bytes === 0) return `0 ${units[0]}`;
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1);
const value = bytes / Math.pow(base, i);
const fixed = Number.isInteger(value) ? value.toString() : value.toFixed(decimals);
return `${fixed} ${units[i]}`;
}
Math.log(bytes) / Math.log(base) 这一步是对数换底公式,用来算当前数值应该落在哪个单位区间。Math.min(..., units.length - 1) 可以防止数值过大时数组越界。文件大小为整数时,不做多余补零,比如 2 KB 而不是 2.00 KB,这也是一个很小的体验细节。我自己做上传组件时,还会额外处理一个场景:上传失败时后端返回的错误信息里有一些非数字字段,所以 Number.isFinite(bytes) 的校验必须先做,否则 NaN 会一路传进 Math.log,得到 NaN。
4.2 金额与百分比格式化
金额格式化在报表类项目里高频使用。很多朋友喜欢用 toLocaleString('zh-CN') 一把梭,但它会受运行环境影响,在某些浏览器上小数位数不稳定。我更推荐封装一个带最小位数和最大位数的格式化函数:
javascript复制function formatNumber(value, { minFraction = 2, maxFraction = 2, grouping = true } = {}) {
const num = Number(value);
if (!Number.isFinite(num)) return '-';
return num.toLocaleString('zh-CN', {
minimumFractionDigits: minFraction,
maximumFractionDigits: maxFraction,
useGrouping: grouping,
});
}
注意 toLocaleString 默认会做千分位分隔,这是符合财务习惯的。如果只想保留整数展示,可以传 minFraction: 0, maxFraction: 0。百分比的格式化稍微特殊一点,因为很多接口返回的是小数而不是已经乘过 100 的数值,比如 0.128 代表 12.8%。为了不搞混,我通常会额外封装一个 formatPercent(value, fractionDigits = 2),内部先乘以 100 再做四舍五入,这样业务侧传参时心智负担小很多:
javascript复制function formatPercent(value, fractionDigits = 2) {
const num = Number(value);
if (!Number.isFinite(num)) return '-';
return `${(num * 100).toFixed(fractionDigits)}%`;
}
第四个坑是这里 toFixed 会直接四舍五入,对于精度极端敏感的场景,比如涉及金额分成,建议用专门的 decimal 库,否则乘法过程会触发浮点误差。关于这一点,下面的章节还会展开。
4.3 经典浮点误差与规避思路
0.1 + 0.2 === 0.3 的结果是 false,这是一个老生常谈的问题。为什么会出现这个现象?因为 JS 采用 IEEE 754 双精度浮点数,二进制并不能精确表示所有的十进制小数。所以我在工具库里提供了一个专门处理金额累加或比较的辅助函数:
javascript复制function stripFloat(value, precision = 12) {
return parseFloat(value.toPrecision(precision));
}
这个函数的核心原理是 toPrecision(12) 能够把二进制浮点数误差“截断”到常见的可展示精度。比如 0.1 + 0.2 的结果是 0.30000000000000004,经过 toPrecision(12) 会变成 0.300000000000,再 parseFloat 就成为 0.3。但这里一定要强调:这个函数只适合展示层的宽松计算,绝对不能用于支付金额的精确核算。支付场景还是要用专门的高精度计算库,或者把金额转成整数分来运算。
5. 性能优化相关的两个调度工具
工具函数不一定都是“数据转换类”,有些是用于控制代码执行节奏的。这一节围绕“减少不必要的计算”和“延迟非关键任务”这两个目标,分享两个我在实际项目里验证过很多次的调度工具。它们的共性是:不会改变业务逻辑,只是调整执行时机。
5.1 与 rAF 结合的防抖式批量渲染
传统防抖适合“输入框搜索”“窗口 resize”这一类延迟执行场景。但如果防抖回调里做的是更新 DOM 这种操作,还需要考虑一个更底层的节奏:浏览器什么时候真正渲染?requestAnimationFrame 机制告诉我们,浏览器会在下一次重绘之前执行对应的回调。所以像“连续往页面里插入大量 DOM”这种任务,更合适的策略是“收集所有变更 + 在下一次渲染帧统一执行”。
一个非常实用的封装是 createBatchHandler:
javascript复制function createBatchHandler(handler) {
let pending = [];
let rafId = null;
return {
push(payload) {
pending.push(payload);
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const items = pending;
pending = [];
rafId = null;
handler(items);
});
},
flush() {
if (!rafId) return;
cancelAnimationFrame(rafId);
const items = pending;
pending = [];
rafId = null;
handler(items);
},
};
}
它的运行逻辑是把每次 push 进来的数据累积到一个数组里,然后在下一帧的 requestAnimationFrame 回调中统一传给 handler。如果同一帧内 push 了多次,只会在最后触发一次执行,因为每次 push 都会 cancel 掉上一次的 rafId 再重新注册。这个工具非常契合“批量高亮标记”“图表数据点追加”“日志面板滚动刷新”这类场景。比如一个实时日志面板,后端 WebSocket 一秒可能推送几十条日志,用这个工具批量渲染,体验会顺畅很多。
flush 方法的作用是在组件卸载前把没来得及渲染的数据强制刷出来。假如页面已经关闭,requestAnimationFrame 不再触发,pending 里的数据就会一直堆积。所以组件 unmount 时记得调用一下 flush()。
5.2 空闲调度与可中断执行
requestIdleCallback 提供了“浏览器空闲时执行低优先级任务”的能力,但它有两个限制让人又爱又恨。一是兼容性,Firefox 和 Safari 支持并不好;二是它本身不支持 Promise 化。业界常用的做法是用 setTimeout 做降级,同时把任务拆成可中断的分片。基于这个思路,我封装了一个 runIdle 工具:
javascript复制function runIdle(task, options = {}) {
const { timeout = 50, chunk = 5, onChunk, signal } = options;
return new Promise((resolve, reject) => {
const start = performance.now();
const runner = () => {
if (signal && signal.aborted) {
reject(new Error('task aborted'));
return;
}
try {
let count = 0;
while (count < chunk && !task.done()) {
task.next();
count++;
if (signal && signal.aborted) {
reject(new Error('task aborted'));
return;
}
}
if (task.done()) {
onChunk && onChunk(count);
resolve();
} else {
onChunk && onChunk(count);
schedule();
}
} catch (e) {
reject(e);
}
};
const schedule = () => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(runner, { timeout });
} else {
setTimeout(runner, 16);
}
};
schedule();
});
}
这次封装的 task 是一个含有 next() 和 done() 两个方法的迭代器对象。每次空闲回调只处理 chunk 个分片,处理不完就在下一轮继续调度。整个任务执行过程中不会长时间阻塞主线程,动画和用户输入都能保持在流畅状态。signal 参数支持类似 AbortController 的中断语义,配合 React 的 useEffect 清理逻辑不错。这个工具我一般在做大型列表虚拟滚动预计算、复杂权限树的懒展开、批量压缩 canvas 数据时使用。
5.3 单行延迟执行版 throttle
如果不想引入上面这种复杂的调度器,只想控制“某个 CPU 密集型函数不要在一个事件循环里反复执行”,一个非常轻量的做法是使用“微任务合并”:
javascript复制function microtaskDebounce(fn) {
let scheduled = false;
let lastArgs = null;
return function (...args) {
lastArgs = args;
if (scheduled) return;
scheduled = true;
Promise.resolve().then(() => {
scheduled = false;
fn.apply(this, lastArgs);
});
};
}
这个实现的妙处在于:同一轮事件循环里,多次调用只会在微任务阶段执行一次,而且执行时机是所有同步代码跑完之后。这意味着你可以在一次循环里反复修改参数,最后真正生效的永远是最新一次。相比 setTimeout,微任务不会有至少 4ms 的延迟(在部分浏览器中嵌套定时器还有节流限制),也不会有任务队列层面的抖动。它的代价是丢失了对执行时机的精细控制——你只知道“稍后”执行,但不知道具体哪一帧。说到底,工具函数的选型永远要看场景,这是我反复强调的一点。
6. 常见问题与排查技巧实录
工具函数写多了以后,你会发现真正的问题往往集中在少数几类:输入校验不够、修改了原始数据、兼容性遗漏、用错了执行时机。下面这组问题是这几年来被问得比较多的,我整理成一份速查清单,方便大家对照。
6.1 为什么 parseQuery 拿到的是“undefined”字符串
一个非常经典的认知错误。在 URL 里写 ?id=undefined,decodeURIComponent 会把 undefined 当作普通字符串解析出来,于是业务里 if (query.id) 判断为可通过,后期就会传一个字符串 "undefined" 给后端。排查方法很简单:解析时增加“字符串 undefined / null 转成真正的空值”的步骤,或者在后端接受参数时统一校验。推荐前者:
javascript复制function cleanEmptyString(value) {
return value === 'undefined' || value === 'null' ? '' : value;
}
6.2 为什么格式化文件大小偶尔出现 0.00 KB
这个现象通常是因为字节数小于单位的阈值,但 toFixed 强制保留小数位,导致 0.00 展示。比如 5 个字节除以 1024 得到 0.0048828125,保留两位小数后就是 0.00 KB。展示层的正确姿势是:当 bytes < base 时直接返回 bytes + ' B',并在逻辑上做一次最小值兜底。下面这行代码可以加进格式化函数里:
javascript复制if (bytes < base && i === 1) return `${bytes} B`;
6.3 深冻结之后对象还是变“脏”了
排查这类问题,先确认有没有循环引用。deepFreeze 里的 WeakSet 只对“已访问过的对象”做拦截,如果对象里有一个 getter 在访问时修改了其他对象,这种情况依然是防不住的。另外,数组的 length 属性即使对象被冻结,某些浏览器下还是可以修改 length?实际上 Object.freeze 的数组 length 也是不可写的,所以问题大概率出在“冻结前已经被其他代码修改”。一个排查技巧是冻结之前先 console.log 一把对象内容,然后对比冻结后的首次访问结果,能快速定位是被谁改的。
6.4 requestIdleCallback 一直不执行
requestIdleCallback 的回调执行条件依赖“当前事件循环空闲”,如果页面上有永不停歇的动画或者频繁的 setInterval,它可能始终得不到执行。设置 timeout 参数可以缓解,但它只代表“最晚延迟时间”,不代表到期后一定会立刻执行,这和 setTimeout 的语义不一样。所以更保险的做法是给 runIdle 加一层超时保护,一旦超过最大等待时间,直接切到 setTimeout 执行:
javascript复制const idleOrTimeout = typeof requestIdleCallback !== 'undefined'
? (cb) => requestIdleCallback(() => cb(), { timeout })
: (cb) => setTimeout(cb, timeout);
6.5 树转数组后,组件里找不到原有的 children
这是 flatten 时做了 delete 导致的。如果你需要保留节点的 children,同时又要输出扁平数组,建议用 node.children = children 重新挂回去,或者额外生成一个没有 children 的副本。下面这个改造版会保留 children 字段:
javascript复制const newNode = { ...node, [parentKey]: parent, [depthKey]: depth };
const children = node[childrenKey] || [];
result.push(newNode);
walk(children, node.id, depth + 1);
6.6 问题速查表
本节的排查思路整理成一目了然的表格,方便收藏:
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
查询参数解析出字符串 "undefined" |
decode 时把原文字符串当成值 | 增加 cleanEmptyString 统一清洗 |
文件大小展示为 0.00 KB |
字节数小于单位阈值但强制补小数位 | 小于 base 时直接返回 B 单位 |
| 深冻结后对象仍被修改 | 存在 getter 副作用或先改后冻 | 冻结前打印快照,确认污染来源 |
requestIdleCallback 未执行 |
页面持续占用主线程 | 加 setTimeout 降级与超时保护 |
| flatten 后 children 丢失 | delete 移除了节点子级 |
需要保留时重新挂载或生成副本 |
0.1 + 0.2 结果不准 |
浮点数二进制存储精度问题 | 展示层用 toPrecision(12),精确业务用整数计算 |
| 同一对象被多个组件共享并互相污染 | 引用传递导致副作用 | 必要时用 deepFreeze + structuredClone 组合 |
6.7 几个强化工具函数健壮性的习惯
写工具函数这几年来,我慢慢养成了一些固定习惯。确保输入可控是排第一位的,所以每个函数入口都尽量做一次防御判断,比如 Array.isArray 或者 typeof fn === 'function',这能避免很多低级报错。保持纯函数也很重要,尽量不修改传入的引用类型,确实要修改的时候必须返回一个新引用或明说“原地修改”。测试用例也不要漏掉边界值:空字符串、0、NaN、null、很大的数字、嵌套很深的对象,都得过一遍。还有一点就是及时写下场景注释,别人接手你代码的时候,看到 parseQuery 脑子里知道它是“服务端渲染出来的查询链接解析专用”,使用起来会更安心。
结尾
工具函数这个东西,单独看每一个都不复杂,但真正把它们组织起来、形成一套适合自己的工具库,需要不断在业务里打磨。我个人体会最深的一点是:不要为了“多一个函数”而造函数,每个工具函数都必须在真实代码里至少被调用过两三次,才有资格沉淀下来。这一篇里的类型判断、查询参数解析、树形数据处理、格式化输出和调度工具,都是从一类高频场景中抽象出来的,你完全可以按需挑几个放进自己的项目。下次再遇到相似的需求时,打开自己整理的工具集,直接拿现成函数替换,比临时查文档、复制粘贴社区代码要稳得多。这就是工具函数系列一直写下来的最大价值。
