Webpack4的SourceMap阶段的性能优化和踩坑

开发 前端
SourceMap生成流程 SourceMap 生成过程中,由于项目过大导致需要计算处理的映射节点(SourceNode)特别多(遇到过10^6数量级的项目),这也导致 SourceMap 生成过程中内存飙升频繁 GC,构建十分缓慢甚至 OOM。

Hello,大家好,我是松宝写代码,写宝写的不止是代码。

由于优化都是在 Webpack 4 上做的,当时 Webpack 5 还未稳定,现在使用 Webpack 5 时可能有些优化方案不再需要或方案不一致,这里主要分享思路,可供参考。

背景

在接触一些大型项目构建速度慢的很离谱,有些项目在 编译构建上30分钟超时,有些构建到一半内存溢出。但当时一些通用的 Webpack 构建优化方案要么已经接入,要么场景不适用:

  • 已接入的方案效果有限。比如 cache-loader、thread-loader,能优化编译阶段的速度,但对于依赖解析、代码压缩、SourceMap 生成等环节无能为力
  • 作为前端基建方案,业务依赖差异极大,难以针对特定依赖优化,如 DllPlugin 方案
  • 作为移动端打包方案,追求极致的首屏加载速度,难以接受频繁的异步资源请求,如 Module Federation、Common Chunk 方案
  • 存在一码多产物场景,需要单仓库多模式构建(1.0/2.0 * 主包/分包)下缓存复用,难以接受耦合度高的缓存方案,如 Persistent Caching

在这种情况下,只好另辟蹊径去寻找更多优化方案,这篇文章主要就是介绍这些“非主流”的优化方案,以及引发的思考。

今天带来的是webapck4sourceMap阶段。

SourceMap阶段

SourceMap生成流程 SourceMap 生成过程中,由于项目过大导致需要计算处理的映射节点(SourceNode)特别多(遇到过10^6数量级的项目),这也导致 SourceMap 生成过程中内存飙升频繁 GC,构建十分缓慢甚至 OOM。

Webpack 内部有大量的代码拼接工作,而每一次代码拼接都涉及到 SourceMap 的处理,因此 Webpack 内封装了 webpack-sources,其中 SourceMapSource 用于保存 SourceMap,ConcatSource 用于代码拼接, SourceMap 操作使用 source-map 和 source-list-map 库来处理。

而其内部实际上是在运行 sourceAndMap()/map() 方法时才进行计算:

// webpack-sources/SourceMapSource
class SourceMapSource extends Source {
  // ...
  node(options) {
    // 此处进行真正的计算
    var sourceMap = this._sourceMap;
    var node = SourceNode.fromStringWithSourceMap(this._value, new SourceMapConsumer(sourceMap));
    node.setSourceContent(this._name, this._originalSource);
    var innerSourceMap = this._innerSourceMap;
    if(innerSourceMap) {
      node = applySourceMap(node, new SourceMapConsumer(innerSourceMap), this._name, this._removeOriginalSource);
    }
    return node;
  }
  // ...
}
// webpack-sources/SourceAndMapMixin
proto.sourceAndMap = function (options) {
  options = options || {};
  if (options.columns === false) {
    return this.listMap(options).toStringWithSourceMap({
      file: "x",
    });
  }

  var res = this.node(options).toStringWithSourceMap({
    file: "x",
  });
  return {
    source: res.code,
    map: res.map.toJSON(),
  };
};

SourceMap 优化方案

很显然,如果把所有模块的 SourceMap 都放到最后一起来计算,对主进程长时间占用导致 SourceMap 生成缓慢。可以通过如下方法进行优化:

  1. 使用行映射 SourceMap
  2. SourceMap 并行化
  3. SourceNode 内存优化

行映射 SourceMap

SourceMap 的本质就是大量的 SourceNode 组成,每一个 SourceNode 存储了产物的位置到源码位置的映射关系。通常映射关系是行号+列号,但我们排查bug时候一般只看哪一行,具体哪一列看的不多。如果忽略列号则可以大幅度减少 SourceNode 的数量。

SourceMapDevToolPlugin 中的 columns 设为 true 时就是行映射 SourceMap。但这个插件处理的逻辑已经是在最后产物生成阶段,而在整个 Webpack 构建流程中流转的 SourceMap 依然是行列映射。因此可以直接代理掉 SourceMapSource 的 map 方法,写死 columns 为 true。

