webpack 的打包原理
Author:zhoulujun Date:
关于webpack还有一个故事,Tobias Koppers是 Webpack 仓库创建者,Tobias 的网络昵称叫 sokra ,后面我们就叫 sokra,sokra 没有写过 web 页面,这个就很有意思了,一个没有写过web页面的人发明了当代web开发的基石。
sokra 一开始是写 Java 的, Java 里面有个很出名的技术叫GWT(Google Web Toolkit),GWT 是把 Java 代码转换成JavaScript,也就是让后端来写前端,本质上也是在AST层面对代码做一层转换,Babel 也是干这件事的,但是 GWT 这门技术没有流行起来,后面 Google 也不推广了。
GWT里面有个feature叫「code splitting」,于是他当时给用来做前端项目 Bundle 的 node.js 库 modules-webmake 提了一个 issue,希望他们能实现,「code splitting」就是Webpack现在提供的主要功能,也是当代前端的基石。
那么webpack如何去做 code splitting 呢?
一个从模块化解析,一个从代码结构进行分析。
模块打包
模块解析过程
模块解析,就是分析模块的导入(依赖)和导出的过程。模块解析在webpack打包过程中有非常重要的地位。
我们先来看下webpack打包过程,大致过程可以描述为:webpack从入口开始构建依赖图,然后把不同类型的模块交给对应的loader处理,处理完成后打包到一起。
这个过程的描述有些不清晰:webpack负责构建依赖图,那么实际项目中那么多种不同类型的模块,它们的依赖如何解析?
loader负责处理不同类型的模块,处理时候也要解析依赖吗,到底是webpack解析依赖,还是loader解析依赖?loader它到底做了哪些事情?最终webpack是如何将不同模块打包到一起的呢?
其实,webpack本身可以支持几种常见的模块:https://webpack.docschina.org/concepts/modules/#supported-module-types
对于这些类型的模块,webpack会对其根据后缀匹配,然后进行默认方式的解析,不需要配置loader,例如JavaScript/auto,支持ESM、CommonJS、AMD,对于asset类型的模块会输出静态资源然后导出引用地址。
对于其他类型的模块,需要对应的loader处理。
loader主要做了两件事:
转译代码。
将模块化代码转为webpack可以识别的格式。例如对于css,@import等引用其他css的语法webpack并不能识别,css-loader会将这些模块引用语法转换为require,这样就能够被webpack识别了。所以,最终负责依赖解析的还是webpack,loader只是将模块转为webpack能识别的模块。loader还有一种方式可以让webpack知道某个模块的依赖,就是使用this.resolve/this.getResolve。(https://webpack.docschina.org/contribute/writing-a-loader/#module-dependencies)
依赖解析完成之后,代码也转译完成后,剩下的就是打包了。
模块打包
普通模块打包
webpack解析好模块后,会将代码都包装成commonjs格式的模块,本质就是闭包。
通过webpack运行时代码完成模块导出导入。
动态引入的模块打包
动态依赖会把依赖的模块单独打包成一个chunk,chunk就是一个文件,一个chunk中包含1个或多个module。
动态依赖语句会被转为一个promise,通过动态创建script标签异步加载chunk。
加载好chunk后,会做几件事情:
把自己注册到已安装依赖中(webpackChunkwebpackmodule3)
加载chunk中的所有模块
把加载chunk的promise resolve
加载好chunk后(即promise resolve后),意味着模块也已经加载好,接下来就会通过require加载模块。然后就可以正常地使用模块了。
Webpack runtime
对 webpack runtime 做进一步的精简,代码如下
const __webpack_modules__ = [() => {}]; const __webpack_require__ = (id) => { const module = { exports: {} }; const m = __webpack_modules__[id](module, __webpack_require__); return module.exports; }; __webpack_require__(0);
多chunk打包
wepback支持分包加载,可以把项目打包成多个chunk,多个chunk的加载和动态依赖类似,也是要通过一个全局变量管理chunk,所以chunk的加载过程都是一样的。
多chunk需要注意一个问题,因为多个chunk是有依赖关系的,如果我们在html中加载chunk的顺序正确,那么执行的顺序和打成一个bundle一样,如果加载顺序和依赖的关系不一致,就需要通过某种机制保证依赖的chunk加载完,再执行模块。
分片打包之后,不能像只有一个bundle那样直接通过require引用模块,因为依赖的模块所在的chunk可能没有加载完,因此要先通过require.Onload方法确保chunk已经加载完,再去执行当前模块,由于chunk加载完时,chunk内的所有模块都会被加载,因此这时候通过require引用依赖的模块是没有问题的。
require.Onload方法就是把每个模块依赖的chunk和回调都保存起来,并且检查当前所有的模块,如果发现某个模块依赖的chunk都已经加载完,就执行其回调。每当某个chunk加载完,都会调用require.Onload,以便依赖它的模块可以马上执行。
这样,webpack就可以保证分包的chunk在页面加载顺序和依赖顺序不一致时候,也可以正常工作,同步地执行。
webpack的打包代码分析
chunk 和 module
webpack 里面有两个很核心的概念,叫 chunk 和 module,这里为了简单,只看 js 相关的,用笔者自己的理解去解释一下他们直接的区别:
module:每一个源码 js 文件其实都可以看成一个 module
chunk:每一个打包落地的 js 文件其实都是一个 chunk,每个 chunk 都包含很多 module
默认的 chunk 数量实际上是由你的入口文件的 js 数量决定的,但是如果你配置动态加载或者提取公共包的话,也会生成新的 chunk。
打包代码解读
有了基本理解后,我们需要去理解 webpack 打包后的代码在浏览器端是如何加载执行的。为此我们准备一个非常简单的 demo,来看一下它的生成文件。
src ---main.js ---moduleA.js ---moduleB.js /** * moduleA.js */ export default function testA() { console.log('this is A'); } /** * main.js */ import testA from './moduleA'; testA(); import('./moduleB').then(module => {});
非常简单,入口 js 是 main.js,里面就是直接引入 moduleA.js,然后动态引入 moduleB.js,那么最终生成的文件就是两个 chunk,分别是:
main.js 和 moduleA.js 组成的 bundle.js
`moduleB.js 组成的 0.bundle.js
如果你了解 webpack 底层原理的话,那你会知道这里是用 mainTemplate 和 chunkTemplate 分别渲染出来的,不了解也没关系,我们继续解读生成的代码
import 变成了什么样
整个 main.js 的代码打包后是下面这样的
(function (module, __webpack_exports__, __webpack_require__) { 'use strict'; __webpack_require__.r(__webpack_exports__); /* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*! ./moduleA */ './src/moduleA.js'); Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__['default'])(); __webpack_require__.e( /*! import() */ 0) .then(__webpack_require__.bind(null, /*! ./moduleB */ './src/moduleB.js')) .then(module => {}); });
可以看到,我们的直接 import moduleA 最后会变成 webpack_require,而这个函数是 webpack 打包后的一个核心函数,就是解决依赖引入的。
webpack_require 是怎么实现的
那我们看一下 webpack_require 它是怎么实现的:
function __webpack_require__(moduleId) { // Check if module is in cache // 先检查模块是否已经加载过了,如果加载过了直接返回 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // Create a new module (and put it into the cache) // 如果一个import的模块是第一次加载,那之前必然没有加载过,就会去执行加载过程 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // Execute the module function modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded module.l = true; // Return the exports of the module return module.exports; }
如果简化一下它的实现,其实很简单,就是每次 require,先去缓存的 installedModules 这个缓存 map 里面看是否加载过了,如果没有加载过,那就从 modules 这个所有模块的 map 里去加载。
__webpack_require__函数本身不关注模块来源
ESM中一个模块是一个文件,而webpack对于ESM的实现并不没有遵从该规则。在webpack的理解中多个module会组成chunk,chunk会组成bundle后输出,这个bundle则是最终的产物,而大部分情况下chunk和bundle是一一对应的。在下面的介绍中统一使用chunk作为webpack的产物,方便理解。
所以一个chunk对应一个文件,而一个chunk中有很多模块。例如我们会将项目依赖的所有三方库打在一个chunk里面生成一个稳定的文件,不会随着业务的迭代重新打包使缓存失效。
那也就是说入口和依赖的包可能不在一个文件中,那么webpack是如何下载依赖之后组装起来并正常运行则是问题的核心。
从从面的介绍中知道__webpack_require__函数本身不关注模块来源,执行时直接从__webpack_modules__中根据模块id获取对应模块即可,也就是说__webpack_require__请求对应模块之前该模块一定完成了安装,即使该模块是通过网络另外获取的。
将入口文件放在依赖之后加载,让依赖先加载之后再加载入口文件并执行,即可解决入口和依赖不在一个文件内的问题。
所以关注的点变成了分开加载的模块是怎么安装到webpack运行时的__webpack_modules__对象上的。
modules 从哪里来的
那相信很多人都有疑问了,modules 这么个至关重要的 map 是从哪里来的呢,我们把 bundle.js 生成的 js 再简化一下:
(function (modules) {})({ "./src/main.js": (function (module, __webpack_exports__, __webpack_require__) {}), "./src/moduleA.js": (function (module, __webpack_exports__, __webpack_require__) {}) });
所以可以看到,这其实是个立即执行函数,modules 就是函数的入参,具体值就是我们包含的所有 module,到此,一个 chunk 是如何加载的,以及 chunk 如何包含 module,相信大家一定会有自己的理解了。
动态引入如何操作呢
上面的 chunk 就是一个 js 文件,所以维护了自己的局部 modules,然后自己使用没啥问题,但是动态引入我们知道是会生成一个新的 js 文件的,那这个新的 js 文件 0.bundle.js 里面是不是也有自己的 modules 呢?那 bundle.js 如何知道 0.bundle.js 里面的 modules 呢?
先看动态 import 的代码变成了什么样:
__webpack_require__.e( /*! import() */ 0) .then(__webpack_require__.bind(null, /*! ./moduleB */ "./src/moduleB.js")) .then(module => { });
从代码看,实际上就是外面套了一层 webpck_require.e,然后这是一个 promise,在 then 里面再去执行 webpack_require。
实际上 webpck_require.e 就是去加载 chunk 的 js 文件 0.bundle.js,具体代码就不贴了,没啥特别的。
等到加载回来后它认为bundle.js 里面的 modules 就一定会有了 0.bundle.js 包含的那些 modules,这是如何做到的呢?
我们看 0.bundle.js 到底是什么内容,让它拥有这样的魔力:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push( [ [0], { "./src/moduleB.js": (function (module, __webpack_exports__, __webpack_require__) {}) } ] );
拿简化后的代码一看,大家第一眼想到的是 jsonp,但是很遗憾的是它不是一个函数,却只是向一个全局数组里面 push 了自己的模块 id 以及对应的 modules。那看起来魔法的核心应该是在 bundle.js 里面了,事实的确也是如此。
var jsonpArray = window['webpackJsonp'] = window['webpackJsonp'] || []; var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); jsonpArray.push = webpackJsonpCallback; jsonpArray = jsonpArray.slice(); for (var i = 0; i < jsonpArray.length; i++) { webpackJsonpCallback(jsonpArray[i]); }
在 bundle.js 的里面,我们看到这么一段代码,其实就是说我们劫持了 push 函数,那 0.bundle.js 一旦加载完成,我们岂不是就会执行这里,那不就能拿到所有的参数,然后把 0.bundle.js 里面的所有 module 加到自己的 modules 里面去!
其实简单来说就是,对于 mainChunk 文件,我们维护一个 modules 这样的所有模块 map,并且提供类似 webpack_require 这样的函数。对于 chunkA 文件(可能是因为提取公共代码生成的、或者是动态加载)我们就用类似 jsonp 的方式,让它把自己的所有 modules 添加到主 chunk 的 modules 里面去。
参考文章:
Webpack 怎么实现按需异步加载模块 https://blog.csdn.net/a675697174/article/details/135621602
webpack的ESM实现 https://blog.csdn.net/letterTiger/article/details/136977101
浏览器、ESM规范、模块化、webpack和vite之间联系? https://www.w3cschool.cn/article/b6e4e9c41df734.html
webpack 的 runtime 做了什么事情 https://q.shanyue.tech/engineering/e729
转载本站文章《webpack 的打包原理》,
请注明出处:https://www.zhoulujun.cn/html/tools/Bundler/webpackTheory/9030.html