webpack 打包原理与简易实现

258 阅读10分钟

项目完整代码

webpack 打包后,做了什么事情?

当在终端执行 npx webpack 命令进行打包后,webpack 都做了什么事情?

我们来看一个简单的demo:

webpack.config.js 文件的配置如下:

const path = require("path");
module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "./dist"),
    filename: "main.js",
  },
  mode: "development",
};

我们指定了模块打包的入口文件是 src 目录下的 index.js 文件,打包完成后的输出目录是 dist 目录,输出文件是 main.js。打包完成后的目录结构如下:

可以看到,打包完成后,dist 目录下多了 main.js 文件。我们来看看main.js 文件的内容:

         // webpackBootStrap 启动函数
         // modules 即为存放所有模块的数组,数组中的每一个元素都是一个函数
/******/ (function(modules) { // webpackBootstrap
/******/    // The module cache
          // 安装过的模块都存放在这里面
          // 作用是把已经加载过的模块缓存在内存中,提升性能
/******/    var installedModules = {};
/******/
/******/    // The require function
          // webpack 自己实现的 require 函数
          // 去数组综合功能加载一个模块,moduleId 为要加载模块在数组中的 index
/******/    function __webpack_require__(moduleId) {
/******/
/******/        // Check if module is in cache
            // 如果要加载的模块已经被加载过,就直接从内存缓存中获取
/******/        if(installedModules[moduleId]) {
/******/            return installedModules[moduleId].exports;
/******/        }

/******/        // Create a new module (and put it into the cache)
            // 如果缓存中没有需要加载的模块,就新建一个模块,并将其存在缓存中
/******/        var module = installedModules[moduleId] = {
              // 模块在数组中的 index
/******/            i: moduleId,
              // 该模块是否已经加载完毕
/******/            l: false,
              // 该模块的导出值
/******/            exports: {}
/******/        };
/******/
/******/        // Execute the module function
            // 从 modules 中获取 index 为 moduleId 的模块对应的函数
            // 然后调用这个函数,同时传入函数需要的参数
/******/        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/        // Flag the module as loaded
            // 把这个模块标为已加载
/******/        module.l = true;
/******/
/******/        // Return the exports of the module
            // 返回这个模块的导出值
/******/        return module.exports;
/******/    }
/******/
/******/
/******/    // expose the modules object (__webpack_modules__)
/******/    __webpack_require__.m = modules;
/******/
/******/    // expose the module cache
/******/    __webpack_require__.c = installedModules;
/******/
/******/    // define getter function for harmony exports
/******/    __webpack_require__.d = function(exports, name, getter) {
/******/        if(!__webpack_require__.o(exports, name)) {
/******/            Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/        }
/******/    };
/******/
/******/    // define __esModule on exports
/******/    __webpack_require__.r = function(exports) {
/******/        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/        }
/******/        Object.defineProperty(exports, '__esModule', { value: true });
/******/    };
/******/
/******/    // create a fake namespace object
/******/    // mode & 1: value is a module id, require it
/******/    // mode & 2: merge all properties of value into the ns
/******/    // mode & 4: return value when already ns object
/******/    // mode & 8|1: behave like require
/******/    __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;
/******/    };
/******/
/******/    // getDefaultExport function for compatibility with non-harmony modules
/******/    __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;
/******/    };
/******/
/******/    // Object.prototype.hasOwnProperty.call
/******/    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/    // __webpack_public_path__
/******/    __webpack_require__.p = "";
/******/
/******/
/******/    // Load entry module and return exports
/******/    return __webpack_require__(__webpack_require__.s = "./src/index.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

/******/ });

将代码折叠后,我们发现,main.js 中的内容就是一个自执行函数,main.js 中的代码是可以直接在浏览器中运行的。

从 main.js 中的代码可以知道,webpack在打包后输出的文件中实现了一个webpack_require 函数来实现自己的模块化,把代码都缓存在 installedModules 里了,代码文件以对象的形式传递进来,其中 key 是文件路径,value是包裹的代码字符串,并且代码内部的 require ,都被替换成了 webpack_require 。这也正是 main.js 能直接运行在浏览器的原因,webpack_require 函数模拟了Node.js 中的require 语句,通过网络请求去加载文件。

