您当前的位置:首页 > 电脑百科 > 程序开发 > 编程百科

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

时间:2023-04-27 13:38:09  来源:微信公众号  作者:松宝写代码

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 也可用来做后续的可移植缓存。



Tags:Webpack4   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
Webpack4编译阶段的性能优化和踩坑
Hello,大家好,我是松宝写代码,写宝写的不止是代码。接下来给大家带来的是关于Webpack4的性能优化的系列,今天带来的是编译阶段的性能优化。由于优化都是在 Webpack 4 上做的,当时...【详细内容】
2023-04-27  Search: Webpack4  点击:(340)  评论:(0)  加入收藏
▌简易百科推荐
即将过时的 5 种软件开发技能!
作者 | Eran Yahav编译 | 言征出品 | 51CTO技术栈(微信号:blog51cto) 时至今日,AI编码工具已经进化到足够强大了吗?这未必好回答,但从2023 年 Stack Overflow 上的调查数据来看,44%...【详细内容】
2024-04-03    51CTO  Tags:软件开发   点击:(6)  评论:(0)  加入收藏
跳转链接代码怎么写?
在网页开发中,跳转链接是一项常见的功能。然而,对于非技术人员来说,编写跳转链接代码可能会显得有些困难。不用担心!我们可以借助外链平台来简化操作,即使没有编程经验,也能轻松实...【详细内容】
2024-03-27  蓝色天纪    Tags:跳转链接   点击:(13)  评论:(0)  加入收藏
中台亡了,问题到底出在哪里?
曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之...【详细内容】
2024-03-27  dbaplus社群    Tags:中台   点击:(9)  评论:(0)  加入收藏
员工写了个比删库更可怕的Bug!
想必大家都听说过删库跑路吧,我之前一直把它当一个段子来看。可万万没想到,就在昨天,我们公司的某位员工,竟然写了一个比删库更可怕的 Bug!给大家分享一下(不是公开处刑),希望朋友们...【详细内容】
2024-03-26  dbaplus社群    Tags:Bug   点击:(5)  评论:(0)  加入收藏
我们一起聊聊什么是正向代理和反向代理
从字面意思上看,代理就是代替处理的意思,一个对象有能力代替另一个对象处理某一件事。代理,这个词在我们的日常生活中也不陌生,比如在购物、旅游等场景中,我们经常会委托别人代替...【详细内容】
2024-03-26  萤火架构  微信公众号  Tags:正向代理   点击:(11)  评论:(0)  加入收藏
看一遍就理解:IO模型详解
前言大家好,我是程序员田螺。今天我们一起来学习IO模型。在本文开始前呢,先问问大家几个问题哈~什么是IO呢?什么是阻塞非阻塞IO?什么是同步异步IO?什么是IO多路复用?select/epoll...【详细内容】
2024-03-26  捡田螺的小男孩  微信公众号  Tags:IO模型   点击:(9)  评论:(0)  加入收藏
为什么都说 HashMap 是线程不安全的?
做Java开发的人,应该都用过 HashMap 这种集合。今天就和大家来聊聊,为什么 HashMap 是线程不安全的。1.HashMap 数据结构简单来说,HashMap 基于哈希表实现。它使用键的哈希码来...【详细内容】
2024-03-22  Java技术指北  微信公众号  Tags:HashMap   点击:(11)  评论:(0)  加入收藏
如何从头开始编写LoRA代码,这有一份教程
选自 lightning.ai作者:Sebastian Raschka机器之心编译编辑:陈萍作者表示:在各种有效的 LLM 微调方法中,LoRA 仍然是他的首选。LoRA(Low-Rank Adaptation)作为一种用于微调 LLM(大...【详细内容】
2024-03-21  机器之心Pro    Tags:LoRA   点击:(12)  评论:(0)  加入收藏
这样搭建日志中心,传统的ELK就扔了吧!
最近客户有个新需求,就是想查看网站的访问情况。由于网站没有做google的统计和百度的统计,所以访问情况,只能通过日志查看,通过脚本的形式给客户导出也不太实际,给客户写个简单的...【详细内容】
2024-03-20  dbaplus社群    Tags:日志   点击:(4)  评论:(0)  加入收藏
Kubernetes 究竟有没有 LTS?
从一个有趣的问题引出很多人都在关注的 Kubernetes LTS 的问题。有趣的问题2019 年,一个名为 apiserver LoopbackClient Server cert expired after 1 year[1] 的 issue 中提...【详细内容】
2024-03-15  云原生散修  微信公众号  Tags:Kubernetes   点击:(6)  评论:(0)  加入收藏
相关文章
    无相关信息
站内最新
站内热门
站内头条