0


Webpack完整打包流程分析

前言

webpack

在前端工程领域起到了中流砥柱的作用,理解它的内部实现机制会对你的工程建设提供很大的帮助(不论是定制功能还是优化打包)。

下面我们基于 webpack5 源码结构,对整个打包流程进行简单梳理并进行实现,便与思考和理解每个阶段所做的事情,为今后扩展和定制工程化能力打下基础。

一、准备工作

在流程分析过程中我们会简单实现

webpack

的一些功能,部分功能的实现会借助第三方工具:

  • tapable 提供 Hooks 机制来接入插件进行工作;
  • babel 相关依赖可用于将源代码解析为 AST,进行模块依赖收集和代码改写。
// 创建仓库
mkdir webpack-demo &&cd webpack-demo &&npm init -y

// 安装 babel 相关依赖
npminstall @babel/parser @babel/traverse @babel/types @babel/generator -D

// 安装 tapable(注册/触发事件流)和 fs-extra 文件操作依赖
npminstall tapable fs-extra -D

接下来我们在

src

目录下新建两个入口文件和一个公共模块文件:

mkdir src &&cd src &&touch entry1.js &&touch entry2.js &&touch module.js

并分别为文件添加一些内容:

// src/entry1.jsconst module =require('./module');conststart=()=>'start';start();
console.log('entry1 module: ', module);// src/entry2.jsconst module =require('./module');constend=()=>'end';end();
console.log('entry2 module: ', module);// src/module.jsconst name ='cegz';
module.exports ={
  name,};

有了打包入口,我们再来创建一个

webpack.config.js

配置文件做一些基础配置:

// ./webpack.config.jsconst path =require('path');const CustomWebpackPlugin =require('./plugins/custom-webpack-plugin.js');

