从零到一实现一个简易版的webpack

Posted by franki on May 5, 2020

webpack

在 webpack 出来之前,让我们来看看前端打包构建的黑历史(不堪回首的年代)。

其实前端一开始并不存在打包构建的这种“玩意”的,js 一开始也是被人忽视的,简单的认为只是用来写写页面动效,仅此而已!那时候甚至连 ajax 都不用,直接写点代码,直接用 script 标签引入。人们通常“乐此不疲”。

直到 ajax 的广泛引用,才使得 js 变得越来越重要,前端做的事情也更多了,由于不断出现的库和插件在页面上越来越多的插入,导致 js 文件爆炸!

要知道,以前还是 3g 的网络,下载速度很慢,所以这个时候对于减少 js 文件的体积,成为迫在眉睫的事情。这个时期,出现了 JSMin、UglifyJS 等文件压缩合并工具,发布的时候,使用工具压缩 js 文件成了基本操作。

想想这样是不是也有问题,如果很多库采用同样的名字,这样不就产生冲突了吗,而且如果有依赖关系的话,还得指定先引入哪些库,后引入哪些库?

果然这时候 CommonJS 闪亮登场了,通过 require 的形式引入文件,文件之前可以存在依赖关系,但是有个缺陷,CommonJS 不能跑在浏览器上,所以这时候又有了 AMD 规范,使用异步回调方式来并行下载多个依赖项。

这个时候,需要利用各种编译工具,编译/压缩 js、css、html、图片等资源。然后 grunt 诞生了,扩展性更强了,采用了流式的处理方式。

后续gulp又诞生了,扩展性更强,效率提高不少。

随着前端应用的形态越来越复杂,SPA(single page application)模式出现了,一个网页就是一个完整的应用程序。

这样的情况,AMD 模块开发持续了一段时间,但是存在以下的问题:

  • 不是所有的第三方库都符合 AMD 规范
  • SPA越来越大的时候,js 分包是个问题,文件之间相互依赖,依赖项需要一个个检查
  • 项目的文件结构不合理,js、html、css、图片散落在不同的模块,代码审查痛苦

直到2012年,webpack 在千呼万唤中出现了,此处应该有掌声!

一个模块化打包工具,从入口文件开始,把所有的依赖到的模块打包到一个文件,这样解决了异步加载的问题,根据文件的不同,运用不同的 loader 进行解析,最终生成 bundle 文件。

好了,下面进入正题

打包原理代码的实现

前方预警:本篇只介绍 js 文件相关的打包实现

首先看看源代码是如何的

先来定义3个文件

// sum.js
import { a } from './a';
import { b } from './b';

console.log(a + b);

//b.js
import { a } from './a';
export const b = a + 1;

// a.js
export const a = 1;

在 sum.js 里面经过打过后会输出3的结果,看看我们如何转化成 es5 代码,让它能跑在浏览器上,并输出结果3。

好戏开场

创建src/pack.js

现在可以新建一个项目,名字随意,然后创建 src/pack.js 文件,首先敲下以下的代码

const fs = require('fs');
const path = require('path');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');

上面的代码就是打包 es6 代码的核心依赖了,如何做到 es6 转化为 es5 呢?

  • babylon 负责把 js 代码生成 AST
  • babel-traverse 把生成的 AST 遍历重新生成源码
  • traverse 的作用就是帮助开发者遍历 AST 抽象语法树,帮助我们获取树节点的重要信息和属性。

创建createAsset函数

let ID = 0; // 记录每一个载入的模块id,所有的模块都用唯一的标识来表示,可以很直观的统计模块的数量
function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = babylon.parse(content, { courseType: 'module' });
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    }
  });
}
const id = ID++;
const { code } = transformFromAst(ast, null, {
  presets: ['env'],
});
return {
  id,
  filename,
  dependencies,
  code,
}

创建createGraph

function createGraph(entry) {
  const mainAsset = createAsset(entry);
  const queue = [mainAsset];
  for (const asset of queue) {
    asset.mapping = {};
    const dirname = path.dirname(asset.filename);
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath);
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }
}

首先通过

const mainAsset = createAsset(entry);
const queue = [mainAsset];

从入口文件开始获取依赖,queue 是一个数组。

继续使用for of 循环

for (const asset of queue) {
  asset.mapping = {};
  // 从asset中获取文件对应的文件夹
  const dirname = path.dirname(asset.filename);
  // ...
}

用 for of 的原因是因为在循环之中会不断地向 queue 加入数据,这就意味着这个循环会一直持续到不再 push 进 queue 才会结束,也就意味着所有的依赖都已经解析完毕。

代码中出现了 asset.mapping,这个属性主要是用来对应 require 找到对应的文件,因为每一个文件都会对应一个数字自增 id,这个自增 id 就是一开始设置的 id,通过 path-id 键值对,最终都是为了解决模块之间的引用错综复杂的问题。

接着每一个文件都会被解析出来一个 dependencies,它是一个数组,需要遍历这个数据,把所有用到的属性都取出来。

asset.dependencies.forEach(relativePath => {
  const absolutePath = path.join(dirname, relativePath);
  const child = createAsset(absolutePath);
  asset.mapping[relativePath] = child.id;
  queue.push(child);
});

最重要的是,解析的每一个模块,需要将它记录在这个文件模块 asset 下的 mapping 中,之后我们 require 的时候,能够通过这个 id 值,找到这个模块对应的代码,并进行执行,将解析的模块推入 queue 中去。最后我们得到的 queue 是如下这个样子。

[
  {
    id: 0,
    filename: './example/sum.js',
    dependencies: [ './a.js', './b.js' ],
    code: '"use strict";\n' +
      '\n' +
      'var _a = require("./a.js");\n' +
      '\n' +
      'var _b = require("./b.js");\n' +
      '\n' +
      'console.log(_a.a + _b.b);',
    mapping: { './a.js': 1, './b.js': 2 }
  },
  {
    id: 1,
    filename: 'example/a.js',
    dependencies: [],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'var a = exports.a = 1;',
    mapping: {}
  },
  {
    id: 2,
    filename: 'example/b.js',
    dependencies: [ './a.js' ],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.b = undefined;\n' +
      '\n' +
      'var _a = require("./a.js");\n' +
      '\n' +
      'var b = exports.b = _a.a + 1;',
    mapping: { './a.js': 3 }
  },
  {
    id: 3,
    filename: 'example/a.js',
    dependencies: [],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'var a = exports.a = 1;',
    mapping: {}
  }
]

创建bundle函数

function bunble(graph) {
  let modules = '';
  graph.forEach(mod => {
    modules += `${mod.id}: [
      function(require, module, exports) {
        ${mode.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });
  // ...
  return result;
}

其实 modules 就是一个字符串,将 graph 中的 asset 都取出,然后使用 nodejs 制作模块的方法将一份代码包起来。我们讲转换好的代码放入到一个 function(require, module, exports) {} 的函数中,这个参数就是我们随处可见可用的 require,module,exports,这就是为什么我们可以随处使用这三个玩意的原因,因为我们的每一个文件都被这样的一个函数给包裹着,最后在导入模块的时候,会为这个字符串加上一个{},变成{1: […], 2: […]},这是一个对象,用数字作为 key,一个二维元组,[0]表示我们被包裹的代码,[1]表示 mapping。

function bundle(graph) {
  // ...
  let results = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];

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

上面的这段代码是模块引入的核心逻辑,我们制造了一个顶层的 require 函数,这个函数的作用接收一个 id 作为值,并且返回一个全新 module 函数,我们传入制作好的模块,加上{},使得它成为{1: [], 2: []}这样的形式,然后塞入到立即执行函数中,在(function(modules) {…})()中,我们先调用 require(0),原因是因为,主模块永远都是排在第一位,紧接着是外面传入进来的 modules,利用全局数据 id 选取对应的模块,每一个模块取出来都是一个二维元组,所以需要制作一个子 require,这样做的理由是在我们的文件使用 require时,我们一般 require 的是地址,而顶层的 require 函数的参数是 id,这里就用上了 mapping,通过用来传入的地址,找到 mapping 对用的 id,然后递归调用 require(id),就能够实现自动导入了,const newModule = {exports: {}};,运行我们的 fn(childRequire, newModule, newModule.exports),将应该丢入的丢入,最后返回 exports 对象。

小结:

做了以上的步骤,一个简易版本 webpack 就制作完毕,实现了将 es6 代码通过入口文件开始编译,根据递归调用所有所的文件所依赖的的文件都解析和加载了一遍。不得不说,webpack 的处理真的是十分巧妙,通过通过 id 的形式去找到对应模块的源代码,webpack 的打包原理就如上述实现一样,大多的细节和工具就得每一个开发者自行体验了。

完整代码链接如下:

webpack-simple-pack