天天看点

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

作者:架构思考
若你对webpack仅仅是处于使用阶段,觉得webpack原理太杂太乱太多,但是觉得大概了解下webpack的大致原理也不错。亦或是想要了解分包优化如何进行配置呢?以及为什么webpack官方分包配置会从 CommmonsChunkPlugin演变成SplitChunksPlugin呢?我按照自己的方式,通过查阅、整理相关文档,梳理一些比较容易让大家纠结的点,让大家通过本篇文章,大概了解webpack是干了什么?

一、webpack前生今世

1.1、前端石器时代----->工业化时代

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

前端变迁转折点

  • 2008年9月2号,当Chrome第一次出现的时候(V8与Chrome同一天宣布开源),它对网页的加载速度让所有人惊叹,是V8引擎把JavaScript的运行速度提上来了,让前端从蒸汽机机时代正式步入内燃机时代。
  • 2009年诞生的Node.js和2010年诞生的npm,迅速将JavaScript变成全球最受欢迎的生态系统之一。前端正式从石器时代进入到了工业化时代。

1.2、前端为什么需要模块化

痛点

  • 变量和方法不容易维护,容易污染全局作用域。
  • 加载资源的方式通过script标签从上到下。
  • 依赖的环境主观逻辑偏重,代码较多就会比较复杂。
  • 大型项目资源难以维护,特别是多人合作的情况下,资源的引入会让人崩溃。

作用

模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常来说,一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。

模块规范

但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到Javascript模块现在还没有官方规范,这一点就更重要了。目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系统。

(1)CommonJS

NodeJS诞生之后,它使用CommonJS的模块化规范。从此,js模块化开始快速发展。它有四个重要的环境变量为模块化的实现提供支持:module、exports、require、global。

(2)AMD

CommonJS对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于“假死”状态。因此,浏览器端的模块,不能采用“同步加载”(synchronous),只能采用“异步加载”(asynchronous)。这就是AMD规范诞生的背景。

AMD是“Asynchronous Module Definition”的缩写,意思就是“异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。主要有两个Javascript库实现了AMD规范:require.js和curl.js。

(3)CMD

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

(4)ES6

在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

模块化总结

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

1.3、为什么需要webpack呢?

前端页面效果越来越酷炫、功能越来越复杂。而前端工程师们为了更方便的开发提高开发效率进行了一系列der探索,模块化思想的提出啊,将复杂的程序分割成更小的文件。这些年优秀的框架层出不穷react、vue、angular、es6这种在javascript基础上拓展的新的语法规范和less、sass、css处理器等等等。所有的事物都是具有双面性的、有利有弊。大大提高开发效率的同时,又为后期维护造成了困扰。因为利用这些工具的文件往往不能直接被浏览器识别,需要手动处理,很影响开发进度。

是否可以有一种方式,不仅可以让我们编写模块,而且还支持任何模块格式(至少在我们到达ESM之前),并且可以同时处理资源和资产?所以webpack应运而生~这就是webpack存在的原因。它是一个工具,可以打包你的JavaScript应用程序(支持ESM和CommonJS),可以扩展为支持许多不同的静态资源,例如:images, fonts和stylesheets。

webpack关心性能和加载时间;它始终在改进或添加新功能,例如:异步地加载chunk和预取,以便为你的项目和用户提供最佳体验。

二、webpack概念

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

webpack是一个用于现代JavaScript应用程序的静态模块打包工具。当 webpack处理应用程序时,它会在内部从一个或多个入口点构建一个依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个bundles,它们均为静态资源,用于展示你的内容。

三、webpack核心流程解析

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

总体流程架构图

上述提及的各类技术名词不太熟悉的同学,可以先看看简介:

  • Entry:编译入口,webpack编译的起点。
  • Compiler:编译管理器,webpack启动后会创建compiler对象,该对象一直存活直到结束退出。
  • Compilation:单次编辑过程的管理器,比如watch=true时,运行过程中只有一个compiler但每次文件变更触发重新编译时,都会创建一个新的 compilation对象。
  • Dependence:依赖对象,webpack基于该类型记录模块间依赖关系。
  • Module:webpack内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以“module”为基本单位进行的。
  • Chunk:编译完成准备输出时,webpack会将module按特定的规则组织成一个一个的chunk,这些chunk某种程度上跟最终输出一一对应。
  • Loader:资源内容转换器,其实就是实现从内容A转换B的转换器。
  • Plugin:webpack构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程。

