草庐IT

Webpack4编译阶段的性能优化和踩坑

songEagle 2024-01-11 原文

Hello,大家好,我是松宝写代码,写宝写的不止是代码。接下来给大家带来的是关于Webpack4的性能优化的系列,今天带来的是编译阶段的性能优化。

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

背景

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

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

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

分析

简化Webpack 的构建流程后,Webpack 的构建流程大体上分为如下几个阶段:


  • 模块编译:需要运行如 babel、postcss 等 loader 对模块进行代码编译
  • 依赖解析:需要使用 acorn 把代码生成 AST 并遍历查找下游依赖
  • 代码压缩:需要生成 AST 并大量修改替换
  • SourceMap:需要将构建流程代码操作产生的位置映射计算、合并

而在尽可能不改变处理逻辑的情况下,常见的优化思路就是“并行”和“缓存”:

  • 并行:如 thread-loader
  • 缓存:如 cache-loader/Persistent Caching

但目前“并行”和“缓存”仅覆盖模块编译阶段,能否把“并行”和“缓存”的方案扩展到整个构建流程呢?

准备

为了让“并行”+“缓存”能够覆盖整个构建流程,需要做如下准备工作:

  1. 引用透明改造:保证各个耗时较高的构建阶段无副作用
  2. 缓存池:统一管理各阶段生成的缓存
  3. 并行调度池:统一管理子进程/子线程的调度

引用透明改造

引用透明改造包括如下几个部分:

  • 以 module 的 request 作为整个生命周期中的唯一标识,模块级粒度的构建控制参数都放到 request 的 query 中。
  • 需要并行任务的配置、参数、结果都能够序列化/反序列化。
  • 函数执行不依赖全局变量,相同的参数一定能得到相同的结果。

缓存池

缓存池的核心功能:

  • 读写时机控制:Webpack 按照 module 维度拆分缓存,而由于 node_modules 黑洞导致 module 数量巨大,因此读写本地文件系统开销也较大,避免在主进程繁忙时读写缓存。
  • 按需读写:通常模块并不一定会全量重新构建,因此按需的读取/写入能大幅度减少文件的操作次数。
  • 整体/分体缓存:不同的场景可能导致缓存的切分粒度不同,比如分体缓存能够更好的处理按需读写,而整体缓存能在 faas 读取 nas 场景下获得较好的性能。

并行调度池

并行调度池类似于数据库连接池,主要功能:

  1. 任务队列:将处理任务放在队列中,同时向并行调度器发送处理请求。
  2. 并行调度器:收到处理请求时,若有空闲并行实例优先调度,若没有则按照最大并行数量新建。
  1. 子进程:使用 child_process 创建子进程,通过 IPC message 传输数据。
  2. 子线程:使用 worker_threads 创建子线程,通过 ArrayBuffer 传输数据(注意 nodejs 版本)。
  1. 并行实例:不处理实际逻辑,负责跨进程/线程通信,处理数据序列化反序列化,按需加载构建任务。
  2. 构建任务:执行具体的处理逻辑:
  3. 编译任务:使用 loader-runner 编译模块代码。

  4. 压缩任务:使用 terser/esbuild 压缩模块代码。

  5. SourceMap 任务:生成序列化 SourceNode。

做好了这些准备工作后,就可以开始进行各个阶段的“并行”+“缓存”改造。

编译阶段优化

编译阶段流程

Webpack 内部的单个模块构建流程大致如下所示:

  1. 从 entry 开始,创建模块。
  2. 模块经过 loader 处理后,得到编译后代码。
  3. 编译后代码经过 AST 解析后,得到模块的下游依赖。
  4. 将下游依赖创建新的模块,回到步骤 2 递归处理。
  5. 直到所有模块都处理完成,模块编译流程结束。

Cache-loader

loader 运行类似于 Express/Koa 的中间件机制,每一个 Loader 分为 pitch 和 normal 两个阶段,cache-loader 利用这一点,在 pitch 阶段进行缓存检测,如果检测到缓存可用则直接返回。无缓存或缓存不可用则继续运行后续流程,直到 normal 阶段生成缓存写入文件系统。