SourceMap 并行化

SourceMap 最后一起堆积在主进程中生成是非常缓慢的,因此可以考虑在模块级压缩的时候,手动模拟 node() 方法,触发一下 applySourceMap 方法提前生成 SourceNode,并将 SourceNode 序列化传递回主进程,当主进程需要使用时直接获取即可。

SourceNode 内存优化

当字符串被 split 时,行为与 substr 不太一样,split 会生成字符串的拷贝,占用额外的内存(chrome memory profile 中为string),而 substr 会生成引用,字符串不会拷贝占用额外内存(chrome memory profile 中为 sliced string),但与此同时也意味着父字符串无法被 GC 回收。

const bigstring = '00000\n'.repeat(50000);
console.log(bigstring); // 触发生成
const array = bigstring.split('\n');

图片

const bigstring = '00000\n'.repeat(500000);
console.log(bigstring)
const array = [];
for (let i = 0; i < 100000;i++) {
  array.push(bigstring.substr(i*5,i*5+5));
}

图片


而看 source-map 中 SourceNode 的代码可以发现:

  • SourceNode 会将完整代码根据换行符 split 切分(生成大量 string 内存占用)。
  • 根据 mapping 对代码求子串并保存(此时意味着这些 string 无法被释放)。
// source-map/SourceNode
SourceNode.fromStringWithSourceMap = function SourceNode_fromStringWithSourceMap(
  aGeneratedCode,
  aSourceMapConsumer,
  aRelativePath
) {
  // ...
  // 此处进行了代码切分
  var remainingLines = aGeneratedCode.split(REGEX_NEWLINE);
  // ...

  aSourceMapConsumer.eachMapping(function (mapping) {
    if (lastMapping !== null) {
      if (lastGeneratedLine < mapping.generatedLine) {
        // ...
      } else {
        var nextLine = remainingLines[remainingLinesIndex] || '';
        // 此处获取子串并长久保存
        var code = nextLine.substr(0, mapping.generatedColumn - lastGeneratedColumn);
        // ...
        addMappingWithCode(lastMapping, code);
        // No more remaining code, continue
        lastMapping = mapping;
        return;
      }
    }
    //...
  }, this);
  // ...
};

那么这个昂贵的 "code" 字段干什么用的呢?实际上只有如下两个功能:

  1. 每一个 code 都会生成一个子 SourceNode,而最终递归生成的子 SourceNode 在 walk 阶段又会拼接回产物代码。
  2. 如果包含了换行符,则会用来做映射位置的偏移计算。
// source-map/SourceNode
SourceNode.prototype.toStringWithSourceMap = function SourceNode_toStringWithSourceMap(aArgs) {
  // ...
  this.walk(function (chunk, original) {
    generated.code += chunk;
    //...
    for (var idx = 0, length = chunk.length; idx < length; idx++) {
      if (chunk.charCodeAt(idx) === NEWLINE_CODE) {
        generated.line++;
        generated.column = 0;
        // Mappings end at eol
        // ...
      } else {
        generated.column++;
      }
    }
  });
  this.walkSourceContents(function (sourceFile, sourceContent) {
    map.setSourceContent(sourceFile, sourceContent);
  });

  return { code: generated.code, map: map };
};

那么问题来了,产物代码有很多其他渠道能够获取不需要在这里计算。而仅仅为了换行计算浪费如此大量的内存显然是不合理的。因此可以在一开始就把换行符的位置计算出来,保留在 SourceNode 内部,然后让切分出来的字符被 GC 回收,等到 walk 的时候直接拿这些换行符记录进行计算即可。

衍生的应用场景

思路

前面构建生成了缓存,我们希望缓存是可移植、可拼接、预生成的:

  1. 可移植:中间产物不依赖特定环境,放到其他场景下依然能够使用。
  2. 可拼接:对于每一个项目都有自己的中间产物,而当一个聚合的项目使用这些项目时,也可以通过聚合生成自己的中间产物。
  3. 预生成:中间产物可以提前生成,存放到云端,在任何有需要的场景下载使用。

通过预生成,按需下发,动态拼接的方式,就能真正做到“绝不构建第二次”。

可移植缓存