module.exports ={entry:{entry1: path.resolve(__dirname,'./src/entry1.js'),entry2: path.resolve(__dirname,'./src/entry2.js'),},context: process.cwd(),output:{path: path.resolve(__dirname,'./build'),filename:'[name].js',},plugins:[newCustomWebpackPlugin()],resolve:{extensions:['.js','.ts'],},module:{rules:[{test:/\.js/,use:[
          path.resolve(__dirname,'./loaders/transformArrowFnLoader.js'),// 转换箭头函数],},],},};

以上配置,指定了两个入口文件,以及一个

output.build

输出目录,同时还指定了一个

plugin

和一个

loader

接下来我们编写

webpack

的核心入口文件,来实现打包逻辑。这里我们创建 webpack 核心实现所需的文件:

// cd webpack-demo
mkdir lib &&cd lib
touch webpack.js // webpack 入口文件
touch compiler.js // webpack 核心编译器
touch compilation.js // webpack 核心编译对象
touch utils.js // 工具函数

这里我们创建了两个比较相似的文件:

compiler

compilation

,在这里做下简要说明:

  • compiler:webpack 的编译器,它提供的 run 方法可用于创建 compilation 编译对象来处理代码构建工作;
  • compilation:由 compiler.run 创建生成,打包编译的工作都由它来完成,并将打包产物移交给 compiler 做输出写入操作。

对于入口文件

lib/webpack.js

,你会看到大致如下结构:

// lib/webpack.jsfunctionwebpack(options){...}

module.exports = webpack;

对于执行入口文件的测试用例,代码如下:

// 测试用例 webpack-demo/build.jsconst webpack =require('./lib/webpack');const config =require('./webpack.config');const compiler =webpack(config);// 调用run方法进行打包
compiler.run((err, stats)=>{if(err){
    console.log(err,'err');}// console.log('构建完成!', stats.toJSON());});

接下来,我们从

lib/webpack.js

入口文件,按照以下步骤开始分析打包流程。

1、初始化阶段 -

webpack
  • 合并配置项
  • 创建 compiler
  • 注册插件

2、编译阶段 -

build
  • 读取入口文件
  • 从入口文件开始进行编译
  • 调用 loader 对源代码进行转换
  • 借助 babel 解析为 AST 收集依赖模块
  • 递归对依赖模块进行编译操作

3、生成阶段 -

seal
  • 创建 chunk 对象
  • 生成 assets 对象

4、写入阶段 -

emit

二、初始化阶段

初始化阶段的逻辑集中在调用

webpack(config)

时候,下面我们来看看

webpack()

函数体内做了哪些事项。

2.1、读取与合并配置信息

通常,在我们的工程的根目录下,会有一个

webpack.config.js

作为

webpack

的配置来源;

除此之外,还有一种是通过 webpak bin cli 命令进行打包时,命令行上携带的参数也会作为 webpack 的配置。

在配置文件中包含了我们要让 webpack 打包处理的入口模块、输出位置、以及各种 loader、plugin 等;

在命令行上也同样可以指定相关的配置,且

权重高于

配置文件。(下面将模拟 webpack cli 参数合并处理)

所以,我们在 webpack 入口文件这里将先做一件事情:合并配置文件与命令行的配置。

// lib/webpack.jsfunctionwebpack(options){// 1、合并配置项const mergeOptions =_mergeOptions(options);...}function_mergeOptions(options){const shellOptions = process.argv.slice(2).reduce((option, argv)=>{// argv -> --mode=productionconst[key, value]= argv.split('=');if(key && value){const parseKey = key.slice(2);
      option[parseKey]= value;}return option;},{});return{...options,...shellOptions };}

module.exports = webpack;

2.2、创建编译器(compiler)对象

好的程序结构离不开一个实例对象,webpack 同样也不甘示弱,其编译运转是由一个叫做

compiler

的实例对象来驱动运转。

compiler

实例对象上会记录我们传入的配置参数,以及一些串联插件进行工作的

hooks

API。

同时,还提供了

run

方法启动打包构建,

emitAssets

对打包产物进行输出磁盘写入。这部分内容后面介绍。参考webpack视频讲解:进入学习

// lib/webpack.jsconst Compiler =require('./compiler');functionwebpack(options){// 1、合并配置项const mergeOptions =_mergeOptions(options);// 2、创建 compilerconst compiler =newCompiler(mergeOptions);...return compiler;}

module.exports = webpack;
Compiler

构造函数基础结构如下:

// core/compiler.jsconst fs =require('fs');const path =require('path');const{ SyncHook }=require('tapable');// 串联 compiler 打包流程的订阅与通知钩子const Compilation =require('./compilation');// 编译构造函数classCompiler{constructor(options){this.options = options;this.context =this.options.context || process.cwd().replace(/\\/g,'/');this.hooks ={// 开始编译时的钩子run:newSyncHook(),// 模块解析完成,在向磁盘写入输出文件时执行emit:newSyncHook(),// 在输出文件写入完成后执行done:newSyncHook(),};}run(callback){...}emitAssets(compilation, callback){...}}

module.exports = Compiler;

当需要进行编译时,调用

compiler.run

方法即可:

compiler.run((err, stats)=>{...});

2.3、插件注册

有 compiler 实例对象后,就可以注册配置文件中的一个个插件,在合适的时机来干预打包构建。

插件需要接收

compiler

对象作为参数,以此来对打包过程及产物产生

side effect

插件的格式可以是函数或对象,如果为对象,需要自定义提供一个

apply

方法。常见的插件结构如下:

classWebpackPlugin{apply(compiler){...}}

注册插件逻辑如下:

// lib/webpack.jsfunctionwebpack(options){// 1、合并配置项const mergeOptions =_mergeOptions(options);// 2、创建 compilerconst compiler =newCompiler(mergeOptions);// 3、注册插件,让插件去影响打包结果if(Array.isArray(options.plugins)){for(const plugin of options.plugins){if(typeof plugin ==="function"){plugin.call(compiler, compiler);// 当插件为函数时}else{plugin.apply(compiler);// 如果插件是一个对象,需要提供 apply 方法。}}}return compiler;}

到这里,webpack 的初始工作已经完成,接下来是调用

compiler.run()

进入编译构建阶段。

三、编译阶段

编译工作的起点是在

compiler.run

,它会:

  1. 发起构建通知,触发 hooks.run 通知相关插件;
  2. 创建 compilation 编译对象;
  3. 读取 entry 入口文件;
  4. 编译 entry 入口文件;

3.1、创建 compilation 编译对象

模块的打包(

build

)和 代码生成(

seal

)都是由

compilation

来实现。

// lib/compiler.jsclassCompiler{...run(callback){// 触发 run hookthis.hooks.run.call();// 创建 compilation 编译对象const compilation =newCompilation(this);...}}
compilation

实例上记录了构建过程中的

entries

module

chunks

assets

等编译信息,同时提供

build

seal

方法进行代码构建和代码生成。

// lib/compilation.jsconst fs =require('fs');const path =require('path');const parser =require('@babel/parser');const traverse =require('@babel/traverse').default;const generator =require('@babel/generator').default;const t =require('@babel/types');const{ tryExtensions, getSourceCode }=require('./utils');classCompilation{constructor(compiler){this.compiler = compiler;this.context = compiler.context;this.options = compiler.options;// 记录当前 module codethis.moduleCode =null;// 保存所有依赖模块对象this.modules =newSet();// 保存所有入口模块对象this.entries =newMap();// 所有的代码块对象this.chunks =newSet();// 存放本次产出的文件对象(与 chunks 一一对应)this.assets ={};}build(){}seal(){}}

有了

compilation

对象后,通过执行

compilation.build

开始模块构建。

// lib/compiler.jsclassCompiler{...run(callback){// 触发 run hookthis.hooks.run.call();// 创建 compilation 编译对象const compilation =newCompilation(this);// 编译模块
    compilation.build();}}

3.2、读取 entry 入口文件

构建模块首先从 entry 入口模块开始,此时首要工作是根据配置文件拿到入口模块信息。

entry 配置的方式多样化,如:可以不传(有默认值)、可以传入 string,也可以传入对象指定多个入口。

所以读取入口文件需要考虑并兼容这几种灵活配置方式。

// lib/compilation.jsclassCompilation{...build(){// 1、读取配置入口const entry =this.getEntry();...}getEntry(){let entry = Object.create(null);const{entry: optionsEntry }=this.options;if(!optionsEntry){
      entry['main']='src/index.js';// 默认找寻 src 目录进行打包}elseif(typeof optionsEntry ==='string'){
      entry['main']= optionsEntry;}else{
      entry = optionsEntry;// 视为对象,比如多入口配置}// 相对于项目启动根目录计算出相对路径
    Object.keys(entry).forEach((key)=>{
      entry[key]='./'+ path.posix.relative(this.context, entry[key]);});return entry;}}

