美文网首页
25.webpack 工程师 > 前端工程师(上)

25.webpack 工程师 > 前端工程师(上)

作者: ikonan | 来源:发表于2021-06-17 00:03 被阅读0次

说起前端工程化, webpack 必然在前端工具链中占有最重要的地位;说起前端工程师进阶,webpack 更是一个绕不开的话题。

从原始的刀耕火种时代,到 Gulp、Grunt 等早期方案的横空出世,再到 webpack 通过其丰富的功能和开放的设计一举奠定「江湖地位」,我想每个前端工程师都需要熟悉各个时代的「打包神器」。

作为团队中不可或缺的高级工程师,能否玩转 webpack,能否通过工具搭建令人舒适的工作流和构建基础,能否不断适应技术发展打磨编译体系,将直接决定你的工作价值。

在这一系列课程里,赘述社区上大量存在的「webpack 配置 demo」,或者讲解一些现成的插件应用意义不大,这些知识都可以免费找到。

分析 webpack 工作原理,探究 webpack 能力边界,结合实践并加以应用将会是本讲的重点。

webpack 主题的知识点如下所示:


接下来,我们通过 2 节内容来学习这个主题。

webpack 到底将代码编译成了什么

项目中经过 webpack 打包后的代码究竟被编译成了什么?也许你认为并不重要。业务中的代码往往非常复杂,经过 webpack 编译后的代码可读性非常差。但是不管是复杂的项目还是最简单的一行代码,其经过 webpack 编译打包的产出本质是相同的。我们试图从最简单的情况开始,研究 webpack 打包产出的秘密。

CommonJS 规范打包结果

如何着手分析呢?首先创建并切入到项目,进行初始化:

mkdir webpack-demo
cd webpack-demo
npm init -y

安装 webpack 最新版本:

npm install --save-dev webpack
npm install --save-dev webpack-cli

根目录下创建 index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
  <script src="./dist/main.js"></script>
</html>

创建 ./src 文件。因为我们要研究模块化打包产出,这一定涉及依赖关系,因此在 ./src 目录下创建 hello.js 和 index.js,其中 index.js 为入口脚本,它将依赖 hello.js:

const sayHello = require('./hello')
console.log(sayHello('lucas'))

hello.js:

module.exports = function (name) {
   return 'hello ' + name
}

这里我们为了演示,采用了 CommonJS 规范,也没有加入 Babel 编译环节。

直接执行命令:

node_modules/.bin/webpack --mode development

便得到了产出 ./dist,打开 ./dist/main.js,得到最终编译结果:

(function(modules) {
   //缓存已经加载过的 module 的 exports,防止 module 在 exports 之前 JS 重复执行
   var installedModules = {};

   //类似 commonJS 的 require(),它是 webpack 加载函数,用来加载 webpack 定义的模块,返回 exports 导出的对象
   function __webpack_require__(moduleId) {
       //缓存中存在,则直接返回结果
       if (installedModules[moduleId]) {
           return installedModules[moduleId].exports
       }

       //第一次加载时,初始化模块对象,并进行缓存
       var module = installedModules[moduleId] = {
           i: moduleId, // 模块 ID
           l: false, // 是否已加载标识
           exports: {} // 模块导出对象
       };

       /**
       * 执行模块
       * @param module.exports -- 模块导出对象引用,改变模块包裹函数内部的 this 指向
       * @param module -- 当前模块对象引用
       * @param module.exports -- 模块导出对象引用
       * @param __webpack_require__ -- 用于在模块中加载其他模块
       */
       modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

       //标记是否已加载标识
       module.l = true;

       //返回模块导出对象引用
       return module.exports
   }

   __webpack_require__.m = modules;
   __webpack_require__.c = installedModules;
   //定义 exports 对象导出的属性
   __webpack_require__.d = function(exports, name, getter) {
       //如果 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
       })
   };
   __webpack_require__.t = function(value, mode) {
       if (mode & 1) value = __webpack_require__(value);
       if (mode & 8) return value;
       if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
       var ns = Object.create(null);
       __webpack_require__.r(ns);
       Object.defineProperty(ns, 'default', {
           enumerable: true,
           value: value
       });
       if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function(key) {
           return value[key]
       }.bind(null, key));
       return ns
   };
   __webpack_require__.n = function(module) {
       var getter = module && module.__esModule ?
       function getDefault() {
           return module['default']
       } : function getModuleExports() {
           return module
       };
       __webpack_require__.d(getter, 'a', getter);
       return getter
   };
   __webpack_require__.o = function(object, property) {
       return Object.prototype.hasOwnProperty.call(object, property)
   };
   // __webpack_public_path__
   __webpack_require__.p = "";

   //加载入口模块并返回入口模块的 exports
   return __webpack_require__(__webpack_require__.s = "./src/index.js")
})({
   "./src/hello.js": (function(module, exports) {
       eval("module.exports = function(name) {\n    return 'hello ' + name\n}\n\n//# sourceURL=webpack:///./src/hello.js?")
   }),
   "./src/index.js": (function(module, exports, __webpack_require__) {
       eval("var sayHello = __webpack_require__(/*! ./hello */ \"./src/hello.js\")\nconsole.log(sayHello('lucas'))\n\n//# sourceURL=webpack:///./src/index.js?")
   })
});