webpack_require 函数中,webpack 还做了缓存优化:执行加载过的模块不会执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。

webpack 构建流程

从执行 npx webpack 到输出文件到指定目录,webpack 的构建流程是怎样的呢?

从上面的流程图可以看出,webpack 的构建流程可以分为三大阶段:

  1. 初始化:启动构建,读取并合并配置参数,加载 plugins,实例化 Compiler。
  2. 编译:从入口 entry 开始,针对每个 module 串行调用对应的 loader 去编译文件内容,再找到该 module 依赖的 module ,递归地进行编译处理。
  3. 输出:对编译后的 module 组合成 chunk ,把 chunk 转换成文件,输出到指定的目录中。

如果只执行一次构建,以上三个阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

下面,我们来看一下 webpack 构建的具体流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第 4 步使用 loader 编译完所有模块后,得到了每个模块被编译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 chunk ,再把每个 chunk 转换成一个单独的文件加入到输出列表,这一步骤是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 webpack 提供的 API 来改变 webpack 的运行结果。

简易版 webpack 实现

我们已经了解了 webpack 的打包原理和构建流程,我们也来实现一个简易版的 webpack。

1、定义 Compiler 类

module.exports = class Compiler {
    constructor(options) {
    // options 为 webpack.config.js 中的配置
    // 从 options 中解构出 entry、output
    const { entry, output } = options;
    // 入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模块
    this.modules = [];
    // 构建启动函数
    run() {}
    // 重写 require 函数,输出 bundle
    generate() {}
  }
}

2、解析入口文件,获取 AST

我们借助 @babel/parser 来提取文件的依赖。@babel/parser 是 babel7的工具,可以帮助我们分析文件内部的语法,也包括 ES6,解析完成后会返回一个 AST 抽象语法树。

在 Compiler 类中定义 getAst 方法来获取 AST:

const fs = require('fs');
const parser = require('@babel/parser');

// 转换成 AST 语法树
getAst(entryFile) {
  // 分析入口模块的内容
  const content = fs.readFileSync(entryFile, "utf-8");

  // 将入口文件的内容转换为 AST 抽象语法树
  const ast = parser.parse(content, {
    sourceType: "module"
  });

  return ast;
}

在 parse 方法中调用 getAst 方法:

parse(entryFile) {
  // 获取 AST 抽象语法树
    const ast = this.getAst(entryFile);
}

3、找出所有的依赖模块

接下来我们需要找出所有的引用模块。Babel 提供了 @babel/traverse (遍历)方法来维护 AST 树的整体状态,我们可以借助它来帮我们找出所有的引用模块。

在 Compiler 类中定义 getDependencies 方法来获取所有引用的模块:

const path = require('path');
const traverse = require("@babel/traverse").default;

// 获取所有引用依赖
getDependencies(ast, entryFile) {
  const dependencies = {};
  // 遍历所有的 import 模块,存入 dependencies 中
  traverse(ast, {
    // 类型为 ImportDeclaration 的 AST 节点(即 import 语句)
    ImportDeclaration({ node }) {
      // 保存依赖模块路径,在后面生层依赖关系图时会用到
      const pathName = './' + path.join(path.dirname(entryFile), node.source.value);
      dependencies[node.source.value] = pathName;
    }
  })
  return dependencies
}

在 parse 方法中调用 getDependencies 方法:

parse(entryFile) {
  // 获取 AST 抽象语法树
    const ast = this.getAst(entryFile);
  // 获取所有引用依赖
  const dependencies = this.getDependencies(ast, entryFile);
}

4、AST 转换为 code

借助 @babel/core 和 @babel/preset-env ,将 AST 语法树转换成浏览器可运行的代码。

在 Compiler 类中定义 getCode 方法来将 AST 语法树转换成浏览器可运行的代码:

const { transformFromAst } = require("@babel/core");

// 将 AST 转换成 浏览器可以运行的代码
getCode(ast) {
  const { code } = transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  return code;
}

在 parse 方法中调用 getCode 方法:

