前端项目构建时的资源监控与分析

2,351 阅读6分钟

在 CI 环境打包前端项目时,你或许遇到过这样的错误(OOM):

<--- Last few GCs --->

[1:0x63b6120]   122046 ms: Mark-sweep (reduce) 2003.3 (2005.1) -> 2003.2 (2005.1) MB, 4.1 / 0.5 ms  (+ 0.1 ms in 1 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 47 ms) (average mu = 0.999, current mu = 0.999) external

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

又或这样:构建进程直接退出,连一点多余的错误信息都没有留下。

Killed
error Command failed with exit code 137.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

遇到类似的问题,尝试设置 max-old-space-size 参数是一条可行的解决之道。但是你有没有思考过为什么这个配置会起作用呢?另外,构建工具相关文档中涉及到资源消耗的内容并不多。因此本文希望结合几个实验,借助一些工具来分析打包过程中的资源消耗问题,解答这些疑问。

基础知识

在 Node.js 中,提供多种形式的方法来测量统计性能方面的数据。对于前端项目构建过程而言,我们主要关注「内存」与「CPU」指标。

内存监控

获取 Node.js 内存相关的数据是非常简单的,主要使用 processos 这两个包。

import { freemem, totalmem } from 'os';

const { rss, heapUsed, heapTotal } = process.memoryUsage();

const sysFree = freemem(); // 获取系统空闲内存

const sysTotal = totalmem(); // 获取系统总内存

借助以上的数据可以计算出内存占用率。

heapUsed / heapTotal; // 堆内存占用率
rss / sysTotal; // 进程内存占用率
1 - sysFree / sysTotal; // 系统内存占用率

这里我们重点关注堆内存。堆内存是指 V8 引擎所使用的内存,它主要用于存储 JavaScript 对象、变量和函数等数据。在 Node.js 应用程序中,大部分内存消耗都来自于堆内存,设置 max-old-space-size 来调整堆内存的大小。

Profile

为了监控 Node.js 应用程序中的内存使用情况,我们还可以使用内存监控工具,如 V8 profiler。这些工具可以帮助我们识别内存泄漏并读取和分析内存快照。可以通过如下的方式来获取内存快照。最后将生成的 heapsnapshot 文件导入 Chrome devtool 即可分析内存快照。

import { Session } from 'inspector';

const session = new Session();
session.connect();

async function dumpProfile() {
  session.on('HeapProfiler.addHeapSnapshotChunk', (m) => {
    writeFileSync('profile.heapsnapshot', m.params.chunk);
  });

  await session.post('HeapProfiler.takeHeapSnapshot', null);
}

由于本文不涉及到内存数据的分析,对于此工具感兴趣的读者可以查阅相关文档。

CPU 分析

process.cpuUsage 用于获取进程 CPU 时间的方法,它返回一个包含用户 CPU 时间和系统 CPU 时间的对象。用户 CPU 时间表示进程使用 CPU 的时间,而系统 CPU 时间表示操作系统使用 CPU 的时间。process.hrtime.bigint 方法是一个高精度计时器,用于获取当前时间的纳秒级别的精确时间戳,返回一个 BigInt 类型的值。结合这两者可以计算出 CPU 利用率。

const startTime = process.hrtime.bigint();
const startUsage = process.cpuUsage();

doSomething();

const endTime = process.hrtime.bigint();
const endUsage = process.cpuUsage(startUsage);

const duration = Number(endTime - startTime) / 1000; // ms
(endUsage.user + endUsage.system) / duration; // cpu 利用率

max-old-space-size 的作用

现在我们已经掌握了足够的基础知识,回到文章开头提到的 OOM 问题,来看一下设置 max-old-space-size 对 Node.js 进程的影响。

通过以下的方式可以计算出最大堆内存大小。

import { getHeapStatistics } from 'v8';

Math.floor(getHeapStatistics().heap_size_limit / 1024 / 1024);

