前端利器:深拷贝、URL参数解析与树形操作工具函数实战
2026/9/9 11:54:01 网站建设 项目流程

这个系列写到第六篇了。前面几篇聊数组去重、类型判断、防抖节流的时候,不少读者留言说“太基础了”。这篇我特意往里塞了点稍微进阶的内容:深拷贝的工业级实现、URL 参数的解析与拼接、树形结构的三板斧,还有几个平时看着小但很影响体验的格式化函数。这些工具函数我在真实项目中几乎每周都会用到,面试也经常被问到,尤其是深拷贝和列表转树。如果你是刚工作一两年的前端,或者正在准备跳槽面试,这篇值得完整看一遍。老规矩,所有代码我都会贴出完整版本,尽量做到拿来就能用,但更重要的是理解它为什么这么写。

1. 深拷贝:从 JSON.parse 到结构化克隆

深拷贝大概是前端面试里被问得最频繁的手写题,没有之一。但很多人的认知还停留在“深拷贝就是JSON.parse(JSON.stringify(obj))”,这个回答在简单场景下能混过去,一旦碰到 Date、Map、循环引用,瞬间就是个雷。我们先看看它到底错在哪。

1.1 JSON.parse(JSON.stringify()) 的三大盲区

先说结论:这个写法本质上是先把对象序列化成 JSON 字符串,再解析回新对象,所以凡是 JSON 表达不了的东西,都会出问题。

第一类问题是类型丢失。Date对象会被转成 ISO 字符串,undefined、函数、Symbol 类型的属性会被直接干掉,NaNInfinity会变成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 之后都能直接用,它才是真正的结构化克隆实现,性能一流,支持循环引用、DateRegExpMapSetArrayBuffer,代码还极简:

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('&'); }

过滤掉undefinednull是刻意的。业务里经常有“只传有值的字段”这种诉求,比如搜索条件对象里用户没填的项,不应该拼进 URL 里污染参数。如果你反过来希望保留空字段,把 filter 删掉即可。

拼接值的时候注意一个细节:encodeURIComponent传数字、布尔值也会正常工作,因为函数内部会先做ToString。所以buildQuery({ page: 1, active: true })能得到page=1&active=true,无需提前转字符串。

如果参数值是一个数组,比如多选筛选条件tagId: ['1','2'],直接传进来会变成tagId=1%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 而不是普通对象?普通对象的键只能存字符串或 Symbol,id是数字 1 时,obj[1]实际会变成obj['1'],取数时没问题,但遇到原型链上的名字比如toStringconstructor,就有被污染的风险。Map 的键可以是任意类型,且天然不存在原型链问题,代码也更语义化。

注意这里每一项做的是浅拷贝{ ...item, [childrenKey]: [] },所以生成的树节点和原数组里的对象共享了那层展开后的属性引用。如果原数组后续被修改,树里的数据也可能跟着变。这是一个副作用隐患,解决办法是先深拷贝再转树,直接用上一节的deepClone

function 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 = '*' }作为默认参数,调用方传入{}时能正常工作,但传入nullundefined就会炸。所以参数签名写成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.ts

index.ts里统一导出,业务代码只需要从@/utils引:

import { deepClone, listToTree, treeFind, treeFilter, getUrlParams, formatFileSize } from '@/utils';

每个文件顶部写清适用场景、不适用场景,再附两三个使用示例。这不是文档负担,而是给半年后的自己省时间。工具函数往往逻辑简洁、单测好写,建议把上面这些函数相关的边界用例都沉淀成单测,尤其是我在文中提到的那些“反直觉输入”——空字符串、非法编码、循环引用、nullundefined。这些才是工具函数最容易翻车的地方。

我个人在实际项目里还有一个收尾技巧:每新写一个工具函数,先想清楚它能不能再拆小。如果一个函数里同时做了格式化和业务判断,通常说明拆分粒度不对。真正好用的工具函数,往往是那种“你一眼就知道它要干什么”的函数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询