likes
comments
collection
share

webpack|学习总结:知其原理,手写一个 mini-webpack

作者站长头像
站长
· 阅读数 19

一、WHAT:webpack 是什么

webpack 是一个静态模块打包工具,将我们项目中的每一个模块组合成一个或多个 bundles(包)。

与之相关的一些核心概念(在 webpack.config.js 配置文件中进行配置):

  • 入口(entry):打包的入口,告诉 webpack 从哪儿开始
  • 输出(output):告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中
  • loader:webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。但我们的项目中不止有这两种类型的文件,还有其他类型,要怎么处理呢?loader 就是 webpack 能够处理其他类型的文件的武器,可以将其他类型文件处理并转换为有效的模块,比如将 SASS 转换为 CSS,或者将 ES6 转换为 ES5。具体来说,loader 是一个函数,负责将输入的源文本转换为特定的文本输出。它们既可以是异步的,也可以是同步的。具体配置时,loader 有2个属性:test 属性【识别哪些文件会被转换】和 use 属性【定义在进行转换时使用哪个 loader】。
const path = require('path');

module.exports = {
  output: {
    filename: 'my-first-webpack.bundle.js',
  },
  // loader 的配置规则要定义在 module.rules 中
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
    // 碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在打包之前,先 use(使用) raw-loader 转换一下
  },
};
  • plugin:插件是用来改变构建过程的行为的,比如自动将静态资源上传到云、去掉输出中重复的文件、注入环境变量等。具体来说,插件是可以挂接到 webpack 更底层 API 的类的实例,想要使用一个插件,只需要 require 它,然后把它添加到 plugins 数组中。多数插件可以通过选项 (option) 自定义,也可以在一个配置文件中因为不同目的而多次使用同一个插件,这时需要通过使用 new 操作符来创建一个插件实例。

如果需要转换 Vue/React代码、SASS 或其他转译语言,就用loader。如果需要调整 JavaScript,或用某种方式处理文件,就用plugin。

const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 用于访问内置插件

module.exports = {
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
  },
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
  // html-webpack-plugin 可以生成一个 HTML 文件,并自动将生成的所有 bundle 注入到此文件中
};

二、WHY:为什么要用 webpack

工欲善其事,必先利其器。

当我们编写大型复杂的项目时,我们通过模块化将业务逻辑解耦, 那是否可以有一种方式,不仅可以让我们编写模块,而且还支持任何模块格式(至少在 ESM 之前),并且可以同时处理各种资源?

这就是我们使用 webpack 的原因,它可以打包 JavaScript 应用程序(支持 ESM 和 CommonJS),可以扩展为支持许多不同的静态资源。

三、HOW:webpack 是怎么工作的

知道了 WHAT & WHY 之后,我们最好奇的应该是 HOW? webpack 到底是如何工作的?不妨从一个简单的例子出发,先看看 webpack 将我们写的代码编译成了什么?因为不论是复杂的项目还是简单的一行代码,经过 webpack 编译打包之后的产出本质是一样的,所以我们可以从最简单的情况开始,研究其打包产出的秘密。

1、一个简单的例子

src 目录下创建 index.js

const sayHello = require('./hello.js') 
console.log(sayHello('慧嚯'))

hello.js:

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

index.html 中引入打包之后的文件::

<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script type="text/javascript" src="../dist/bundle.js" charset="utf-8"></script>
    </body>
</html>

执行打包命令之后,打开 dist/bundle.js(指定的输出文件名):

/******/ (() => { // webpackBootstrap
/******/  var __webpack_modules__ = ({

/***/ "./src/hello.js":
/*!**********************!*\
  !*** ./src/hello.js ***!
  \**********************/
/***/ ((module) => {

    eval("module.exports = function (name) {\n    return 'hello ' + name\n}\n\n//# sourceURL=webpack://webpack/./src/hello.js?");

    /***/ }),
    
    /***/ "./src/index.js":
    /*!**********************!*\
      !*** ./src/index.js ***!
      \**********************/
    /***/ ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
    
    eval("const sayHello = __webpack_require__(/*! ./hello */ \"./src/hello.js\")\nconsole.log(sayHello('慧嚯'))\n\n//# sourceURL=webpack://webpack/./src/index.js?");
    
    /***/ })
    
    /******/  });
    /************************************************************************/
    /******/  // The module cache
    /******/  var __webpack_module_cache__ = {};
    /******/  
    /******/  // The require function
    /******/  function __webpack_require__(moduleId) {
    /******/    // Check if module is in cache
    /******/    var cachedModule = __webpack_module_cache__[moduleId];
    /******/    if (cachedModule !== undefined) {
    /******/      return cachedModule.exports;
    /******/    }
    /******/    // Create a new module (and put it into the cache)
    /******/    var module = __webpack_module_cache__[moduleId] = {
    /******/      // no module.id needed
    /******/      // no module.loaded needed
    /******/      exports: {}
    /******/    };
    /******/  
    /******/    // Execute the module function
    /******/    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    /******/  
    /******/    // Return the exports of the module
    /******/    return module.exports;
    /******/  }
    /******/  
    /************************************************************************/
    /******/  
    /******/  // startup
    /******/  // Load entry module and return exports
    /******/  // This entry module can't be inlined because the eval devtool is used.
    /******/  var __webpack_exports__ = __webpack_require__("./src/index.js");
    /******/  
    /******/ })()
    ;