parse(entryFile) {
  // 获取 AST 抽象语法树
    const ast = this.getAst(entryFile);
  // 获取所有引用依赖
  const dependencies = this.getDependencies(ast, entryFile);
  // 将 AST 转换成浏览器可运行的代码
  const code = this.getCode(ast);
}

最后,将 entryFile、dependencies 和 code 在 parse 函数中返回出去,以便在生成依赖关系图时使用:

parse(entryFile) {
  // 获取 AST 抽象语法树
    const ast = this.getAst(entryFile);
  // 获取所有引用依赖
  const dependencies = this.getDependencies(ast, entryFile);
  // 将 AST 转换成浏览器可运行的代码
  const code = this.getCode(ast);

  return {
    entryFile,
    dependencies,
    code
  }
}

5、递归解析所有依赖模块,生成依赖关系图

在 parse 函数中,我们从入口文件开始,获取了所有的引用依赖,接下来,我们递归解析所有的依赖模块,将其生成依赖关系图。

定义 recursionDependent 函数来递归解析所有的依赖模块,在该函数中,我们将会使用两个 for 循环来实现递归的效果:

// 解析所有依赖模块
recursionDependent() {
  // 递归解析所有依赖模块
  for (let i = 0; i < this.modules.length; i++) {
    const item = this.modules[i];
    const dependencies = item.dependencies;
    // 判断是否有依赖对象,递归解析所有的依赖模块
    if (dependencies) {
      for(let j in dependencies) {
        this.modules.push(this.parse(dependencies[j]))
      }
    }
  }
}

在 run 函数中调用 recursionDependent 函数:

run() {
    // 解析入口模块
    const info = this.parse(this.entry);
    this.modules.push(info);
    // 递归解析所有依赖
    recursionDependent()
  }

然后定义 generateDependencyGraph 函数来生成依赖关系图,也就是将数组结构的 dependencies 转换成 Object 对象结构:

// 生成依赖关系图
generateDependencyGraph() {
  const graphMap = {}
  this.modules.forEach(item => {
    graphMap[item.entryFile] = {
      dependencies: item.dependencies,
      code: item.code
    }
  })
  return graphMap
}

在 run 函数中调用 generateDependencyGraph 函数:

run() {
    // 解析入口模块
    const info = this.parse(this.entry);
    this.modules.push(info);
    // 递归解析所有依赖
    recursionDependent();
    // 生成依赖关系图
    const graphMap = generateDependencyGraph();
  }

6、重写 require 函数,输出 bundle

类似于 webpack 在打包后输出的文件中实现的webpack_require 函数,我们重写 require 函数,然后生成可以在浏览器运行的代码:

// 重写 require 函数,输出 bundle
generate(code) {
  // 生成代码内容 webpack启动函数
  const filePath = path.join(this.output.path, this.output.filename);
  const newCode = JSON.stringify(code);
  const bundle = `(function(graph){
      function require(module){
          function PathRequire(relativePath){
             return require(graph[module].dependencies[relativePath])
          }
          const exports = {};
          (function(require,exports,code){
             eval(code) 
          })(PathRequire,exports,graph[module].code)
          return exports;
      }
      require('${this.entry}')
  })(${newCode})`;
  // 把文件内容写入到文件,生成 main.js,位置是 dist 目录 
  fs.writeFileSync(filePath, bundle, "utf-8");
}

在 run 函数中调用 generate 函数:

run() {
    // 解析入口模块
    const info = this.parse(this.entry);
    this.modules.push(info);
    // 递归解析所有依赖
    recursionDependent();
    // 生成依赖关系图
    const graphMap = generateDependencyGraph();
    // 生成输出文件
    this.generate(graphMap)
  }

Compiler 类完整代码

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("@babel/core");
//@babel/preset-env

