常用JavaScript代码片段沉淀:日期、数组、异步与存储实践 不知道你们有没有这种经历一个功能明明已经写过好几遍临时要用的时候还是翻老项目、查历史代码、再重新改一遍。我之前就是这样尤其是那些“常用功能代码记录”散落在各个项目里真正需要时要么找不到要么找到的是过期版本。后来我花了一个周末把日常开发里反复出现的高频片段统一整理到一个私有代码片段库做上分类、标好依赖、配上可运行示例业务开发的检索时间一下子从半小时降到了几分钟。这篇笔记不像完整组件库那样系统而是更像我自己工作台旁边的手写便签——把沉淀下来的筛选标准和几个最常用的 JavaScript 片段分享出来内容包括日期处理、数组操作、异步控制、本地存储等适合正在整理个人代码库、或者想把重复劳动真正减下来的开发者。1. 常用功能代码的沉淀思路与记录原则1.1 什么样的代码才值得存进“常用库”我发现很多人整理代码片段时容易走极端一种是只收藏不整理收藏夹几百条内容真到用的时候一条也想不起来另一种是恨不得把整个业务模块都塞进片段库结果片段之间互相耦合根本没法复用。我自己现在只保留三类代码第一类是纯工具函数输入输出明确、边界条件清晰比如日期格式化、数组去重、对象深拷贝第二类是可以参数化的业务模块比如上传组件的封装、请求队列的处理虽然业务逻辑有差异但核心流程能抽出来第三类是“配置型片段”比如构建脚本、环境变量读取规则、接口层错误码映射这类内容改动频率低但每次搭新项目都要重新查一遍非常值得固化。判断标准其实很简单如果这个功能我两次以上在不同项目里写过并且每次都是复制粘贴后改几个参数那它就够格进入常用库。如果写完就再也用不上或者和项目里的业务状态深度绑定那就别硬塞。1.2 片段入库前必须写清楚的四个要素光把代码粘进笔记里没有任何意义我吃过亏才明白记录片段时要配套写四样东西缺少任何一个三个月后大概率会看不懂。第一个是输入输出定义也就是函数接收什么参数、返回什么结构。第二是依赖环境这段代码需要什么运行环境比如某些浏览器 API、某个 NPM 包还是 Node.js 特定版本。第三是边界条件说明传空值、传超长字符串、传非法日期时会发生什么。第四是可运行示例哪怕只是一个最简单的调用演示也比十行注释值钱。这四个要素写清楚之后片段库才真正具备“可检索性”。否则你存进去的不是解决问题的工具而是一堆需要重新读一遍才能猜出意图的原始代码。1.3 版本管理与失效淘汰机制代码片段不是录一次就一劳永逸。前端框架升级、运行时 API 变化、ES 规范调整都会让旧片段从“能用”变成“带病运行”。我现在的做法是每季度花十分钟做一次清理主要检查三类内容第一类是代码块内有没有标出过期的 API 调用第二类是现有项目里还在不在用这个片段第三类是查看收藏站点的访问情况如果一段代码连续半年没有被复制过就归档掉。这个过程听起来很琐碎但恰恰是代码片段库能长期保持价值的核心。一个被淘汰的片段留在库里比没有片段更危险因为它会让你在不知情的情况下把旧问题重新带进新项目。2. 时间日期处理最容易踩坑的高频片段2.1 通用日期格式化函数时间处理是我日常见到出问题最多的类别几乎每个项目都需要日期格式化但很多人还是直接手动拼接字符串导致“YYYY-MM-DD”和“YYYY-M-D”的格式在不同模块里都不一致。这里直接给一个我改过多次的格式化函数function formatDate(date, pattern YYYY-MM-DD HH:mm:ss) { const pad (value) String(value).padStart(2, 0); const map { YYYY: date.getFullYear(), MM: pad(date.getMonth() 1), DD: pad(date.getDate()), HH: pad(date.getHours()), mm: pad(date.getMinutes()), ss: pad(date.getSeconds()) }; return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (key) map[key]); }为什么要用padStart而不是直接拼date.getMonth() 1因为月份和日期小于 10 时用户看到的应该是“2025-01-05”而不是“2025-1-5”。而YYYY必须保留四位不能裁掉。这个函数支持自定义 pattern非常适合作为统一入口。用的时候要特别注意getMonth()返回值是 0 到 11不是 1 到 12所以你必须加 1。这个低级错误在每年年初都会重新流行一遍几乎可以断言是 JavaScript 社区最高频的问题之一。我曾经在项目里看到月份一直是 0 月排查半天才发现代码里没有做加一处理。2.2 获取某月天数和季度信息日期处理里还有一个高频题目给定某年某月怎么知道这个月有多少天。很多人的第一反应是维护一个数组把 31 天的月份列出来再单独处理 2 月闰年逻辑。其实没必要那么麻烦function getDaysInMonth(year, month) { return new Date(year, month, 0).getDate(); }这个函数的原理是 JavaScript 的Date构造器允许你传入“越界”的值并且会自动进位或回退。比如new Date(2024, 2, 0)中的月份参数 2 表示 3 月而 day 传 0 会回退到上一个月的最后一天也就是 2024 年 2 月最后一天此时用getDate()就能拿到 29。这个方案天然涵括了闰年判断不需要你自己写复杂的年份条件。如果需要判断季度我常用这一个一行式function getQuarter(month) { return Math.floor(month / 3) 1; }注意这里传入的 month 是 1 到 12 的自然月不是 Date 对象里的 0-based 月份。如果从new Date().getMonth()出发必须先加 1 再丢进这个函数。这种“入参口径一致性”问题非常隐蔽我自己就因为在不同工具函数之间传值口径不一致统计报表足足错了一整个季度。2.3 时区偏移与 UTC 时间计算日期格式化处理的是“长什么样”时区处理的是“到底对应世界标准时间的哪一刻”。国内项目通常只关心本地时间但一旦涉及国际用户、海外服务器日志或者跨时区排期就很容易出问题。获取本地时区偏移量可以直接用function getTimezoneOffsetMinutes(date new Date()) { return date.getTimezoneOffset(); }这个返回值是分钟数而且要注意它的正负规则UTC 东部时区的偏移是负值UTC 西部时区是正值和我们直觉里的“东八区应为 8”完全相反。比如在中国运行时返回的是 -480。如果你把-480 / 60 -8当作“本地比 UTC 慢 8 小时”就会在时间戳转换时出现整天的误差。做一个项目时我建议所有存储和传输统一使用 UTC 时间戳或 ISO 字符串只在展示层调用格式化函数转换为本地时间。理由很简单时间戳本身没有时区概念它是绝对的而格式化之后的人类可读字符串并不带时区标记很容易被二次解析时误解。封装一个toUTCISOString的便捷函数比每次都手动处理安全得多function toUTCDateString(date) { return date.toISOString().slice(0, 10); }如果后端有单独的服务器时区设置前端显示时间和后端日志时间不一致不要自己去猜先在代码里打印new Date().toISOString()和new Date().toString()对照这两个输出判断是显示层问题还是存储层问题。3. 字符串、数组与对象的高频操作3.1 判空与字符串清理“判空”听着简单实际参与过真实项目的人都懂用户输入、接口返回参数经常携带各种不可见字符和异常格式。我比较常用的一组函数是function isEmptyString(value) { return value null || value undefined || String(value).trim() ; } function normalizeText(value) { return String(value ?? ) .replace(/\s/g, ) .trim(); }第一段里有个细节很多判断空字符串的代码只写!value这样会把数字 0 和布尔值 false 也当成空值导致业务分支走错。判断字符串是否为空必须明确这个变量预期是字符串类型如果是其他类型要显式转换。第二段清理函数将连续的换行、制表符、空格压缩成单个空格再掐头去尾。为什么用\s因为用户可能从文档里复制多行文本也可能在中文和英文之间带上全角空格一个trim()根本不够。这类清理在表单信息展示和搜索索引生成前非常常用值得作为固定工具函数保存。3.2 数组去重、分组与排序数组操作是 JavaScript 里最高频的场景之一几乎每个项目都能用上。先看几个我用得最多的片段。去重的做法有很多种但如果你是做业务开发不需要整花活function unique(arr) { return [...new Set(arr)]; } function uniqueBy(arr, key) { const seen new Set(); return arr.filter((item) { const val item[key]; if (seen.has(val)) { return false; } seen.add(val); return true; }); }第一段适合基本类型数组内部使用Set的去重语义。要注意Set对对象的比较是引用比较两个结构一模一样但不是同一个引用的对象不会被去重所以对象数组必须用uniqueBy。uniqueBy里的判断逻辑使用的是循环 filter遍历过程中维护一个已见集合复杂度为 O(n)不会因为数组变大而明显变慢。分组操作也是家常便饭。比如把订单一堆数据按照状态分组我习惯这样写function groupBy(arr, key) { return arr.reduce((result, item) { const groupKey item[key]; if (!result[groupKey]) { result[groupKey] []; } result[groupKey].push(item); return result; }, {}); }这段代码需要注意 key 是否一定存在。如果数据里某些行的groupKey是 undefined最后会多出一个 grouped by undefined 的数组。我一般在分组前先对数据做过滤或者在上面的函数中加一层默认 key 的处理。排序方面中文按拼音排序不能直接localeCompare一把梭因为不同运行环境的 ICU 支持程度不一样。我比较保守的写法是function sortByLocale(arr, key) { return [...arr].sort((a, b) String(a[key]).localeCompare(String(b[key]), zh-Hans-CN) ); }注意我用了[...arr]而不是直接arr.sort()。原因很简单Array.prototype.sort是原地排序会修改原数组。如果你在组件状态里直接 sort有可能引发意外的状态变更和重复渲染。复制一份再排序逻辑更安全。3.3 深拷贝与对象路径访问对象深拷贝几乎是面试必问也是日常最常被引用的代码块之一。我最早用的方式是JSON.parse(JSON.stringify(obj))后来在真实项目里遇到过 Date 类型被转成字符串、undefined 属性被丢弃、循环引用直接报错的问题才意识到它只能覆盖非常有限的场景。对于普通业务数据我有几个分层方案function deepClone(obj, seen new WeakMap()) { if (obj null || typeof obj ! object) { return obj; } if (seen.has(obj)) { return seen.get(obj); } const result Array.isArray(obj) ? [] : {}; seen.set(obj, result); for (const key of Object.keys(obj)) { result[key] deepClone(obj[key], seen); } return result; }这个实现的关键在于WeakMap记录已经克隆过的对象解决循环引用问题。不过它不考虑 Map、Set、Date、RegExp 等特殊对象如果业务里需要完整支持建议直接使用结构化克隆 APIstructuredClone或者成熟的工具库不必自己重复造轮子。对象路径访问也是经常用到的能力。比如拿到一个嵌套接口返回想安全地读取data.list[0].user.name多级判断写起来很累赘。我保存了一个小函数function getByPath(obj, path, defaultVal) { const parts Array.isArray(path) ? path : path.split(.); let current obj; for (const part of parts) { if (current null) { return defaultVal; } current current[part]; } return current undefined ? defaultVal : current; }路径参数既可以传字符串data.list[0].user.name也可以传数组。如果你用字符串方式arr[0]里的方括号并不会被自动解析需要自己转换成数组路径我上面的实现只支持点号路径如果路径中包含数组索引建议直接用数组形式调用。很多业务场景为什么非要封装这种函数因为接口数据结构来自第三方服务字段名随时可能变化或缺失。与其在每个页面上写满if (res res.data res.data.list)的三层判空不如统一用getByPath(res, [data, list, 0, name], )处理返回默认值保证页面不崩。4. 异步控制防抖、节流、并发与重试4.1 防抖和节流到底怎么选防抖和节流是两个高频工具函数但是很多人用反了。我自己的记忆方式是防抖是“等一等再执行”只要触发事件后的一段时间内再次被触发计时器就重新计时节流是“固定间隔最多执行一次”不管触发多频繁都按固定的时间节奏放行。代码片段如下function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; } function throttle(fn, interval 300) { let lastTime 0; return function (...args) { const now Date.now(); if (now - lastTime interval) { fn.apply(this, args); lastTime now; } }; }从代码上看防抖就是每次触发都会重置一个定时器如果连续操作一直发生函数可能一直不执行直到停顿下来才执行最后一下。节流则是用时间戳做闸门间隔期内只管放行第一次后面的全部丢弃。实际场景怎么选这里有一个我总结的速查表场景推荐原因搜索输入框自动补全防抖用户停止输入后再请求减少无用请求窗口 resize 触发重排节流希望以固定频率更新布局不要拖到最后按钮点击防止重复提交防抖快速连点时只保留最后一次符合预期滚动加载更多节流滚动过程中不断触发固定频率判断底部即可我踩过的一个坑防抖函数如果配上async回调必须手动处理返回 Promise 状态。上面的实现直接返回 undefined如果你在回调里执行await someRequest()调用方拿不到请求结果。需要 Promise 版防抖的话建议在回调内部把fn.apply(...)的结果包成 Promise 再返回并同时处理定时器取消逻辑。4.2 限制并发数量的任务队列前端并发请求控制是个容易被忽视的问题。如果一次性向接口发出几十个请求浏览器会变慢服务端也可能因为这波流量返回限流错误。这时候需要的就是一个任务队列让同一时刻最多只跑固定数量的任务。我常用的实现如下async function runWithConcurrency(tasks, limit 3) { const results []; const queue [...tasks]; const workers []; for (let i 0; i Math.min(limit, tasks.length); i) { workers.push( (async () { while (queue.length) { const task queue.shift(); results.push(await task()); } })() ); } await Promise.all(workers); return results; }这段代码的核心思想是为每个 worker 开启一个异步循环worker 数固定为 limit每个 worker 每次从任务队列头部取一个任务执行执行完继续取下一个直到队列清空。这种方式比直接Promise.all(tasks)更平滑也不会一次性抬手把资源全占满。如果任务之间需要保留原始顺序不能简单地results.push因为并发完成顺序是不确定的。我的做法是给任务带上索引在结果数组的固定下标处写入{ index, value }最后统一排序。否则并发请求返回后数据大乱排查起来非常痛苦。不过要注意这种队列只适合任务与任务之间相互独立的场景。如果某个任务依赖前一个任务的执行结果那就不是并发控制问题而是串行编排问题应该用 reduce 逐条 await或者引入更正式的任务编排工具不要硬套这个片段。4.3 带退避重试的请求封装接口偶发超时、网络抖动、偶尔 5xx 错误这些情况重试一次往往就好了。但盲目固定间隔重试会在服务端恢复阶段造成压力叠加所以我会推荐带指数退避的版本async function retryRequest(fn, maxRetries 3, baseDelay 300) { let lastError; for (let attempt 0; attempt maxRetries; attempt) { try { return await fn(); } catch (err) { lastError err; const delay baseDelay * Math.pow(2, attempt) Math.random() * 200; await new Promise((resolve) setTimeout(resolve, delay)); } } throw lastError; }指数退避的含义是第一次失败等待约 300ms第二次约 600ms第三次约 1200ms。每次加一点随机抖动是为了避免多个客户端在同一时刻发起重试造成“惊群效应”。这个设计来源于网络协议冲突避免思路在 HTTP 客户端里同样适用。实现时要斟酌两点一是重试只适合幂等请求也就是重复提交不会产生副作用。对于下单、扣款这类非幂等操作盲目重试可能造成重复扣款必须要求后端接口做幂等性处理后再允许重试。二是重试次数不是越多越好超过三次之后大概率问题已经从“偶发抖动”变成了“持续故障”继续重试只会浪费用户的等待时间。5. 本地存储与数据持久化封装5.1 localStorage 安全封装localStorage 平时看起来很好用但直接裸调其实有不少隐患。存储空间不足时setItem会抛异常隐私模式下的访问也可能被拒绝而且存储的值只能是字符串对象类型必须手动序列化。一个简单封装可以一次性规避这三个问题const safeStorage { get(key, defaultVal null) { try { const raw localStorage.getItem(key); return raw null ? defaultVal : JSON.parse(raw); } catch { return defaultVal; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } }, remove(key) { localStorage.removeItem(key); } };关键点在于把所有可能抛错的操作都包进try/catch读取失败时返回默认值写入失败时返回 false。如果你在业务代码里直接调用 localStorage一旦存储空间满了整行代码就会中断体验非常差。我自己在低端机上复现过这种问题最后发现页面白屏的原因是某个自动保存状态时抛出的 QuotaExceededError。封装里的JSON.parse也有一个隐含约束如果存的是普通字符串且不是合法 JSON解析会失败并走 catch 分支返回默认值。所以如果你确实需要存纯字符串可以在 get 里先判断 raw 是否符合 JSON 格式再做解析或者改成一个独立的方法避免互伤。5.2 给本地存储加上过期时间localStorage 本身没有过期机制但业务上经常需要“这个缓存半小时后失效”。最简单的方案是给每个 key 绑定一个时间戳function setWithExpire(key, value, ttlSeconds) { const payload { value, expireAt: Date.now() ttlSeconds * 1000 }; localStorage.setItem(key, JSON.stringify(payload)); } function getWithExpire(key, defaultVal null) { const raw localStorage.getItem(key); if (!raw) { return defaultVal; } try { const payload JSON.parse(raw); if (!payload || !payload.expireAt) { return defaultVal; } if (Date.now() payload.expireAt) { localStorage.removeItem(key); return defaultVal; } return payload.value; } catch { return defaultVal; } }这种方式和 cookie 的过期策略思路一致是把“过期时间”作为附加元数据存进去读取时再比较当前时间。比较适合做接口响应缓存、用户偏好临时记录等场景。还有一个容易被忽略的点如果你的应用有多个标签页同时运行localStorage 的写入不会自动同步到其他页面的内存状态里。要监听跨标签页数据变化应该封装storage事件监听器而不是每次手动刷新页面。这个特性在刷新缓存、登出同步、配置变更等场景下非常实用。5.3 用 IndexedDB 存大体积数据localStorage 的容量上限一般在 5MB 到 10MB 之间一旦超过这个边界就不是“封装”能解决的问题。遇到必须存储大体积结构化数据比如离线文档、音视频文件、批量导入的数据快照IndexedDB 是更合适的方案。IndexedDB 本身 API 比较繁琐上手成本高我一般只提供一个最小的 Promise 封装function openDB(dbName, storeName) { return new Promise((resolve, reject) { const request indexedDB.open(dbName, 1); request.onupgradeneeded () { const db request.result; if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } async function putObject(dbName, storeName, key, value) { const db await openDB(dbName, storeName); return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); tx.objectStore(storeName).put(value, key); tx.oncomplete () resolve(); tx.onerror () reject(tx.error); }); }这里只展示了写入读取逻辑类似。你会发现核心就是把 IndexedDB 基于事件的回调转换成 Promise调用方看起来舒服很多。如果你真的需要频繁使用它建议直接装一个成熟封装库不要重复造轮子尤其要注意旧版本浏览器对 IndexedDB 各 API 的支持差异。对于大多数中后台项目数据量其实远没到需要 IndexedDB 的程度。我自己的判断标准是存储内容超过 1MB或者单个字段超过几千行的嵌套数组才值得引入 IndexedDB否则用 localStorage 封装就够了引入额外依赖只会增加维护成本。6. 常见问题与排查技巧实录6.1 代码片段失效的常见原因我整理过的片段库里出现过问题的比例不算低。回顾这些失效案例原因高度集中大致可以归纳成三类。第一类是底层 API 行为变化。典型例子是浏览器更新后的安全策略调整比如旧版可以同步读取 cookie、新版强制要求安全上下文或者某个运行时 API 在新版本里改了参数默认值。这类问题只能靠定期回归验证去发现没有捷径。第二类是依赖包版本升级带来的破坏性变更。代码片段里写明了依赖某个库但片段本身往往不会跟着这个库的版本走。比如一个依赖旧版本工具函数行为的片段在升级到新版本后因为接口签名变化而直接报错这时候最需要的就是版本标注。第三类是复制粘贴时引入的“环境代入”。这段代码在上一个项目里依赖了特定的 polyfill 或者全局变量粘贴到新项目后缺少对应环境代码看起来没问题但运行结果就是不对。这也是我在 1.2 节里反复强调要写清依赖环境的原因。6.2 环境差异引发的隐蔽坑环境差异是代码片段从“个人笔记”变成“团队可用工具”时最难逾越的坎。时区问题最典型。同一个时间戳在不同操作系统的 Node 环境、不同浏览器的 JavaScript 引擎里得出的人类可读时间可能完全不同。排查时要做的第一件事不是盯代码而是先检查运行环境的时区设置和相关依赖是否完整。特别是 Node 容器环境默认时区常常是 UTC而不是开发机上的东八区时差八小时的问题经常把人逼疯。另一个隐蔽环境差异来自浏览器对 Unicode 和字符串处理规则的不一致。比如localeCompare的排序结果、String.prototype.normalize对特殊字符的处理在 macOS 和 Windows 上可能不同在移动端和桌面端更可能不同。尽量使用稳定可预测的操作逻辑不依赖那些“通常表现一致”的 API。调试这类问题我有一个很推荐的排查顺序先在浏览器控制台或 Node REPL 里单独执行函数确认当前环境行为再放回业务代码中运行排除调用方式问题最后检查环境版本和运行时参数。不要上来就直接改代码否则很可能绕了一大圈发现只是环境时区不对。6.3 复制粘贴型 Bug 的预防代码片段库的最大风险不是没能进入库而是被错误地复制进新项目。即使片段本身可靠使用者的每一次微小改动都可能引入问题。我的预防思路有三层。第一层是在片段里写清楚入参类型和返回值类型配合 JSDoc 可以让编辑器直接做类型提示从源头减少误用。第二层是为关键函数补充最少一个测试用例。哪怕只是几条assert也能在复制后快速跑一遍验证是否仍然成立。第三层是控制可变性像 3.2 节中的排序函数那样先复制一份再操作可以把很多意外副作用挡在外面。举个例子很多人在使用深拷贝函数时忽略循环引用使用节流函数时忽略 this 绑定使用防抖函数时忽略延迟返回值。这类问题的共性是代码看起来正常数据结果也基本正常只有极端情况才暴露。如果你能在注释里明确写出“边界条件对象中存在循环引用时会怎样”“返回值默认不返回 Promise”复制代码的人就能提前预判行为而不是等线上故障发生之后才来复盘。最后说点维护心得我现在的代码片段库并不大总共只有几十个函数但每个函数都经过真实项目反复验证。比起追求片段数量我更在意它们能不能在关键时刻一击命中问题。整理这些代码不只是为了减少打字量更深一层的作用是逼自己把一个功能从“能跑”提升到“理解它为什么这样写”这个思考过程本身价值很高。如果你刚开始建库我建议不要急着把随处可见的例子全部收进来而是从自己最近三个项目里选出真正重复过两三次的功能配上说明和依赖放进一个带检索标签的文件夹。等这个库慢慢滚起来之后再考虑要不要做成团队共享。最后分享一个小技巧给每个片段打上“输入特征”标签比如“从时间戳格式化到日期”“从嵌套对象中安全取值”这样你用自然语言搜索时命中率会远超用函数名去猜。保持这个习惯半年后你会感谢当时肯花时间整理这些代码的自己。