注释很多,我们把核心部分提取出来:

// 最外层是一个立即执行函数 IIFE
(() => { // webpackBootstrap
    // 定义了一个 __webpack_modules__ 对象,对象以文件名为属性,属性值是对应的文件内容
    var __webpack_modules__ = ({
      "./src/hello.js": ((module) => {
        eval("module.exports = function (name) {\n    return 'hello ' + name\n}\n\n//# sourceURL=webpack://webpack/./src/hello.js?");
       }),
      "./src/index.js":
        eval("const sayHello = __webpack_require__(/*! ./hello */ \"./src/hello.js\")\nconsole.log(sayHello('慧嚯'))\n\n//# sourceURL=webpack://webpack/./src/index.js?");
      })
    
    // The module cache
    // 缓存模块
    var __webpack_module_cache__ = {};
    // The require function
    function __webpack_require__(moduleId) {
      // Check if module is in cache
      var cachedModule = __webpack_module_cache__[moduleId];
      if (cachedModule !== undefined) {
        return cachedModule.exports;
      }
      // Create a new module (and put it into the cache)
      var module = __webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
        exports: {}
      };
      // Execute the module function
       __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
      // Return the exports of the module
      return module.exports;
    }
    var __webpack_exports__ = __webpack_require__("./src/index.js");
})();

我们可以得出结论:

  • webpack 打包结果就是一个 IIFE,一般称它为 webpackBootstrap;
  • 打包结果中,定义了一个模块加载函数 webpack_require
  • 首先使用 webpack_require 加载函数去加载入口模块 ./src/index.js。
  • 加载函数 webpack_require 使用了闭包变量 webpack_module_cache,将已加载过的模块结果缓存。

2、工作原理

简单总结为下图【来源于网络】:

webpack|学习总结:知其原理,手写一个 mini-webpack

  • 首先,webpack 会读取项目中由开发者定义的 webpack.config.js 配置文件,或者从 shell 语句中获得必要的参数,完成配置读取。
  • 接着,实例化所需 webpack 插件,在 webpack 事件流上挂载插件钩子,这样在合适的构建过程中,插件具备了改动产出结果的能力。
  • 同时,根据配置所定义的入口文件,以入口文件(可以不止有一个)为起始,进行依赖收集:对所有依赖的文件进行编译,这个编译过程依赖 loader,不同类型文件根据开发者定义的不同 loader 进行解析。编译好的内容解析生成 AST 静态语法树,分析文件依赖关系,用 webpack 自己的加载器进行模块化实现。
  • 上述过程进行完毕后,产出结果,根据开发者的配置,将结果打包到相应目录。

一些核心概念

  • AST:我们的老朋友了,抽象语法树,是 JS 对象,帮助我们进行代码分析。
  • compiler :compiler 对象的实例包含了完整的 webpack 配置,全局只有一个 compiler 实例。当插件被实例化的时候,会收到一个 compiler 对象,通过这个对象可以访问 webpack 的内部环境
  • compilation 对象:当 webpack 以开发模式运行时,每当检测到文件变化,一个新的 compilation 对象将被创建。这个对象包含了当前的模块资源、编译生成资源、变化的文件等信息。也就是说,所有构建过程中产生的构建数据都存储在该对象上,它掌控着构建过程中的每一个环节,该对象也提供了很多事件回调供插件做扩展。

四、不如自己实现一个 mini-webpack

