手把手带你分析 Webpack 的打包结果

59 阅读15分钟

前言: 要想了解一个新知识,你得先它理解为什么要有这个东西,它带来了什么优势?如果没有它,会有什么问题?只有这样,我们才会在脑子里建立一个从无到有的认知,才会慢慢去理解这个工具带来的便利。

🌂:故本文不是 webpack 的源码解读,而是带你建立对于 webpack 的基础认知。 等你有了这些认知,再去阅读站内优秀的源码解读,我相信读起来就不会像以前那么生涩。


一. 阅读之前,你需要准备

  1. 创建一个空的文件夹;
  2. 创建一个 html 文件,并且搭好基本的骨架;
  3. 创建一个 src 目录,在该目录下创建一个 index.js image.png
  4. live-server 插件 image.png

⚠️注意: 在阅读本文之前,请忽略你脑中的传统前端框架 vue、 react 等。构建工具(webpack)和框架二者在工程化上的职责完全不同,你现在无需考虑任何 vue 的代码,让我们回归前端最本质的时代。

二. 早期前端的开发弊端

  1. 我们回忆一下,在早期的前端工程开发过程中,我们只需要三件套即可,一个 html 文件,一个 css 文件,一个 js 文件,然后在 html 文件中引入另外两个文件即可。(css 不是本文的重点,故后面的内容不会再出现 css 相关的内容) image.png

  2. 一个 html 文件,几个 cssjs 通过 <link><script> 标签插进去后,就可以将一个完整的界面呈现出来。 是的,因为浏览器最终需要的文件就是这么简单的几个文件,我们项目文件本身的结构就应该是这样简单。

  3. 有问题吗?完全没有问题!早期的 web 网站就是这样去做的。对一个小型站点来讲,因为本身需要用到的文件就不多,需要考虑的点本身就很少,这样的做法完全可以满足开发需求。

  4. 但是一旦项目上了规模,问题也就接踵而来。假设有一个项目之前是你一个人在开发和维护的,但是随着功能越来越复杂,你的工作日益加重,头发愈来越少。于是公司给你安排了一个新的同事 A ,来和你共同维护这个项目。有一天他写的代码需要引入一个新的 js 文件,于是他在 src/ 下创建了一个新的文件名叫 a.js,然后打开了 index.html 文件,在你的 <script> 标签下引入了这个 a.js 文件。 image.png

  5. 此时你们对应的 js 文件如下:

     // 这是 a.js 文件
    console.log('这是 a 文件');
    
    function sayHello() {
      console.log('我是新创建的 a 文件');
    }
    
    ------------------------------ 文件分割线 ----------------------------
    
    //这是 index.js 文件
    console.log('这是index.js 文件');
    
    function sayHello() {
      console.log('hello juejin');
    }
    
  6. 同事 A 编写了一个叫做 sayHello 的函数用来实现自己各种的功能,但是他并不知道你之前已经有了一个叫做 sayHello 的函数。由于 script 标签的特性,你们书写的所有代码都会被直接挂载到 window 对象上,但又因为函数名重名,按照 script 标签的特性,它会按照在 html 文件的引用顺序重新加载一遍,浏览器最终会采用 a.js 的函数体,最后导致之后 sayHello 的输出统统变成了你同事的代码。
    image.png

  7. 到这里你可能会想,尽量不要重名还不简单?我们两个写的时候约定一下不就行了嘛!这样的做法可以吗?当然可以!,但是假如后面又来了一个同事小 C 、小 D该怎么办呢?难道你们每次写一个变量名和函数名的时候,四个人就需要开会来讨论吗?这样的做法会不仅仅会极大增加开发者的心智负担而且会严重拖慢整个项目的开发周期。

  8. 并且作为开发者,我们聚焦的关注点更多应该是如何实现项目功能的问题,而不是在这种和业务逻辑毫不相干的琐碎问题上浪费时间,所以当时的前端就迫切需要一种方法来解决这样的问题,于是 es6 的模块化方案随之产生。