在一个 4GB 的 Node.js v16 执行上述脚本得到的最大对内存值为 2015M。编写一个简单的脚本来测试内存。

// 改编自 https://github.com/mcollina/climem/blob/master/app.js
const array = [];

setInterval(() => {
  array.push(Buffer.alloc(1024 * 1024 * 50).toString()); // 50M
}, 3000);

最终内存数据的变化指标如下图所示。Node.js 进程在 122 秒后出现 OOM 问题,此时堆内存非常接近 heap_size_limit ,另外还有空闲内存 700M

内存分配

参考Node.js 官方文档提供的建议,设置 max-old-space-size=3584 后再次执行脚本。内存变化指标如下所示。此时该进程在 220 秒后才出现 OOM 问题,剩余的空闲内存快接近于 0。

内存分配

从以上的变化曲线可以看出,将 max-old-space-size 调大确实可以充分地利用内存,从而做到减少构建过程中 OOM 问题。

构建分析

使用 create-react-app 新建个项目,让我们结合具体的前端项目来进行分析。

Webpack ProfilingPlugin

ProfilingPlugin 是一个非常好用的插件,可以很方便地生成 Profile 文件用以分析构建过程。通过以下方式开启该插件。

{
  plugins: [
    // ...
    new ProfilingPlugin({
      outputPath: join(__dirname, 'profile', `profile.json`),
    }),
  ];
}

将生成的 profile.json 文件导入到 Chrome devtools 中的 performance 面板得到如下的结果。

profile.json

耗时较长的几个 Plugin 如下表所示:

构建插件耗时

ProfilingPlugin 生成的 profile 不包含内存统计信息,因此还需要编写一个简单的内存统计插件。结合内存监控数据的变化可以探索更多的细节。

不要在业务项目中使用 ProfilingPlugin,该插件消耗资源多,另外生成 profile 文件非常大,直接导入 Chrome devtools 甚至会崩溃

内存监控

基于前文介绍的基础知识,编写一个内存监控插件是非常容易的。向 compiler 示例中注册相应的生命周期,用以开始监控或者上报监控数据。

// 按照一定的间隔收集数据
async function collectMemoryUsage() {}

class MemWatchPlugin {
  apply(compiler) {
    // 构建开始前
    compiler.hooks.beforeRun.tap(pluginName, collectMemoryUsage);

    // 构建结束后
    compiler.hooks.done.tap(pluginName, saveMemoryUsageData);

    // 构建失败后
    compiler.hooks.failed.tap(pluginName, saveMemoryUsageData);
  }
}

对收集到的数据进行可视化处理,得到如下的内存变化趋势图。

内存变化趋势

分析

忽略一些耗时极短的 Plugin 后,将前两步得到的数据结合在一起,大致得到下面这样一张图。

两者结合起来

将图中的这几个阶段与 Plugin 对应起来如下表所示。注意到此时图中 x 轴(时间)在 1,2 阶段的增长比较迅速。内存监控的函数是在一个定时器中触发的。也可以从侧面说明这两个阶段消耗了大量的资源,影响到了定时器的触发。在 js 代码压缩完成后,内存会有一个小幅度的下降。在完成 js/css 压缩后,后面都没有什么特别耗时的任务了,构建也基本要结束了。

插件期间的内存

max-old-space-size 不生效的场景

如果在项目中中引入了 monaco-editor ,构建时内存的变化趋势将会更加明显。下图是一个真实的业务项目内存变化趋势图。并且随着构建的进行,可用的内存越来越少,时刻在内存耗尽的边缘徘徊。针对这种情况,设置 max-old-space-size 的作用不大了,最有效的解决方案是将 monaco-editor 这类的包配置成 externals

内存耗尽的例子

总结

本文介绍了测量统计性能相关的工具,并且结合具体的前端项目构建案例,分析了构建过程中的资源消耗,从而更好地帮助前端开发者更加深入地理解构建过程,做到知其然更知其所以然。读者在阅读完本文后,下次遇到构建性能优化问题时,也有一定的解决方案策略。