【Wbpack原理】基础流程解析,实现 mini-webpack ⛄:webpack 对前端同学来说并不陌生,它是我们学习前端工程化的第一站,在最开始的 vue-cli
中我们就可以发现它的身影。我们的 vue/react
项目是如何打包成 js
文件并在浏览器中运行的呢?本系列文章将会帮助你由浅入深理解 webpack
原理,了解其中的 loader/plugin
机制,熟悉 webpack
打包流程。实现简易 webpack
核心代码,run-loader
模块,示例 loader
与 plugin
。Tip:在阅读本文前需要了解一些 webpack
的基础概念及常用配置。
本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具 。当 webpack
处理应用程序时,它会在内部从一个或多个入口点构建一个**依赖图(dependency graph)**,然后将你项目中所需的每一个模块组合成一个或多个 bundles ,它们均为静态资源,用于展示你的内容。
本系列全部代码与文章会在 LonelySnowman/mini-webpack 同步,如果能帮到你的话请帮我点个 star 吧 😀。
基础流程解析 webpack 打包流程可大致分为以下四部分。
初始化准备:
webpack
会读取 webpack.config.js
文件中的参数,并将 shell
命令中的参数合并形成最终参数。
然后 webpack
根据最终参数初始化 compiler
对象,注册配置中的插件,执行 compiler.run()
开始编译。
模块编译:
从打包入口开始,调用匹配文件的 loader
对文件进行处理,并分析模块间的依赖关系,递归对模块进行编译。
模块生成:
输出文件:
根据模块间的依赖关系及配置文件,将处理后的模块输出到 output
的目录下。
compiler 对象记录着构建过程中 webpack 环境与配置信息,整个 webpack 从开始到结束的生命周期。
目录结构 首先搭建一下我们项目结构的基础目录,讲解过程中会给出具体代码,使用 pnpm-workspace 构建一个 monorepo 仓库,除 mini-webpack
代码外,后续还会带大家实现其他 webpack
相关知识模块。
也可以直接去 LonelySnowman/mini-webpack 克隆本系列相关代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 packages ├─core │ │ compilation.js │ │ compiler.js │ │ index.js │ │ webpack.js │ └─util │ index.js ├─example │ │ webpack.config .js │ └─src │ entry1.js │ entry2.js │ module .js ├─loaders │ loader-1. js │ loader-2. js └─plugins plugin-1. js plugin-2. js plugin-test.js
1 2 3 packages: - 'packages/*'
新建打包案例 在开始编写 mini-webpack
核心代码前,我们先编写一个用于我们编写完成后的测试用例。
新建一个 webpack
配置文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 const path = require ('path' )const Plugin1 = require ('../plugins/plugin-1' )const Plugin2 = require ('../plugins/plugin-2' )module .exports = { mode : 'development' , entry : { main : path.resolve (__dirname, './src/entry1.js' ), second : path.resolve (__dirname, './src/entry2.js' ), }, devtool : false , context : process.cwd (), output : { path : path.resolve (__dirname, './build' ), filename : '[name].js' , }, plugins : [new Plugin1 (), new Plugin2 ()], resolve : { extensions : ['.js' , '.ts' ], }, module : { rules : [ { test : /\.js/ , use : [ path.resolve (__dirname, '../loaders/loader-1.js' ), path.resolve (__dirname, '../loaders/loader-2.js' ), ], }, ], }, };
新建一下我们需要打包用的文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const depModule = require ('./module' );console .log (depModule, 'Entry 1 dep' );console .log ('This is entry 1 !' );const depModule = require ('./module' );console .log (depModule, 'Entry 2 dep' );console .log ('This is entry 2 !' );const name = 'This is module' ;module .exports = { name, };
新建我们用到的 plugin
与 loader
,如果你对这两个的实现原理都不太了解也不要担心,后续我们会详细讲解,这里只编写了一些简单的小案例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Plugin1 { apply (compiler ) { compiler.hooks .run .tap ('Plugin1' , () => { console .log ('Plugin1 Start' ); }); } } module .exports = Plugin1 ;class Plugin2 { apply (compiler ) { compiler.hooks .done .tap ('Plugin2' , () => { console .log ('Plugin2 Done' ); }); } } module .exports = Plugin2 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function loader (source ) { console .log ('loader1: normal' , source); return source + '\n// loader1' ; } loader.pitch = function ( ) { console .log ('loader1 pitch' ); }; module .exports = loader;function loader (source ) { console .log ('loader2: normal' , source); return source + '\n// loader2' ; } loader.pitch = function ( ) { console .log ('loader2 pitch' ); }; module .exports = loader;
初始化准备阶段 webpack cli
运行入口打包打包时 webpack
会读取 webpack.config.js
的配置并与 shell
中的参数合并,生成 compiler
对象并调用 compiler.run()
方法进行打包。
我们新建 index.js
作为 webpack
运行的入口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const webpack = require ('./webpack' );const config = require ('../example/webpack.config' );const compiler = webpack (config);compiler.run ((err, stats ) => { if (err) { console .log (err, 'err' ); } });
新建 webpack.js
去读取参数并返回 compiler
对象。
1 2 3 4 5 6 7 8 9 10 11 12 const Compiler = require ('./compiler' )function webpack (options ) { const mergedOptions = mergeOptions (options); const compiler = new Compiler (mergedOptions) return compiler } module .exports = webpack;
补充 mergeOptions
方法。
在运行 webpack
命令时我们可以使用 --mode=production
去覆盖 webpack.config.js
的参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function mergeOptions (options ) { const shellOptions = process.argv .slice (2 ).reduce ((option, argv ) => { const [key, value] = argv.split ('=' ) if (key && value) { const parseKey = key.slice (2 ) option[parseKey] = value } return option; }, {}) return { ...options, ...shellOptions } }
实现 compiler 对象 新建 compiler.js
文件,实现 compiler
对象核心逻辑。
compiler 对象记录着构建过程中 webpack 环境与配置信息,整个 webpack 从开始到结束的生命周期。我们需要实现 plugin
插件机制与 loader
机制。下面是 compiler
对象的基础骨架。
1 2 3 4 5 6 7 8 9 10 11 class Compiler { constructor (options ) { this .options = options; } run (callback ) { } } module .exports = Compiler
实现基础插件钩子 插件是 webpack 生态的关键部分, 它为我们提供了一种强有力的方式来直接触及 webpack 的编译过程(compilation process)。 插件能够 hook 到每一个编译(compilation)中发出的关键事件中。 在编译的每个阶段中,插件都拥有对 compiler
对象的完全访问能力, 并且在合适的时机,还可以访问当前的 compilation
对象。
compilation 对象记录编译模块的信息,只要项目文件有改动,compilation 就会被重新创建。
webpack
插件可以简单理解为可以在 wepack
整个生命周期中触发的钩子,类似与 vue
中的 created
,mounted
等生命周期。
这里简单讲解一下,后续有单独的章节详细讲解 plugin
我们实现一个简易的 webpack
插件,packages/plugins/plugin-test.js
,插件就是一个 javascript
类,需要实现 apply
方法供 webpack
调用,webpack
会在 compiler
及 compilation
对象上预设一系列钩子供我们调用。
1 2 3 4 5 6 7 8 9 10 11 12 class PluginTest { apply (compiler ) { compiler.hooks .run .tap ('Plugin Test' , () => { console .log ('PluginTest Start' ); }); } } module .exports = PluginTest ;
接下来我们在 compiler
实现一些基本的钩子,webpack
的插件借助 tapable
这个库去实现,我们可以使用 new SyncHook()
去初始化一个钩子对象,放在 compiler.hooks
下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const { SyncHook } = require ('tapable' )class Compiler { constructor (options ) { this .hooks = { run : new SyncHook (), emit : new SyncHook (), done : new SyncHook (), compilation : new SyncHook (["compilation" , "params" ]), }; } run (callback ) { this .hooks .run .call () } }
在初始化 compiler
对象时我们还需要去执行插件实例中的 apply
方法,用于注册插件中的钩子。
添加 loadPlugin
方法后的完整 webpack.js
如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const Compiler = require ('./compiler' )function webpack (options ) { const mergedOptions = mergeOptions (options); const compiler = new Compiler (mergedOptions) loadPlugin (options.plugins , compiler); return compiler } function mergeOptions (options ) { const shellOptions = process.argv .slice (2 ).reduce ((option, argv ) => { const [key, value] = argv.split ('=' ) if (key && value) { const parseKey = key.slice (2 ) option[parseKey] = value } return option; }, {}) return { ...options, ...shellOptions } } function loadPlugin (plugins, compiler ) { if (plugins && Array .isArray (plugins)) { plugins.forEach ((plugin ) => { plugin.apply (compiler); }); } } module .exports = webpack;
webpack
插件本质上就是通过发布订阅者模式,在 compiler.hooks
上监听事件,通过 compiler.hooks.xxx.tap
去订阅事件,用 compiler.hooks.xxx.call
去触发事件,触发方法在后续会逐步添加。
至此已实现了初始化准备阶段的内容,我们实现了 webpack 配置的读取及初始化合并,注册 webpack 插件并调用 compiler.run() 方法开始编译。
模块编译阶段 寻找编译入口 entry
打包前我们需要根据合并后的配置找到打包入口文件,对 entry
文件进行编译处理。入口配置可以为字符串也可以为对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { entry : 'entry.js' } { entry : { main : 'entry.js' } } { entry : { 'entry1' : './entry1.js' , 'entry2' : './entry2.js' } }
我们在 compiler.js
中实现 getEntry
寻找打包入口的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const path = require ('path' )const { toUnixPath } = require ('./util' )class Compiler { constructor (options ) { this .rootPath = this .options .context || toUnixPath (process.cwd ()) } run (callback ) { const entry = this .getEntry (); } getEntry ( ) { let entry = Object .create (null ) const { entry : optionsEntry } = this .options if (typeof optionsEntry === 'string' ) entry['main' ] = optionsEntry else entry = optionsEntry Object .keys (entry).forEach ((key ) => { const value = entry[key] if (!path.isAbsolute (value)) { entry[key] = toUnixPath (path.join (this .rootPath , value)) } }) return entry } } module .exports = Compiler
补充一下用到的工具函数。
1 2 3 4 5 6 7 8 9 10 function toUnixPath (path ) { return path.replace (/\\/g , '/' ); } module .exports = { toUnixPath }
这一步我们通过读取 webpack
配置中的 entry
获取打包入口文件转化为绝对路径并统一路径分分隔符。
从入口文件开始编译 🤔 编译阶段我们需要完成以下内容:
根据入口文件构建 compilation
对象,compilation
对象会负责模块编译过程的处理。
根据入口文件路径分析入口文件,使用 loader
处理匹配的文件。
将 loader
处理完成的入口文件进行编译。
分析入口文件依赖,重复上边两个步骤编译对应依赖。
如果嵌套文件存在依赖文件,递归调用依赖模块进行编译。
递归编译完成后,组装一个个包含多个模块的chunk
。
新建 Compilation
类进行编译模块的处理,保存该次编译过程中的入口模块对象、依赖模块对象、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Compilation { constructor (compiler, params ) { this .options = compiler.options ; this .rootPath = compiler.rootPath ; this .entries = new Set (); this .modules = new Set (); this .chunks = new Set (); this .assets = new Set (); this .files = []; } }
根据配置中的入口文件,开始从入口文件开始进行编译,并创建入口文件对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Compilation { buildEntryModule (entry ) { Object .entries (entry).forEach (([entryName, entryPath] ) => { const entryObj = this .buildModule (entryName, entryPath); this .entries .add (entryObj); this .buildUpChunk (entryName, entryObj); }); } }
模块编译 在编写模块编译的方法前,我们可以先使用原版的 webpack
对我们的案例进行打包,看一下打包后的结果。
1 2 3 4 5 6 7 8 9 10 11 12 const webpack = require ('webpack' );const config = require ('../example/webpack.config' );const compiler = webpack (config);compiler.run ((err, stats ) => { if (err) { console .log (err, 'err' ); } });
然后在根目录执行 node .\packages\core\index.js
进行打包。
我们可以看到依据我们的 entry
打包出了两个文件,分别来自 entry1
与 entry2
,我们可以看一下 packages/example/build/main.js
文件。下面的代码是剔除了注释后的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 (() => { var __webpack_modules__ = ({ "./packages/example/src/module.js" : ((module ) => { const name = 'This is module' ; module .exports = { name, }; }) }); var __webpack_module_cache__ = {}; function __webpack_require__ (moduleId ) { var cachedModule = __webpack_module_cache__[moduleId]; if (cachedModule !== undefined ) { return cachedModule.exports ; } var module = __webpack_module_cache__[moduleId] = { exports : {} }; __webpack_modules__[moduleId](module , module .exports , __webpack_require__); return module .exports ; } var __webpack_exports__ = {}; (() => { const depModule = __webpack_require__ ( "./packages/example/src/module.js" ); console .log (depModule, 'Entry 1 dep' ); console .log ('This is entry 1 !' ); })(); })();
🤔 这样一看原理其实很简单,webpack
最终打包出的文件是一个立即执行函数,依次读取 entry
中引用的文件全部编译在 __webpack_modules__
中的一个对象 ,key 为模块的相对路径(作为一个模块的唯一 id),value 为一个函数直接执行 module 中的代码。然后再封装一个 __webpack_require__
方法从 __webpack_modules__
获取 module
代码并执行。并将代码中的 require
全部替换为 __webpack_require__
。
那么再编译模块的方法主要进行两步操作,获取代码文件的源代码字符串,然后使用 loader 对代码进行处理,再对处理后的代码进行编译,就是将代码中的 require 全部替换为 __webpack_require__
,最后我们输出模块的时候再将 module
中的代码打包进 __webpack_modules__
就可以了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 buildModule (moduleName, modulePath ) { const originSourceCode = fs.readFileSync (modulePath, 'utf-8' ) this .originSourceCode = originSourceCode this .moduleCode = originSourceCode; this .handleLoader (modulePath); const module = this .handleWebpackCompiler (moduleName, modulePath); return module }
首先我们需要用 loader
处理读取的源文件内容。loader
本质上就是一个函数,接收文件源代码并可以在 this
中调用 webpack
上下文对象,返回 loader
处理后的代码内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 handleLoader (modulePath ) { const matchLoaders = []; const rules = this .options .module .rules ; rules.forEach ((loader ) => { const testRule = loader.test ; if (testRule.test (modulePath)) { if (typeof loader.use === 'string' ) { matchLoaders.push (loader.use ); } else { matchLoaders.push (...loader.use ); } } for (let i = matchLoaders.length - 1 ; i >= 0 ; i--) { const loaderFn = require (matchLoaders[i]); this .moduleCode = loaderFn.call (this , this .moduleCode ); } }); }
🤔 loader
处理完毕后我们需要进行 webpack
编译阶段,也就是需要将源代码中的 require
全部替换为 __webpack_require__
,并生成 module
对象。这个操作可以利用 bable
将代码转化为 ast
语法树,并直接操作语法树生成新的代码,非常方便。
并且在处理过程中我们要进行递归操作,一个模块依赖其他模块时,也需要对该模块的依赖模块进行编译处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 const parser = require ('@babel/parser' );const traverse = require ('@babel/traverse' ).default ;const generator = require ('@babel/generator' ).default ;const t = require ('@babel/types' );handleWebpackCompiler (moduleName, modulePath ) { const moduleId = toUnixPath ('./' + path.relative (this .rootPath , modulePath)); const module = { id : moduleId, dependencies : new Set (), name : [moduleName], source : this .originSourceCode }; const ast = parser.parse (this .moduleCode , { sourceType : 'module' , }); traverse (ast, { CallExpression :(nodePath ) => { const node = nodePath.node ; if (node.callee .name === 'require' ) { const requirePath = node.arguments [0 ].value ; const moduleDirName = path.dirname (modulePath); const absolutePath = tryExtensions ( path.join (moduleDirName, requirePath), this .options .resolve .extensions , requirePath, moduleDirName ); const moduleId = toUnixPath ('./' + path.relative (this .rootPath , absolutePath)); node.callee = t.identifier ('__webpack_require__' ); node.arguments = [t.stringLiteral (moduleId)]; module .dependencies .add (moduleId); } }, }); const { code } = generator (ast); module ._source = code; const alreadyModules = Array .from (this .modules ).map ((i ) => i.id ); module .dependencies .forEach ((dependencyPath ) => { if (!alreadyModules.includes (dependencyPath)) { const depModule = this .buildModule (moduleName, dependencyPath); this .modules .add (depModule); } else { this .modules .forEach ((value ) => { if (value.id === dependencyPath) { value.name .push (moduleName); } }) } }); return module }
补充一下匹配文件后缀的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function tryExtensions ( modulePath, extensions, originModulePath, moduleContext ) { extensions.unshift ('' ); for (let extension of extensions) { if (fs.existsSync (modulePath + extension)) { return modulePath + extension; } } throw new Error ( `No module, Error: Can't resolve ${originModulePath} in ${moduleContext} ` ); }
😀 到这里我们就完成了模块编译阶段,我们从打包入口开始,依次对入口文件以及引用的依赖模块进行 loader
处理以及 webpack
编译,构建出一个 **依赖图(dependency graph)**,使用 entries
与 modules
分别保存了入口对象和模块对象,我们可以根据这些信息去构建我们的 chunks
,最后将打包后的模块输出。
模块生成阶段 组装 chunk 🤔 这一阶段比较简单,一个 entry
生成一个 chunk
根据相关 modules
生成对象即可。
1 2 3 4 5 6 7 8 9 10 11 12 buildUpChunk (entryName, entryObj ) { const chunk = { name : entryName, entryModule : entryObj, modules : Array .from (this .modules ).filter ((i ) => i.name .includes (entryName) ), }; this .chunks .add (chunk); }
接下来补充一下在 compiler
对象中调用 compilation
进行编译的代码。
1 2 3 4 5 6 7 8 9 10 11 run (callback ) { const entry = this .getEntry (); const compilation = this .newCompilation (); compilation.buildEntryModule (entry); }
补充一下构建 compilation
对象的方法。
1 2 3 4 5 6 7 newCompilation (params ) { const compilation = new Compilation (this , {}) this .hooks .compilation .call (compilation, params); return compilation; }
输出文件阶段 最后我们需要根据我们生成的 chunks
去输出最终编译完成的文件即可,在模块编译阶段中已经讲解了 webpack
打包的原理,是在内部封装了一个 __webpack_require__
方法去调用 __webpack_modules__
中的方法,需要变更的地方只有 __webpack_modules__
对象和处理后的源代码内容,这些在 entrys
、modules
和 chunks
中我们都已经生成好了,其他地方直接使用原版 webpack
打包后的内容即可,这样我们就能生成我们的 assets
并输出文件。
编写一个根据 chunk
信息去生成最终代码的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 function getSourceCode (chunk ) { const { name, entryModule, modules } = chunk; return ` (() => { var __webpack_modules__ = { ${modules .map((module ) => { return ` '${module .id} ': (module) => { ${module ._source} } ` ; }) .join(',' )} }; // The module cache var __webpack_module_cache__ = {}; // The require function function __webpack_require__(moduleId) { // Check if module is in cache var cachedModule = __webpack_module_cache__[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } // Create a new module (and put it into the cache) var module = (__webpack_module_cache__[moduleId] = { // no module.id needed // no module.loaded needed exports: {}, }); // Execute the module function __webpack_modules__[moduleId](module, module.exports, __webpack_require__); // Return the exports of the module return module.exports; } var __webpack_exports__ = {}; // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. (() => { ${entryModule._source} })(); })(); ` ;}
最后我们根据 chunks
中的信息直接输出文件即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 emitAssets (compilation ) { const output = this .options .output ; compilation.chunks .forEach ((chunk ) => { const parseFileName = output.filename .replace ('[name]' , chunk.name ); compilation.assets [parseFileName] = getSourceCode (chunk); }); this .hooks .emit .call (); if (!fs.existsSync (output.path )) fs.mkdirSync (output.path ); compilation.files = Object .keys (this .assets ); compilation.files .forEach ((fileName ) => { const filePath = path.join (output.path , fileName); fs.writeFileSync (filePath, this .assets [fileName]); }); }
还需要在 compiler.run
函数中调用一下并补充回调逻辑,触发钩子等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 run (callback ) { this .emitAssets (compilation); this .hooks .done .call (); callback (null , { toJson : () => { return { entries : compilation.entries , modules : compilation.modules , files : compilation.files , chunks : compilation.chunks , assets : compilation.assets , }; }, }); }
到这里我们简易 webpack
的核心逻辑就全部实现了 😀,我们可以在项目根目录下执行 node .\packages\core\index.js
下我们的编译命令。发现 packages/example/build
目录下生成了 main.js
与 second.js
两个文件,分别对应两个入口打包出的 chunk
。
结语 想写在自己实操一遍后你已经对 webpack
有了更深刻的理解,之后我们还会深挖 loader
机制去实现 loader-runner
,理解 plugin
机制并实现简易的 tapable
,并实现简易的 loader
与 pluin
,最终能通过 index.html
运行我们的 web 项目。
参考文章 :
Webpack - 19组清风的专栏 - 掘金 (juejin.cn)
Github地址 :
LonelySnowman/mini-webpack
如果对你有帮助的话记得帮我点个赞 👍。
文章内容有不正确的地方请指出,我会及时更改 😀。