thread-loader也是同理,只不过把后续的 loader 以及相关参数交给了子进程,并在子进程中模拟了 Webpack 的 loader 运行机制。

Persistent Caching

但 cache-loader 无法解决 AST Parser + 遍历生成依赖带来的消耗,开源界有 hard-source-webpack-plugin 尝试解决这个问题(但问题很多)。Webpack 团队自己也意识到了这个问题, 因此在 Webpack 5 中增加的 Persistent caching 来优化,但它的实现思路是将 Webpack 整个上下文都缓存下来,因此 Webpack 5 给几乎每个对象都增加了序列化/反序列化的方法:

// webpack@5.9.0/lib/NormalModule.js L1068 ~ L1105

serialize(context) {
  const { write } = context;
  // deserialize
  write(this._source);
  write(this._sourceSizes);
  write(this.error);
  write(this._lastSuccessfulBuildMeta);
  write(this._forceBuild);
  super.serialize(context);
}

deserialize(context) {
  const { read } = context;
  this._source = read();
  this._sourceSizes = read();
  this.error = read();
  this._lastSuccessfulBuildMeta = read();
  this._forceBuild = read();
  super.deserialize(context);
}

但由于当时无法升级 Webpack 5,且 Persistent caching 脱离了统一的缓存控制,最终选择自己实现缓存来保证可移植、可拼接、预生成,如果在 Webpack 5 上实现,理论上可以复用一部分模块、依赖的序列化/反序列化能力,并桥接到缓存池上。

依赖解析缓存方案

方案设计

方案如下图所示:

  • 缓存管理:将缓存池桥接到 Webpack 构建的生命周期 hooks 上。
  • 模块处理器:模块的序列化与反序列化工具。
  • 缓存匹配器:判断模块是否可以使用缓存中的数据。
  • Hash 生成器:全局统一的 Hash 生成器。

处理流程

  • 通过 NormalModuleFactory 干预模块生成,并代理掉模块自身的 build 方法。
  • 当模块触发构建时,先进行缓存匹配:
  • 首先需要通过模块 Request 生成 Hash 并从上面说的缓存池中找到对应的项目。
  • 读取缓存中的 metaHash,并将 Request 里的文件通过 fs.stat 读取文件的元信息,将其中的文件名、文件大小、修改时间等信息生成 hash,与 metaHash 进行比对,相等则认为缓存可用。
  • 读取缓存中的 contentHash,并读取文件文本内容生成 Hash 比对,相等则认为缓存可用。
  • 缓存匹配时,使用模块反序列化器将缓存恢复成模块实例属性,并写入到当前模块中,跳过构建流程直接回调。
  • 未匹配时,使用 Webpack 内置的模块 build 方法(上面被代理的方法)进行构建,但拦截其回调函数,在外面套娃进行模块的序列化。

模块处理器

模块的序列化分为两部分:模块本体序列化、模块依赖序列化。

模块本体的序列化较为简单:

  • 模块的 Request,也就是模块的唯一 ID。
  • 模块的 source 对象,一个 Webpack Source 实例,通过 sourceAndMap 方法获取其结果代码和 SourceMap 并序列化。
  • 模块的构建信息对象,包括 buildInfo、buildMeta 对象。

模块的依赖序列化较为复杂,因为依赖由 Webpack 解析 AST 后遍历生成,依赖内部会直接保留相关联的 AST 节点,这些 AST 节点在后续的 chunk 产物生成的 dependency template 阶段会用来生成模块引用依赖的相关代码。

但实际上,依赖内部并不会真正使用多少 AST 的节点,仅仅是从其中读取少量信息用来做代码替换的位置判断和字符串拼接,因此序列化的过程就变成了提取 AST 上依赖使用的关键信息,而反序列化则是将这些关键信息伪造成 AST 节点即可。