3.3、编译 entry 入口文件

拿到入口文件后,依次对每个入口进行构建。

// lib/compilation.jsclassCompilation{...build(){// 1、读取配置入口const entry =this.getEntry();// 2、构建入口模块
    Object.keys(entry).forEach((entryName)=>{const entryPath = entry[entryName];const entryData =this.buildModule(entryName, entryPath);this.entries.set(entryName, entryData);});}}

构建阶段执行如下操作:

  1. 通过 fs 模块读取 entry 入口文件内容;
  2. 调用 loader 来转换(更改)文件内容;
  3. 为模块创建 module 对象,通过 AST 解析源代码收集依赖模块,并改写依赖模块的路径;
  4. 如果存在依赖模块,递归进行上述三步操作;

读取文件内容:

// lib/compilation.jsclassCompilation{...buildModule(moduleName, modulePath){// 1. 读取文件原始代码const originSourceCode = fs.readFileSync(modulePath,'utf-8');this.moduleCode = originSourceCode;...}}

调用 loader 转换源代码:

// lib/compilation.jsclassCompilation{...buildModule(moduleName, modulePath){// 1. 读取文件原始代码const originSourceCode = fs.readFileSync(modulePath,'utf-8');this.moduleCode = originSourceCode;// 2. 调用 loader 进行处理this.runLoaders(modulePath);...}}
loader