扩展

Compiler是plugin的apply接口传进来的参数,它代表了完整的 webpack环境配置。这个对象在启动webpack时被一次性建立,并配置好所有可操作的设置,包括options,loader和plugin。当在webpack环境中应用一个插件时,插件将收到此compiler对象的引用,可以使用它来访问webpack的主环境。对于plugin而言,通过它来注册事件钩子。

Compilation对象代表了一次资源版本构建。当运行webpack开发环境中间件时,每当检测到一个文件变化,就会创建一个新的compilation,从而生成一组新的编译资源。一个compilation对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。对于plugin而言,通过它来完成数据的处理。

3.1、初始化阶段

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘
  • 初始化参数:从配置文件、 配置对象、Shell参数中读取,与默认配置结合得出最终的参数。
  • 创建编译器对象:用上一步得到的参数。
  • 创建Compiler对象初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化RuleSet集合、加载配置的插件等。
  • 开始编译:执行compiler对象的run方法。
  • 确定入口:根据配置中的entry找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象。

这个过程需要在webpack初始化的时候预埋下各种插件,经历4个文件,7次跳转才开始进入主题。

3.2、构建阶段

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘
  • 编译模块(make):根据entry对应的dependence创建module对象,调用loader将模块转译为标准JS内容,调用JS解释器将内容转换为AST对象,从中找出该模块依赖的模块,再递归。本步骤直到所有入口依赖的文件都经过了本步骤的处理。
  • 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间依赖关系图。

构建阶段从entry开始递归解析资源与资源的依赖,在compilation对象内逐步构建出module集合以及module之间的依赖关系。

这个过程中数据流module=>ast=>dependences=>module,先转AST再从AST找依赖。compilation按这个流程递归处理,逐步解析出每个模块的内容以及module依赖关系,后续就可以根据这些内容打包输出。

3.3、生成阶段

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。

写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

seal的关键逻辑是将module按规则组织成chunks,webpack内置的chunk封装规则比较简单:entry及entry触达到的模块,组合成一个 chunk 使用动态引入语句引入的模块,各自组合成一个chunk。

chunk是输出的基本单位,默认情况下这些chunks与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个entry会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源。

四、chunk概念及分包基本规则

4.1、webpack资源形态流转

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

webpack资源形态流转

从资源流转的层面,我们来看下webpack的打包流程:

  • compiler.make阶段:

entry文件以dependence对象形式加入compilation的依赖列表,dependence对象记录有entry的类型、路径等信息。

根据dependence调用对应的工厂函数创建module对象,之后读入module对应的文件内容,调用loader-runner对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为module。

  • compilation.seal阶段:

遍历module集合,根据entry配置及引入资源的方式,将module分配到不同的chunk。

遍历chunk集合,调用compilation.emitAsset方法标记chunk的输出规则,即转化为assets集合。

  • compiler.emitAssets阶段:

将assets写入文件系统

综上,Module主要作用在webpack编译过程的前半段,解决原始资源“如何读”的问题;而Chunk对象则主要作用在编译的后半段,解决编译产物“如何写”的问题,两者合作搭建起webpack搭建主流程。

4.2、chunk概念

从上面的webpack资源形态流转图以及解析中,我们不难发现chunk的大概概念。

chunk:webpack实现中,原始的资源模块以Module对象形式存在、流转、解析处理。

而Chunk则是输出产物的基本组织单位,在生成阶段webpack按规则将entry及其它Module插入Chunk中,之后再由SplitChunksPlugin插件根据优化规则与ChunkGraph对Chunk做一系列的变化、拆解、合并操作,重新组织成一批性能(可能)更高的Chunks 。运行完毕之后webpack继续将 chunk一一写入物理文件中,完成编译工作。代码块,是webpack根据功能拆分出来的(chunk是无法在打包结果中看到的,打包结果中看到的是bundle)。

4.3、chunk的基本分包规则