不过,Webpack 内部这样的依赖有数十个(webpack/lib/dependencies目录下),需要一个个处理。同时,对于一些特殊的场景,比如 Block 类型的依赖(通常是异步加载的代码)无法支持。(Webpack 5 中可以直接用这些 Dependency 上面的序列化/反序列化方法)。

'use strict';
const NullDependency = require('./NullDependency');
class HarmonyExportHeaderDependency extends NullDependency {
  constructor(range, rangeStatement) {
    super();
    this.range = range;
    this.rangeStatement = rangeStatement;
  }
  get type() {
    return 'harmony export header';
  }
}
HarmonyExportHeaderDependency.Template = class HarmonyExportDependencyTemplate {
  apply(dep, source) {
    const content = '';
    const replaceUntil = dep.range ? dep.range[0] - 1 : dep.rangeStatement[1] - 1;
    source.replace(dep.rangeStatement[0], replaceUntil, content);
  }
};
module.exports = HarmonyExportHeaderDependency;

如此这般,当缓存命中时,模块的依赖解析流程会被完全跳过。但这个流程并行化难度较高,主要原因是 Webpack 内 Parser Hooks 的桥接较为复杂,可以说 Hooks 的存在本身就是副作用的一种体现。

其他优化

Resolver

对 Webpack 的 enhance-resolver 进行缓存,降低 Webpack 在文件系统中查找的成本。由于 Resolver 较为复杂,且不同的 node_modules 组织方式、不同的依赖版本、不同的起始路径,都可能使得相同的 request 被解析到完全不同的文件,因此针对不同类型的 request,缓存的处理逻辑不同:

  • Loader resolver:Loader 均由构建器统一管理,可以设置持久化缓存。
  • 动态注入路径:在构建过程中添加的依赖,而非源码本身的依赖,受构建器统一管理,可以设置持久化缓存。
  • node_modules:在一次构建中,相同 context 下的相同 request 可以使用内存缓存,但不宜使用持久化缓存。
  • 项目源码:不宜使用缓存。

Hash

构建器和 Webpack 的处理流程中存在大量的 Hash 计算。而使用 md5 作为 Hash 的成本较高,可以采用如 imurmurhash 等碰撞率高一些但性能更好的 Hash 方案进行替换。同时代理的 Hash 也可用来做后续的可移植缓存。