首先我们安装几个要用到的包:

  • @babel/parser:用于将输入代码解析成抽象语法树(AST)
  • @babel/traverse:用于对输入的抽象语法树(AST)进行遍历
  • @babel/core:babel 的核心模块,进行代码的转换
  • @babel/preset-env:可根据配置的目标浏览器或者运行环境来自动将 ES6 + 的代码转换为 ES5
npm install @babel/parser @babel/traverse @babel/core @babel/preset-env -D

1、核心代码

首先我们需要一个配置文件:

// mini-webpack.config.js
const path = require('path')

module.exports = {
    entry: './src/index.js',
    mode: 'development',
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'bundle.js'
    }
}

然后新建一个 bundle.js ,用来实现打包核心代码:

const options = require("./mini-webpack.config");
const fs = require("fs");
const path = require('path');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

// 创建一个 MiniWebpack 类
class MiniWebpack {
  constructor(options) {
    // 配置项
    this.options = options;
  }

  // 解析入口文件
  parse(filename) {
    // 利用 Node 的核心模块 fs 读取文件
    const fileBuffer = fs.readFileSync(filename, "utf-8");
    // 基于 @babel/parser 解析文件得到 AST
    const ast = parser.parse(fileBuffer, { sourceType: "module" });
    const deps = {}; // 用来收集依赖
    // 遍历 AST
    traverse(ast, {
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename);
        const absPath = "./" + path.join(dirname, node.source.value);
        deps[node.source.value] = absPath;
      },
    });
    // 代码转换
    const { code } = babel.transformFromAst(ast, null, {
      presets: ["@babel/preset-env"],
    });
    const moduleInfo = { filename, deps, code };
    return moduleInfo;
  }
  
  // 收集模块依赖图
  analyse(file) {
    // 定义依赖图
    const depsGraph = {};
    // 首先获取入口的信息
    const entry = this.parse(file);
    const temp = [entry];
    for (let i = 0; i < temp.length; i++) {
      const item = temp[i];
      const deps = item.deps;
      if (deps) {
        // 遍历模块的依赖,递归获取模块信息
        for (const key in deps) {
          if (deps.hasOwnProperty(key)) {
            temp.push(this.parse(deps[key]));
          }
        }
      }
    }
    temp.forEach((moduleInfo) => {
      depsGraph[moduleInfo.filename] = {
        deps: moduleInfo.deps,
        code: moduleInfo.code,
      };
    });
    return depsGraph;
  }
  
  // 生成最终执行的代码
  generate(graph, entry) {
    // 是一个立即执行函数
    return `(function(graph){
        function require(file) {
            var exports = {};
            function absRequire(relPath){
                return require(graph[file].deps[relPath])
            }
            (function(require, exports, code){
                eval(code)
            })(absRequire, exports, graph[file].code)
            return exports
        }
        require('${entry}')
    })(${graph})`;
  }
  
  // 指定打包后的文件的目录
  outputFile(output, code) {
      const {path: dirPath, filename} = output;
      const outputPath = path.join(dirPath, filename);
      if(!fs.existsSync(dirPath)){
          fs.mkdirSync(dirPath)
      }
      fs.writeFileSync(outputPath, code, 'utf-8')
  }
  
  // 将所有打包逻辑串起来
  bundle(){
      const {entry, output} = this.options
      const graph = this.analyse(entry)
      const graphStr = JSON.stringify(graph)
      const code = this.generate(graphStr, entry)
      this.outputFile(output, code)
  }
}

// 实例化一个 MiniWebpack
const miniWebpack = new MiniWebpack(options)

// 运行打包逻辑
miniWebpack.bundle()

2、来个例子

整个目录如图所示:

webpack|学习总结:知其原理,手写一个 mini-webpack

在 src 目录下新建几个文件:

index.html:

<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script type="text/javascript" src="../dist/bundle.js" charset="utf-8"></script>
    </body>
</html>

index.js

import minus from './minus.js' 
import add from './add.js' 

console.log('3-1 = >>>>>>', minus(3,1)) 
console.log('3+1 = >>>>>>', add(3,1))

minus.js:

// minus.js 
export default (a, b) => { return a - b }

add.js:

// add.js 
export default (a, b) => { return a + b }

通过 node bundle.js 命令运行打包逻辑,在 scripts 里面写为:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "node bundle.js"
},

执行 npm run build

webpack|学习总结:知其原理,手写一个 mini-webpack

OK!成功打包,生成了打包文件:dist/bundle.js,在 index.html 中引入了这个文件,现在我们在浏览器打开 index.html

