news 2026/10/16 14:39:48

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端工具函数进阶:树形处理、格式化、校验与浏览器辅助

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 把函数之间耦合得很深。最后,如果项目里有测试环境,建议给这些函数顺手写上单元测试,特别是树结构、格式化、校验这三类函数。它们输入输出都很确定,是最适合做单测的代码,成本不高,收益却很直接。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 0:31:56

UltraISO制作U盘启动盘完整教程:从写入模式到启动失败排查

1. 为什么一个“过时”的工具还值得写一篇完整教程如果你现在去搜“U盘启动盘制作”&#xff0c;跳出来的结果大概率是各种一键装机工具&#xff0c;界面花哨、按钮巨大、点一下就完事。但我自己经手的机器没有一千也有八百台了&#xff0c;从老旧的BIOS主板到最新的UEFI平台&a…

作者头像 李华
网站建设 2026/10/16 22:48:03

机器人租赁亿元融资背后:商业模式、成本结构与运营实战

上周末跟几个做设备租赁的老朋友吃饭&#xff0c;话题从传统的叉车、高空作业平台&#xff0c;一路聊到刚官宣没多久的“机器人租赁亿元级融资”。桌上有人半开玩笑半认真地问&#xff1a;“机器人也能租了&#xff1f;租金比请人还便宜吗&#xff1f;”我说你先别急&#xff0…

作者头像 李华
网站建设 2026/10/13 11:06:46

计算机网络课程设计:MFC Socket编程与局域网通信完整解析

简介&#xff1a;面向计算机网络课程设计的完整资料包&#xff0c;覆盖网络聊天程序、Tracert与Ping、基于IP多播的网络会议程序、TELNET终端等经典题目&#xff0c;基于MFC/C在Visual Studio 2019环境下编写&#xff0c;适合高校学生完成类似实验或课程设计时参考。压缩包共19…

作者头像 李华
网站建设 2026/10/16 11:49:20

本地模型健康检查:从加载到推理的完整验证指南

1. 从“能跑起来”到“跑得对不对”&#xff1a;本地模型运行状态的完整拆解很多朋友第一次部署本地模型时&#xff0c;都会经历这样一个阶段&#xff1a;模型下载下来了&#xff0c;加载成功了&#xff0c;对话框也能输入了&#xff0c;然后就不知道该怎么办了。问一句“你好”…

作者头像 李华
网站建设 2026/10/14 18:40:30

数据结构实验课完整代码怎么用?编译、改造与查重避坑指南

简介&#xff1a;这份压缩包是面向数据结构课程学习者的完整实验资料&#xff0c;包含全部题目、C源码与实验报告。内容覆盖四个典型实验&#xff1a;一元多项式相乘&#xff08;链表与数组操作&#xff09;、迷宫问题&#xff08;DFS/BFS搜索&#xff09;、霍夫曼编码&#xf…

作者头像 李华
网站建设 2026/10/16 15:18:52

React Router双解码路径遍历漏洞剖析与修复指南

拿到CVE-2025-61686这个编号的时候&#xff0c;我心里咯噔一下。React Router和Remix Run几乎是现在React生态里全栈开发的默认选项&#xff0c;这样一个路径遍历漏洞直接落在framework模式的静态资源处理上&#xff0c;等于在无数生产环境里埋了一颗定时炸弹。刚开始我以为是那…

作者头像 李华