二. ES6 模块方案的弊端

  1. 大家都很熟悉,随着 ES6 为 script 标签带来的 type=module 属性,搭配使用新引入的 importexport 语句,script 标签原本的弊端被解决了。

  2. 我们为 a.jsindex.js 添加 type=module
    image.png 此时这两个标签里的变量将会被互相隔离开来,并且所有的变量不会被自动挂载到 window 对象上,任何作为模块出现的文件,都会被相互隔离,任何变量名和函数名也不会因为重名而产生冲突。 image.png

  3. 此时我们也可以通过 export 语句导出,比如你和同事 A 都需要在各种的代码中使用一个 sleep 函数,那么你们就可以编写一个新的文件utils/index.js,书写一些公共代码,然后在其他模块用 import 引入,不仅减少了重复代码量,而且使各个模块之间的职责划分更加清晰。
    image.png image.png

  4. 然而这样的方案就完美了吗?并不是,我们要理清一个重要概念,目前我们这些文件都是存储在本地的。
    image.png

  5. 什么意思?目前我们这些文件和浏览器没有任何关系,一个是存储在电脑磁盘上的内容,一个是我们电脑上的应用,它们本身没办法产生联系。平常我们浏览网页,之所以可以看到各种各样的界面,是因为浏览器本身实现了网络通讯的功能,它可以通过请求一个 url地址 ,来访问提供了相应服务的服务器,如果请求合法,那么服务器就会把相应的文件返回给浏览器,最终界面呈现在我们眼前。

  6. 举个栗子🌰,你现在正在看我写的这篇文章,但是这篇文章又不是你在本地电脑手动创建的一个 txt 文件,你却为什么可以看到呢?整个过程其实是:文章本身是存放在掘金服务器上的,而这篇文章不过就是存放在掘金服务器磁盘上的一个普通文件罢了。和上述的 a.js 或者 index.html 存放在你自己电脑磁盘上无异。你通过浏览器请求掘金服务器这篇文章的地址,然后掘金服务器通过网络传输最终传递给浏览器,浏览器帮你解析了这个文件,最终文章内容呈现在你的设备上。

  7. 回到正题,将所有 js 都统一写为 type=module 的写法,会随着项目的依赖增大,导致浏览器需要处理的网络请求越来越多。下图一共发送了3个网络请求,一个是 src/index.js,一个是 src/a.js,另一个是 utils/index.js,而浏览器对同一域名下的并发网络请求也是有限制的。 image.png

  8. 如果此时我们依赖的某个模块又依赖于别的模块,仍拿上述的 util/js 为例,假如这个文件依赖于一个大型第三方库,而第三方库又依赖成百上千的别的模块,别的模块又有自己的依赖...而我们知道,网络条件是不可控因素,任何网络波动都会影响文件数据传输。最简单的例子就是,假如你有100个文件需要请求,但是当要加载第90个的时候,恰好网络中断,导致的结果就是整个界面加载失败。

  9. 对于我们人来讲的。为了代码可阅读性,我们会讲究缩进和换行;为了代码逻辑清晰,我们会讲究变量名语意化和写注释;为了项目的工程化,我们会划分模块...这样的结构划分有错吗?一点错都没有! 且十分优雅。但可惜的是,在浏览器眼里,我们所做的这些事情都是在增加文件体积,增加传输压力和增加网络传输时间。因为对于浏览器来讲,无论代码写的多么优雅,无非都是冰冷 0 和 1,对于它来讲,它需要的是尽量少和尽量小的文件。(注意:我这里指的是尽量小和尽量少,我们不能为了浏览器而放弃我们为项目可维护性做出的任何努力

  10. 于是我们可以总结:

    造成上述不便的种种原因,归根到底来讲就是:开发人员在开发过程中期望的代码结构和浏览器需要的最终产物不对称

  11. 此时聪明的你就会想,有没有一个工具,既可以满足我们开发时尽情书写规范的代码,最后的时候又可以自动转化为尽可能方便浏览器解析和进行网络传输的代码呢?有的,这就是 webpack 要做的事,它出现的目的就是帮你消除这个不对称性

三. webpack 初体验

1.web 就是指我们前端开发工程, pack 本身就包含打包的意思📦。 image.png

  1. 有了上述的认知,此时我们再来看 webpack 官网的图,或许就能更好的理解了。左边是我们为了满足开发便利所创建出来的各种格式文件。开发完成后,经过 webpack 的中间处理,最终代码会变成浏览器需要的那几个简单文件格式。 image.png

  2. 改造一下我们目前的代码,很简单,就是 index.js 引入了 a.js 而已。

    // index.js 文件
    import a from './a.js';
    console.log('这是 index.js 文件');
    
    ---------------------------------- 文件分割线 ----------------------------
    
    // a.js 文件
    console.log('这是 a.js 文件');
    
  3. 趁热打铁,我们在原有项目的根目录下执行 npm init,然后执行 npm i -D webpack webpack-cli

  4. 然后在命令行执行 npx webpack --mode=development,这行命令是告诉 webpack 使用开发环境模式去生成打包产物。

  5. 产物会自动生成在 dist/main.js,而这个 main.js 就是我们之前提到的 webpack 帮我们消除过不对称性以后产生的文件。这里我简化了一下代码结构,去掉了注释和一些兼容性代码。不要被这个代码吓到了,它就是一个普普通通的 js 代码文件而已,接下来我要做的就是带你书写这段 js 代码,体会这个打包结果 main.js 代码含义。

    (() => {
    // 模块定义表
    var **webpack\_modules** = {
    './src/a.js': (module) => {
    eval("console.log('这是 a.js 文件');\n\n\n//# sourceURL=webpack://webpack-test/./src/a.js?");
    },
    
            './src/index.js': (module, exports, __webpack_require__) => {  
            "use strict";
            eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./a.js */ \"./src/a.js\");\n/* harmony import */ var _a_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_a_js__WEBPACK_IMPORTED_MODULE_0__);\n\nconsole.log('这是 index.js 文件');\n\n\n//# sourceURL=webpack://webpack-test/./src/index.js?");
            },
          };
    
          // 模块缓存
          var __webpack_module_cache__ = {};
    
          // 模块加载函数
          function __webpack_require__(moduleId) {
            // 检查缓存
            if (__webpack_module_cache__[moduleId]) {
              return __webpack_module_cache__[moduleId].exports;
            }
    
            // 初始化新模块
            var module = { exports: {} };
            __webpack_module_cache__[moduleId] = module;
    
            // 执行模块代码
            __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    
            // 返回模块导出
            return module.exports;
          }
    
          // 启动入口模块
          var __webpack_exports__ = __webpack_require__('./src/index.js');
          console.log(__webpack_exports__); // 输出结果
        })();
    
    
  6. 接下来我们在 html 去将 script 的引入替换为 ./dist/main.js,这里我们已经不需要 type=module 属性值了,所以可以去掉。
    image.png 接下来用 live-server 启动这个 index.html 文件,当看到控制台这段输出的时候,说明 webpack 已经帮你成功打包了。
    image.png