不要着急阅读,我们先把最核心的代码骨架提出来,上面的代码其实就是一个 IIFE(立即执行函数表达式):

(function(modules){
 // ...
})({
 "./src/hello.js": (function(){
   // ...
 }),
 "./src/index.js": (function() {
   // ...
 })
})

Ben Cherry 的著名文章 JavaScript Module Pattern: In-Depth"> JavaScript Module Pattern: In-Depth 介绍了 IIFE 实现模块化的多种进阶尝试,阮一峰老师在其博客中也提到了相关内容。用 IIFE 实现模块化,我们并不陌生。

深入上述代码结果(已添加注释),我们可以提炼出以下关键几点。

  • webpack 打包结果就是一个 IIFE,一般称它为 webpackBootstrap,这个 IIFE 接收一个对象 modules 作为参数,modules 对象的 key 是依赖路径,value 是经过简单处理后的脚本(它不完全等同于我们编写的业务脚本,而是被 webpack 进行包裹后的内容)。
  • 打包结果中,定义了一个重要的模块加载函数 webpack_require
  • 我们首先使用 webpack_require 加载函数去加载入口模块 ./src/index.js。
  • 加载函数 webpack_require 使用了闭包变量 installedModules,它的作用是将已加载过的模块结果保存在内存中。

如果读者对于产出结果源码存在不理解的地方,请继续阅读,我们将会在 webpack 工作基本原理部分进一步说明,同时欢迎随时在评论区跟我讨论。

ES 规范打包结果

以上是基于 CommonJS 规范的模块化写法,业务中我们的代码往往遵循 ES Next 模块化标准,并通过 Babel 进行编译,这样的流程下,会得到什么结果呢?

我们动手尝试一下,安装依赖:

npm install --save-dev webpack
npm install --save-dev webpack-cli
npm install --save-dev babel-loader
npm install --save-dev @babel/core
npm install --save-dev @babel/preset-env

同时配置 package.json,加入:

"scripts": {
   "build": "webpack --mode development --progress --display-modules --colors --display-reasons"
s},

设置 npm script 以方便运行 webpack 构建,同时在 package.json 中加入 Babel 配置:

"babel": {
   "presets": ["@babel/preset-env"]
}

将 index.js 和 hello.js 改写为 ESM 方式:

// hello.js
const sayHello = name => `hello ${name}`
export default sayHello

// index.js
import sayHello from './hello.js'
console.log(sayHello('lucas'))

执行:

npm run build

得到的打包主体与之前内容基本一致。但是细节上,我们发现 IIFE 传入参数 modules 对象的 value 部分,即执行脚本内容多了以下语句:

__webpack_require__.r(__webpack_exports__)

实际上 webpack_require.r 这个方法是给模块的 exports 对象加上 ES 模块化规范的标记。

具体标记方式为:如果支持 Symbol 对象,则通过 Object.defineProperty 为 exports 对象的 Symbol.toStringTag 属性赋值 Module,这样做的结果是 exports 对象在调用 toString 方法时会返回 Module;同时,将 exports.__esModule 赋值为 true。

除了 CommonJS 和 ES Module 规范,webpack 同样支持 AMD 规范,这里不再进行分析,读者可以重新打包来观察它们的区别。总之,希望大家记住 webpack 打包输出的结果就是一个 IIFE,通过这个 IIFE,以及 webpack_require 支持了各种模块化打包方案。

按需加载打包结果

现代化的业务,尤其是在单页应用中,我们往往使用「按需加载」,那么对于这种相对较新的依赖技术,webpack 又会产出什么样的代码呢?

我们加入 Babel 插件,以支持 dynamic import:

npm install --save-dev babel-plugin-dynamic-import-webpack

并在 webpack.config.js 中添加相关插件配置:

