Webpack浅析
随着前端技术的飞速发展,前端开发也从静态页面发展到了web应用,简单的静态页面已经不能满足前端开发的需求。这时涌现了大量的工程化工具,例如早期的gulp,grunt等任务流工具,他们类似于使用javascript完成了shell的一些功能。这些工具虽然解决了大量静态文件的批处理问题,但本质上还是对静态文件的操作。
而webpack的出现将前端工程化提升到了一个新的高度,它可谓是将前端的模块化功能发挥到了极致,进而也成为了目前前端市场最火爆的打包工具。在这篇文章里,我们将会为您分析一下webpack的打包原理,帮助您更好的理解这一前端打包神器。
webpack是一个现代javascript模块打包器,由于commonJS提出了js的模块化规范,webpack很好的利用了这一规范。奉行一切文件皆模块的原则,会依据入口文件递归的编译并打包所有依赖到的文件,并将这些文件打包成一个或多个bundle。
您也许会想webpack到底把文件打包成了什么?在实际工作中我们并不需要去关心这个问题,因为webpack已经帮我们做好了一切,并且webpack打包后的代码非常难以阅读。不管是复杂的项目或者简简单单的几行代码,打包产出的结果本质是相同的。接下来就让我们一起探究其打包本质,了解webpack是如何打包我们的源文件的。
首先先初始化我们的项目:
mkdir webpack-bundle-analysis cd webpack-bundle-analysis npm init 或者 yarn init
接下来安装webpack依赖:
npm install --save-dev webpack webpack-cli 或者 yarn add --dev webpack webpack-cli
然后在根目录创建我们的源文件:
#index.js const { add } = require('./add'); add(1, 1); #add.js export function add (a, b) { return a + b; }
接下来就到了配置webpack的阶段了,在这里我们只需要最简单的webpack配置即可:
#webpack.config.js module.exports = { mode: 'development', entry: { index: './index.js', }, }
注意:这里一定要将mode设置为development,否则webpack会默认使用生产模式打包代码,使得代码难以阅读。
接下来在我们的package.json文件内填写如下打包指令:
"scripts": { "build": "webpack --config ./webpack.config.js", },
最后我们只需要执行 npm run build 或者yarn run build我们就会看见webpack已经帮我们把代码打包到了根目录的/dist/index.js文件内。
现在让我们来看一下webpack打包后的代码:
/******/ (function(modules) { // webpack启动函数 /******/ // webpack会在此处缓存已经加载过的模块,以防止模块的重复加载 /******/ var installedModules = {}; /******/ /******/ // webpack自定义的模块加载函数 /******/ function __webpack_require__(moduleId) { /******/ /******/ // 如果模块已经被缓存则使用缓存中的模块 /******/ if(installedModules[moduleId]) { /******/ return installedModules[moduleId].exports; /******/ } /******/ // 创建一个模块并将该模块放入缓存中,注意:这里的exports属性为空对 象,webpack会在下面的执行模块方法是将模块导出的对象挂载到module.exports /******/ var module = installedModules[moduleId] = { /******/ i: moduleId, /******/ l: false, /******/ exports: {} /******/ }; /******/ /******/ // 执行模块方法,并将模块导出对象挂载在module.exports /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ /******/ // 标记模块已加载过 /******/ module.l = true; /******/ /******/ // 返回模块导出对象 /******/ return module.exports; /******/ } /******/ /******/ /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ /******/ // 这个方法很关键,我们可以在启动函数参数内部的./add.js模块中看到此方法,此方法便起到了挂载导出对象的作用 /******/ __webpack_require__.d = function(exports, name, getter) { /******/ if(!__webpack_require__.o(exports, name)) { /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); /******/ } /******/ }; /******/ __webpack_require__.r = function(exports) { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; . . . /******/ /******/ /******/ // 加载入口模块并将导出模块返回 /******/ return __webpack_require__(__webpack_require__.s = "./index.js"); /******/ }) /************************************************************************/ /******/ ({ /***/ "./add.js": /*!****************!*\ !*** ./add.js ***! \****************/ /*! exports provided: add */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"add\", function() { return add; });\nfunction add (a, b) {\n return a + b;\n}\n\n//# sourceURL=webpack:///./add.js?"); /***/ }), /***/ "./index.js": /*!******************!*\ !*** ./index.js ***! \******************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { eval("const { add } = __webpack_require__(/*! ./add */ \"./add.js\");\n\nadd(1, 1);\n\n//# sourceURL=webpack:///./index.js?"); /***/ })
从上面代码我们可以看出webpack的打包结果实际上就是一个立即执行函数表达式(IIFE)。
(function(...args){})(...args)
参数是一个模块对象,对象的键值是打包模块module_id,即模块相对根目录的相对路径,对象的值则是一个方法。这个方法接收三个参数,第一个参数即该模块对象,第二个参数为模块的导出对象,第三个模块是webpack自定义的模块导入方法__webpack_require__。该导入方法接收一个参数:模块id,在该方法内部首先会判断webpack是否缓存过该模块,如果缓存过便从缓存对象installedModules取出该模块,如果没有缓存便会定义该模块对象并加入缓存,接下来执行模块方法并使用定义好的__webpack_require__.d将执行方法定义在模块的导出对象上。最后标记此模块已加载过,并返回该模块导出对象。
前文我们简单分析了webpack打包后的代码,虽然看起来比较晦涩难懂,但当我们提取出代码的关键部分之后,一切困难也就瞬间迎刃而解了。但是,知其然也要知其所以然,我们也要了解webpack的如何编译代码的。接下来,我们将通过webpack的源码来逐步解析webpack的代码编译过程。
在分析webpack的打包流程之前,我们先简单了解一下webpack配置的几个核心概念,webpack的打包会依赖这几个概念:
入口(entry):入口起点指示webpack应该以哪个模块作为打包构建的起点,webpack会从该起点开始不断遍历依赖模块进行打包构建。
loader:loader可以帮助webpack处理非.js(es5)类型的文件(webpack只能解析js文件),loader将不同类型的模块解析成webpack可以处理的模块。
plugin:插件的功能极为强大,其职责范围极广,从打包,优化到最后的输出。它的功能贯穿整个webpack打包流程。
优化(optimization):webpack资源优化,包含分离第三方模块,文件大小控制等等。
分块(Chunk):chunk即代码块,一个代码块由一个或多个模块组成,用于代码合并与分割。
哈希(hash): 代码块哈希命名
出口(output):output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件。
了解了上述基本概念之后,我们应该能对webpack打包流程有了一个粗浅的认识:即webpack从入口模块开始打包,并寻找其依赖,递归的将所有依赖模块打包,每次遇到webpack无法解析的模块,便使用loader将其解析成为webpack可以理解的模块。在打包结束后会对资源进行优化,比如分离第三方模块等等,然后进行代码块的分割创建,哈希命名,最后输出到指定文件目录。而插件则贯穿整个打包流程,打包过程中的每一步都需要插件来提供其能力。
简单了解完webpack的打包流程之后,接下来该进入本篇文章的重点了,我们会先介绍一下webpack打包所依赖的一些核心对象,然后根据列举源码关键流程,让大家能够通过源码去进一步熟悉该流程。
webpack的打包流程是采用事件流的模式,其内部使用Tapable进行事件定义。
Tapable是一个类似于nodejs的EentEmitter的库,主要控制钩子函数的事件发布和事件订阅。而webpack内部定义着大量功能丰富的插件,我们在前面提到过插件的功能贯穿了整个webpack打包流程,这些插件都会注册在Tapable定义的事件上,在打包过程中会依次有序的触发这些事件以调用插件执行其功能。
webpack内部定义了两个核心对象:compiler和compilation。这两个对象都集成自Tapable类,以便在其内部定义事件调用插件
Compiler:Compiler是webpack编译打包的核心对象,webpack编译每次编译开始时会创建一个全局唯一的compiler对象,compiler对象再创建一个compilation对象来负责模块的打包流程。
Compilation:compilation对象是Compiler编译过程中由compiler对象创建的,其内部也定义了大量的事件钩子以便插件调用。compilation负责打包的整个流程:加载(loaded),封存(sealed),优化(optimization),分块(chunked)和重新创建(restored)。当采用非watch模式启动编译时,只会创建一次compilation对象,当使用watch模式时(通常使用webpack-dev-server调用底层代码进行监听),每次模块内容变动时都会创建一个新的compilation对象。
上面我们了解了webpack的核心对象,现在让我们来分析webpack的源码。通常阅读源码时我们需要从主模块来开始阅读,其主模块为/node_modules/webpack/lib/webpack.js文件。主模块的webpack方法内首先会检查我们的配置文件格式是否有误。当配置文件格式准确无误时,接下来便开始创建compiler对象
compiler = new Compiler(options.context);
在webpack方法的底部会判断是否由监听模式启动(通常我们会使用webpack-dev-server来启动监听模式),如果为监听模式,便会调用compiler对象的watch方法,反之便会调用run方法。
if ( options.watch === true || (Array.isArray(options) && options.some(o => o.watch)) ) { const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : options.watchOptions || {}; return compiler.watch(watchOptions, callback); } compiler.run(callback);
我们先不要急着离开这里。文件的下面导出了大量webpack内置的插件,这些插件内部会监听不同的事件。即上面所说的,webpack编译过程中会触发不同的事件,那是便是这些插件大显神通的时候了。
#compiler.js exportPlugins(exports, { AutomaticPrefetchPlugin: () => require("./AutomaticPrefetchPlugin"), BannerPlugin: () => require("./BannerPlugin"), ...
接下来让我们走进webpack的编译过程。
调用compiler实例的run方法时,会依次触发beforeRun和run两个钩子
|
|
|
|
#compiler.js method:run() this.hooks.beforeRun.callAsync(this, err => { if (err) return finalCallback(err); this.hooks.run.callAsync(this, err => { if (err) return finalCallback(err); this.readRecords(err => { if (err) return finalCallback(err); this.compile(onCompiled); }); }); });
在compiler.run方法内部会触发compiler的compile方法,compiler.compile方法内部会创建一个compilation对象
#compiler.js method:compile() const compilation = this.newCompilation(params);
创建完compilation对象过程中,会触发thisCompilation和compilation钩子。
|
|
|
|
#compiler.js method: newCompilation() this.hooks.thisCompilation.call(compilation, params); this.hooks.compilation.call(compilation, params);
在compilation钩子执行过程中,会调用不同的loader来完成对文件的编译工作。同时也会触发一些文件转换的钩子事件。
|
|
|
|
|
|
创建完compilation对象,回到compiler的compile方法内部,触发make钩子。
|
|
#compiler.js method:compile() this.hooks.make.callAsync(compilation, err => {
编译过程结束,接下来调用compilation对象的finish方法,在finish方法内部会触发compilation对象的finishModule钩子,代表所有模块全部完成构建。
|
|
后面会调用compilation的seal方法,seal方法内部完成的功能比较多,会依次完成模块的优化,哈希化等工作。这里涉及了很多打包过程的细节,由于我们今天只探讨webpack的打包流程,便不对这些细节一一列举。
当所有的模块打包工作做好之后,便会触发compiler对象的afterCompile钩子,代表此次编译已经完成。
#compiler.js method: compile() this.hooks.afterCompile.callAsync(compilation, err => {
上述过程全部结束之后会根据开发者的配置文件出口配置将输出文件打包到相应的输出目录。
Webpack是一个及其庞大的项目,源码相对来说也非常复杂,这里只列出关键流程,帮助大家能够对webpack打包过程有一个比较清晰的认识。
module.exports = function(input){ // do something transform... return output }
const loaderUtils = require("loader-utils") module.exports = function(input) { // 获取 options const options = loaderUtils.getOptions(this) // do something transform... return output }
module.exports = function(input) { // 获取 options const options = loaderUtils.getOptions(this) // do something transform... this.callback(null, content) // 这里不要返回任何内容,这样webpack才能知道我们的内容是通过this.callback返回的 return }
module.exports = async function(input) { const output = await fetchSomething(); this.callback(null, output); return }
module.exports = async function(input) { const callback = this.async(); fetchSomething().then(res => { callback(res); }) }
// webpack.config.js module.exports = { ... module: { rules: [{ test: /^\/src\/api\.js$/, loader: 'api-resolver-loader' }] } ... }
module.exports = function(input) { return input.replace(/\/(.*)/g, "/api/$1") }
compiler.hooks.beforeCompile.tap(...)
class MyPlugin { constructor(options) { this.options = options } apply(compiler) { compiler.hooks.someHook.tap('MyPlugin', () => { // do something... }) } }
const fs = require('fs'); const path = require('path') class MyPlugin { constructor(options) { this.outputPath = options.outputPath } apply(compiler) { compiler.hooks.beforeRun.tap('MyPlugin', () => { const rootPath = process.cwd(); const outputPath = path.resolve(rootPath, this.outputPath); const files = fs.readDirSync(outputPath); for (const file of files) { fs.unLinkSync(file); } }) } }