chunk可以分为三类:

  • 每个entry项都会对应生成一个chunk对象,称之为initial chunk。
  • 每个异步模块都会对应生成一个chunk对象,称之为async chunk。
  • Webpack 5之后,如果entry配置中包含runtime值,则在entry之外再增加一个专门容纳runtime的chunk对象,此时可以称之为runtime chunk。

默认情况下initial chunk通常包含运行该entry所需要的所有runtime代码,但webpack 5之后出现的第三条规则打破了这一限制,允许开发者将runtime从initial chunk中剥离出来独立为一个多entry间可共享的 runtime chunk。

注意:

  • 「业务模块」是指开发者所编写的项目代码。
  • 「runtime 模块」是指Webpack分析业务模块后,动态注入的用于支撑各项特性的运行时代码。

4.4、bundle vs chunk

bundle: bundle是webpack打包之后的各个文件,一般就是和chunk是一对一的关系,但有时候也不完全是一对一的关系。bundle就是对chunk进行编译压缩打包等处理之后的产出。

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

Chunk是过程中的代码块,Bundle是结果的代码块。

五、SplitChunksPlugin的前世今生

默认情况下,Webpack会将所有代码构建成一个单独的包,这在小型项目通常不会有明显的性能问题,但伴随着项目的推进,包体积逐步增长可能会导致应用的响应耗时越来越长。归根结底这种将所有资源打包成一个文件的方式存在两个弊端:

  • 「资源冗余」:客户端必须等待整个应用的代码包都加载完毕才能启动运行,但可能用户当下访问的内容只需要使用其中一部分代码。
  • 「缓存失效」:将所有资源达成一个包后,所有改动——即使只是修改了一个字符,客户端都需要重新下载整个代码包,缓存命中率极低。

一个多页面应用,所有页面都依赖于相同的基础库,那么这些所有页面对应的entry都会包含有基础库代码,这岂不浪费?这些问题都可以通过对产物做适当的分解拆包解决 ,诞生了CommonsChunkPlugin。

5.1、CommmonsChunkPlugin的弊端

CommmonsChunkPlugin的思路是Create this chunk and move all modules matching minChunks into the new chunk,即将满足minChunks配置想所设置的条件的模块移到一个新的chunk文件中去,这个思路是基于父子关系的,也就是这个新产出的new chunk是所有chunk的父亲,在加载孩子chunk的时候,父亲chunk是必须要提前加载的。举例:

  • 同步模块加载
example:
entryA:  vue  vuex  someComponents 
entryB:  vue axios someComponents 
entryC: vue vux axios someComponents minchunks: 2           

产出后的chunk:

vendor-chunk:vue vuex axios 
chunkA~chunkC: only the components           

对entryA和entryB来说,vendor-chunk都包含了多余的module。

  • 异步的模块
example:
 entryA:  vue  vuex  someComponents 
 asyncB:vue axios someComponents 
 entryC: vue vux axios someComponents 
 minchunks: 2           

产出后的chunk:

vendor-chunk:vue vuex  
chunkA: only the components
chunkB: vue axios someComponents 
 chunkC: axios someComponents           

带来的问题是:如果asyncB在entryA中动态引入,则会引入多余的module。

总的来说,CommonsChunkPlugin有以下三个问题:

  • 产出的chunk在引入的时候,会包含重复的代码。
  • 无法优化异步chunk。
  • 高优的chunk产出需要的minchunks配置比较复杂。

综上,CommonsChunkPlugin有很多问题:

  • 它可能会导致许多不必要的代码代码被加载。
  • 它会影响异步加载的chunk。
  • 它很难使用。
  • 它使用起来很难理解。

5.2、SplitChunksPlugin的诞生

针对以上种种问题,webpack4集成了新的插件:SplitChunksPlugin

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

其中,可以发现SplitChunksPlugin产出的vendor-chunk有多个,对于入口A来说,引入的代码只有chunkA、vendor-chunkA-B、vendor-chunkA-C、vendor-chunkA-B-C;这时候chunkA、vendor-chunkA-B、vendor-chunkA-C、vendor-chunkA-B-C形成了一个chunkGroup。SplitChunksPlugin它会使用模块重复计数和模块种类(node_modules)自动识别哪些chunk需要被分离。可以类比一下两个插件。