四. 入口文件 entry

  1. 当我们运行 npx webpack --mode=development 的时候,webpack 首先会去找一个项目根目录下的一个叫做是否有叫做 webpack.config.js 的文件。我这里为了尽量简化文章内容,所以并没有去书写这个文件,但是我们需要知道,当没有这个文件时, webpack 会默认去找 src/index.js 这个文件作为入口文件(entry)

  2. 该如何理解这个 entry 呢?以当前目录结构为例,我们的项目结构很简单,只有两个 js 文件,它们的关系是 index.js 引入了 a.js,所以我们就可以认为 index.js 依赖于 a.js
    image.png

  3. webpack 需要一个起点,来进行依赖关系的分析,才能生成最终完整的符合我们功能的代码。但我们必须以 index.js 为入口吗?No no no,现在让我们明确指定 a.js 文件为入口。

    npx webpack --mode=development --entry=./src/a.js

  4. 你会发现 dist/main.js 依旧可以正常生成,只不过控制台已经不会输出关于 index.js 的任务代码了。
    image.png

  5. 这是因为我们的 a.js 文件没有依赖任何其它文件,所以 webpack 眼里根本没有 index.js 文件,它分析出来的结果就是:这个项目除了 a.js 的文件,没有依赖其他文件。 //a.js 文件,没有依赖别的文件 console.log('这是 a.js 文件');

  6. 这样的结果就有可能导致我们意料之外的事情发生----我们的 index.js 丢失了,所以一开始正确设置好 entry ,是我们使用 webpack 极为重要的一步。

  7. 搞清楚了 entry 这一步,接下来我们就可以完成 main.js 的复原了。