有关Webpack4编译阶段的性能优化和踩坑的更多相关文章

  1. ruby - Sinatra set cache_control to static files in public folder编译错误 - 2

    我不知道为什么,但是当我设置这个设置时它无法编译设置:static_cache_control,[:public,:max_age=>300]这是我得到的syntaxerror,unexpectedtASSOC,expecting']'(SyntaxError)set:static_cache_control,[:public,:max_age=>300]^我只想将“过期”header设置为css、javaascript和图像文件。谢谢。 最佳答案 我猜您使用的是Ruby1.8.7。Sinatra文档中显示的语法似乎是在Ruby1.

  2. 安卓apk修改(Android反编译apk) - 2

    最近因为项目需要,需要将Android手机系统自带的某个系统软件反编译并更改里面某个资源,并重新打包,签名生成新的自定义的apk,下面我来介绍一下我的实现过程。APK修改,分为以下几步:反编译解包,修改,重打包,修改签名等步骤。安卓apk修改准备工作1.系统配置好JavaJDK环境变量2.需要root权限的手机(针对系统自带apk,其他软件免root)3.Auto-Sign签名工具4.apktool工具安卓apk修改开始反编译本文拿Android系统里面的Settings.apk做demo,具体如何将apk获取出来在此就不过多介绍了,直接进入主题:按键win+R输入cmd,打开命令窗口,并将路

  3. Ruby 的数字方法性能 - 2

    我正在使用Ruby解决一些ProjectEuler问题,特别是这里我要讨论的问题25(Fibonacci数列中包含1000位数字的第一项的索引是多少?)。起初,我使用的是Ruby2.2.3,我将问题编码为:number=3a=1b=2whileb.to_s.length但后来我发现2.4.2版本有一个名为digits的方法,这正是我需要的。我转换为代码:whileb.digits.length当我比较这两种方法时,digits慢得多。时间./025/problem025.rb0.13s用户0.02s系统80%cpu0.190总计./025/problem025.rb2.19s用户0.0

  4. ruby - Ruby 性能中的计时器 - 2

    我正在寻找一个用ruby​​演示计时器的在线示例,并发现了下面的代码。它按预期工作,但这个简单的程序使用30Mo内存(如Windows任务管理器中所示)和太多CPU有意义吗?非常感谢deftime_blockstart_time=Time.nowThread.new{yield}Time.now-start_timeenddefrepeat_every(seconds)whiletruedotime_spent=time_block{yield}#Tohandle-vesleepinteravalsleep(seconds-time_spent)iftime_spent

  5. ruby-on-rails - 如果条件与 &&,是否有任何性能提升 - 2

    如果用户是所有者,我有一个条件来检查说删除和文章。delete_articleifuser.owner?另一种方式是user.owner?&&delete_article选择它有什么好处还是它只是一种写作风格 最佳答案 性能不太可能成为该声明的问题。第一个要好得多-它更容易阅读。您future的自己和其他将开始编写代码的人会为此感谢您。 关于ruby-on-rails-如果条件与&&,是否有任何性能提升,我们在StackOverflow上找到一个类似的问题:

  6. .net - 是否有 Ruby .NET 编译器? - 2

    是否有适用于Ruby语言的.NETFramework编译器?我听说过DLR(动态语言运行时),这是否将使Ruby能够用于.NET开发? 最佳答案 IronRuby是Microsoft支持的项目,建立在动态语言运行时之上。 关于.net-是否有Ruby.NET编译器?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/199638/

  7. python - 使用 Python、Ruby 和 Perl 重新编译 MacPort 版本的 MacVim - 2

    关闭。这个问题是off-topic.它目前不接受答案。想改进这个问题吗?Updatethequestion所以它是on-topic用于堆栈溢出。关闭10年前。ImprovethisquestionLinux专家正在转向Mac(10.8)。因为我懒...我使用MacPorts安装MacVim。它似乎安装没有错误。我只需要mvim中的python、ruby和perl支持。$/opt/local/bin/mvim--version|egrep'patches|python|ruby|perl'Includedpatches:1-244,246-646+multi_lang-mzscheme+

  8. ruby - 如何找到我的 Ruby 应用程序中的性能瓶颈? - 2

    我编写了一个Ruby应用程序,它可以解析来自不同格式html、xml和csv文件的源中的大量数据。我如何找出代码的哪些区域花费的时间最长?有没有关于如何提高Ruby应用程序性能的好资源?或者您是否有任何始终遵循的性能编码标准?例如,你总是用加入你的字符串吗?output=String.newoutput或者你会使用output="#{part_one}#{part_two}\n" 最佳答案 好吧,有一些众所周知的做法,例如字符串连接比“#{value}”慢得多,但是为了找出您的脚本在哪里消耗了大部分时间或比所需时间更多,您需要进行分

  9. ruby - 为什么 `middleman serve` 有效,但是 `middleman build` 编译这个 Sass 失败? - 2

    当我刚刚运行middleman时服务,all.css编译得很好,只包含对+box-shadow(none)的调用:/*line1,/home/yang/asdf/source/stylesheets/content.css.sass*/div{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}但是当我构建网站时,我得到了这个Sass/Compass错误:$middlemanbuildSlim::EmbeddedEngineisdeprecated,itiscalledSlim::EmbeddedinSlim2.0

  10. elasticsearch源码关于TransportSearchAction【阶段三】 - 2

    1.回顾.TransportServicepublicclassTransportServiceextendsAbstractLifecycleComponentTransportService:方法:1publicfinalTextendsTransportResponse>voidsendRequest(finalTransport.Connectionconnection,finalStringaction,finalTransportRequestrequest,finalTransportRequestOptionsoptions,TransportResponseHandlerT>

随机推荐