本身是一个 JS 函数,接收模块文件的源代码作为参数,经过加工改造后返回新的代码。

// lib/compilation.jsclassCompilation{...runLoaders(modulePath){const matchLoaders =[];// 1、找到与模块相匹配的 loaderconst rules =this.options.module.rules;
    rules.forEach((loader)=>{const testRule = loader.test;if(testRule.test(modulePath)){// 如:{ test:/\.js$/g, use:['babel-loader'] }, { test:/\.js$/, loader:'babel-loader' }
        loader.loader ? matchLoaders.push(loader.loader): matchLoaders.push(...loader.use);}});// 2. 倒序执行 loaderfor(let i = matchLoaders.length -1; i >=0; i--){const loaderFn =require(matchLoaders[i]);// 调用 loader 处理源代码this.moduleCode =loaderFn(this.moduleCode);}}}

执行 webpack 模块编译逻辑:

// lib/compilation.jsclassCompilation{...buildModule(moduleName, modulePath){// 1. 读取文件原始代码const originSourceCode = fs.readFileSync(modulePath,'utf-8');this.moduleCode = originSourceCode;// 2. 调用 loader 进行处理this.runLoaders(modulePath);// 3. 调用 webpack 进行模块编译 为模块创建 module 对象const module =this.handleWebpackCompiler(moduleName, modulePath);return module;// 返回模块}}
  1. 创建 module 对象;
  2. 对 module code 解析为 AST 语法树;
  3. 遍历 AST 去识别 require 模块语法,将模块收集在 module.dependencies 之中,并改写 require 语法为 __webpack_require__
  4. 将修改后的 AST 转换为源代码;
  5. 若存在依赖模块,深度递归构建依赖模块。
// lib/compilation.jsclassCompilation{...handleWebpackCompiler(moduleName, modulePath){// 1、创建 moduleconst moduleId ='./'+ path.posix.relative(this.context, modulePath);const module ={id: moduleId,// 将当前模块相对于项目启动根目录计算出相对路径 作为模块IDdependencies:newSet(),// 存储该模块所依赖的子模块entryPoint:[moduleName],// 该模块所属的入口文件};// 2、对模块内容解析为 AST,收集依赖模块,并改写模块导入语法为 __webpack_require__const ast = parser.parse(this.moduleCode,{sourceType:'module',});// 遍历 ast,识别 require 语法traverse(ast,{CallExpression:(nodePath)=>{const node = nodePath.node;if(node.callee.name ==='require'){const requirePath = node.arguments[0].value;// 寻找模块绝对路径const moduleDirName = path.posix.dirname(modulePath);const absolutePath =tryExtensions(
            path.posix.join(moduleDirName, requirePath),this.options.resolve.extensions,
            requirePath,
            moduleDirName
          );// 创建 moduleIdconst moduleId ='./'+ path.posix.relative(this.context, absolutePath);// 将 require 变成 __webpack_require__ 语句
          node.callee = t.identifier('__webpack_require__');// 修改模块路径(参考 this.context 的相对路径)
          node.arguments =[t.stringLiteral(moduleId)];if(!Array.from(this.modules).find(module=> module.id === moduleId)){// 在模块的依赖集合中记录子依赖
            module.dependencies.add(moduleId);}else{// 已经存在模块集合中。虽然不添加进入模块编译 但是仍要在这个模块上记录被依赖的入口模块this.modules.forEach((module)=>{if(module.id === moduleId){
                module.entryPoint.push(moduleName);}});}}},});// 3、将 ast 生成新代码const{ code }=generator(ast);
    module._source = code;// 4、深度递归构建依赖模块
    module.dependencies.forEach((dependency)=>{const depModule =this.buildModule(moduleName, dependency);// 将编译后的任何依赖模块对象加入到 modules 对象中去this.modules.add(depModule);});return module;}}

通常我们 require 一个模块文件时习惯不去指定文件后缀,默认会查找 .js 文件。

这跟我们在配置文件中指定的

resolve.extensions

配置有关,在

tryExtensions

方法中会尝试为每个未填写后缀的 Path 应用

resolve.extensions

// lib/utils.jsconst fs =require('fs');functiontryExtensions(modulePath,  extensions,  originModulePath,  moduleContext){// 优先尝试不需要扩展名选项(用户如果已经传入了后缀,那就使用用户填入的,无需再应用 extensions)
  extensions.unshift('');for(let extension of extensions){if(fs.existsSync(modulePath + extension)){return modulePath + extension;}}// 未匹配对应文件thrownewError(`No module, Error: Can't resolve ${originModulePath} in  ${moduleContext}`);}

module.exports ={
  tryExtensions,...}

至此,「编译阶段」到此结束,接下来是「生成阶段」

seal

四、生成阶段

在「编译阶段」会将一个个文件构建成

module

存储在

this.modules

之中。

在「生成阶段」,会根据

entry

创建对应

chunk

并从

this.modules

中查找被

entry

所依赖的

module

集合。

最后,结合

runtime

webpack 模块机制运行代码,经过拼接生成最终的

assets

产物。

// lib/compiler.jsclassCompiler{...run(callback){// 触发 run hookthis.hooks.run.call();// 创建 compilation 编译对象const compilation =newCompilation(this);// 编译模块
    compilation.build();// 生成产物
    compilation.seal();...}}