CommonsChunkPlugin就类似于:创建这个chunk并且把所有与minChunks字段匹配的模块移到新的chunk中。而SplitChunksPlugin就类似于:这是启发式的,确保你需要他们(命令式vs声明式)

5.3、SplitChunksPlugin的概念和优点

SplitChunksPlugin是Webpack 4之后引入的分包方案(此前为 CommonsChunkPlugin),它能够基于一些启发式的规则将Module编排进不同的Chunk序列,并最终将应用代码分门别类打包出多份产物,从而实现分包功能。

SplitChunksPlugin有一些很赞的属性:

  • 不会下载不必要的模块(只要你强制使用name属性合并chunk)
  • 对异步加载的chunk也有效
  • 默认情况下,只对异步加载的chunk有效
  • 处理从多个vendor chunks分离出来的vendor
  • 更容易使用
  • 不需要依赖chunk graph的hack手法
  • 更加自动化

命名规则大致举例如下:

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

六、SplitChunksPlugin使用

使用上,SplitChunksPlugin的配置规则比较抽象,算得上Webpack的一个难点,仔细拆解后关键逻辑在于:

SplitChunksPlugin通过module被引用频率、chunk大小、包请求数三个维度决定是否执行分包操作,这些决策都可以通过 optimization.splitChunks配置项调整定制,基于这些维度我们可以实现:

  • 单独打包某些特定路径的内容,例如node_modules打包为 vendors;
  • 单独打包使用频率较高的文件。

SplitChunksPlugin还提供配置组概念optimization.splitChunks.cacheGroup,用于为不同类型的资源设置更有针对性的配置信息。

SplitChunksPlugin还内置了default与defaultVendors两个配置组,提供一些开箱即用的特性:

  • node_modules资源会命中defaultVendors规则,并被单独打包。
  • 只有包体超过20kb的Chunk才会被单独打包。
  • 加载 Async Chunk 所需请求数不得超过30。
  • 加载 Initial Chunk 所需请求数不得超过30。

6.1、分包范围(chunks)

SplitChunksPlugin默认只对Async Chunk生效,开发者也可以通过optimization.splitChunks.chunks调整作用范围,该配置项支持如下值:

  • 字符串'all' :对Initial Chunk与Async Chunk都生效,建议优先使用该值。
  • 字符串'initial':只对Initial Chunk生效。
  • 字符串'async':只对Async Chunk生效。
  • 函数 (chunk)=>boolean:该函数返回true时生效。
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}           

6.2、根据Module使用频率分包(minChunks)

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 设定引用次数超过 3 的模块才进行分包
      minChunks: 3
    },
  },
}           

SplitChunksPlugin支持按Module被Chunk引用的次数决定是否进行分包,开发者可通过optimization.splitChunks.minChunks设定最小引用次数。

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

示例中,entry-a、entry-b分别被视作Initial Chunk处理;async-module被entry-a以异步方式引入,因此被视作Async Chunk处理。那么对于common模块来说,分别被三个不同的Chunk引入,此时引用次数为 3,命中optimization.splitChunks.minChunks=2规则,因此该模块「可能」会被单独分包,最终产物:entry-a.js entry-b.js async-module.js common.js

6.3、限制分包数量(maxInitialRequest/maxAsyncRequests)

在满足minChunks基础上,还可以通过maxInitialRequest/maxAsyncRequests配置项限定分包数量,配置项语义。

  • maxInitialRequest:用于设置Initial Chunk最大并行请求数。
  • maxAsyncRequests:用于设置 Async Chunk 最大并行请求数。

注意:这里所说的“请求数”,是指加载一个Chunk时所需同步加载的分包数。例如对于一个Chunk A,如果根据分包规则(如模块引用次数、第三方包)分离出了若干子Chunk A¡,那么请求A时,浏览器需要同时请求所有的A¡,此时并行请求数等于¡个分包加A主包,即¡+1。

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

若minChunks=2,则common-1 、common-2同时命中minChunks规则被分别打包,浏览器请求entry-b时需要同时请求common-1、common-2两个分包,并行数为2+1=3,此时若maxInitialRequest= 2,则分包数超过阈值,SplitChunksPlugin会放弃common-1、common-2中体积较小的分包。maxAsyncRequest逻辑与此类似,不在赘述。

