# mini-webpack 源码浅析

950 阅读7分钟

其实“打包”对于前端来说再熟悉不过了,但是深入其中的原理,却不是人人都熟悉。由于webpack功能的强大和盛行,我们大部分都是所谓的“配置工程师”。借此,特地简单分析了一官方文档中提到的一个mini-webpack minipack 项目的源码,以此深入了解下什么是打包?以及打包的原理是什么?

前置知识

首先可能你需要知道打包工具是什么存在

  • 基本的模块化演变进程
  • 对模块化bundle有一定了解
  • 了解babel的一些常识
  • 对node有一定常识

常见的一些打包工具

如今最常见的模块化构建工具 应该是webpack,rollup,fis,parcel等等各种各样。

但是现在可谓是webpack社区较为庞大。

其实呢,模块化开发很大的一点是为了程序可维护性

那么其实我们是不是可以理解为打包工具是将我们一块块模块化的代码进行智能拼凑。使得我们程序正常运行。

基本的模块化演变

1. 全局函数

function module1 () {
    // do somethings
}
function module2 () {
    // do somethings
}

2. 以对象做单个命名空间

var module = {}

module.addpath = function() {}

3. IIFE保护私有成员

var module1 = (function () {
    var test = function (){}
    var dosomething = function () {
        test();
    }
    return {
        dosomething: dosomething
    }
})();

4. 复用模块

var module1 = (function (module) {
    module.moduledosomething = function() {}
    return module
})(modules2);

5.再到后来的COMMONJS、AMD、CMD

// node module是COMMONJS的典型

(function(exports, require, module, __filename, __dirname) {
    // 模块的代码实际上在这里
    function test() {
        // dosomethings
    }
    modules.exports = {
        test: test
    }
});

// AMD 异步加载 依赖前置

// requireJS示例

define('mymodule', ['module depes'], function () {
    function dosomethings() {}
    return {
        dosomethings: dosomethings
    }
})
require('mymodule', function (mymodule) {
    mymodule.dosomethings()
})

// CMD 依赖后置 
// seajs 示例
// mymodule.js
define(function(require, exports, module) {
    var module1 = require('module1')
    module.exports = {
        dosomethings: module1.dosomethings
    }
})

seajs.use(['mymodule.js'], function (mymodule) {
    mymodule.dosomethings();
})

6. 还有现在流行的esModule

// mymodule 
export default {
    dosomething: function() {}
}

import mymodule from './mymodule.js'
mymodule.dosomething()

minipack的打包流程

可以分成两大部分

  • 生成模块依赖(循环引用等问题没有解决的~,只是原理解析)
  • 根据处理依赖进行打包

源码的分析

const fs = require('fs')
const path = require('path')
const babylon = require('babylon')// AST 解析器
const traverse = require('babel-traverse').default // 遍历工具
const { transformFromAst } = require('babel-core') // babel-core

let ID = 0

/**
 *  获得文件内容, 从而在下面做语法树分析
 * @param {*} filename
 */
function createAsset (filename) {
  const content = fs.readFileSync(filename, 'utf-8')
  const ast = babylon.parse(content, { // 解析内容至AST
    sourceType: 'module'
  })

  const dependencies = [] // 初始化依赖集, dependencies存放该文件依赖项的相对path

  traverse(ast, { // 声明traverse的statement, 这里进ImportDeclaration 这个statement内。然后对节点import的依赖值进行push进依赖集
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value)
    }
  })

  const id = ID++ // id自增

  const { code } = transformFromAst(ast, null, { // 再将ast转换为文件
    presets: ['env']
  })

  // 返回这么模块的所有信息,设置的id filename 依赖集 代码
  return {
    id,
    filename,
    dependencies,
    code
  }
}

/**
 *从entry入口进行解析依赖图谱
 * @param {*} entry
 */
