手写 min-webpack 弄懂 webpack 核心源码思想

在写min-webpack之前,我们先试着理解webpack打包产物bundle的代码思路。通过了解了bundle代码的逻辑后,再去理解webpack的核心源码到底是通过什么处理自动生成的bundle,最终实现一个min-webpack。

初始化一个项目

shell 复制代码
npm init -y

package.json 添加 "type": "module" 以支持ESM模块

json 复制代码
// package.json
{
  "type": "module"
}

这样,在项目中就可以以ESM的方式引入模块

js 复制代码
import fs from 'fs';

新建一个example文件夹,模拟webpack打包,里面新建文件:

  • main.js 可以理解为打包入口文件
  • foo.jsmain.js引入,模拟文件间的依赖关系
  • bundle.js 模拟webpack打包后的bundle,这里会先分析webpack的打包思路,后面逐步完善代码
  • index.html 用于引入打包后的bundle
js 复制代码
// main.js
import foo from './foo.js';

foo();
console.log('This is main.js');
js 复制代码
// foo.js
export default function foo() {
  console.log('This is foo.js');
}
js 复制代码
// bundle.js
// 这个是核心,后面写
html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>min-webpack</title>
</head>
<body>
  <script src="./bundle.js"></script>
</body>
</html>

bundle 代码思路分析

在说bundle的代码思路之前,我们先了解下webpack的核心原理,那就是根据配置文件,找到入口文件,找到文件间的依赖关系,形成依赖树,遍历这个依赖树,把所有资源都打包到一个bundle文件中(这里不考虑分包机制),所以,我们可以这样理解bundle产物的内容:

  1. 所有资源模块都汇聚在bundle.js
  2. 为了防止各个模块间命名冲突,需要有个函数包裹着各个模块,这个暂且称为模块函数。
  3. 从入口文件模块开始,找到入口模块所在的模块函数并执行
js 复制代码
// bundle.js

// 立即执行入口文件
mainjs();

function mainjs() {
  // 模仿 main.js 写的伪代码方便理解
  import foo from './foo.js';

  foo();
  console.log('This is main.js');
}

function foojs() {
  export default function() {
    console.log('This is foo.js');
  }
}

意思是这么个意思了,先写段伪代码便于理解。这里是先理解webpack的打包思路,后面再逐一完善代码。

然而ESMimport只能在顶层作用,不支持在函数里写ESM,但是支持CJS的模块化规范,所以需要把ESM模块翻译成CJS模块规范,需要自定义实现require方法。

js 复制代码
// bundle.js
function webpackRequire(filePath) {}

// 立即执行入口文件
mainjs();

function mainjs() {
  // 模仿 main.js 写的伪代码
  const foo = webpackRequire('./foo.js');

  foo();
  console.log('This is main.js');
}

function foojs() {
  export default function() {
    console.log('This is foo.js');
  }
}

这里,我们使用webpackRequire的目的是引入模块,而引入模块是为了能够执行模块的逻辑,同时获得模块导出的成员。这里,我们便明确了webpackRequire的内部需要实现的功能是:

  1. 执行入参filePath对应的模块代码
  2. 返回该模块导出的成员

我们知道CJSrequire导入对应了导出module.exports,所以对于文件中的export我们可以用module.exports来实现。

js 复制代码
// bundle.js
function webpackRequire(filePath) {
  const module = {
    exports: {},
  };

  // 返回该模块导出的成员
  return module.exports;
}
js 复制代码
// bundle.js
function foojs() {
  // export default function() {
  //   console.log('This is foo.js');
  // }

  // 改成cjs

  function foo() {
    console.log('This is foo.js');
  }

  // 这里module我们还不明确怎么来的,可以先这样写着
  module.exports = {
    foo,
  }
}

而对于执行模块代码这部分,为了方便理解,我们可以先构造一个映射关系表示模块路径filePath与模块之间的关系:

js 复制代码
// bundle.js
const FnMap = {
  './main.js': mainjs,
  './foo.js': foojs,
}

这样就简单地创建了一个模块路径和模块之间的映射关系。

那要执行模块代码就简单了:

js 复制代码
// bundle.js
function webpackRequire(filePath) {

  const module = {
    exports: {},
  }

  const fn = FnMap[filePath];
  // 执行入参`filePath`对应的模块代码
  fn();

  // 返回该模块导出的成员
  return module.exports;
}

这个fn就是我们写的mainjsfoojs。上面说到不明确module的来源,那这里,我们在webpackRequire这里就有一个创建好了的module,刚好也可以给到fn作为入参。

所以mainjsfoojs这些代表模块的函数,可以有统一的入参:

js 复制代码
function mainjs(module) {
  // 模仿 main.js 写的伪代码
  // import foo from './foo.js';
  const { foo } = webpackRequire('./foo.js');

  foo();
  console.log('This is main.js112');
}

function foojs(module) {
  // export default function() {
  //   console.log('This is foo.js');
  // }

  // 改成cjs

  function foo() {
    console.log('This is foo.js');
  }

  module.exports = {
    foo,
  }
}

而入口文件的立即执行,可以直接改成调用webpackRequire('./main.js')

至此,放上整个bundle.js的代码:

js 复制代码
const FnMap = {
  './main.js': mainjs,
  './foo.js': foojs,
}

function webpackRequire(filePath) {

  const module = {
    exports: {},
  }

  const fn = FnMap[filePath];
  fn(module);

  return module.exports;
}

webpackRequire('./main.js');

function mainjs(module) {
  // import foo from './foo.js';
  // 改成cjs
  const { foo } = webpackRequire('./foo.js');

  foo();
  console.log('This is main.js112');
}

function foojs(module) {
  // export default function() {
  //   console.log('This is foo.js');
  // }

  // 改成cjs

  function foo() {
    console.log('This is foo.js');
  }

  module.exports = {
    foo,
  }
}

然而事实上webpack的模块路径与模块之间的映射关系并不是定义常量实现的。

我们可以进一步改进上面的bundle.js代码:

  1. 把映射关系作为立即执行函数的入参传进去
  2. 原本写在里面的mainjsfoojs模块函数抽出去写到入参映射关系那里
  3. 里面用到webpackRequire函数,这个也好办,也把它作为模块函数的入参传过去供其调用
js 复制代码
(function(modules) {
  function webpackRequire(filePath) {

    const module = {
      exports: {},
    }
  
    const fn = modules[filePath];
    fn(webpackRequire, module);
  
    return module.exports;
  }
  
  webpackRequire('./main.js');
   
})({
  './main.js': function(require, module) {
    const { foo } = require('./foo.js');
  
    foo();
    console.log('This is main.js112');
  },
  './foo.js': function(require, module) {
    function foo() {
      console.log('This is foo.js');
    }
  
    module.exports = {
      foo,
    }
  },
})

在入参那里,模块函数的key./main.js./foo.js,这个还可以改进一下。

这个key需要是模块函数的唯一标识才行,这里我们可以改成数字,从0开始,依次递增,这样就可以做到模块被唯一标识,就不会造成冲突了。修改后:

js 复制代码
(function(modules) {
  function webpackRequire(filePath) {

    const module = {
      exports: {},
    }
  
    const fn = modules[filePath];
    fn(webpackRequire, module);
  
    return module.exports;
  }
  
  webpackRequire(0);
   
})({
  0: function(require, module) {
    const { foo } = require(1);
  
    foo();
    console.log('This is main.js112');
  },
  1: function(require, module) {
    function foo() {
      console.log('This is foo.js');
    }
  
    module.exports = {
      foo,
    }
  },
})

我们知道webpack的bundle是webpack通过控制台命令自动生成的,那么我想你肯定很想知道webpack是通过什么来实现自动生成了这么一个bundle.js的呢?

接下来将实现一个min-webpack,了解webpack内部生成bundle的核心源码。

min-wqebpack

在项目根目录中创建一个新文件index.js,在这里写min-webpack的主要代码。

bundle.js可以知道,我们最为关键需要获取到的是模块的key与模块函数之间的映射关系,而且模块函数内部的模块内容必须是遵循CJS规范的,由上面分析过,在函数里不能使用ESM

一个模块有其依赖的模块集合,所以还需要找到模块的依赖集合。

所以,初步可以得出,webpack在打包的时候主要干的事是:

  1. 从0开始定义模块的key
  2. 获取到模块本身的内容code,需要遵循CJS规范
  3. 获取模块的依赖集合deps
  4. 遍历模块的依赖集合,找到依赖的依赖,这时依赖模块对应的key需要依次递增以唯一标识依赖模块;在遍历模块依赖集合时,把模块的以上数据添加到一个依赖关系数据(一种数据结构)中

当然,这里还会有loader、plugins相关的逻辑,我们这里先不关注这些,主要先关注主要的打包流程。

从0开始定义模块的key

入口文件的唯一key0开始,这里先定义一个变量id表示key,后面遍历依赖集合的话需要依次递增,所以可以在返回id的时候就可以先给它自增id++

因为需要遍历依赖集合,所以一开始我们就先定义好一个函数createAsset,里面专门实现获取模块数据idcodedeps

js 复制代码
let id = 0;

function createAsset() {
  return {
    id: id++,
  }
}

获取到模块本身的内容code

获取文件内容,其实需要获取代码的抽象语法树,需要用到@babel-paser依赖,还有代码需要转成CJS规范,所以需要用到babel-corebabel-preset-env

shell 复制代码
pnpm i @babel/parser@7.16.7 
pnpm i babel-core@6.26.3
pnpm i babel-preset-env@1.7.0
js 复制代码
import fs from 'fs';
import parser from '@babel/parser';
import { transformFromAst } from 'babel-core';

function createAsset(filePath) {
  // 获取文件内容
  let source = fs.readFileSync(filePath, {
    encoding: 'utf-8',
  });

  // 文件内容转 ast 即 抽象语法树
  const ast = parser.parse(source, {
    sourceType: 'module',
  });

  // 通过配置presets把代码的esm规范转成cjs规范
  const { code } = transformFromAst(ast, null, {
    presets: ['env'],
  });

  return {
    id: id++,
    code,
  }
}

createAsset('./example/main.js');

这是生成的抽象语法树 ast

js 复制代码
// ast
Node {
  type: 'File',
  start: 0,
  end: 67,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 4, column: 31 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 67,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'module',
    interpreter: null,
    body: [ [Node], [Node], [Node] ],
    directives: []
  },
  comments: []
}

这是根据ast生成的CJS代码code

js 复制代码
"use strict";

var _foo = require("./foo.js");

var _foo2 = _interopRequireDefault(_foo);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

(0, _foo2.default)();
console.log('This is main.js');

获取模块的依赖集合deps

接着需要遍历整个抽象语法树,获取到node.source.value这个值,这个值表示的是本模块依赖到的模块集合。

遍历抽象语法树需要用到依赖@babel/traverse

shell 复制代码
pnpm i @babel/traverse@7.16.7

定义一个常量deps数组,在遍历ast时不断把依赖到的模块路径值添加到数组里。

js 复制代码
import fs from 'fs';
import parser from '@babel/parser';
import { transformFromAst } from 'babel-core';
import traverse from '@babel/traverse';

function createAsset(filePath) {
  // 获取文件内容
  let source = fs.readFileSync(filePath, {
    encoding: 'utf-8',
  });

  // 文件内容转 ast 即 抽象语法树
  const ast = parser.parse(source, {
    sourceType: 'module',
  });

  // 通过配置presets把代码的esm规范转成cjs规范
  const { code } = transformFromAst(ast, null, {
    presets: ['env'],
  });

  // 获取模块的依赖集合
  const deps = [];
  traverse.default(ast, {
    ImportDeclaration({ node }) {
      deps.push(node.source.value);
    }
  });

  return {
    id: id++,
    code,
    deps,
  }
}

createAsset('./example/main.js');

遍历模块的依赖集合,找到依赖的依赖

经过上面的代码,我们只获取到了入口文件的依赖模块集合,但是我们要获取这些依赖模块的依赖模块,那就要对依赖集合deps遍历,在遍历模块依赖集合时,把模块的数据添加到一个依赖关系(一种数据结构)中。

js 复制代码
import fs from 'fs';
import parser from '@babel/parser';
import traverse from '@babel/traverse';
import { transformFromAst } from 'babel-core';
import path from 'path';

function createGraph() {
  const mainAsset = createAsset('./example/main.js');

  // 定义一个依赖关系结构`queue`
  const queue = [mainAsset];

  for(const asset of queue) {
    // 遍历依赖集合
    asset.deps.forEach(relativePath => {
      const child = createAsset(path.resolve('./example', relativePath));
      // 把模块数据添加到依赖关系中
      queue.push(child);
    });
  }

  return queue;
}

const group = createGraph();

这是模块依赖关系的数据:

js 复制代码
[
  {
    id: 0,
    code: '"use strict";\n' +
      '\n' +
      'var _foo = require("./foo.js");\n' +
      '\n' +
      'var _foo2 = _interopRequireDefault(_foo);\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
      '\n' +
      '(0, _foo2.default)();\n' +
      "console.log('This is main.js');",
    deps: [ './foo.js' ]
  },
  {
    id: 1,
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.default = foo;\n' +
      '\n' +
      'function foo() {\n' +
      "  console.log('This is foo.js');\n" +
      '}',
    deps: []
  }
]

到了这里,就剩下打包的功能了。在打包之前,我们先思考下,我们怎么才可以自动生成一个立即执行函数呢?并生成到输出文件夹里呢?

还有怎么把模块id和模块函数一一对应起来作为立即执行函数的入参呢?

还有怎么把模块code嵌入到模块函数里呢?

实现打包功能

我们可以创建一个bundle的模板文件bundle.ejs,把一开始我们写好的bundle.js文件内容放到这里:

js 复制代码
// bundle.ejs

(function(modules) {
  function webpackRequire(filePath) {

    const module = {
      exports: {},
    }
  
    const fn = modules[filePath];
    fn(webpackRequire, module);
  
    return module.exports;
  }
  
  // 立即执行入口文件
  webpackRequire(0);
   
})({
  0: function(require, module) {
    const { foo } = require(1);
  
    foo();
    console.log('This is main.js');
  },
  1: function(require, module) {
    function foo() {
      console.log('This is foo.js');
    }
  
    module.exports = {
      foo,
    }
  },
})

而入参并不可能是我们自己手写,所以需要用到一个模板引擎ejs,把依赖关系数据丢给ejs,让它遍历依赖关系数据,自动生成入参。那么我们的模板文件bundle.ejs可以进一步改进,假设传过来的依赖关系结构数据用data表示:

js 复制代码
(function(modules) {
  function webpackRequire(filePath) {

    const module = {
      exports: {},
    }
  
    const fn = modules[filePath];
    fn(webpackRequire, module);
  
    return module.exports;
  }
  
  // 立即执行入口文件
  webpackRequire(0);
   
})({
  <% data.forEach(info => { %>
    "<%- info["id"] %>": function(require, module, exports) {
      <%- info['code'] %>
    },
  <% });%>
})

安装ejs

shell 复制代码
pnpm i ejs@3.1.6

创建函数build用来实现打包的功能,这个函数需要实现:

  1. 获取模板文件
  2. 根据依赖关系数据结构group获取需要传给ejs的数据data
  3. 使用ejs渲染出最终的打包代码code
  4. 把最终的代码code使用fs模块的writeFileSync写入输出文件夹
js 复制代码
import fs from 'fs';

function build(group) {
  const template = fs.readFileSync('bundle.ejs', { encoding: 'utf-8' });

  const data = group.map(asset => {
    const { id, code } = asset;
    return {
      id,
      code,
    }
  });

  const code = ejs.render(template, { data });

  // 打包输出文件夹
  const outputPath = './dist/bundle.js';

  fs.writeFileSync(outputPath, code);
}

build(group);

至此,我们查看下生成的dist/bundle.js

js 复制代码
(function (modules) {
  function webpackRequire(filePath) {
    const module = {
      exports: {},
    };

    const fn = modules[filePath];
    fn(webpackRequire, module);

    return module.exports;
  }

  // 立即执行入口文件
  webpackRequire(0);
})({
  0: function (require, module, exports) {
    "use strict";

    var _foo = require("./foo.js");

    var _foo2 = _interopRequireDefault(_foo);

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : { default: obj };
    }

    (0, _foo2.default)();
    console.log("This is main.js");
  },

  1: function (require, module, exports) {
    "use strict";

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

    function foo() {
      console.log("This is foo.js");
    }
  },
});

可以发现一个问题,在模块函数内部,require('./foo.js')这个地方,我们并没有实现传给require的参数为数字这么一个功能。所以代码还需改进下。

找到遍历deps的那个代码,给asset新增一个属性mapping,用于存放模块的依赖与该依赖作为模块时的id对应的关系:

js 复制代码
function createAsset(filePath) {
  ...
  return {
    ...
    mapping: {}
  }
}

function createGraph() {
  const mainAsset = createAsset('./example/main.js');

  // 定义一个依赖关系图结构`queue`
  const queue = [mainAsset];

  for(const asset of queue) {
    // 遍历依赖集合
    asset.deps.forEach(relativePath => {
      const child = createAsset(path.resolve('./example', relativePath));
      // 模块的依赖与依赖作为模块时的`id`对应的关系
      asset.mapping[relativePath] = child.id;
      // 把模块数据添加到依赖关系图中
      queue.push(child);
    });
  }

  return queue;
}

function build(group) {
  ...

  const data = group.map(asset => {
    const { id, code, mapping } = asset;
    return {
      ...
      // mapping需要传给ejs模板引擎给到bundle.ejs内部
      mapping,
    }
  });

  ...
}

bundle.ejs拿到mapping后,修改下模板数据,id对应的值不再是模块函数,而是一个数组,数组第一项才是模块函数,第二项是mapping

ejs 复制代码
<% data.forEach(info => { %>
  "<%- info["id"] %>": [function(require, module, exports) {
    <%- info['code'] %>
  },<%- JSON.stringify(info["mapping"]) %>],
<% });%>

webpackRequire内部,我们可以拿到mapping,实现一个函数localRequire,内部根据mapping进一步拿到依赖的id,返回webpackRequire的返回值。而我们传给依赖模块的require不再是webpackRequire,而是改成localRequire

ejs 复制代码
// bundle.ejs
function webpackRequire(id) {

  const module = {
    exports: {},
  }

  const [fn, mapping] = modules[id];
  const localRequire = function(filePath) {
    const id = mapping[filePath];
    return webpackRequire(id);
  }
  fn(localRequire, module);

  return module.exports;
}

// 立即执行入口文件
webpackRequire(0);

现在打包后的dist/bundle.js变成了这样:

js 复制代码
(function (modules) {
  function webpackRequire(id) {
    const module = {
      exports: {},
    };

    const [fn, mapping] = modules[id];
    const localRequire = function (filePath) {
      const id = mapping[filePath];
      return webpackRequire(id);
    };
    fn(localRequire, module);

    return module.exports;
  }

  // 立即执行入口文件
  webpackRequire(0);
})({
  0: [
    function (require, module, exports) {
      "use strict";

      var _foo = require("./foo.js");

      var _foo2 = _interopRequireDefault(_foo);

      function _interopRequireDefault(obj) {
        return obj && obj.__esModule ? obj : { default: obj };
      }

      (0, _foo2.default)();
      console.log("This is main.js");
    },
    { "./foo.js": 1 },
  ],

  1: [
    function (require, module, exports) {
      "use strict";

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

      function foo() {
        console.log("This is foo.js");
      }
    },
    {},
  ],
});

至此,还有个小问题,就是模块函数接收的有3个参数,分别是requiremoduleexports,而我们的模板文件bundle.ejs少了第三个参数exports

这个好办,我们直接把module.exports作为fn的第三个参数即可。

ejs 复制代码
(function(modules) {
  function webpackRequire(id) {

    const module = {
      exports: {},
    }
  
    const [fn, mapping] = modules[id];
    const localRequire = function(filePath) {
      const id = mapping[filePath];
      return webpackRequire(id);
    }
    fn(localRequire, module, module.exports);
  
    return module.exports;
  }
  
  // 立即执行入口文件
  webpackRequire(0);
   
})({
  <% data.forEach(info => { %>
    "<%- info["id"] %>": [function(require, module, exports) {
      <%- info['code'] %>
    },<%- JSON.stringify(info["mapping"]) %>],
  <% });%>
})

我们在控制台执行下最终的dist/bundle.js文件:

shell 复制代码
node dist/bundle.js

结果输出:

csharp 复制代码
This is foo.js
This is main.js

那么,min-webpack的核心代码就实现了。

感谢你的阅读。

参考

[1] 手摸手带你实现打包器 仅需 80 行代码理解 webpack 的核心-b站

相关推荐
DT——4 小时前
Vite项目中eslint的简单配置
前端·javascript·代码规范
学习ing小白6 小时前
JavaWeb - 5 - 前端工程化
前端·elementui·vue
真的很上进7 小时前
【Git必看系列】—— Git巨好用的神器之git stash篇
java·前端·javascript·数据结构·git·react.js
胖虎哥er7 小时前
Html&Css 基础总结(基础好了才是最能打的)三
前端·css·html
qq_278063717 小时前
css scrollbar-width: none 隐藏默认滚动条
开发语言·前端·javascript
.ccl7 小时前
web开发 之 HTML、CSS、JavaScript、以及JavaScript的高级框架Vue(学习版2)
前端·javascript·vue.js
小徐不会写代码7 小时前
vue 实现tab菜单切换
前端·javascript·vue.js
2301_765347547 小时前
Vue3 Day7-全局组件、指令以及pinia
前端·javascript·vue.js
ch_s_t7 小时前
新峰商城之分类三级联动实现
前端·html
辛-夷7 小时前
VUE面试题(单页应用及其首屏加载速度慢的问题)
前端·javascript·vue.js