并行请求数关键逻辑总结如下:

Initial Chunk本身算一个请求Async Chunk不算并行请求 通过 runtimeChunk拆分出的runtime不算并行请求 如果同时有两个Chunk满足拆分规则,但是maxInitialRequests(或maxAsyncRequest) 的值只能允许再拆分一个模块,那么体积更大的模块会被优先拆解。

6.4、限制分包体积(minSize、maxSize.........)

在满足minChunks与maxInitialRequests的基础上,SplitChunksPlugin还会进一步判断Chunk包大小决定是否分包,这一规则相关的配置项非常多:

  • minSize:超过这个尺寸的Chunk才会正式被分包。
  • maxSize:超过这个尺寸的Chunk会尝试继续做分包。
  • maxAsyncSize:与maxSize功能类似,但只对异步引入的模块生效。
  • maxInitialSize:与maxSize类似,但只对entry配置的入口模块生效。
  • enforceSizeThreshold:超过这个尺寸的Chunk会被强制分包,忽略上述其它size限制。

那么,结合前面介绍的两种规则,SplitChunksPlugin的主体流程如下:

  • SplitChunksPlugin尝试将命中minChunks规则的Module统一抽到一个额外的Chunk对象。
  • 判断该Chunk是否满足maxInitialRequests阈值,若满足则进行下一步。
  • 判断该 Chunk 资源的体积是否大于上述配置项minSize声明的下限阈值。

如果体积「小于」 minSize则取消这次分包,对应的Module依然会被合并入原来的Chunk。

如果Chunk体积「大于」minSize则判断是否超过maxSize、maxAsyncSize、maxInitialSize声明的上限阈值,如果超过则尝试将该Chunk继续分割成更小的部分。

注意,这些属性的优先级顺序为:

maxInitialRequest/maxAsyncRequests < maxSize < minSize

而命中 enforceSizeThreshold 阈值的 Chunk 会直接跳过这些属性判断,强制进行分包。           

6.5、cacheGroups

cacheGroups配置项用于为不同文件组设置不同的规则,例如:

基本规则

module.exports = {
  //...
  optimization: {
    splitChunks: {
      minChunks: 2,
      cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            minChunks: 1,
            minSize: 0
        },
      },
    },
  },
};           

示例通过cacheGroups属性设置vendors缓存组,所有命中vendors.test规则的模块都会被视作vendors分组,优先应用该组下的minChunks、minSize等分包配置。

除了minChunks等分包基础配置项之外,cacheGroups还支持一些与分组逻辑强相关的属性,包括:

  • test:接受正则表达式、函数及字符串,所有符合test判断的Module或Chunk都会被分到该组。
  • type:接受正则表达式、函数及字符串,与test类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如type='json'会命中所有JSON文件。
  • name:chunk命名。
  • priority:数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到priority更大的组。

缓存组的作用在于能为不同类型的资源设置更具适用性的分包规则,一个典型场景是将所有node_modules下的模块统一打包到vendors产物,从而实现第三方库与业务代码的分离。

默认分组

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};           

这两个配置组能帮助我们:

  • 将所有node_modules中的资源单独打包到vendors.js命名的产物。
  • 对引用次数大于等于2的模块,也就是被多个Chunk引用的模块,单独打包。

七、SplitChunksPlugin最佳实战

那么,如何设置最适合项目情况的分包规则呢?这个问题并没有放诸四海皆准的通用答案,因为软件系统与现实世界的复杂性,决定了很多计算机问题并没有银弹,不过还是有几条可供参考的最佳实践:

  • 「尽量将第三方库拆为独立分包」
  • 「保持按路由分包,减少首屏资源负载」
  • 「尽量保持」 chunks='all'

八、构建打包工具比较

「前端」腾讯前端工程师总结的 webpack基础、分包大揭秘

文章来源:李倩倩_腾讯前端开发工程师_https://mp.weixin.qq.com/s?__biz=MzI2NDU4OTExOQ==&mid=2247539154&idx=1&sn=04b1c5e2f8d36257c9c71068b95caeea&chksm=eaa84382dddfca9494c58a871e5e0f41304185b5e7c3b09d885781bcf5882e6e6130c9ed97e5&scene=178&cur_album_id=2721018755119595521#rd