二十张图片彻底讲明白Webpack设计理念,以看懂为目的

lxf2023-03-17 07:39:02

一、前言

本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第八篇。

Webpack 一直都是有些人的心魔,不清楚原理是什么,不知道怎么去配置,只会基本的 API 使用。它就像一个黑盒,让部分开发者对它望而生畏。

而本节最大的作用,就是帮大家一点一点的消灭心魔。

大家之所以认为 Webpack 复杂,很大程度上是因为它依附着一套庞大的生态系统。其实 Webpack 的核心流程远没有我们想象中那么复杂,甚至只需百来行代码就能完整复刻出来。

因此在学习过程中,我们应注重学习它本身的设计思想,不管是它的 Plugin 系统还是 Loader 系统都是建立于这套核心思想之上。所谓万变不离其宗,一通百通。

在本文中,我将会从 Webpack 的整体流程出发,通篇采用结论先行、自顶向下方式进行讲解。在涉及到原理性的知识时,尽量采用图文的方式辅以理解,注重实现思路注重设计思想

另外,如果在阅读过程中感到吃力(很正常),可自行补一补 Webpack 专栏中前置性的知识,每一节均完全解耦,可放心食用:

不了解也没关系,在本节中我都会一一讲到。

  • 从构建产物洞悉模块化原理
  • 【Webpack】异步加载(懒加载)原理
  • 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用
  • 【万字长文|趣味图解】彻底弄懂Webpack中的Loader机制
  • 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果......
  • 中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?

文中所涉及到的代码均放到个人 github 仓库中:github.com/noBaldAaa/h…

二、基本使用

初始化项目

npm init  //初始化一个项目
yarn add webpack //安装项目依赖

安装完依赖后,根据以下目录结构来添加对应的目录和文件:

├── node_modules
├── package-lock.json
├── package.json
├── webpack.config.js #配置文件
├── debugger.js #测试文件
└── src # 源码目录
     |── index.js
     |── name.js
     └── age.js

webpack.config.js

const path = require("path");
module.exports = {
  mode: "development", //防止代码被压缩
  entry: "./src/index.js", //入口文件
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
  },
  devtool: "source-map", //防止干扰源文件
};

src/index.js(本文不讨论CommonJS 和 ES Module之间的引用关系,以CommonJS为准

const name = require("./name");
const age = require("./age");
console.log("entry文件打印作者信息", name, age);

src/name.js

module.exports = "不要秃头啊";

src/age.js

module.exports = "99";

文件依赖关系:

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

Webpack 本质上是一个函数,它接受一个配置信息作为参数,执行后返回一个 compiler 对象,调用 compiler 对象中的 run 方法就会启动编译。run 方法接受一个回调,可以用来查看编译过程中的错误信息或编译信息。

debugger.js

// const { webpack } = require("./webpack.js"); //后面自己手写
const { webpack } = require("webpack");
const webpackOptions = require("./webpack.config.js");
const compiler = webpack(webpackOptions);

//开始编译
compiler.run((err, stats) => {
  console.log(err);
  console.log(
    stats.toJson({
      assets: true, //打印本次编译产出的资源
      chunks: true, //打印本次编译产出的代码块
      modules: true, //打印本次编译产出的模块
    })
  );
});

执行打包命令:

node ./debugger.js

得到产出文件 dist/main.js(先暂停三十秒读一读下面代码,命名经优化):

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

运行该文件,得到结果:

entry文件打印作者信息 不要秃头啊 99

三、核心思想

我们先来分析一下源代码和构建产物之间的关系:

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

从图中可以看出,入口文件(src/index.js)被包裹在最后的立即执行函数中,而它所依赖的模块(src/name.jssrc/age.js)则被放进了 modules 对象中(modules 用于存放入口文件的依赖模块key 值为依赖模块路径,value 值为依赖模块源代码)。

require 函数是 web 环境下 加载模块的方法( require 原本是 node环境 中内置的方法,浏览器并不认识 require,所以这里需要手动实现一下),它接受模块的路径为参数,返回模块导出的内容。

要想弄清楚 Webpack 原理,那么核心问题就变成了:如何将左边的源代码转换成 dist/main.js 文件?


核心思想:

  • 第一步:首先,根据配置信息(webpack.config.js)找到入口文件(src/index.js
  • 第二步:找到入口文件所依赖的模块,并收集关键信息:比如路径、源代码、它所依赖的模块等:
    var modules = [
      {
        id: "./src/name.js",//路径
        dependencies: [], //所依赖的模块
        source: 'module.exports = "不要秃头啊";', //源代码
      },
      {
        id: "./src/age.js",
        dependencies: [], 
        source: 'module.exports = "99";',
      },
      {
        id: "./src/index.js",
        dependencies: ["./src/name.js", "./src/age.js"], 
        source:
          'const name = require("./src/name.js");\n' +
          'const age = require("./src/age.js");\n' +
          'console.log("entry文件打印作者信息", name, age);',
      },
    ];
    
  • 第三步:根据上一步得到的信息,生成最终输出到硬盘中的文件(dist): 包括 modules 对象、require 模版代码、入口执行文件等

在这过程中,由于浏览器并不认识除 html、js、css 以外的文件格式,所以我们还需要对源文件进行转换 —— Loader 系统

Loader 系统 本质上就是接收资源文件,并对其进行转换,最终输出转换后的文件:

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

除此之外,打包过程中也有一些特定的时机需要处理,比如:

  • 在打包前需要校验用户传过来的参数,判断格式是否符合要求
  • 在打包过程中,需要知道哪些模块可以忽略编译,直接引用 cdn 链接
  • 在编译完成后,需要将输出的内容插入到 html 文件中
  • 在输出到硬盘前,需要先清空 dist 文件夹
  • ......

这个时候需要一个可插拔的设计,方便给社区提供可扩展的接口 —— Plugin 系统

Plugin 系统 本质上就是一种事件流的机制,到了固定的时间节点就广播特定的事件,用户可以在事件内执行特定的逻辑,类似于生命周期:

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

这些设计也都是根据使用场景来的,只有理清需求后我们才能更好的理解它的设计思想。

四、架构设计

在理清楚核心思想后,剩下的就是对其进行一步步拆解。

上面提到,我们需要建立一套事件流的机制来管控整个打包过程,大致可以分为三个阶段:

  • 打包开始前的准备工作
  • 打包过程中(也就是编译阶段)
  • 打包结束后(包含打包成功和打包失败)

这其中又以编译阶段最为复杂,另外还考虑到一个场景:watch mode(当文件变化时,将重新进行编译),因此这里最好将编译阶段(也就是下文中的compilation)单独解耦出来。

Webpack 源码中,compiler 就像是一个大管家,它就代表上面说的三个阶段,在它上面挂载着各种生命周期函数,而 compilation 就像专管伙食的厨师,专门负责编译相关的工作,也就是打包过程中这个阶段。画个图帮助大家理解:

二十张图片彻底讲明白Webpack设计理念,以看懂为目的

大致架构定下后,那现在应该如何实现这套事件流呢?

这时候就需要借助 Tapable 了!它是一个类似于 Node.js 中的 EventEmitter 的库,但更专注于自定义事件的触发和处理。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。

类比到 Vue 和 React 框架中的生命周期函数,它们就是到了固定的时间节点就执行对应的生命周期,tapable 做的事情就和这个差不多,我们可以通过它先注册一系列的生命周期函数,然后在合适的时间点执行。

example