module.exports={
   module:{
       rules:[
           {
               test: /\.js$/,
               exclude: /node_modules/,
               loader: "babel-loader",
               options: {
                   "plugins": [
                       "dynamic-import-webpack"
                   ]
               }
           }
       ]
   }
}

同时,将 index.js 使用 dynamic import 的方式实现按需加载:

import('./hello').then(sayHello => {
   console.log(sayHello('lucas'))
})

最后执行:

npm run build

这样一来,我们发现重新构建后会输出两个文件,分别是执行入口文件 main.js 和异步加载文件 0.js,因为异步按需加载显然不能把所有的代码再打到一个 bundle 当中了。

0.js 内容为:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
[0],
{
   "./src/hello.js": (function(module, __webpack_exports__, __webpack_require__) {
       "use strict";
       eval("__webpack_require__.r(__webpack_exports__);\n// module.exports = function(name) {\n//     return 'hello ' + name\n// }\nvar sayHello = function sayHello(name) {\n  return \"hello \".concat(name);\n};\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (sayHello);\n\n//# sourceURL=webpack:///./src/hello.js?")
   })
}])

main.js 内容也与之前相比变化较大:

(function(modules) {
   /***
   * webpackJsonp 用于从异步加载的文件中安装模块
   * 把 webpackJsonp 挂载到全局是为了方便在其他文件中调用
   *
   * @param chunkIds 异步加载的文件中存放的需要安装的模块对应的 Chunk ID
   * @param moreModules 异步加载的文件中存放的需要安装的模块列表
   * @param executeModules 在异步加载的文件中存放的需要安装的模块都安装成功后,需要执行的模块对应的 index
   */
   function webpackJsonpCallback(data) {
       var chunkIds = data[0];
       var moreModules = data[1];
       var moduleId, chunkId, i = 0,
           resolves = [];
       // 把所有 chunkId 对应的模块都标记成已经加载成功
       for (; i < chunkIds.length; i++) {
           chunkId = chunkIds[i];
           if (installedChunks[chunkId]) {
               resolves.push(installedChunks[chunkId][0])
           }
           installedChunks[chunkId] = 0
       }
       for (moduleId in moreModules) {
           if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
               modules[moduleId] = moreModules[moduleId]
           }
       }
       if (parentJsonpFunction) parentJsonpFunction(data);
       while (resolves.length) {
           resolves.shift()()
       }
   };

   var installedModules = {};
   // 存储每个 Chunk 的加载状态
   // 键为 Chunk 的 ID,值为 0 代表已经加载成功
   var installedChunks = {
   "main": 0
   };

   function jsonpScriptSrc(chunkId) {
       return __webpack_require__.p + "" + ({}[chunkId] || chunkId) + ".js"
   }
   function __webpack_require__(moduleId) {
       if (installedModules[moduleId]) {
           return installedModules[moduleId].exports
       }
       var module = installedModules[moduleId] = {
           i: moduleId,
           l: false,
           exports: {}
       };
       modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
       module.l = true;
       return module.exports
   }

   /**
   * 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件
   * @param chunkId 需要异步加载的 Chunk 对应的 ID
   * @returns {Promise}
   */
   __webpack_require__.e = function requireEnsure(chunkId) {
       var promises = [];
       var installedChunkData = installedChunks[chunkId];
       // 如果加载状态为 0 表示该 Chunk 已经加载成功了,直接返回 resolve Promise
       if (installedChunkData !== 0) {
           if (installedChunkData) {
               promises.push(installedChunkData[2])
           } else {
               var promise = new Promise(function(resolve, reject) {
                   installedChunkData = installedChunks[chunkId] = [resolve, reject]
               });
               promises.push(installedChunkData[2] = promise);
               var script = document.createElement('script');
               var onScriptComplete;
               script.charset = 'utf-8';
               // 设置异步加载的最长超时时间
               script.timeout = 120;
               if (__webpack_require__.nc) {
                   script.setAttribute("nonce", __webpack_require__.nc)
               }
               // 文件的路径为配置的 publicPath、chunkId 拼接而成
               script.src = jsonpScriptSrc(chunkId);
               onScriptComplete = function(event) {
                   script.onerror = script.onload = null;
                   clearTimeout(timeout);
                   var chunk = installedChunks[chunkId];
                   if (chunk !== 0) {
                       if (chunk) {
                           var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                           var realSrc = event && event.target && event.target.src;
                           var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
                           error.type = errorType;
                           error.request = realSrc;
                           chunk[1](error)
                       }
                       installedChunks[chunkId] = undefined
                   }
               };
               var timeout = setTimeout(function() {
                   onScriptComplete({
                       type: 'timeout',
                       target: script
                   })
               }, 120000);
               script.onerror = script.onload = onScriptComplete;head
               // 通过 DOM 操作,往 HTML head 中插入一个 script 标签去异步加载 Chunk 对应的 JavaScript 文件
               document.head.appendChild(script)
           }
       }
       return Promise.all(promises)
   };

   __webpack_require__.m = modules;
   __webpack_require__.c = installedModules;
   __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
       })
   };

   __webpack_require__.t = function(value, mode) {
       if (mode & 1) value = __webpack_require__(value);
       if (mode & 8) return value;
       if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
       var ns = Object.create(null);
       __webpack_require__.r(ns);
       Object.defineProperty(ns, 'default', {
           enumerable: true,
           value: value
       });
       if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function(key) {
           return value[key]
       }.bind(null, key));
       return ns
   };

   __webpack_require__.n = function(module) {
       var getter = module && module.__esModule ?
       function getDefault() {
           return module['default']
       } : function getModuleExports() {
           return module
   };
   __webpack_require__.d(getter, 'a', getter);
       return getter
   };
   __webpack_require__.o = function(object, property) {
       return Object.prototype.hasOwnProperty.call(object, property)
   };
   __webpack_require__.p = "";
   __webpack_require__.oe = function(err) {
       console.error(err);
       throw err;
   };

   var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
   var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
   jsonpArray.push = webpackJsonpCallback;
   jsonpArray = jsonpArray.slice();
   for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);

   var parentJsonpFunction = oldJsonpFunction;
   return __webpack_require__(__webpack_require__.s = "./src/index.js")
   })({
       // 所有没有经过异步加载的,随着执行入口文件加载的模块
       "./src/index.js": (function(module, exports, __webpack_require__) {
       eval("// var sayHello = require('./hello')\n// console.log(sayHello('lucas'))\n// import sayHello from './hello.js'\n// console.log(sayHello('lucas'))\nnew Promise(function (resolve) {\n  __webpack_require__.e(/*! require.ensure */ 0).then((function (require) {\n    resolve(__webpack_require__(/*! ./hello */ \"./src/hello.js\"));\n  }).bind(null, __webpack_require__)).catch(__webpack_require__.oe);\n}).then(function (sayHello) {\n  console.log(sayHello('lucas'));\n});\n\n//# sourceURL=webpack:///./src/index.js?")
   })
});

按需加载相比常规打包产出结果变化较大,也更加复杂。我们仔细对比其中差异,发现 main.js:

  • 多了一个 webpack_require.e
  • 多了一个 webpackJsonp

其中 webpack_require.e 实现非常重要,它初始化了一个 promise 数组,使用 Promise.all() 进行异步插入 script 脚本;webpackJsonp 会挂在到全局对象 window 上,进行模块安装。

熟悉 webpack 的读者可能会知道 CommonsChunkPlugin 插件(在 webpack v4 版本中已经被取代),这个插件用来分割第三方依赖或者公共库的代码,将业务逻辑和稳定的库脚本分离,以达到优化代码体积、合理使用缓存的目的。实际上,这样的思路和上述「按需加载」不谋而合,具体实现思路也一致。我们可以推测开发者在使用 CommonsChunkPlugin 插件打包后的代码结果和上面的代码结构类似,都存在 webpack_require.e 和 webpackJsonp。因为提取公共代码和异步加载本质上都是前置进行代码分割,再在必要时加载,具体实现可以观察 webpack_require.e 和 webpackJsonp。

到此,我们分析了业务中几乎所有的打包方式以及 webpack 产出结果。虽然这些内容较为晦涩,源码冗长而难以阅读,但是这对我们理解 webpack 内部工作原理,编写 loader、plugin 意义重大。只有分析过所有这些最基本的编译后代码,我们才能对上线代码的质量做到「心里有底」。在出现问题时,能够驾轻就熟,独当一面。这也是高级 Web 工程师所必备的素养。

如果读者在阅读 webpack 打包后代码存在一些困难,也没有关系,细节实现相对打包思想设计并没有那么重要。也许你试着去设计一个模块系统,了解一下 require.js 或者 sea.js 的实现,这些内容也就不再「那么高深」了。这些代码实现细节可以放在一边,通过后续章节的学习之后,再返回来看,可能效果更好。

相关文章

网友评论

      本文标题:25.webpack 工程师 > 前端工程师(上)

      本文链接:https://www.haomeiwen.com/subject/bhizeltx.html