五. 手写 dist/main.js 文件

  1. 首先我们在 dist/ 目录下创建 my-main.js 文件。 image.png

  2. 在这一步我们可以把每个 js 文件当成一个普通对象来看待,这个对象有两个属性,分别是我们模块 index.jsa.js 的名字,它的属性值我们暂时还不知道是什么。
    image.png

  3. 目前这样有一个很大的问题,如果我新创建了一个 utils/ 文件,并且也声明的一个 index.js ,那么我们往这个 modules 对象里写,问题就产生了-----属性重复了js 仍会采用后面声明的那一个值。
    image.png

  4. 此时我们就可以用文件路径来表示键值,这样不管你是 utils/index 还是 src/c.js,它们都有唯一的属性名与之对应。
    image.png

  5. 属性名的问题解决了,但是属性值到底该写什么呢?🤔,细品一下,我们在 index.html 里引入 <script> 标签目的是为什么呢?不就是为了执行里面的对应的 js 代码吗?
    image.png

  6. 那我们就可以想到把这模块的属性值定义为各自模块对应的 js 代码,然后最后执行 /src/index.js 对应属性不就行了吗?
    image.png

  7. 但是上面的写法又会将 modules 这个变量声明到 window 对象上,最简单的方式你应该马上可以想到,使用一个立即执行函数来包裹住这一切,这样我们所有的变量作用域就被限制在函数内部了。这是此时我们的代码。
    image.png

  8. 全局污染的问题解决了,此时需要解决的问题就是这个在 index.js 里的 import a from './a.js' 该如何处理。此时我们可以声明一个辅助函数__require,通过一些特殊的路径解析,来寻找与之对应的模块,(省略解析过程,这里牵扯到 node 的路径解析)总之它可以将相对路径,解析为 modules 里与之对应的路径名。也就是将 ./a.js 解析为了 src/a.js
    image.png

  9. 此时我们将 index.html<srcpt> 引入换成我们的 my-main.js 文件,你会看到控制台成功输出了之前 webpack 帮我们处理过 dist/main.js 一样的代码。 image.png image.png

  10. 往往我们的模块还会导出一些变量来供其他模块使用,假设我们现在的 a.js 模块导出了一个新的变量。
    image.png 然后我们在 index.js 里去打印了这个 person变量 image.png

  11. 听起来很复杂?其实特别简单,我们改造一下 __require 函数,在函数内部声明一个普通空对象变量 exports, 然后在调用 a.js 的函数时,将这个对象传递给它。 image.png

  12. 然后在调用 a.js 的时候,我们将原来 exportperson 变量,挂载到传递进来的 exports 对象上。 image.png

  13. 然后我们在 index.js 里接收这个值,然后就可以进行所需要的处理,通过闭包的形式巧妙的完成了 index.jsa.js 模块之间的通信。 image.png 这是控制台与之对应的结果: image.png

  14. 现在我们还会有个新的问题,就是模块之间多次重复引用,比如 index.js 重复引用了多次 a.js。那么就会导致 a.js 多次重复执行。 image.png 控制台展示: image.png

  15. 此时我们就可以做一个优化,增加一个新的变量来保存已经执行过的模块。
    image.png
    控制台展示: image.png

  16. 至此,我们就手动完成了 webpack 的简易版打包结果,只不过 webpack 是通过 eval 将我们模块的代码变成字符串去执行,其他概念并无差异。

    eval("console.log('这是 a.js 文件');\n\nfunction sayhello(){console.log("123")})

六. 源码

// my-main.js 文件

(function () {
  const moduleCache = {};

  function __require(modulePath) {
    if (moduleCache[modulePath]) return moduleCache[modulePath]; //判断缓存是否存在
    const exports = {};
    // 通过特殊的方式分析,将相对路径解析,找到当前的 modules 对象里与之对应的模块
    modules[modulePath](exports);
    moduleCache[modulePath] = exports;
    return exports;
  }

  const modules = {
    './src/index.js': function () {
      const a = __require('./src/a.js');
      const b = __require('./src/a.js');
      const c = __require('./src/a.js');
      console.log('a.person', a.person);
      console.log('这是 index.js 文件');
    },

    './src/a.js': function (exports) {
      console.log('这是 a.js 文件');
      function sayhello() {
        console.log('123');
      }
      exports.person = {
        name: '韩振方',
      };
    },
  };

  // 十分简单的 js 属性调用
  modules['./src/index.js']();
})();