两年前我接手过一个内部后台的导出功能需求很简单把列表接口里的数据导出成 Excel。第一版代码半小时写完里面全是“高效关键字”——用了Promise.all、用了map、用了链式调用跑起来也确实是毫秒级返回。结果一上生产数据量到几十万条浏览器直接白屏后端内存占用直接顶到告警线。后来我才意识到所谓“高效代码示例”绝大多数人理解成“代码写得短、跑得快”但真正的高效是另一件事在特定场景下用最少的资源把功能稳定交付。这篇内容我打算用一个完整的实战案例来拆解“快速实现功能”背后的逻辑。从一段看起来没毛病的低效实现开始逐步改成流式、分批、可测试的版本再穿插我在代码评审里经常拦下的几种“貌似高效实则低效”的写法。不管你写 JavaScript、Python 还是 Java这套思路都能直接抄走。1. 一次导出功能的血泪改造从页面白屏到百万级数据流畅导出1.1 那段“看着没什么问题”的原始实现当时的业务场景不复杂后端提供了一个分页查询接口一页最多返回 5000 条记录前端要把全部数据下载成 Excel。第一版实现长这样// 第一版看起来简洁问题藏在看不见的地方 async function exportAllData() { const allData []; let page 1; while (true) { const { list, total } await fetch(/api/list, { method: POST, body: JSON.stringify({ page, pageSize: 5000 }) }).then(r r.json()); allData.push(...list); if (allData.length total) break; page; } const rows [[姓名, 部门, 金额, 时间]]; for (const item of allData) { rows.push([item.name, item.dept, item.amount, item.time]); } const csv rows.map(r r.join(,)).join(\n); const blob new Blob([csv], { type: text/csv }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download export.csv; a.click(); }当时写这版代码的时候我还在心里夸自己用了while循环做自动翻页最后用 Blob 下载都是常规操作。代码确实能跑5000 条数据测试的时候很顺滑点击导出按钮几秒钟就下载了。问题出在“测试数据只是测试数据”。真正生产环境里这个列表接口关联了用户表、订单表、操作日志表单条数据展开后有好几十个字段总量在 60 万行左右。当我第一次用真实数据点击导出按钮Chrome 的标签页直接卡死等了十几秒后弹出了“页面无响应”的提示。1.2 压测数据出来后的三个异常特征后来我做了个简单的压测脚本模拟不同数据量下的内存占用结果非常有代表性数据量前端内存增量接口响应体大小是否卡顿1 万行约 20 MB约 5 MB无感10 万行约 180 MB约 50 MB明显卡顿60 万行超过 1 GB约 320 MB白屏崩溃三个异常特征摆在眼前内存全部集中在allData.push(...list)这一步。因为接口返回的是完整 JSON 字符串JSON.parse之后会产生一套对象结构字段名重复存储60 万行数据占的内存远大于接口响应本身。rows数组又复制了一份。从allData到rows等于所有数据在内存里存了两遍。最后join()生成 CSV 字符串时又产生了第三份大字符串。三份数据同时存在内存自然爆炸。这段代码的“原罪”不是某个语法用错了而是它假设“所有数据都可以一次性放在内存里”。这在小数据量下完全成立一旦数据量跨过某个临界点这种写法就成了定时炸弹。1.3 改造目标不是更快而是更稳我后来定下的改造目标不是“把导出速度变快”——因为瓶颈根本不在 CPU 计算而在内存和 IO。真正的目标有三个让任意时刻内存里只存在“一个批次”的数据而不是所有数据。让 Excel 写入变成流式写入边拉取边落盘而不是全部拼好再写。让代码可复现、可测试至少压测的时候能明确知道“它能扛多少数据”。带着这三个目标我开始重写。这也就是下面要展开的核心内容。2. 我判断一个代码示例是否“高效”的四个标准在讲具体改造之前我想先把“高效”这个词的定义厘清。因为这个定义错了后面所有优化方向都会跑偏。2.1 可读性别人接手时能不能在三分钟内知道在干什么我在很多项目里见过这样的“高效代码”一个函数一百多行变量名全是a、b、tmp、data逻辑里还嵌套了三层三元表达式。作者写的时候觉得很爽觉得自己一行顶别人十行。结果两周后自己都看不懂更别说交接给其他人。真正高效的示例代码应该是“三分钟可读”的一个新人接手后不需要问作者只看函数名和注释就能知道输入是什么、输出是什么、中间分几步。我后来重写导出功能时第一个要求就是函数名必须直接表达行为比如fetchPage、writeToSheet、buildRowMapper而不是getData、handleIt。2.2 复杂度到底有没有把“不该背的复杂度”背上有经验的工程师都知道一个反直觉的事实很多代码之所以慢不是因为算法不够高级而是因为它做了大量无意义的重复劳动。比如要对 10 万条数据做映射每条数据都要判断一次字段是否存在或者每条数据都要重新创建一个样式对象这些都属于“可以批量复用却被反复计算”的复杂度。高效示例一定要能区分两种复杂度业务本身必须的复杂度和代码实现额外引入的复杂度。前者不能省后者必须砍。还是拿导出举例把字段映射成表头是业务必须的但每条记录都创建一个新的单元格样式对象就是实现额外引入的完全可以只创建一次。2.3 资源边界除了时间有没有算过内存和 IO大多数程序员判断代码高不高效只看一个指标执行时间。但线上系统里内存、文件句柄、网络请求次数、数据库连接数这些资源往往比 CPU 更稀缺。一个典型的例子同样的数据量一次全量查询和一千次分页查询执行时间可能差不多但前者的数据库连接会长时间占用后者的网络开销会明显上升。真正的高效示例必须明确说清楚“我这个方案在数据规模达到多少时哪个资源会先到瓶颈”。这点在后面讲流式处理时会反复提到。2.4 可测试性高效的东西必须能被验证最后一条标准特别重要不能压测的优化都是玄学。很多人改完代码只跑一次功能测试发现功能正常就提交了从不看内存曲线也不看 GC 日志。但“高效”属性如果没有量化数据支撑就只是自我感觉良好。所以我在重写导出功能时顺手写了一个压测脚本分别测试 1 万、10 万、60 万行数据下的内存占用和执行时间把结果直接写进注释里。这样以后任何人改这段代码都知道不能把内存优化改回去。3. 实战拆解JSON 导出 Excel 的三种写法演进3.1 第一版全量加载加暴力遍历第一版就是上面那段代码它的问题可以总结成一句话“先把所有数据变成对象再变成数组再变成字符串最后变成文件”。每一步都在制造新的完整副本每一步都浪费一次完整内存空间。当时还有一个隐藏问题前端把数据全部拉下来以后如果用户切换了浏览器标签页或者点了一下刷新所有的拉取工作全部白费。这个场景下全量加载的方案不仅浪费内存还浪费用户的流量和时间。3.2 第二版分页拉取加流式写入第二版的核心思路就是抛弃“先全量后处理”的模型改成“边拉边写”。我用 Node.js 环境举例前端浏览器里也可以用 Web Streams 达到类似效果但思路完全一致const ExcelJS require(exceljs); async function exportToExcel(outputPath) { const workbook new ExcelJS.stream.xlsx.WorkbookWriter({ stream: require(fs).createWriteStream(outputPath) }); const worksheet workbook.addWorksheet(数据); // 只创建一次样式对象供所有单元格复用 const headerStyle { font: { bold: true }, alignment: { horizontal: center } }; worksheet.columns [ { header: 姓名, key: name, width: 20 }, { header: 部门, key: dept, width: 20 }, { header: 金额, key: amount, width: 15 }, { header: 时间, key: time, width: 25 } ]; // 逐页拉取并逐行写入而不是先攒着 let page 1; const pageSize 5000; while (true) { const { list, total } await fetchPage(page, pageSize); if (list.length 0) break; for (const item of list) { worksheet.addRow({ name: item.name, dept: item.dept, amount: item.amount, time: formatTime(item.time) }).commit(); // commit 表示这一行可以写入磁盘了 } page; if ((page - 1) * pageSize total) break; } await workbook.commit(); console.log(导出完成); }这版的进步是决定性的任意时刻内存里只保留pageSize条数据addRow().commit()会把单行数据交给写入流由文件流自己决定何时落盘。数据量从 60 万行降到 10 万行时内存占用几乎不变——因为内存占用只取决于单页数据量而不是总数据量。但这一版还有一个缺陷数据映射逻辑写在了循环里字段多的时候for (const item of list)那段会变得很长而且每加一个字段就要改这里。代码能跑但不够“高效示例”的标准。3.3 第三版按数据管道组织的高效示例第二版解决了内存问题但代码结构还是很“过程式”。第三版我做了两件事把数据源改成异步生成器把字段映射抽成单独的映射表。// 异步生成器每次只产生一页数据用完即弃 async function* fetchAllData(pageSize 5000) { let page 1; let hasMore true; while (hasMore) { const { list, total } await fetchPage(page, pageSize); yield* list; hasMore (page * pageSize) total; page; } } // 字段映射表字段增删只改这里不碰主流程 const ROW_MAPPER { name: 姓名, dept: 部门, amount: 金额, time: (v) formatTime(v) }; async function exportToExcel(outputPath) { const workbook new ExcelJS.stream.xlsx.WorkbookWriter({ stream: require(fs).createWriteStream(outputPath) }); const worksheet workbook.addWorksheet(数据); // ... columns 配置同第二版 for await (const item of fetchAllData()) { const row {}; for (const [key, mapper] of Object.entries(ROW_MAPPER)) { row[key] typeof mapper function ? mapper(item[key]) : item[key]; } worksheet.addRow(row).commit(); } await workbook.commit(); }第三版的最大价值不是性能而是结构。fetchAllData这个生成器把“数据从哪来”封装成了独立单元ROW_MAPPER把“字段怎么映射”独立出来主流程只剩下“生成一行写一行”这唯一动作。这种结构的可测试性也强我可以单独测试fetchAllData能否正确翻页可以单独测试ROW_MAPPER对空值、非法日期、特殊字符的处理不用启动整个导出流程。将来要加字段、改表头、换成 CSV 导出都是只改一个局部。3.4 三版对比你以为的差异和实际差异有人可能会觉得第三版和第一版相比“只是代码结构好看了点性能不是一样吗”这正是我要强调的点对比维度第一版第二版第三版内存峰值60 万行超过 1 GB约 120 MB约 120 MB支持的最大数据量受内存限制约 20 万行内勉强可用理论无上限理论无上限字段改动成本修改遍历函数主体修改循环内映射代码只改ROW_MAPPER可测试性低只能端到端测中循环内逻辑难单独测高每个环节都能独立验证性能差异集中在第一版到第二版之间内存占用直接降了一个数量级而第二版到第三版的提升集中在代码维护成本上。真正的高效方案两个维度都要抓。4. 高效示例背后容易被忽略的底层思维数据什么时候真正“存在”4.1 数组只是引用集合数据量幻觉很多程序员对“内存里现在有多少数据”这件事没有概念。他们觉得let allData []只是一个数组存起数据来好像不需要什么成本。实际上数组里存的是引用真正占内存的是对象本身。每条数据如果包含 10 个字段每个字段都是一个字符串或数字60 万条下来就是 600 万个独立值。再加上 JavaScript 对象本身有隐藏类、属性描述符等额外结构最终内存占用通常是接口响应体大小的 3 到 5 倍。我自己做过一个简单实验一个包含 20 个字段的 JSON 对象数组原始 JSON 字符串约 500 KB解析成对象后内存占用约 1.8 MB。数据量越小越不明显数据量一大这种翻倍的放大效应就是压垮浏览器的元凶。所以分析数据规模时永远要记住内存里的数据比网络传输的数据胖得多。4.2 分批、流式、懒加载三类手段的适用边界很多人一听“高效处理大数据”第一反应就是“用流式”。但流式不是银弹它只适合“顺序处理”的场景。我梳理了三类手段的适用边界手段适用场景不适合的场景实际案例分批有明确分页接口、总量已知单次接口返回就是全量分页拉取接口数据流式数据可顺序处理、无需整体排序需要全部数据才能做全局聚合Excel 逐行写入懒加载数据处理有分支可能提前结束无论如何都要遍历全部数据找到第一条满足条件的数据就停导出场景非常契合流式因为 Excel 的行与行之间是独立的我可以逐行写入。但如果需求变成“按某个字段排序后再导出”流式就行不通了因为排序必须加载全部数据到内存。这时候更合理的方向是让后端在 SQL 层排好序再分页返回而不是前端硬扛。4.3 一个高效示例里的思维模型生产者-消费者我在指导新同事时经常用一个类比来解释这种结构把数据处理想象成一条流水线上游接口是生产者负责一车一车地拉原料Excel 写入是消费者负责一件一件地加工入库中间不需要仓库拉到一车就加工一车。这个模型中最危险的动作是“把上游所有原料先堆到仓库再开始加工”。仓库容量有限原料一多就爆仓。而流式方案的本质是消灭仓库生产者和消费者的速度可以不完全匹配只要消费者够快内存就不会堆积。第二版代码里fetchPage是生产者worksheet.addRow().commit()是消费者pageSize就是两者之间允许存在的最大库存。这个参数控制了内存上界是整段代码里最值得关注的数字。5. 那些“貌似高效实则低效”的写法我在代码评审里经常拦下5.1 用正则做万能解析正则表达式在很多场景下确实高效但它不是万能的。尤其是解析 JSON、解析嵌套 HTML、解析带转义的 CSV这些场景的正则方案看起来只有一行实际上覆盖不了所有边界情况。我在某个项目里见过用正则把一串日志字符串拆成字段的写法当时测了三种日志格式都能通过结果线上来了第四种格式——字段里带引号和转义逗号——整条日志被切成了五六段。最终这个正则被替换成了一个专门的状态机解析器。真正的“高效”是正确性有保障的效率而不是拿正确性赌效率。5.2 提前优化和过度抽象代码评审时我最常说的一句话是“你先证明这个位置是瓶颈再去优化它。”有些同事喜欢在写功能时顺手做一堆“可能用得上”的抽象——给每个函数加缓存装饰器把简单的判断逻辑封装成规则引擎给三个字段的配置加一个插件机制。这些做法单看都在“提升效率”但合在一起会造成两个问题第一代码可读性断崖式下降新接手的人要理解三层抽象才能改一个字段第二额外抽象本身带来了运算开销和内存占用。一个简单的映射表能解决的问题非要引入十几层包装这是我最常见到的“貌似高效”。5.3 只看理论复杂度不看常数项有一次评审一个去重功能同事用哈希表把时间复杂度从 O(n²) 降到了 O(n)理论上是巨大优化。但他没注意到原始数据只有 2000 条而且每条数据需要先做一次字符串拼接才能作为哈希键。这个拼接操作本身就不便宜最终优化后不仅没变快反而因为多了一次遍历和拼接比原来的双重循环还慢了 30%。高效示例必须警惕这种“理论正确、实际无效”的优化。我的习惯是任何优化都至少用真实数据跑一次前后对比几百条数据能跑完的功能优化了却变慢那就要重新审视方案。5.4 魔法数字与“聪明”写法最后一种为了“高效”写出让人看不懂的代码。比如const n ~~(length / 2)这种用位运算取整的写法或者用!!做布尔转换。这些写法在某些场景下确实比标准写法快个零头但代价是别人根本不知道你在表达什么。我见过最离谱的是把Math.min(a, b)写成(a b - Math.abs(a - b)) / 2理由是不想调用函数。这种优化在绝大多数场景下毫无意义还增加了出错的概率。代码是写给人看的顺便让机器执行。让人看不懂的代码本身就是最大的低效。6. 把高效示例变成团队习惯的几个落地动作6.1 沉淀“示例清单”而不是“代码模板”项目做了三年我陆陆续续总结了不少常用场景的高效写法。一开始我的做法是把它们整理成“代码模板”直接复制粘贴。但后来发现模板太死板遇到稍微不同的场景同事要么硬套要么放弃使用。后来我改成“示例清单”的方式每条记录只包含三层信息——“场景描述”“关键思路”“参考代码片段”。比如“大量数据导出 Excel”这一条关键思路是“分页拉取 流式写入 避免全量数组”参考代码就是上面第三版的那个生成器结构。这样同事理解的是思路而不是死记代码遇到变体场景也能灵活调整。6.2 压测数据写进注释这个习惯是从那次导出功能改造之后养成的。我要求在涉及大数据量、高性能的代码块注释里必须写明“实测数据”// 实测60 万行数据导出 Excel // 内存峰值约 120 MB耗时约 42 秒 // 瓶颈在网络 IO本地内存占用只取决于单页大小5000 条这行注释的效果很好。很久以后有人想“优化”这段代码把分页大小改成 50000想减少请求次数他只要看一眼注释里的内存数据就会意识到这个改动会把内存上界提升 10 倍而不是盲目动手。压测数据比任何抽象的描述都更能约束后续的修改。6.3 一次评审一个检查点最后分享一个团队协作层面的经验代码评审时不要同时强调所有“高效原则”。如果一场评审既要求“改为流式”又要求“减少抽象”又要求“加压测注释”提交代码的人大概率一件都做不好。我的做法是给每周的评审定一个检查点这周主要盯“内存里有没有多余的数据副本”下周盯“循环里有没有重复创建的对象”。一段时间下来团队整体写代码的思维方式会被慢慢扭转而不是靠某一次评审强行灌输所有概念。说到底“高效代码示例”不是一个可以背诵的标准答案而是在无数次“功能能跑但一压测就崩”的教训里总结出来的思维框架。上面这条从全量加载到流式处理的改造链路最值得记住的不是某个 API 的用法而是那个反直觉的结论大多数情况下你以为的“性能问题”本质都是“数据在内存里堆了太多”解决它的方式不是让计算更快而是让数据流动起来。
阅读完成 · 觉得有帮助?