缓存与环境解耦是可以让缓存跨机器使用,遗憾的是 Webpack 在其模块的 request 中包含绝对路径(要找到对应的文件),导致与其相关的 AST 解析、模块 ID 生成等等都受到影响。因此要做到可移植缓存,需要如下改造:

  1. 统一的缓存管理:不受控的缓存难以做后续的环境解耦。
  2. 路径替换&复原:对于写入缓存的所有内容,一旦出现了本地路径,都需要替换成占位符。读取时则需要将占位符恢复成新环境的路径。
  3. AST 偏移矫正:由于路径替换过程中,路径长度发生变化,从而导致上述依赖解析阶段的 AST 位置信息缓存失效,因此需要根据路径长度差异对 AST 位置进行矫正。
  4. Hash 代理:由于构建流程中有大量的 Hash 生成场景,而一旦包含了本地路径字符串加入到 Hash 生成中,则必然导致 Hash 在新环境下无法被匹配。

图片

增量的构建

有了可移植的缓存,就能实现增量的构建。核心思路如下:

  • 项目某个特定版本源码作为项目基线,基线初始化构建生成基线缓存和基线文件元数据
  • 当文件发生变化时:
  • 收集变化的文件生成变更元数据。
  • 变更元数据 + 基线缓存 + 基线文件元数据,构建生成变更后产物+热更新产物,同时产出增量补丁。
  • 增量补丁主要包含文件目录的增量、缓存的增量。
  • 如果有前代增量补丁,可以合并。
  • 当环境发生变化时,在新环境下:
  • 增量补丁+基线缓存+基线文件元数据,通过增量消费构建,也可以再次产出构建产物。
  • 当需要提升一个特定增量补丁的版本作为基线时,将其增量变更与基线缓存、基线文件元数据合并即可。

图片

增量构建最大的好处:解决长迭代链导致的缓存存储成本爆炸问题。

举个例子:如果要做一个类似于 codepen、jsfiddle 那样的 playground,可以在线编辑项目代码,迭代中的每次编辑都可以回退,同时也能随时将一次修改派生成为一个新的迭代。

在这种场景下,显然不能给每次代码修改都完整复刻一套缓存。增量的构建仅需要保存一个基线和对应版本相对于基线的增量,当切换到一个特定版本时,使用基线+增量就可以编译出最新的产物,实现版本的快速恢复。这个同理可以应用在项目自身迭代过程的构建缓存池中。

最后

一些思考

  • 函数编写:牢记“引用透明”原则,这是缓存、并行化的基本前提。
  • 模型设计:保证可序列化/反序列化,为缓存、并行化打好基础。
  • 缓存设计:所有缓存应当结构简单且路径无关,保证缓存可移植、可拼接。
  • 对象引用:尽早释放巨大对象的引用,仅保留需要的数据。
  • 插件机制:tapable 这种 pub/sub 机制是否真的合理且灵活,也许高阶函数更加合适。
  • TypeScript:非 TS 代码阅读难度很大,运行时的数据流不去 debug 无法理解。

一些脑洞:

图片

责任编辑:姜华
相关推荐

2023-04-27 08:35:20

Webpack 4性能优化

2022-08-26 13:24:03

version源码sources

2020-09-19 21:26:56

webpack

2021-06-09 08:21:14

Webpack环境变量前端

2019-03-15 15:00:49

Webpack构建速度前端

2021-12-15 09:21:59

Webpack 前端Sourcemap

2010-05-05 11:48:27

Oracle设计开发阶

2021-11-09 09:57:46

Webpack 前端分包优化

2023-02-20 08:11:04

2019-03-05 10:20:49

WebWebpack分离数据

2021-12-28 08:17:41

循环 forgo

2010-04-21 14:00:48

Oracle数据库

2024-04-10 08:39:56

BigDecimal浮点数二进制

2017-07-17 15:46:20

Oracle并行机制

2024-04-01 08:05:27

Go开发Java

2022-09-23 15:23:08

webpack5改变代码

2016-09-21 13:17:31

LibreOfficeJava缓冲区

2019-03-26 10:02:16

WebpackJavascript前端

2021-12-24 08:01:44

Webpack优化打包

2020-03-16 13:16:48

Kubernetes选型踩坑
点赞
收藏

51CTO技术栈公众号