module.exports = class Webpack {
  constructor(options) {
    // options 为 webpack.config.js 中的配置
    // 从 options 中解构出 entry、output
    const { entry, output } = options;
    // 入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模块
    this.modules = [];
  }

  // 构建启动函数
  run() {
    // 解析入口模块
    const info = this.parse(this.entry);
    this.modules.push(info);

    // 递归解析所有依赖
    this.recursionDependent()

    // 数组结构转对象结构
    const graphMap = this.generateDependencyGraph()

    // 生成输出文件
    this.generate(graphMap);
  }

  // 解析所有依赖模块
  recursionDependent() {
    for (let i = 0; i < this.modules.length; i++) {
      const item = this.modules[i];
      const dependencies = item.dependencies;
      if (dependencies) {
        for(let j in dependencies) {
          this.modules.push(this.parse(dependencies[j]))
        }
      }
    }
  }

  // 生成依赖关系图
  generateDependencyGraph() {
    const graphMap = {}
    this.modules.forEach(item => {
      graphMap[item.entryFile] = {
        dependencies: item.dependencies,
        code: item.code
      }
    })
    return graphMap
  }

  // 转换成 AST 语法树
  getAst(entryFile) {
    // 分析入口模块的内容
    const content = fs.readFileSync(entryFile, "utf-8");

    // 处理依赖
    const ast = parser.parse(content, {
      sourceType: "module",
    });

    return ast
  }

  // 获取所有引用依赖
  getDependencies(ast, entryFile) {
    const dependencies = {};
    traverse(ast, {
      ImportDeclaration({ node }) {
        const pathName = './' + path.join(path.dirname(entryFile), node.source.value);
        dependencies[node.source.value] = pathName;
      }
    })
    return dependencies
  }

  // 将 AST 转换成 浏览器可以运行的代码
  getCode(ast) {
    const { code } = transformFromAst(ast, null, {
      presets: ["@babel/preset-env"]
    })
    return code;
  }

  // 解析依赖模块
  parse(entryFile) {
    // 获取 AST 抽象语法树
    const ast = this.getAst(entryFile);

    // 获取所有引用依赖
    const dependencies = this.getDependencies(ast, entryFile)

    // 将 AST 转换成浏览器可运行的代码
    const code = this.getCode(ast)
    return {
      entryFile,
      dependencies,
      code,
    };
  }

  // 重写 require 函数,输出 bundle
  generate(code) {
    // 生成代码内容 webpack启动函数
    const filePath = path.join(this.output.path, this.output.filename);
    const newCode = JSON.stringify(code);
    const bundle = `(function(graph){
        function require(module){
            function PathRequire(relativePath){
               return require(graph[module].dependencies[relativePath])
            }
            const exports = {};
            (function(require,exports,code){
               eval(code) 
            })(PathRequire,exports,graph[module].code)
            return exports;
        }
        require('${this.entry}')
    })(${newCode})`;
    // 生成main.js 位置是./dist目录
    fs.writeFileSync(filePath, bundle, "utf-8");
  }
};

我们在根目录下创建 bundle.js 文件作为我们的应用启动入口:

// 读取配置
const options = require("./webpack.config.js");
// 引入webpack

const webpack = require("./lib/webpack.js");
// webpack接收配置 启动入口函数,执行打包

new webpack(options).run();

在终端执行 npm run start 命令,在根目录的 dist 目录下生成了 main.js 文件,main.js 的内容如下:

(function (graph) {
   function require(module) {
      function PathRequire(relativePath) {
         return require(graph[module].dependencies[relativePath])
      }
      const exports = {};
      (function (require, exports, code) {
         eval(code)
      })(PathRequire, exports, graph[module].code)
      return exports;
   }
   require('./src/index.js')
})({
   "./src/index.js": {
      "dependencies": { "./a.js": "./src/a.js" },
      "code": "\"use strict\";\n\nvar _a = require(\"./a.js\");\n\nconsole.log(\"hello \".concat(_a.str));"
   },
   "./src/a.js": {
      "dependencies": { "./b.js": "./src/b.js" },
      "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.str = void 0;\n\nvar _b = require(\"./b.js\");\n\nvar str = \"webpack5 \".concat(_b.str2);\nexports.str = str;"
   },
   "./src/b.js": {
      "dependencies": {},
      "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.str2 = void 0;\nvar str2 = \"!!!!\";\nexports.str2 = str2;"
   }
})

我们将这段代码拷贝到浏览器运行:

可见,webpack 打包输出的 bundle 文件内容是可以直接在浏览器中运行的代码。

参考:

深入浅出Webpack