这个系列写到第六篇了。前面几篇聊数组去重、类型判断、防抖节流的时候不少读者留言说“太基础了”。这篇我特意往里塞了点稍微进阶的内容深拷贝的工业级实现、URL 参数的解析与拼接、树形结构的三板斧还有几个平时看着小但很影响体验的格式化函数。这些工具函数我在真实项目中几乎每周都会用到面试也经常被问到尤其是深拷贝和列表转树。如果你是刚工作一两年的前端或者正在准备跳槽面试这篇值得完整看一遍。老规矩所有代码我都会贴出完整版本尽量做到拿来就能用但更重要的是理解它为什么这么写。1. 深拷贝从 JSON.parse 到结构化克隆深拷贝大概是前端面试里被问得最频繁的手写题没有之一。但很多人的认知还停留在“深拷贝就是JSON.parse(JSON.stringify(obj))”这个回答在简单场景下能混过去一旦碰到 Date、Map、循环引用瞬间就是个雷。我们先看看它到底错在哪。1.1 JSON.parse(JSON.stringify()) 的三大盲区先说结论这个写法本质上是先把对象序列化成 JSON 字符串再解析回新对象所以凡是 JSON 表达不了的东西都会出问题。第一类问题是类型丢失。Date对象会被转成 ISO 字符串undefined、函数、Symbol 类型的属性会被直接干掉NaN和Infinity会变成nullRegExp会变成空对象{}。我踩过一个特别典型的坑后端返回的配置项里有RegExp前端直接存进 Vuex 前用这招拷贝了一下结果正则没了页面路由匹配全部失效。这类问题很隐蔽因为项目里不是每个字段都会被立刻用到。第二类问题是循环引用直接抛错。比如一个树形数据子节点反手存了个parent指向父节点这在做组织架构、文件目录之类的数据模型时非常常见。一旦用JSON.stringify马上报TypeError: Converting circular structure to JSON整个流程直接崩掉。第三类问题是非枚举属性和原型链丢失。对象上通过Object.defineProperty定义的不可枚举属性或者挂在类实例上、依赖原型链的方法在 JSON 序列化时全部不参与。换句话说你拷贝回来的不是原来的那类对象而是一个“长得像”的普通对象。那为什么很多项目还这么写因为它简单、直观而且 80% 的业务数据都是纯 JSON 结构。但剩下的 20% 一旦踩中排查成本远高于手写一个健壮的深拷贝。所以我建议工具函数库里必须有 deepClone而不是每次临时写JSON.parse(JSON.stringify())。1.2 工业级 deepClone基于结构化克隆语义的递归实现我项目里用的深拷贝是下面这版核心思路是模拟浏览器的结构化克隆语义但保留了对函数和 DOM 节点的宽容处理更适合业务代码。function deepClone(value, cache new WeakMap()) { // 基础类型直接返回 if (value null || typeof value ! object) return value; // 处理日期 if (value instanceof Date) return new Date(value.getTime()); // 处理正则 if (value instanceof RegExp) { const clone new RegExp(value.source, value.flags); clone.lastIndex value.lastIndex; return clone; } // 处理 Map if (value instanceof Map) { const clone new Map(); cache.set(value, clone); value.forEach((val, key) { clone.set(deepClone(key, cache), deepClone(val, cache)); }); return clone; } // 处理 Set if (value instanceof Set) { const clone new Set(); cache.set(value, clone); value.forEach((val) { clone.add(deepClone(val, cache)); }); return clone; } // 处理 ArrayBuffer 及其视图 if (value instanceof ArrayBuffer) { return value.slice(0); } if (ArrayBuffer.isView(value)) { return new value.constructor(value); } // 处理循环引用 if (cache.has(value)) { return cache.get(value); } // 普通对象和数组反射拿到所有键包括 Symbol 键 const keys Reflect.ownKeys(value); const clone Array.isArray(value) ? [] : {}; cache.set(value, clone); for (const key of keys) { const desc Object.getOwnPropertyDescriptor(value, key); if (desc desc.enumerable) { clone[key] deepClone(value[key], cache); } } return clone; }几个关键设计点逐一说一下。WeakMap是专门为循环引用准备的。把原对象和克隆对象一一对应记录起来递归过程中再次遇到同一个对象时直接返回之前克隆好的引用这样父子互相引用、兄弟共享引用都不会出问题。选WeakMap而不是Map是因为它不会阻止垃圾回收对象拷贝完就算不再使用也能被正常回收。Reflect.ownKeys能同时拿到字符串键和 Symbol 键比Object.keys覆盖得更全。业务代码里用 Symbol 做私有属性的场景虽然不多但一旦遇到Object.keys就会漏。这里还做了可枚举性判断因为只拷贝可枚举属性才符合日常认知但如果你明确要连不可枚举属性一起拷贝把if (desc desc.enumerable)这行删掉即可。关于原型链我选择了“不保留”的策略。也就是说拷贝后得到的对象不再属于原来的类这在业务场景里通常是可接受的。如果你要拷贝的是一个类实例且希望保留原型方法那就不能用通用深拷贝而是应该让那个类自己实现clone方法。通用工具函数要的是稳定和可预期不是万能的。1.3 structuredClone 原生 API 与自定义实现的取舍写到这里必须提一下浏览器原生的structuredClone。Chrome 98、Node 17 之后都能直接用它才是真正的结构化克隆实现性能一流支持循环引用、Date、RegExp、Map、Set、ArrayBuffer代码还极简const cloned structuredClone(original);那为什么还要保留自己的deepClone因为structuredClone有两个业务场景下绕不过去的限制。第一它不支持函数遇到函数直接抛DataCloneError而配置项里经常有回调函数。第二它不支持 DOM 节点Vue 或 React 项目里如果对象里混入了 ref 获取到的元素也会抛错。所以我的建议是分场景如果你确定数据结构里只有纯数据和内置类型优先用structuredClone省心省力如果数据里可能混入函数、DOM 节点或者需要兼容老浏览器就退回自己封装的deepClone。工具函数里可以做个自动降级function safeDeepClone(value) { if (typeof structuredClone function) { try { return structuredClone(value); } catch (e) { return deepClone(value); } } return deepClone(value); }这样即使在老项目里也能逐步迁移到原生实现。2. URL 参数处理解析与拼接的高频坑位日常开发里 URL 参数处理的频率比你想象的高得多活动页从链接里取 userId、分享页拼回跳链接、H5 页面之间互相传参。这里面的坑集中在两部分解析时的解码兼容拼接时的编码规范。2.1 getUrlParams兼容旧环境的参数解析很多人直接用new URLSearchParams(window.location.search)顺手、干净。但老项目要兼容 IE 时这条路走不通而且URLSearchParams在部分低版本移动端 WebView 里的行为差异也让人头大。所以我保留了纯字符串解析的版本作为兜底。function getUrlParams(url) { const source url || window.location.href; const params {}; // 先去掉 hash再取 ? 后面的部分 const cleanUrl source.split(#)[0]; const queryString cleanUrl.split(?)[1] || ; if (!queryString) return params; const pairs queryString.split(); for (const pair of pairs) { if (!pair) continue; const eqIndex pair.indexOf(); if (eqIndex -1) { // 只有 key 没有 value例如 ?flag const key safeDecode(pair); params[key] ; continue; } const key safeDecode(pair.slice(0, eqIndex)); const value safeDecode(pair.slice(eqIndex 1)); params[key] value; } return params; } function safeDecode(str) { try { return decodeURIComponent(str); } catch (e) { // 遇到 % 开头的非法编码序列时原样返回 return str; } }为什么手动写indexOf()而不是split()因为参数值本身可能包含字符。某个参数值是 base64 字符串时split()会把后面的部分全丢掉这是非常隐蔽的 bug。先用indexOf()找到第一个等号左边是 key右边剩余所有内容都是 value逻辑上才是对的。decodeURIComponent不能省。中文参数在地址栏里会变成百分号编码比如 ?name%E5%BC%A0%E4%B8%89不解码出来的就是乱码。注意这里不能用decodeURI它不会解码%E5%BC%A0这类字符必须用Component版本。另外我特意处理了?flag这种只有 key 没有 value 的情况解析出来 value 为空字符串而不是undefined。这是有意的设计——当你用它判断“链接里有没有这个参数”时空字符串和undefined有本质区别。2.2 buildQuery参数对象转 Query 字符串的编码规范解析是解码拼接是编码。很多人拼接 URL 参数时习惯用模板字符串手工拼const url /api?name${name}age${age};这个写法在参数值简单时没问题一旦值里有中文、空格、、生成的 URL 就会出乱子。正确做法是用encodeURIComponent统一编码。function buildQuery(params) { if (!params || typeof params ! object) return ; return Object.keys(params) .filter((key) { const value params[key]; return value ! undefined value ! null; }) .map((key) { const value params[key]; return ${encodeURIComponent(key)}${encodeURIComponent(value)}; }) .join(); }过滤掉undefined和null是刻意的。业务里经常有“只传有值的字段”这种诉求比如搜索条件对象里用户没填的项不应该拼进 URL 里污染参数。如果你反过来希望保留空字段把 filter 删掉即可。拼接值的时候注意一个细节encodeURIComponent传数字、布尔值也会正常工作因为函数内部会先做ToString。所以buildQuery({ page: 1, active: true })能得到page1activetrue无需提前转字符串。如果参数值是一个数组比如多选筛选条件tagId: [1,2]直接传进来会变成tagId1%2C2后台拿到的是1,2而不是数组。这种情况下需要单独做数组展开把同一个 key 拼多份function buildQueryWithArray(params, arrayKeys []) { const segments []; for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { value.forEach((item) { segments.push(${encodeURIComponent(key)}${encodeURIComponent(item)}); }); } else if (value ! undefined value ! null) { segments.push(${encodeURIComponent(key)}${encodeURIComponent(value)}); } } return segments.join(); }两种方式后台都能解析但“同 key 多份值”是大家约定俗成的标准做法后端处理起来比逗号拼接更容易。这算是我平时写代码的一个偏好凡是可能产生歧义的数据结构宁可在前端多写几行也不给后端留解释空间。3. 树形结构三件套列表转树、节点查找、节点过滤后台管理系统里树形结构无处不在部门架构、权限菜单、分类目录。后端最常给的数据格式是扁平列表每条记录带一个parentId前端要自己组装成树。等树建好之后还要在树里做条件查找、关键字过滤。这三个函数我封装在一起用互相配合非常顺手。3.1 listToTree一次遍历完成的列表转树低效的写法是双重循环每处理一个节点就find一次父节点时间复杂度 O(n²)数据量上百就明显卡顿。用 Map 预索引可以把复杂度降到 O(n)。function listToTree(list, options {}) { const { idKey id, parentKey parentId, childrenKey children } options; const map new Map(); const roots []; list.forEach((item) { map.set(item[idKey], { ...item, [childrenKey]: [] }); }); for (const item of map.values()) { const parent map.get(item[parentKey]); if (parent) { parent[childrenKey].push(item); } else { roots.push(item); } } return roots; }为什么要用 Map 而不是普通对象普通对象的键只能存字符串或 Symbolid是数字 1 时obj[1]实际会变成obj[1]取数时没问题但遇到原型链上的名字比如toString、constructor就有被污染的风险。Map 的键可以是任意类型且天然不存在原型链问题代码也更语义化。注意这里每一项做的是浅拷贝{ ...item, [childrenKey]: [] }所以生成的树节点和原数组里的对象共享了那层展开后的属性引用。如果原数组后续被修改树里的数据也可能跟着变。这是一个副作用隐患解决办法是先深拷贝再转树直接用上一节的deepClonefunction listToTreeSafe(list, options) { return listToTree(deepClone(list), options); }具体用哪个版本取决于你能不能保证原数据不修改。我自己的习惯是从接口拿到的数据本来就是新对象浅拷贝够用但如果是直接引用了全局状态里的数据就稳妥一点先拷贝再转。还有一个常见的边界情况某个节点的parentId指向了一个不在列表中的 id。这时候它没有对应的父节点会被当成顶层节点处理。如果业务上这类数据属于脏数据你可以在函数里加一个警告输出方便排查if (parent) { parent[childrenKey].push(item); } else { if (item[parentKey] ! null item[parentKey] ! undefined item[parentKey] ! ) { console.warn([listToTree] 找不到父节点${item[idKey]} - ${item[parentKey]}); } roots.push(item); }3.2 treeFind短路返回的节点查找在树里找某个节点最直接的思路是递归 find语义命中立即返回不再继续遍历。这是性能上的关键优化避免白白遍历整棵树。function treeFind(tree, predicate, options {}) { const { childrenKey children } options; for (const node of tree) { if (predicate(node)) return node; const children node[childrenKey]; if (Array.isArray(children) children.length 0) { const found treeFind(children, predicate, options); if (found) return found; } } return null; }用法很直观const target treeFind(treeData, (node) node.id menu-3-2);这里传入的是predicate函数而不是写死“按 id 找”是因为业务里的条件可能五花八门按 code 找、按 path 找、按自定义字段找。函数式设计能让这个工具无限复用。递归深度的风险要留意。如果树的层级特别深比如几千层正常业务不会但手动构造数据时可能递归会爆栈。一般后台管理系统树深度在 10 层以内可以不在乎但如果你在做文件系统类的工具建议改成显式的栈遍历function treeFindIterative(tree, predicate, options {}) { const { childrenKey children } options; const stack [...tree]; while (stack.length 0) { const node stack.pop(); if (predicate(node)) return node; const children node[childrenKey]; if (Array.isArray(children) children.length 0) { stack.push(...children); } } return null; }两个版本我都在工具库里保留默认用递归版因为可读性好遇到极端场景再切迭代版。3.3 treeFilter保留路径的节点过滤树查找是“找一个”树过滤是“找一批”——而且过滤后的结果必须还保持树形结构。比如搜索菜单时一个父节点命中关键字它的子孙不该全丢反过来父节点没命中但子节点命中了父节点要作为路径保留下来否则子节点在树上就“悬空”了。function treeFilter(tree, predicate, options {}) { const { childrenKey children } options; const result []; for (const node of tree) { const selfMatched predicate(node); let children []; if (Array.isArray(node[childrenKey]) node[childrenKey].length 0) { children treeFilter(node[childrenKey], predicate, options); } if (selfMatched || children.length 0) { result.push({ ...node, [childrenKey]: children, }); } } return result; }核心逻辑就一条当前节点留下来当且仅当它自己命中了条件或者它的过滤后子节点列表不为空。这保证了“路径存在性”——父节点即使不匹配也会被保留为结构骨架只是它的枝桠被修剪掉了。这里返回的是新节点对象不会改动原树所以你可以安全地把过滤结果再交给treeFind或渲染层。如果项目里状态管理要求不可变数据这个设计能省很多麻烦。和listToTree配合时有一个细节值得提如果树是listToTree从扁平列表生成的每个节点必然带一个children数组但如果你拿到的树结构是后端直接给好的有些叶子节点可能没有children字段甚至为undefined。所以三个函数里我都用Array.isArray(...)做了防御避免在undefined上调用.length报错。4. 日常打磨脱敏、布尔解析与文件大小格式化的细节这一节讲的函数都不大每一个单独拎出来都写不了多少行但恰恰是这种小函数最容易写出隐藏 bug。它们在我的项目里迭代了好几轮下面是最稳定的版本。4.1 maskText参数驱动的通用脱敏脱敏是合规需求催生的高频场景管理后台的用户列表要展示手机号、邮箱但不能完整暴露日志里要打印身份证号又不能全量落盘。最朴素的写法是各自写正则但一旦规则变化就要改多个地方。我更倾向于做一个通用maskText用参数控制保留头尾的字符个数。function maskText(text, options {}) { const { start 1, end 1, maskChar * } options; const str String(text ?? ); if (str.length start end) { return str; } const head str.slice(0, start); const tail str.slice(-end); const maskedCount str.length - start - end; const masked maskChar.repeat(maskedCount); return ${head}${masked}${tail}; }几种实际用法maskText(13812345678, { start: 3, end: 4 }); // 138****5678 maskText(张三, { start: 1, end: 0 }); // 张* maskText(610102199001011234, { start: 6, end: 4 }); // 610102********1234设计上要注意options的默认值处理。如果直接写{ start 1, end 1, maskChar * }作为默认参数调用方传入{}时能正常工作但传入null或undefined就会炸。所以参数签名写成options {}在函数体内部再做解构默认值这个顺序不能颠倒。边界情况也处理过字符串比要保留的头尾还短时直接原样返回不做脱敏。这是合理的行为——一个 3 位数的字段你要求保留 2 位头尾中间只剩 0 位再脱敏就显得很奇怪不如不动。如果需求带到了邮箱我会单独写一个专用函数而不是硬套maskText因为邮箱脱敏规则是“保留第一个字符 打码 保留域名后缀”。function maskEmail(email) { const parts String(email ?? ).split(); if (parts.length ! 2) return String(email ?? ); const [name, domain] parts; if (name.length 1) return ${name}***${domain}; return ${name[0]}${*.repeat(Math.min(name.length - 1, 3))}${domain}; }4.2 toBoolean让字符串 false 真的变成 false很多低代码平台和配置中心把布尔值以字符串形式下发比如true和false。如果直接Boolean(false)得到的是true——因为非空字符串在 JS 里本来就是真值。这个坑我见过不止一次后果五花八门最典型的是接口返回false前端判断为真把本不该展示的按钮展示出来了。function toBoolean(value, defaultValue false) { if (typeof value boolean) return value; if (value null || value undefined) { return defaultValue; } if (typeof value string) { const normalized value.trim().toLowerCase(); const trueValues [true, 1, yes, on, y]; const falseValues [false, 0, no, off, n, ]; if (trueValues.includes(normalized)) return true; if (falseValues.includes(normalized)) return false; return defaultValue; } if (typeof value number) { if (value 1) return true; if (value 0) return false; return defaultValue; } return defaultValue; }设计原则是“宁缺毋滥”除了明确的白名单值其他输入一律落到默认值上而不是交给隐式类型转换去猜。被归为false也要符合多数表单场景的直觉用户没填 - 没开。顺带一提这个函数对接口返回不可信数据的场景非常有用。你可以在 axios 响应拦截器里对某些已知字段做一次清洗或者在使用时显式包装。重点是你再也不用写value true这种散落各处的判断了。4.3 formatFileSize按 1024 进制而不是 1000文件大小格式化是上传组件、文件管理列表的刚需。最常见的 bug 是用bytes / 1000去计算 KB这样算出来的“容量”和操作系统、后端返回的数值对不上。文件系统用的是二进制单位1 KB 等于 1024 字节所以必须按 1024 进制去切。function formatFileSize(bytes, fractionDigits 2) { if (!Number.isFinite(bytes) || bytes 0) return 0 B; if (bytes 0) return 0 B; const units [B, KB, MB, GB, TB, PB]; const exponent Math.min( Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1 ); const size bytes / Math.pow(1024, exponent); return ${size.toFixed(fractionDigits)} ${units[exponent]}; }对数运算求出指数避免用while循环反复除以 1024代码更简洁。Math.min兜底防止超大数值比如Number.MAX_SAFE_INTEGER把 exponent 推到 units 数组外面。调用效果formatFileSize(0); // 0 B formatFileSize(1024); // 1.00 KB formatFileSize(1536); // 1.50 KB formatFileSize(1048576); // 1.00 MB两个细节可以说说。第一fractionDigits默认 2 但可以被调用方覆盖列表页想少占宽度就传 1上传详情页想精确一点就传 2。第二小于 1 KB 时 exponent 为 0输出的是xx B不会出现0.00 KB这种反直觉结果。5. 这些工具函数在项目里怎么组织最后说说落地层面的事。工具函数最忌讳的是“失踪”——散落在各个业务组件里的同名工具函数改了一个忘了另一个最后行为完全不一致。我在团队里推广的做法是建一个utils目录每个功能模块拆一个文件统一出口。src/utils/ deepClone.ts url.ts tree.ts format.ts boolean.ts index.tsindex.ts里统一导出业务代码只需要从/utils引import { deepClone, listToTree, treeFind, treeFilter, getUrlParams, formatFileSize } from /utils;每个文件顶部写清适用场景、不适用场景再附两三个使用示例。这不是文档负担而是给半年后的自己省时间。工具函数往往逻辑简洁、单测好写建议把上面这些函数相关的边界用例都沉淀成单测尤其是我在文中提到的那些“反直觉输入”——空字符串、非法编码、循环引用、null与undefined。这些才是工具函数最容易翻车的地方。我个人在实际项目里还有一个收尾技巧每新写一个工具函数先想清楚它能不能再拆小。如果一个函数里同时做了格式化和业务判断通常说明拆分粒度不对。真正好用的工具函数往往是那种“你一眼就知道它要干什么”的函数。