function createGraph (entry) {
  const mainAsset = createAsset(entry) // 从入口文件开始
  const queue = [mainAsset] // 最初的依赖集

  for (const asset of queue) { // 一张图常见的遍历算法有广度遍历与深度遍历,这里采用的是广度遍历
    asset.mapping = {} // 给当前依赖做mapping记录
    const dirname = path.dirname(asset.filename)// 获得依赖模块地址
    asset.dependencies.forEach(relativePath => { // 刚开始只有一个asset 但是dependencies可能多个
      const absolutePath = path.join(dirname, relativePath)// 这边获得绝对路径
      const child = createAsset(absolutePath) // 递归依赖的依赖
      asset.mapping[relativePath] = child.id // 将当前依赖及依赖的依赖都放入到mappnig里
      queue.push(child) // 广度遍历借助队列
    })
  }

  return queue // 返回遍历完依赖的队列
}

/**
 * 将graph模块打包bundle输出
 * @param {*} graph
 */
function bundle (graph) {
  let modules = ''
  graph.forEach(mod => {
    modules += `${mod.id}: [
      function (require, module, exports) { ${mod.code} },
      ${JSON.stringify(mod.mapping)},
    ],`
  })

  // CommonJS风格
  const result = `
    (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);
    })({${modules}})
  `
  return result
}

module.exports = {
  bundle,
  createGraph
}

模块依赖生成

具体步骤

  • 给定入口文件
  • 根据入口文件分析依赖(借助bable获取)
  • 广度遍历依赖图获取依赖
  • 根据依赖图生成(模块id)key:(数组)value的对象表示
  • 建立require机制实现模块加载运行

一个简单的实例

原始代码

// 入口文件 entry.js
import message from './message.js'

console.log(message);


// message.js
import {name} from './name.js'

export default `hello ${name}!`


// name.js
export const name = 'world'

读取文件内容,分析依赖,第一步需要解析源码,生成抽象语法树。

  • 第一步,读取入口文件,生成 AST,递归生成依赖关系对象 graph

其中,createAsset 函数是解析js文本,生成每个文件对应的一个对象,其中 code 的代码是经过babel-preset-env转换后可在浏览器中执行的代码。

const { code } = transformFromAst(ast, null, {
    presets: ['env']
  })

createGraph 函数生成依赖关系对象。

[ 
  { id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
    mapping: { './message.js': 1 } },

  { id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
    mapping: { './name.js': 2 } },

  { id: 2,
    filename: 'example/name.js',
    dependencies: [],
    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nvar name = exports.name = \'world\';',
    mapping: {} } 
	
]

有了依赖关系图,下一步就是将代码打包可以在浏览器中运行的包。

首先我们将依赖图解析成如下字符串(其实是对象没用{}包裹的格式): 关键代码是这句:

modules += `${mod.id}: [
  function (require, module, exports) {
    ${mod.code}
  },
  ${JSON.stringify(mod.mapping)},
],`;

生成出来的代码如下:

  0: [
      function (require, module, exports) {
        // -------------- mod.code --------------
        "use strict";
        var _message = require("./message.js");
        var _message2 = _interopRequireDefault(_message);
        function _interopRequireDefault(obj) { 
          return obj && obj.__esModule ? obj : { default: obj }; 
        }

        console.log(_message2.default);
        // --------------------------------------
      },
      {"./message.js":1},
    ],
    1: [
      function (require, module, exports) {
        // -------------- mod.code --------------
        "use strict";
        Object.defineProperty(exports, "__esModule", {
          value: true
        });
        var _name = require("./name.js");
        exports.default = "hello " + _name.name + "!";
        // --------------------------------------
      },
      {"./name.js":2},
    ],
    
    2: [
      function (require, module, exports) {
        // -------------- mod.code --------------
        "use strict";
        Object.defineProperty(exports, "__esModule", {
          value: true
        });
        var name = exports.name = 'world';
        // --------------------------------------
      },
      {},
    ],

依赖的图生成的文件可以简化为:

modules = {
    0: [function code , {deps} ],
    1: [function code , {deps} ]
}

这里,我们比较下源码:

// 入口文件 entry.js
import message from './message.js';

console.log(message);

// ---
"use strict";
var _message = require("./message.js");
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) { 
  return obj && obj.__esModule ? obj : { default: obj }; 
}

console.log(_message2.default);


// message.js
import {name} from './name.js';

export default `hello ${name}!`;

// ---
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
var _name = require("./name.js");
exports.default = "hello " + _name.name + "!";


// name.js
export const name = 'world';

// ---
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
var name = exports.name = 'world';