webpack|学习总结:知其原理,手写一个 mini-webpack

成功!🎉

五、实现一个简单的 loader

一个 loader 秉承单一职责,完成最小单元的文件转换。一个源文件可能需要经历多步转换才能正常使用,比如 Sass 文件先通过 sass-loader 输出 CSS,之后将内容交给 css-loader 处理,甚至 css-loader 输出的内容还需要交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码。如下使用方式:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          {
            loader: "style-loader", // 通过 JS 字符串,创建 style node
          },
          {
            loader: "css-loader", // 编译 css 使其符合 CommonJS 规范
          },
          {
            loader: "less-loader", // 编译 less 为 css
          },
        ],
      },
    ],
  },
};

当我们调用多个 loader 串联去转换一个文件时,每个 loader 会链式地顺序执行。webpack 中,在同一文件存在多个匹配 loader 的情况下,遵循以下原则:

  • loader 的执行顺序是和配置顺序相反的,即配置的最后一个 loader 最先执行,第一个 loader 最后执行。
  • 第一个执行的 loader 接收源文件内容作为参数,其他 loader 接收前一个执行的 loader 的返回值作为参数。最后执行的 loader 会返回最终结果。

配置 loader 时,可以增加一些配置,比如:

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

这里传入的 options 怎么获取呢?通过 loader-utils 模块:

const loaderUtils = require("loader-utils");
module.exports = function (source) {
  // 获取开发者配置的 options
  const options = loaderUtils.getOptions(this);
  // ...
  return content;
};

loader 本质上是个函数,我们只关心它的输入输出即可。

比如我们想实现一个 replace-loader,这个 loader 可以自定义替换我们配置的要进行替换的字符串,使用和配置方式为:

const path = require("path");
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "replaceLoader",
          options: {
            str: "let",
            replaceStr: "const",
            // 将 let 替换为 const
          },
        },
      },
    ],
  },
};

replaceLoader.js 中:

const loaderUtils = require("loader-utils");
module.exports = function (source) {
  // 获取 options
  const options = loaderUtils.getOptions(this);
  return source.replace(options.str, options.replaceStr);
};

回到前面的例子:

let sayHello = require('./hello.js') 
console.log(sayHello('慧嚯'))

打包之后:

webpack|学习总结:知其原理,手写一个 mini-webpack

loader 生效了~

六、实现一个简单的 plugin

webpack有事件流机制,在 webpack 构建的生命周期中,会触发许多事件。这时候,开发中注册的各种插件,便可以根据需要监听与自身相关的事件。捕获事件后,在合适的时机通过 webpack 提供的 API 去改变编译输出结果。

所以 loader 和 plugin 的区别很明显了:

  • loader 其实就是一个转换器,执行单纯的文件转换操作。
  • plugin 是一个扩展器,它丰富了 webpack 本身,在 loader 过程结束后,webpack 打包的整个过程中,weback plugin 并不直接操作文件,而是基于事件机制工作,监听 webpack 打包过程中的某些事件,见缝插针,修改打包结果。

webpack 插件是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象。

由于插件可以携带参数/选项,你必须在 webpack 配置中,向 plugins 属性传入一个 new 实例

所以我们实现的 plugin 应当是一个 class 或是 构造函数

我们来简单实现一个 HtmlWebpackPlugin 插件,打包之后生成一个 html 文件,并在这个文件中引入打包生成的 js 文件。

// my-html-webpack-plugin.js
const pluginName = "MyHtmlWebpackPlugin";

class MyHtmlWebpackPlugin {
  apply(compiler) {
    const filename = compiler.options.output.filename;
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      const content = `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="utf-8">
          <title>Webpack</title>
          <script defer src="./${filename}"></script>
        </head>
        <body>
        </body>
      </html>
      `;
      // 将这个文件作为一个新的文件资源,插入到 webpack 构建中:
      compilation.assets["index.html"] = {
        source: function () {
          return content;
        },
        size: function () {
          return content.length;
        },
      };
      callback();
    });
  }
}

module.exports = MyHtmlWebpackPlugin;

webpack.config.js 中配置:

const path = require("path");
const MyHtmlWebpackPlugin = require('./my-html-webpack-plugin');

module.exports = {
  mode: "development",
  entry: {
    main: "./src/index.js",
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },
  // ...
  plugins: [new MyHtmlWebpackPlugin()],
};

执行打包命令:

webpack|学习总结:知其原理,手写一个 mini-webpack

成功!

👉:🐶 compiler-hooks