entry + module

-->

chunk

-->

assets

过程如下:

// lib/compilation.jsclassCompilation{...seal(){// 1、根据 entry 创建 chunkthis.entries.forEach((entryData, entryName)=>{// 根据当前入口文件和模块的相互依赖关系,组装成为一个个包含当前入口所有依赖模块的 chunkthis.createChunk(entryName, entryData);});// 2、根据 chunk 创建 assetsthis.createAssets();}// 根据入口文件和依赖模块组装chunkscreateChunk(entryName, entryData){const chunk ={// 每一个入口文件作为一个 chunkname: entryName,// entry build 后的数据信息entryModule: entryData,// entry 的所依赖模块modules: Array.from(this.modules).filter((i)=>
        i.entryPoint.includes(entryName)),};// add chunkthis.chunks.add(chunk);}createAssets(){const output =this.options.output;// 根据 chunks 生成 assetsthis.chunks.forEach((chunk)=>{const parseFileName = output.filename.replace('[name]', chunk.name);// 为每一个 chunk 文件代码拼接 runtime 运行时语法this.assets[parseFileName]=getSourceCode(chunk);});}}
getSourceCode

是将

entry

modules

组合而成的

chunk

,接入到

runtime

代码模板之中。

// lib/utils.jsfunctiongetSourceCode(chunk){const{ entryModule, modules }= chunk;return`  (() => {    var __webpack_modules__ = {      ${modules        .map((module)=>{return`          '${module.id}': (module) => {            ${module._source}
      }        `;})        .join(',')}
    };    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;    }    (() => {      ${entryModule._source}
    })();  })();  `;}

到这里,「生成阶段」处理完成,这也意味着

compilation

编译工作的完成,接下来我们回到

compiler

进行最后的「产物输出」。

五、写入阶段

「写入阶段」比较容易理解,

assets

上已经拥有了最终打包后的代码内容,最后要做的就是将代码内容写入到本地磁盘之中。