可以看出,babel在转换原始code的时候,引入了require函数来解决模块引用问题。但是其实浏览器仍然是不认识的。因此还需要额外定义一个require函数(其实这部分和requirejs原理类似的模块化解决方案,其中原理其实也很简单)

得到这个字符串后,再最后拼接起来即最终结果。

最后,我们还需要定义一个自执行函数文本,并将上述字符串传入其中,拼接结果如下:

(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);
})({
	0: [
		function (require, module, exports) {
			"use strict";
			var _message = require("./message.js");
			var _message2 = _interopRequireDefault(_message);
			function _interopRequireDefault(obj) {
				return obj && obj.__esModule ? obj : { default: obj };
			}

			console.log(_message2.default);
		},
		{ "./message.js": 1 },
	],
	1: [
		function (require, module, exports) {
			"use strict";
			Object.defineProperty(exports, "__esModule", {
				value: true
			});
			var _name = require("./name.js");
			exports.default = "hello " + _name.name + "!";
		},
		{ "./name.js": 2 },
	],

	2: [
		function (require, module, exports) {
			"use strict";
			Object.defineProperty(exports, "__esModule", {
				value: true
			});
			var name = exports.name = 'world';
		},
		{},
	],
})

我们执行最后的结果,会输出"hello world"

那我们仔细分析下打包后的这段代码:

首先这是一个自执行函数,传入的字符串外面包裹上{}后是一个对象,形如<moduleId>: <value>的格式。

自执行函数的主体部分定义了一个require函数:

function require(id) {
  // 1. 解构`module`,获取`fn`和当前`module`的依赖路径
	const [fn, mapping] = modules[id];

 // 2. 定义引入依赖函数
	function localRequire(name) {
		return require(mapping[name]);
	}

 // 3. 定义`module`变量,保存的是依赖模块导出的对象,存储在`module.exports`中
	const module = { exports: {} };

 // 4. 递归执行,直到子`module`中不再执行传入的`require`函数
	fn(localRequire, module, module.exports);

	return module.exports;
}

在 require 方法中,传入模块 id,根据模块 id 获取隔离作用域的执行函数 fn 以及依赖信息 mapping,预处理一下 require 函数,目的是将模块中 require 函数入参的相对地址转为 id,然后传入执行 fn,最后返回 module.exports 对象给上级调用。

过程如下, 接收一个模块id,

  • 第一步:解构module,获取fn和当前module的依赖路径
  • 第二步:定义引入依赖函数(相对引用),函数体同样是获取到依赖moduleidlocalRequire 函数传入到fn
  • 第三步:定义module变量,保存的是依赖模块导出的对象,存储在module.exports中,modulemodule.exports也传入到fn中
  • 第四步:递归执行,直到子module中不再执行传入的require函数

简单来说,模块之间通过requireexports联系,至于模块内部的实现,只在模块内可见。

第三步,我们模块代码会被执行。并且执行的结果会存储在module.exports中并接受三个参数 require, module, module.exports

类似COMMONJS module会在模块闭包内注入exports, require, module, __filename, __dirname

会在入口处对其代码进行require执行一遍。

总结

这个方法显然是有一些瑕疵的:

  • 比如所有的模块执行 require 时都会执行一遍 require 模块的代码,可能会导致重复引入。
  • 比如只支持 es6 模块收集,无法兼容 CommonJS/CMD/AMD 模块; 模块引用路径必须写后缀名等问题。

不过通过对 require 方法的简单实现,已经足够让我们理解 webpack 这类打包工具的本质:就是通过函数来划分作用域,通过 module 以及 module.exports 来共享数据。

通过上述分析,我们可以了解

  • minipack的基本构造
    • 从主入口中将代码转换为AST,
    • 然后后找出主入口的依赖关系,
    • 通过这个依赖关系可以构建依赖图,
    • 最后通过依赖图转化为类commonjs模块的代码,打包在一块。
  • 打包工具的基本形态
  • 模块的一些问题

由此,可以看出,其实原理并不是很复杂,但是却很巧妙,要了解“打包”的原理,也需要了解“模块化”的一些知识。前端发展虽快,但是深入到基础,会发现其实是一脉相通的。

本文转载自下面的两篇文章: