「动手试试」手把手实现一个mini-webpack

176 阅读13分钟

前提

现在说到前端打包工具,我们首先提及的就是webpack

webpack有着丰富的api,高度的可拓展性

让前端工程师可以在前端模块化方面大展身手

但是webpack具体实现原理是怎么样的

很多人都不太清楚

本篇水文主要记录的就是如何实现一个mini-webpack

本次目标

在完整的webpack实现中,主要有三个阶段

  1. 初始化阶段
  2. 构建阶段
  3. 生成阶段

而我们本次我们实现的mini-webpack也与上面一样会拥有完整的三个阶段

但是由于是简易版,我们只讨论实现,而不讨论进一步优化

我们要达成的目标就是

把一些使用es模块规范的代码打包成一个bundle

看完本篇可以学到什么

  1. 了解webpack的基础打包流程
  2. 了解webpack的实现
  3. 了解webpack打包后的产物

初始化一个项目

初始化package.json

// 初始化package.json

npm init

// package.json添加依赖(避免大家因为版本问题踩坑)

"dependencies": {
    "@babel/generator": "^7.15.0",
    "@babel/parser": "^7.15.3",
    "@babel/traverse": "^7.15.0",
    "tapable": "^2.2.0"
  },
 "devDependencies": {
   "babel-core": "^6.26.3",
   "babel-preset-env": "^1.7.0"
 }

// 安装依赖

npm i

// package.json设置type,本次我们全部使用es语法

"type": "module",



初始化文件夹以及入口文件

在根目录下创建三个文件夹与文件

  1. build :存放打包的脚本与webpack配置
  2. build/index.js :打包的脚本与webpack配置
  3. src :待打包的代码
  4. src/index.js :待打包的代码入口
  5. webpack:mimi-webpack的核心代码
  6. webpack/index.js:mimi-webpack的代码入口

build/index.js

import path, { dirname } from "path";
import { webpack } from "../webpack/index.js";
import { fileURLToPath } from "url";

// ES语法中没有预设的__dirname
const __dirname = dirname(fileURLToPath(import.meta.url));

// 打包配置
const webpackConfig = {
  entry: path.join(__dirname, "../src/index.js"),
  output: {
    path: path.join(__dirname, "../dist"),
    filename: "bundle.js",
  },
};
// 执行打包
webpack(webpackConfig);

src/index.js

console.log('我是mini-webpack打包入口')

package.json添加脚本

 "scripts": {
    "build": "node build/index.js"
  },

webpack/index.js

export function webpack(config) {
   console.log(config)
}

创建一些模拟代码

src/index.js

import { a } from "./a.js";
import { b } from "./b.js";

const index = () => {
  console.log("我是mini-webpack打包入口");
};
index();
a();
b();

src/a.js

import { c } from "./c.js";

export function a() {
  console.log("我是a");
  c();
}

src/b.js

export function b() {
  console.log("我是b");
}

src/c.js

export function c() {
  console.log("我是c");
}

package.json添加脚本

 "scripts": {
    "build": "node build/index.js",
   "dev": "node src/index.js"
  },

我们用跑一下这个dev

结果如下:

我是mini-webpack打包入口 我是a 我是c 我是b

这也是我们的mini-webpack打包后的bundle.js执行后所要达到的结果

ok正式开撸我们的mini-webpack

初始化阶段

在这个阶段我们主要完成个操作:

  1. 初始化参数(从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数,这里使用默认配置)
  2. 创建编译器对象(传入上一步的配置new Compiler(config))
  3. 初始化编译环境(包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等,这一部分会在后面plugin部分讲解)
  4. 开始编译(compiler.run()启动编译)

创建编译器对象Compiler

修改webpack/index.js

// 引入编译器类
import { Compiler } from "./Compiler.js";
// 初始化编译器并调用run函数
export function webpack(config) {
  const compiler = new Compiler(config);
  compiler.run();
}

创建webpack/Compiler.js

export class Compiler {
  constructor(config) {
    const { entry, output } = config;
    // 保存入口
    this._entry = entry;
    // 保存输出设置
    this._output = output;
    // 保存的Compilation实例
    this._compilation = null;
  }
  //启动编译器

  run() {
    // 创建Compilation实例
    //Compilation构造函数中传入entry入口
    this._compilation = new Compilation({
      entry: this._entry,
    });
    //执行make构建依赖关系图
    this._compilation.make();
  }
}

创建webpack/Compilation.js

export default class Compilation {
  constructor({ entry }) {
    // 存放入口
    this._entry = entry;
    // 存放module
    this.graph = [];
  }
  // 启动
  make() {
    console.log("make");
  }
}

我们启动build.js一下看看有没有报错

输出:

> node build/index.js
make

ok一切正常进行下一个阶段

构建阶段

这个阶段主要是进行编译模块(make)

先处理入口module

将内容转换为 AST 对象,从中找出该模块依赖的模块,

根据入口 entryModule 对应的 dependence 创建 module 对象,

再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理

构建module

修改webpack/Compilation.js

读取入口源代码

并通过parse产出转换后的code, 与对应的dependencies

import fs from "fs";
import { parse } from "./parser.js";
export default class Compilation {
  constructor({ entry }) {
    // 存放入口
    this._entry = entry;
    // 存放module
    this.graph = [];
  }
  // 启动
  make() {
    // 构建模块
    function _buildModule(filename) {
      // 使用fs模块获取模块的代码
      let sourceCode = fs.readFileSync(filename, { encoding: "utf-8" });

      // 获取模块的依赖关系和把 import 替换成 require
      // 输出转换后的源码code和依赖dependencies
      const { code, dependencies } = parse(sourceCode);
      return {
        code,
        dependencies,
        filename,
      };
    }
    // 处理入口Module
    const entryModule = _buildModule(this._entry);
    // 放入模块关系图
    this.graph.push(entryModule);
    console.log(entryModule)
  }
}

解析 Parse与转换 Transform

接下来我们编写parse.js的内容

// 用于解析我们的源内容生成AST树
import { parse as parseBabel } from "@babel/parser";
// traverse是babel提供的遍历和更新节点的工具
import rawTraverse from "@babel/traverse";
// transformFromAst是babel-core中用于把Ast转换成es5-js的
import { transformFromAst } from "babel-core";
// es
const traverse = rawTraverse.default;

export function parse(source) {
  // 保存文件内import引用的依赖路径
  const dependencies = [];
  // 将传入的sourceCode源内容js转换成ast树
  // 这里采用的是es module
  const ast = parseBabel(source, {
    sourceType: "module",
  });
  // 遍历整个ast树,获取import的路径
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      // 基于 import 来获取当前文件需要的依赖
      dependencies.push(node.source.value);
    },
  });
  // 把里面的 import 替换成 require
  const { code } = transformFromAst(ast, null, {
    // 需要使用 babel-preset-env
    presets: ["env"],
  });
  //  返回转换后的代码与模块依赖关系
  return {
    code,
    dependencies,
  };
}

我们在Compilation.js中设置

console.log(entryModule)

然后执行一下build

输出一下entryModule

{
  code: '"use strict";\n' +
    '\n' +
    'var _a = require("./a.js");\n' +
    '\n' +
    'var _b = require("./b.js");\n' +
    '\n' +
    'var index = function index() {\n' +
    '  console.log("我是mini-webpack打包入口");\n' +
    '};\n' +
    '\n' +
    'index();\n' +
    '(0, _a.a)();\n' +
    '(0, _b.b)();',
  dependencies: [ './a.js', './b.js' ],
  filename: '/Users/xxx/Documents/myCode/webpack-mini/src/index.js'
}

我们可以看到输出了转换后的js,

还有我们在index.js中import的'./a.js', './b.js'

接下来我们就利用dependencies中的只去递归创建一个模块关系图

创建依赖关系图

这里我们使用while循环结合队列的方式

从入口module进行递归遍历

产出每个module对应的code与dependencies

放入模块关系图的结构(this.graph)中

import path from "path";
import fs from "fs";
import { parse } from "./parser.js";
export default class Compilation {
  constructor({ entry }) {
    // 存放入口
    this._entry = entry;
    // 存放module
    this.graph = [];
  }
  // 启动
  make() {
    // 构建模块
    function _buildModule(filename) {
      // 1. 获取模块的代码
      let sourceCode = fs.readFileSync(filename, { encoding: "utf-8" });

      // 2. 获取模块的依赖关系和把 import 替换成 require
      const { code, dependencies } = parse(sourceCode);
      return {
        code,
        dependencies,
        filename,
      };
    }

    // 处理入口Module
    const entryModule = _buildModule(this._entry);
    // 放入模块关系图
    this.graph.push(entryModule);
    // 通过队列的方式来遍历_buildModule所有的文件都
    const moduleQueue = [];
    // 放入入口entryModule
    moduleQueue.push(entryModule);
    while (moduleQueue.length > 0) {
      const currentModule = moduleQueue.shift();
      currentModule.dependencies.forEach((dependence) => {
        // 提前处理下 dependence 的路径
        // 需要完整的文件路径
        const childPath = path.resolve(
          path.dirname(currentModule.filename),
          dependence
        );
        const childModule = _buildModule(childPath);
        // 放入队列
        moduleQueue.push(childModule);
        this.graph.push(childModule);
      });
    }
    console.log(this.graph)
  }
}

这里我们build一下看看结果

[
  {
    code: '"use strict";\n' +
      '\n' +
      'var _a = require("./a.js");\n' +
      '\n' +
      'var _b = require("./b.js");\n' +
      '\n' +
      'var index = function index() {\n' +
      '  console.log("我是mini-webpack打包入口");\n' +
      '};\n' +
      '\n' +
      'index();\n' +
      '(0, _a.a)();\n' +
      '(0, _b.b)();',
    dependencies: [ './a.js', './b.js' ],
    filename: '/Users/xxx/Documents/myCode/webpack-mini/src/index.js'
  },
  {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.a = a;\n' +
      '\n' +
      'var _c = require("./c.js");\n' +
      '\n' +
      'function a() {\n' +
      '  console.log("我是a");\n' +
      '  (0, _c.c)();\n' +
      '}',
    dependencies: [ './c.js' ],
    filename: '/Users/xxx/Documents/myCode/webpack-mini/src/a.js'
  },
  {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.b = b;\n' +
      '\n' +
      'function b() {\n' +
      '  console.log("我是b");\n' +
      '}',
    dependencies: [],
    filename: '/Users/xxx/Documents/myCode/webpack-mini/src/b.js'
  },
  {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.c = c;\n' +
      '\n' +
      'function c() {\n' +
      '  console.log("我是c");\n' +
      '}',
    dependencies: [],
    filename: '/Users/xxx/Documents/myCode/webpack-mini/src/c.js'
  }
]

可以看到产出了一个对象数组,每个对象对应着一个模块

code是babel转换后的js代码

dependencies是模块中引入的其他模块

filename是当前模块的绝对路径

到这里我们已经递归处理所有能触达到的模块

得到了每个模块被翻译后的内容以及它们之间的依赖关系图

接下来就到了我们的生成阶段

生成阶段

接下来就是将我们得到的所有模块生成我们的bundle.js

但我们依然对要生成的这个bundle.js没啥概念

这里我们可以先去找一个我们自己做的项目里webpack的bundle

去看看是如何实现的模块化

查看原版实现

这里我们可以看到bundle的产物是一个自执行函数

这个自执行函数的参数是由各个模块内容组成的map

3221648996293_.pic.jpg

由于在通常的web开发中,我们的js是无法实现模块化的,

为了兼容性,我们通常也不会在script标签直接使用es模块化的语法,

不过未来的趋势一定是各大浏览器厂商会对模块化的实现进行统一标准

这里webpack实现了了一个webpack_require方法,

实际上我们可以发现webpack其实是把所有的代码都转换成了cjs的标准

并内部实现了一套cjs的模块化机制

这使得我们的代码得以在多数浏览器中都可以完美运行

3201648995736_.pic.jpg

(很显然这是个key-value的结构)

3231648996339_.pic.jpg

接下来我们就模拟实现一套cjs的逻辑

实现bundle.js的基础模版

创建webpack/temp.js

首先我们来个自执行函数

(function (modules) {
    //定义方法
  })({
	// 你的模块
  });
  

接下来我们按照key-value的模式填充上我们之前生成的module的内容

我们先以相对路径作为key,用一个函数包裹着code,作为value

这个函数拥有三个参数require, module, exports

提供code对应的cjs模块化能力

因为在代码中,我们都是使用的相对路径(该不会有人用绝对路径吧???)

代码如下:

(function (modules) {
  //定义方法
})({
  'index.js': 
    function (require, module, exports) {
      "use strict";

      var _a = require("./a.js");

      var _b = require("./b.js");

      var index = function index() {
        console.log("我是mini-webpack打包入口");
      };

      index();
      (0, _a.a)();
      (0, _b.b)();
    },
  './a.js': 
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.a = a;

      var _c = require("./c.js");

      function a() {
        console.log("我是a");
        (0, _c.c)();
      }
    },
  './b.js': 
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.b = b;

      function b() {
        console.log("我是b");
      }
    },
    
    './c.js': 
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.c = c;

      function c() {
        console.log("我是c");
      }
    },
});

接下来我们实现一个require方法

这个方法的作用是传入一个path

在modules中拿到这个path对应的模块代码并执行

执行的时候需要传入require, module, exports三个参数

其中module.exports是需要导出的内容

是实现模块化的关键

(function (modules) {
  function require(path) {
    //以path为key在modules中找到需要执行的fn
    const fn = modules[path];
    // 构建一个module对象,里面包含exports
    const module = { exports: {} };
    //传入到fn中
    //代码中会把需要用到的模块,浅拷贝的方式存到module.exports中
    fn(require, module, module.exports);
    //导出
    return module.exports;
  }
  //执行入口模块
  require('index.js');
})({
 // 省略
});

ok我们用node去跑一下这个模拟的bundle.js

结果如下:

我是mini-webpack打包入口
我是a
我是c
我是b

如我们的预期的结果一样

但是以相对路径作为key还是会有点问题

因为这无法保证模块的唯一性

模块需要一个唯一的值作为key

那我们就改造一下这个模版

(function (modules) {
  function require(id) {
    //根据id获取模块信息
    const [fn, mapping] = modules[id];
    //通过每个模块的path映射到它对应的id
    // { "./a.js": 1, "./b.js": 2 },
    // ./a.js -> 1
    function localRequire(name) {
      return require(mapping[name]);
    }
    const module = { exports: {} };
    fn(localRequire, module, module.exports);
    return module.exports;
  }
  require(0);
})({
  0: [
    function (require, module, exports) {
      "use strict";

      var _a = require("./a.js");

      var _b = require("./b.js");

      var index = function index() {
        console.log("我是mini-webpack打包入口");
      };

      index();
      (0, _a.a)();
      (0, _b.b)();
    },
    { "./a.js": 1, "./b.js": 2 },
  ],
  1: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.a = a;

      var _c = require("./c.js");

      function a() {
        console.log("我是a");
        (0, _c.c)();
      }
    },
    { "./c.js": 3 },
  ],
  2: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.b = b;

      function b() {
        console.log("我是b");
      }
    },
    {},
  ],
  3: [
    function (require, module, exports) {
      "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true,
      });
      exports.c = c;

      function c() {
        console.log("我是c");
      }
    },
    {},
  ],
});

用node去跑一下这个模拟的bundle.js

结果如下:

我是mini-webpack打包入口
我是a
我是c
我是b

如我们的预期的结果一样

ok接下来要做的就是以这个模版为目标,让我们的mini-webpack产出这个bundle.js

修改依赖关系图

由于产出的bundle.js中需要{'模块相对路径':id}这样的映射关系

所以我们改造一下这个Compilation.js类

加上mapping与id

webpack/Compilation.js

import path from "path";
import fs from "fs";
import { parse } from "./parser.js";
let ID = 0;
export default class Compilation {
  constructor({ entry }) {
    // 存放入口
    this._entry = entry;
    // 存放module
    this.graph = [];
  }
  // 启动
  make() {
    // 构建模块
    function _buildModule(filename) {
      // 1. 获取模块的代码
      let sourceCode = fs.readFileSync(filename, { encoding: "utf-8" });

      // 2. 获取模块的依赖关系和把 import 替换成 require
      const { code, dependencies } = parse(sourceCode);
      return {
        code,
        dependencies,
        filename,
        mapping: {},
        id: ID++,
      };
    }

    // 处理入口Module
    const entryModule = _buildModule(this._entry);
    // 放入模块关系图
    this.graph.push(entryModule);
    // 通过队列的方式来遍历_buildModule所有的文件都
    const moduleQueue = [];
    // 放入入口entryModule
    moduleQueue.push(entryModule);
    while (moduleQueue.length > 0) {
      const currentModule = moduleQueue.shift();
      currentModule.dependencies.forEach((dependence) => {
        // 提前处理下 dependence 的路径
        // 需要完整的文件路径
        const childPath = path.resolve(
          path.dirname(currentModule.filename),
          dependence
        );
        const childModule = _buildModule(childPath);
        // mapping 的  key  需要是相对路径
        currentModule.mapping[dependence] = childModule.id;
        // 放入队列
        moduleQueue.push(childModule);
        this.graph.push(childModule);
      });
    }
    //console.log(this.graph)
  }
}

依赖关系图转换为key-value形式

webpack/Compiler.js

import path from "path";
import fs from "fs";
import Compilation from "./Compilation.js";

export class Compiler {
  constructor(config) {
    const { entry, output } = config;
    // 保存入口
    this._entry = entry;
    // 保存输出设置
    this._output = output;
    // 保存的Compilation实例
    this._compilation = null;
  }
  //启动编译器
  run() {
    // 创建Compilation实例
    //Compilation构造函数中传入entry入口
    this._compilation = new Compilation({
      entry: this._entry,
    });
    //执行make构建依赖关系图
    this._compilation.make();
    // 输出bundle
    this.emitFiles();
  }
  // 输出
  emitFiles() {
    // 遍历依赖关系图
    // 将其转换为id为key的modules对象
    const modules = {}
    this._compilation.graph.forEach((m) => {
      modules[m.id] = {
          code: m.code,
          mapping: m.mapping,
        };
      });
    console.log(modules)
  }
}

我们输出一下modules看看结果

{
  '0': {
    code: '"use strict";\n' +
      '\n' +
      'var _a = require("./a.js");\n' +
      '\n' +
      'var _b = require("./b.js");\n' +
      '\n' +
      'var index = function index() {\n' +
      '  console.log("我是mini-webpack打包入口");\n' +
      '};\n' +
      '\n' +
      'index();\n' +
      '(0, _a.a)();\n' +
      '(0, _b.b)();',
    mapping: { './a.js': 1, './b.js': 2 }
  },
  '1': {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.a = a;\n' +
      '\n' +
      'var _c = require("./c.js");\n' +
      '\n' +
      'function a() {\n' +
      '  console.log("我是a");\n' +
      '  (0, _c.c)();\n' +
      '}',
    mapping: { './c.js': 3 }
  },
  '2': {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.b = b;\n' +
      '\n' +
      'function b() {\n' +
      '  console.log("我是b");\n' +
      '}',
    mapping: {}
  },
  '3': {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.c = c;\n' +
      '\n' +
      'function c() {\n' +
      '  console.log("我是c");\n' +
      '}',
    mapping: {}
  }
}

创建输出模版

创建webpack/createBundleCode.js

以刚刚的模拟bunde.js为模版,结合输出的modules

利用es的模版字符串,产出对应的代码字符串

const render = ({ modules }) => {
  let mapping = "";
  Object.keys(modules).forEach((id) => {
    let res = `${id}: [
    function (require, module, exports){
      ${modules[id].code}
    },${JSON.stringify(modules[id].mapping)}],`;
    mapping += res;
  });

  let resCode = `(function (modules) {
    function require(id) {
      const [fn, mapping] = modules[id];
      function localRequire(name) {
        return require(mapping[name]);
      }
      const module = { exports: {} };
      fn(localRequire, module, module.exports);
      return module.exports;
    }
    require(0);
  })({
    ${mapping}
  })`;

  return resCode;
};

export function createBundleCode(data) {
  const { modules } = data;
  let code = render({ modules });
  return code;
}

最终产出

我们修改一下webpack/Compiler.js

在output目录中写入我们产出的bundle.js

import path from "path";
import fs from "fs";
import Compilation from "./Compilation.js";
import { createBundleCode } from "./createBundleCode.js";

export class Compiler {
  constructor(config) {
    const { entry, output } = config;
    // 保存入口
    this._entry = entry;
    // 保存输出设置
    this._output = output;
    // 保存的Compilation实例
    this._compilation = null;
  }
  //启动编译器
  run() {
    // 创建Compilation实例
    //Compilation构造函数中传入entry入口
    this._compilation = new Compilation({
      entry: this._entry,
    });
    //执行make构建依赖关系图
    this._compilation.make();
    // 输出bundle
    this.emitFiles();
  }
  // 输出
  emitFiles() {
    // 遍历依赖关系图
    // 将其转换为id为key的modules对象
    const modules = {}
    this._compilation.graph.forEach((m) => {
      modules[m.id] = {
          code: m.code,
          mapping: m.mapping,
        };
      });
      console.log(modules)
    // 最后基于 output 生成 bundle 文件即可
    const outputPath = path.join(this._output.path, this._output.filename);

    // 创建对应的目录
    if (!fs.existsSync(this._output.path)) {
      fs.mkdirSync(this._output.path);
    }
    // 写入bundle中
    fs.writeFileSync(outputPath, createBundleCode({ modules }));
  }
}

ok我们调用build命令打包一下代码

然后再用node跑一下打包后的bunde.js

结果如下:

我是mini-webpack打包入口
我是a
我是c
我是b

到此,一个mini-webpack完成!

min-webpack流程图

min-webpack流程图 (1).png

结语

这次的mini-webpack只是webpack的一个最简实现

我也是参考了很多大佬的代码来进行这次的手写学习记录

webpack已经迭代到5.x了

真实的源码实现肯定是更加复杂的

有兴趣的话可以去拉下来自己研究

源码

mini-webpack

参考资料:

# [万字总结] 一文吃透 Webpack 核心原理

# 手摸手带你实现打包器 仅需 80 行代码理解 webpack 的核心