前端工具函数进阶:树形处理、格式化、校验与浏览器辅助

发布时间:2026/10/10 15:20:04
前端工具函数进阶:树形处理、格式化、校验与浏览器辅助
1. 这个系列第四篇我为什么选这四个方向从第一期写到现在这个系列一直在做一件事把前端日常开发里那些高频、能复用、又容易被写坏的函数单独拎出来讲透。前三期聊过字符串与类型判断、数组与对象处理、日期与本地存储到了第四期我决定换一个切入点不再按数据类型去分类而是按真实业务场景去选函数。这一期集中聊四类树形结构处理、展示格式化、数据校验与转换、浏览器环境与交互辅助。这些函数几乎在每个后台管理系统、电商项目、可视化平台里都会遇到但很多开发者每次都是现写现扔写出来的版本要么性能不好要么边界没考虑全。这篇内容适合两类读者一类是刚工作一两年想把业务代码写得更干净的前端开发者另一类是已经在写工具函数但想知道别人怎么处理边界条件、怎么设计参数、怎么避免埋坑的进阶开发者。代码我会给完整实现同时会把每个函数背后的“为什么”拆开讲而不是只丢一段能跑的代码。毕竟工具函数这种东西真正值钱的不是那几行逻辑而是你对边界情况的判断。1.1 这一期的内容定位树形结构处理主要解决中后台项目里的菜单、部门、分类、权限这类嵌套数据展示格式化解决数字、文件大小、时间、敏感信息在界面上如何展示更友好数据校验与转换解决接口返回的数据不可信、字段类型不稳定、字符串和布尔值相互搞混的问题浏览器环境与交互辅助则解决设备识别、剪贴板复制、URL 参数读取这类跟运行环境强相关的小需求。这四个方向有一个共同特点它们不是某个特定框架的 API而是横跨 React、Vue、小程序、甚至纯 Node 脚本都能用的纯函数。所以这篇文章里的代码你直接复制到项目的 utils 目录稍微改改命名风格就能用。我也会尽量避免让某个函数过度依赖另一个函数方便你按需摘取。1.2 阅读之前你需要具备什么基础说实话这些函数本身不依赖任何高深的前端知识你只要熟悉基本的数组方法map、forEach、reduce、递归调用、正则表达式就能看懂。难点在于几个容易忽略的细节引用类型会互相影响、递归深度可能导致调用栈溢出、浏览器 API 的兼容性差异、字符串和布尔值之间的隐式转换。这些坑我在每个小节里都会专门标出来希望大家看完之后不只是拿到代码而是以后自己写工具函数时能条件反射般地去想输入为空怎么办输入类型不对怎么办改了原对象会不会影响别的地方2. 树形结构处理列表转树、查找、路径回溯树形结构是后台项目里最常出现的数据形态。很多接口返回的并不是已经嵌套好的树而是一份扁平的列表每项带着id和parentId需要前端自己组装成树。这个需求看起来简单实际操作中却有不少细节最典型的就是父节点出现在子节点之后、环状引用、孤儿节点这些情况。处理得好后面所有基于树的逻辑都会省心很多。2.1 扁平列表转树的实现Map中继比递归更省心先给出一版我平时最常用的实现function listToTree(list, options {}) { const { idKey id, parentKey parentId, childrenKey children, rootPid 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) { parent[childrenKey].push(current); } else { roots.push(current); } }); return roots; }为什么推荐用 Map 而不是递归因为这里完全不需要递归。第一遍遍历先把所有节点放进 Map完成“每个 id 对应一个节点”的登记第二遍遍历只需要拿着每一项的parentId去 Map 里找父节点找到就挂上去找不到就按根节点处理。整个过程是 O(n) 的代码也更线性比每次查找父节点都要遍历一次原始数组的写法性能好很多。这里面有两个容易踩的坑。第一如果直接把item放进 Map后面把当前节点 push 进parent.children时实际上是通过引用修改了 Map 里那个对象这个行为是符合预期的但也意味着你改 tree 里的节点原始 list 里的对应对象不会被影响吗不一定因为{ ...item }做了一层浅拷贝所以第一层属性被拷贝了但如果 item 里面还有对象类型的属性那依然是共享引用。第二如果列表里存在“父节点 id 存在但父节点本身没出现”的情况这段代码会静默地把它当作根节点有时候这会掩盖接口数据的异常。更稳妥的做法是可以加一个配置项比如strictRoot开启后把这类节点单独收集到errors里返回。2.2 深度优先树查找把判断条件留给调用方树查找函数最需要斟酌的是 API 设计。你写一个treeFind不可能预先知道使用者要按 id 找、还是按 name 找所以最好的做法是让调用方传入一个 predicate 函数function findTreeNode(tree, predicate, options {}) { const { childrenKey children } options; if (!Array.isArray(tree)) return null; for (let i 0; i tree.length; i) { if (predicate(tree[i])) return tree[i]; const found findTreeNode(tree[i][childrenKey] || [], predicate, options); if (found) return found; } return null; }这种设计的好处是判断逻辑完全外置函数本身只负责遍历不关心你拿节点去做什么。使用上非常自然const target findTreeNode(tree, (node) node.id targetId); const adminNode findTreeNode(tree, (node) node.code admin);有一点需要说明默认实现里我在进入子节点之前先判断当前节点也就是前序遍历。如果你需要先处理子节点再处理当前节点把两个 if 的顺序换一下就行。递归在处理很深很深的树时有可能碰到调用栈上限但因为前端页面里树的层级一般不会特别夸张这个问题大多数时候遇不到。真遇到极深结构可以改成显式栈迭代不过那会明显增加代码复杂度如果没有真实性能瓶颈不建议一开始就这么干。2.3 叶子节点回溯根路径面包屑和权限匹配都用得上有时候你需要的不是找到那个节点而是找到从根到它的整条路径。比如面包屑导航比如目录树里默认展开当前选中项的父级比如要根据某个子节点反推出它的祖先链。function getTreePath(tree, predicate, options {}) { const { childrenKey children } options; const path []; const walk (nodes) { for (const node of nodes || []) { path.push(node); if (predicate(node)) return true; if (walk(node[childrenKey] || [])) return true; path.pop(); } return false; }; walk(tree); return path; }注意 path 里存的是节点引用不是拷贝。当你找到目标节点后函数返回 true递归一路冒泡返回那些path.pop()就不会执行所以最终 path 里就是一条完整链路。使用也很简单const path getTreePath(tree, (node) node.id 42); // path [根节点, 中间节点, targetNode]这里有一个真实踩过的坑一套树数据里如果存在多个名称相同但是 id 不同的节点用name去匹配路径可能拿到完全错误的链路。所以路径匹配一定要优先用 id、code 这类唯一字段。如果真要用 name 匹配函数设计上最好支持返回所有匹配路径或者至少让你传入的 predicate 内部做更严格的判断。2.4 树形数据拉平导出保留路径字段方便表格展示除了列表转树很多时候也需要把树转回列表。比如你在一个层级结构中勾选了一批节点要导出成表格或者要传给后端做批量操作后端并不想收嵌套结构而是希望看到扁平数据每项自带一个“路径”字段方便定位。function treeToFlatList(tree, options {}) { const { childrenKey children, pathKey _path, pathLabelKey name, joinWith / , } options; const result []; const walk (nodes, ancestors []) { for (const node of nodes || []) { const currentPath [...ancestors, node]; const pathText currentPath .map((n) n[pathLabelKey] ?? n.id) .join(joinWith); result.push({ ...node, [childrenKey]: undefined, [pathKey]: pathText, }); walk(node[childrenKey] || [], currentPath); } }; walk(tree); return result; }注意我在拉平的时候把children置成 undefined而不是直接删除。这样拿到表格里做展示比较干净同时不会影响你继续用原来的引用。如果你不希望输出里出现这个键可以再用delete或者对象解构的方式剔除。函数里做了一个默认行为路径文字优先取name字段如果节点没有 name就用 id 兜底这样即使不同模块的数据结构不一致也不至于报错。3. 展示格式化数字、文件大小、相对时间与脱敏做前端的人应该都经历过这种需求后端返回一个1234567页面上一句话不说直接展示成“1234567”用户看着累产品也会不满意后端返回一个2342423423字节的文件大小如果直接展示成多少字节用户根本看不懂。格式化函数就是干这个的把底层数据转换成人类友好、展示规范的文本。3.1 数字千分位正则和 toFixed 的分工最直接的实现思路是使用 JavaScript 内置的toLocaleString但它在不同运行环境下的表现并不完全一致而且如果你需要固定保留几位小数行为更不容易控制。我平时更倾向于自己处理function formatNumberWithCommas(value, digits 0) { const num Number(value); if (!Number.isFinite(num)) return ; const fixed num.toFixed(digits); const [intPart, decimalPart] fixed.split(.); const withCommas intPart.replace(/\B(?(\d{3})(?!\d))/g, ,); return decimalPart ? ${withCommas}.${decimalPart} : withCommas; }正则/\B(?(\d{3})(?!\d))/g的含义是在“非单词边界”的位置上如果后面跟着一组或多组三位数字并且这些三位数字后面不再是数字就插入逗号。简单说就是每隔三位插一个分隔符。先toFixed(digits)再处理可以避免直接对浮点数做正则时出现1234.5678这种小数部分也被误判的情况。调用方式formatNumberWithCommas(1234567.891, 2); // 1,234,567.89 formatNumberWithCommas(-1234567); // -1,234,567 formatNumberWithCommas(abc, 2); // 一个隐藏的坑是toFixed的舍入规则。比如(1.005).toFixed(2)在多数浏览器里得到的是1.00而不是1.01。这是浮点数精度导致的不是你代码写错。如果财务场景对精度很敏感建议直接用整数分单位计算比如金额用“分”存格式化时再换算能少踩很多坑。3.2 文件大小1024 还是 1000要说清楚文件大小格式化看起来简单其实有个单位标准问题。存储行业习惯用 1024硬盘厂商习惯用 1000前端做展示一般跟随后端或者产品规范。我默认使用 1024。function formatFileSize(bytes, decimals 2) { if (!Number.isFinite(bytes) || bytes 0) return ; if (bytes 0) return 0 B; const units [B, KB, MB, GB, TB, PB]; const index Math.min( Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1 ); const value bytes / Math.pow(1024, index); return ${value.toFixed(decimals)} ${units[index]}; }用对数求单位索引是常见的做法Math.log(bytes) / Math.log(1024)得到的整数部分就是数量级。Math.min的作用是防止 bytes 大到超出单位数组长度虽然实际场景里很难遇到 PB 级别的数据但边界还是要守住。这里要注意 decimals 的取值。很多上传组件喜欢用2但如果你把0.1 KB显示成0.10 KB用户反而会觉得奇怪。我自己的习惯是当值小于 1 时最多保留 1 位小数当值大于 100 时直接保留整数位。当然这属于产品偏好不是函数本身必须做的事。3.3 相对时间不仅仅是个“刚刚”社交类产品里经常用到“刚刚”“5 分钟前”“3 小时前”这种相对时间。这个函数的核心是计算时间差然后按阈值分段function formatRelativeTime(dateInput, now Date.now()) { const date new Date(dateInput); if (Number.isNaN(date.getTime())) return ; const diff date.getTime() - now; const abs Math.abs(diff); const minute 60 * 1000; const hour 60 * minute; const day 24 * hour; if (abs minute) return 刚刚; if (abs hour) { return diff 0 ? ${Math.floor(diff / minute)}分钟后 : ${Math.floor(abs / minute)}分钟前; } if (abs day) { return diff 0 ? ${Math.floor(diff / hour)}小时后 : ${Math.floor(abs / hour)}小时前; } const month 30 * day; if (abs month) { return diff 0 ? ${Math.floor(diff / day)}天后 : ${Math.floor(abs / day)}天前; } const year 12 * month; if (abs year) { return diff 0 ? ${Math.floor(diff / month)}个月后 : ${Math.floor(abs / month)}个月前; } return diff 0 ? ${Math.floor(diff / year)}年后 : ${Math.floor(abs / year)}年前; }我特意把未来时间也处理了因为有些场景会有定时发布、预约功能你不希望看到“-5 分钟前”这种文字。threshold 的取值其实也可以按产品需求调整比如有些产品要求 24 小时内按小时显示超过 24 小时显示具体日期而另外一些产品则更极端超过 7 天就显示YYYY-MM-DD。函数是基础逻辑最终怎么组合取决于业务。3.4 敏感信息脱敏展示归展示数据归数据脱敏函数和上面的格式化函数不太一样它不仅要考虑展示效果还得考虑数据合规。手机号、邮箱、身份证这类信息不能把完整明文直接打到界面上。function maskPhone(phone) { return String(phone).replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); } function maskEmail(email) { const parts String(email).split(); if (parts.length ! 2 || !parts[1]) return String(email); const [name, domain] parts; const prefix name.length 3 ? ${name[0]}*** : ${name.slice(0, 3)}***; return ${prefix}${domain}; }手机号脱敏比较简单前三位后四位保留中间四位用星号代替。邮箱脱敏需要考虑用户名长度如果用户名只有两个字符截断以后可能变成空串那就没有意义了所以我对短用户名做特殊处理。需要提醒的是脱敏只是展示层的处理数据请求和存储层面该加密的还是要加密前端脱敏更多是“少在界面上暴露完整信息”而不是安全防护的全部。4. 数据校验与转换把不确定的输入变成确定的结果接口返回的数据经常比我们想象的更“脏”。字段可能没有返回类型可能与文档不一致甚至undefined、null、空字符串混在一起。如果不做任何校验和转换直接渲染或赋值页面很容易出现异常。这一节集中处理校验和转换。4.1 手机号、邮箱、身份证号先简单再严格很多人写校验喜欢直接上一大段复杂的正则结果不仅难读还容易误杀。我的建议是先满足业务主要场景再逐步收紧。比如手机号校验国内现在主要就是1开头第二位是 3 到 9后面跟着 9 位数字一个简单的正则就够了function isMobile(phone) { return /^1[3-9]\d{9}$/.test(String(phone)); } function isEmail(email) { return /^[^\s][^\s]\.[^\s]$/.test(String(email)); } function isIdCard(idCard) { const normalized String(idCard).toUpperCase(); return /^\d{15}$/.test(normalized) || /^\d{17}[\dX]$/.test(normalized); }这些正则都不是银弹。比如邮箱的正则只判断“有没有 、 前后非空、有没有点”它不会去验证域名是否真实存在身份证正则只是判断格式是否符合 15 位或 18 位的形状并不做校验码验证。如果你的业务要求非常严格比如注册流程、银行开户那必须在后端做完整校验前端校验只是提升用户体验。4.2 深层取值 safeGet让可选链上保险可选链?.确实让深层取值变好写了但它不能完全替代工具函数。比如你从接口拿到一个嵌套对象中间某层可能是字符串也可能是 undefined你用obj?.a?.b?.c也要写很长一串。而且有些场景下你要取多层数据或者路径来自配置这时候一个safeGet更顺手function safeGet(obj, path, defaultValue) { if (obj null) return defaultValue; const keys Array.isArray(path) ? path : String(path) .split(.) .filter((key) key ! ); let current obj; for (const key of keys) { if (current null) return defaultValue; current current[key]; } return current undefined ? defaultValue : current; }这个函数同时支持两种路径写法safeGet({ a: { b: { c: 1 } } }, a.b.c); // 1 safeGet({ a: [{ b: 1 }] }, [a, 0, b]); // 1 safeGet({ a: null }, a.b.c, fallback); // fallback需要注意的是如果路径中间某层的值是null函数会直接返回默认值不会继续往下取。这是符合直觉的因为null本来就没有属性。另外路径字符串里的空字符我已经提前过滤掉了避免出现a..b这种写法时踩到意外的坑。这个函数和可选链不是互斥关系我的习惯是代码里只有一两个层级时用可选链层级多、路径动态生成时用safeGet。4.3 parseBoolean字符串 false 不是 false这是一个非常经典的 JavaScript 坑。很多人从接口拿到一个字符串false直接放进Boolean(false)得到的结果竟然是true因为非空字符串会被转成布尔值true。所以数据清洗时必须有一个明确规则function parseBoolean(value) { if (typeof value boolean) return value; if (typeof value number) return value ! 0; if (typeof value string) { const normalized value.trim().toLowerCase(); if ([true, 1, yes, y, on, 是].includes(normalized)) { return true; } if ([false, 0, no, n, off, 否].includes(normalized)) { return false; } } return Boolean(value); }这个函数的默认兜底行为是Boolean(value)。也就是说空字符串、null、undefined、0、NaN最终都会变成false其他值变成true。但如果你业务上希望“空字符串表示默认开启”那就在调用之前先单独判断空字符串不要试图让一个工具函数覆盖所有业务语义。还有一个细节函数内部对字符串做了trim()和toLowerCase()这样用户在表单里输入 TRUE 也能被正确识别成true。5. 浏览器环境与交互辅助UA判断、复制、URL解析剩下这组函数跟浏览器环境绑定得比较紧。它们在一些老项目里很有用但同时又都有各自的限制。我会把限制一起写出来免得你上线以后才发现某个函数在某些环境里不好使。5.1 设备与平台判断UA判断可以但别迷信判断设备是移动端还是 PC最朴素的方式就是读navigator.userAgent。虽然这个方法有很多瑕疵但在多数业务场景里尤其是纯前端展示层面它已经够用了function getDeviceInfo(ua navigator.userAgent) { const isAndroid /Android/i.test(ua); const isIOS /iPhone|iPad|iPod/i.test(ua); const isWeChat /MicroMessenger/i.test(ua); let platform other; if (isIOS) platform ios; if (isAndroid) platform android; return { platform, isAndroid, isIOS, isWeChat, }; }实际使用中最常见的两点问题一是在 iPad 上新系统很多设备会伪装成 Macintosh所以isIOS可能判断不到需要额外检测navigator.platform MacIntel navigator.maxTouchPoints 1二是用户代理可以被修改所以这套逻辑只能用来做前端展示适配不能用来做任何有安全要求的判定。真要做权限控制、风险识别必须结合后端能力。5.2 复制到剪贴板剪贴板API与兼容方案复制功能看起来简单实际写起来会碰到浏览器安全策略问题。现代浏览器推荐用异步剪贴板 API但它要求页面必须在安全上下文中而且必须由用户主动触发调用这里有严格的限制。所以兼容方案一般都会把execCommand(copy)作为降级路径async function copyText(text) { if (navigator.clipboard window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.opacity 0; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { document.execCommand(copy); } finally { document.body.removeChild(textarea); } }用 textarea 而不是 div 或者 input是为了保证select()之后能执行 copy 命令而且 textarea 不会把字符串里的换行符吞掉。样式上必须设置position: fixed和opacity: 0不能直接display: none因为隐藏元素无法被选中。这个函数是异步的调用方最好用await copyText(...)并捕获异常这样复制失败时能给出提示而不是用户点了没反应。5.3 URL 参数解析重复参数、中文编码、hash 都要处理URL 参数解析有现成的URLSearchParams但它不是所有场景都方便。比如某些老版本浏览器或者你需要把整个 URL 字符串作为输入而不是当前地址。自己封装一个兼容性更强的版本也不难function getQueryParams(url window.location.href) { let search url.includes(?) ? url.split(?)[1] : ; search search.split(#)[0]; const params {}; if (!search) return params; search.split().forEach((pair) { if (!pair) return; const [rawKey, rawValue ] pair.split(); let key; let value; try { key decodeURIComponent(rawKey.replace(/\/g, )); value decodeURIComponent(rawValue.replace(/\/g, )); } catch (error) { key rawKey; value rawValue; } if (Object.prototype.hasOwnProperty.call(params, key)) { const old params[key]; params[key] Array.isArray(old) ? [...old, value] : [old, value]; } else { params[key] value; } }); return params; }这里我处理了几个容易出错的地方第一先把#后面的内容截掉否则 hash 值会被混进 query 参数第二号在 query 里表示空格不能直接用解码函数处理第三遇到重复 key 时转成数组避免丢失数据第四如果参数本身不是合法编码比如用户手动输入了%decodeURIComponent会抛异常这时候我选择保留原始字符串不让整个解析崩掉。6. 一个串联示例从接口数据到页面回显前面五节都是独立函数但实际业务里它们往往是配合使用的。我拿一个相对完整的中后台页面场景举例把这些函数串起来演示一遍。6.1 场景串起来假设某后台管理系统有一个“部门文件管理”页面。接口返回的是一个扁平的部门列表需要先转成树当前路由 id 传过来之后需要在树里找到对应部门并生成面包屑同时列表里每条记录有一个文件大小字段单位是字节需要格式化成人类友好文本。// 1. 接口拿原始数据 const originList await fetchDepartmentList(); // originList: [{ id: 1, parentId: null, name: 总公司 }, ...] // 2. 扁平列表转树 const tree listToTree(originList); // 3. 根据路由 id 找到当前部门 const currentDepartment findTreeNode( tree, (node) node.id routeQuery.departmentId ); // 4. 获取当前部门从根到自身的路径 const path getTreePath( tree, (node) node.id routeQuery.departmentId ); // 5. 渲染面包屑 renderBreadcrumb(path.map((node) node.name)); // 6. 格式化展示大小 renderFileSize( formatFileSize(currentDepartment.fileSizeBytes, 1) );整个串联过程没有引入框架你可以在 React 的useEffect里写也可以在 Vue 的onMounted里写。核心思路是接口数据先经过“结构化”函数变成页面需要的形态再经过“格式化”函数变成用户看得懂的展示文本最后渲染。这里最值得养成的习惯是不要把数据处理逻辑直接堆在组件里而是放进工具函数通过返回值驱动页面更新这样组件里只剩“取数、调用、渲染”逻辑清晰测试也容易写。6.2 常见问题与排查速查表我把自己在实际项目中遇到过的、还有朋友问过的一些典型问题整理成了表格方便大家遇到类似情况时快速定位。现象可能原因处理建议列表转树后出现重复节点原始数据里存在重复 id塞进 Map 前先做 id 去重或对重复 id 打日志修改树节点导致原数据变化浅拷贝导致对象内部引用共享根据需求决定是否使用深拷贝或用不可变数据流千分位格式化后小数位不对浮点数精度 toFixed 舍入规则对精度要求高的场景改用整数计算文件大小显示为负数或空传入 NaN、字符串、负数函数入口统一做 Number.isFinite 校验getQueryParams 解析中文乱码缺少 decodeURIComponent 处理注意先替换再解码复制按钮在部分浏览器无反应非安全上下文或未用异步 API走 execCommand 降级路径并提示用户手动复制设备判断在 iPad 上不准新系统 UA 伪装成 Mac增加 maxTouchPoints 辅助判断6.3 我的几个实操建议工具函数写多了我最大的体会就是很多坑不是函数主体逻辑难而是边界情况没想清楚。写任何工具函数之前先在脑子里把这几类输入过一遍undefined、null、空字符串、对象、数组、特殊字符、超长字符串。别嫌麻烦边界条件往往占了工具函数代码量的一半以上但也是真正体现价值的地方。另外工具函数尽量不要彼此强依赖。你可能会觉得listToTree内部如果直接调用treeToFlatList能省几行代码但一旦未来某个函数改动了结构牵一发动全身。宁可让每个函数独立自包含也不要为了 DRY 把函数之间耦合得很深。最后如果项目里有测试环境建议给这些函数顺手写上单元测试特别是树结构、格式化、校验这三类函数。它们输入输出都很确定是最适合做单测的代码成本不高收益却很直接。