美文网首页
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