// lib/compiler.jsclassCompiler{...run(callback){// 触发 run hookthis.hooks.run.call();// 创建 compilation 编译对象const compilation =newCompilation(this);// 编译模块
    compilation.build();// 生成产物
    compilation.seal();// 输出产物this.emitAssets(compilation, callback);}emitAssets(compilation, callback){const{ entries, modules, chunks, assets }= compilation;const output =this.options.output;// 调用 Plugin emit 钩子this.hooks.emit.call();// 若 output.path 不存在,进行创建if(!fs.existsSync(output.path)){
      fs.mkdirSync(output.path);}// 将 assets 中的内容写入文件系统中
    Object.keys(assets).forEach((fileName)=>{const filePath = path.join(output.path, fileName);
      fs.writeFileSync(filePath, assets[fileName]);});// 结束之后触发钩子this.hooks.done.call();callback(null,{toJSON:()=>{return{
          entries,
          modules,
          chunks,
          assets,};},});}}

至此,webpack 的打包流程就以完成。

接下来我们完善配置文件中未实现的

loader

plugin

,然后调用测试用例,测试一下上面的实现。

六、编写 loader

webpack.config.js

中我们为

.js

文件类型配置了一个自定义 loader 来转换文件内容:

// webpack.config.jsmodule:{rules:[{test:/\.js/,use:[
        path.resolve(__dirname,'./loaders/transformArrowFnLoader.js'),],},],},

loader 本身是一个函数,接收文件模块内容作为参数,经过改造处理返回新的文件内容。

下面我们在

loaders/transformArrowFnLoader.js

中,对文件中使用到的箭头函数,转换为普通函数,来理解

webpack loader

的作用。

// loaders/transformArrowFnLoader.jsconst parser =require('@babel/parser');const traverse =require('@babel/traverse').default;const generator =require('@babel/generator').default;const t =require('@babel/types');functiontransformArrowLoader(sourceCode){const ast = parser.parse(sourceCode,{sourceType:'module'});traverse(ast,{ArrowFunctionExpression(path, state){const node = path.node;const body = path.get('body');const bodyNode = body.node;if(bodyNode.type !=='BlockStatement'){const statements =[];
        statements.push(t.returnStatement(bodyNode));
        node.body = t.blockStatement(statements);}
      node.type ="FunctionExpression";}});const{ code }=generator(ast);return code;}

module.exports = transformArrowLoader;

最终,箭头函数经过处理后变成如下结构:

conststart=()=>'start';||||conststart=function(){return'start';};

七、编写插件

从上面介绍我们了解到,每个插件都需要提供一个

apply

方法,此方法接收

compiler

作为参数。

通过

compiler

可以去订阅

webpack

工作期间不同阶段的

hooks

,以此来影响打包结果或者做一些定制操作。

下面我们编写自定义插件,绑定两个不同时机的

compiler.hooks

来扩展 webpack 打包功能:

  • hooks.emit.tap 绑定一个函数,在 webpack 编译资源完成,输出写入磁盘前执行(可以做清除 output.path 目录操作);
  • hooks.done.tap 绑定一个函数,在 webpack 写入磁盘完成之后执行(可以做一些静态资源 copy 操作)。
// plugins/custom-webpack-pluginsconst fs =require('fs-extra');const path =require('path');classCustomWebpackPlugin{apply(compiler){const outputPath = compiler.options.output.path;const hooks = compiler.hooks;// 清除 build 目录
    hooks.emit.tap('custom-webpack-plugin',(compilation)=>{
      fs.removeSync(outputPath);});// copy 静态资源const otherFilesPath = path.resolve(__dirname,'../src/otherfiles');
    hooks.done.tap('custom-webpack-plugin',(compilation)=>{
      fs.copySync(otherFilesPath, path.resolve(outputPath,'otherfiles'));});}}

module.exports = CustomWebpackPlugin;

现在,我们通过

node build.js

运行文件,最终会在

webpack-demo

下生成

build

目录以及入口打包资源。

文末

相信读完本篇文章,你对 webpack 的打包思路有了清晰的认识。

标签: webpack

本文转载自: https://blog.csdn.net/gogo2027/article/details/127532122
版权归原作者 gogo2027 所有, 如有侵权,请联系我们删除。

“Webpack完整打包流程分析”的评论:

还没有评论