从手写bundler到分析webpack打包结果

1,407 阅读7分钟

最近在整理webpack的内容,在此小记一笔。

模块

作为一个菜鸟,以我对webpack浅薄的认识,大概知道webpack的定位是一个bundler,解决的是如何将多个模块打包到一起去的问题。这里的模块定义十分广泛,可以是一个JS文件,可以是一张图片,也可以是一个样式文件……当然,因为我们是前端开发,对模块的概念理解的最多的,应该是JS文件。

在平时的开发过程中,我们一般把一个JS文件当做一个模块,对接到ES Module的模块规范,大概可以给出这样的例子:

// import 引入
import message from './message.js';
import {word} from './word.js'
import a from './a.js'
// export 导出
export const a = 1;
export default ...
export function ...

我觉得这里需要明白的是:

  1. 我们可以通过import或者export引入或导出东西。
  2. 我们将import进来的东西,赋给了当前模块内部的一个变量。
  3. 我们export出去的东西,可以作为其他模块文件的导入,我们可以把这个导出去的东西叫exports

大概还是能画出一张图的:

由此我们可以知道,import语句的作用就是得到其他模块文件导出的exports对象。我们同样知道,对于浏览器来说,当我们不给<script>标签上加module属性的话,浏览器是无法识别import语法的。

那为什么webpack由入口文件对我们的代码进行打包,打包出的JS文件挂在HTML文件上就能运行了呢?

根据上面的行为基本可以想到:

  • 对于import的行为,我们可以抽象出一个函数require(path),去获取path下那个JS文件导出的exports对象。
  • 对于export的行为,我们简单理解为它在为某个叫exports的对象赋值,至于这个exports对象哪里来,暂时先放一放。

概念先理到这里,这时我找出了以前不知道哪里抄的手写bundler的代码,略做整理。

手写一个bundler

代码在这里。请务必忽略我长草的狗窝。以下内容请参考代码阅读。

首先我们看下整体流程。

首先,对所有文件,我们都应该将其的语法做一个转换,至少不应该再出现importexport的语法,而应该转为require函数获取另一个模块的exports这种语法,这样我们可以给每个文件生成转换后的code

然后,问题来了,我们如何从入口文件出发,找到这次打包所有相关的文件呢?

模块分析

还是先从入口文件的分析开始吧。

我们用fs模块根据路径读取到了入口文件的内容,使用@babel/parser将其转换成了抽象语法树AST。

我们只需要知道AST是一个树状结构,而每一个import语句对应着这颗树中的一个type为ImportDeclaration节点,其中包含了对应import语句的一些元信息,比如from后面的依赖模块的路径。

我们通过@babel/traverse遍历了这颗AST,对于每个ImportDeclaration节点,我们都将其保存的相对于入口文件的路径,和入口文件的路径放到一起做些路径上的映射处理,并将映射放到一个dependencies对象里。

最后,我们通过@babel/core结合@babel/preset-env预设,将这颗AST转换成了我们之前说的我们需要的语法格式,也就是不存在importexport语法的格式。

我们可以看一下当前入口文件index.js

import message from './message.js';
console.log(message);
export const a = 1;

我们可以得到这样的东西:

const result = {
  filename: './src/index.js',
  dependencies: { './message.js': 'src/message.js' },
  code:
    '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\n exports.a = void 0;\n\nvar _message = _interopRequireDefault(require("./message.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_message["default"]);\nvar a = 1;\nexports.a = a;',
};
  • import语法已经变成一个require函数了,export语法,也变成了在给一个exports变量赋值。

  • 得到了一个denpendencies对象,里面key是相对于当前文件(此处为入口文件)的路径,value为相对于我们的bundler的路径。

依赖图谱生成

我们拿到了入口文件的dependencies映射,我们自然可以拿其中的依赖路径再一次做模块分析,其实就是广度优先遍历,可以很轻松得到这次打包所有需要的模块的分析结果。

const graph = {
  './src/index.js': {
    dependencies: { './message.js': 'src/message.js' },
    code:'...'
  },
  'src/message.js': {
    dependencies: { './word.js': 'src/word.js' },
    code:'...'
  },
  'src/word.js': {
    dependencies: {},
    code:'...'
  },
};

生成代码

我们需要开始生成最终可运行的代码了。

为了不污染全局作用域,我们使用立即执行函数来包装我们的代码,将依赖图谱graph作为参数传入:

(function(graph){
  // todo
})(graph)

我们需要开始运行入口文件的代码,因此我们必须在graph中找到入口文件对应的code,并运行它:

(function(graph){
  function require(module){
    eval(graph[module].code)
  }
  require('./src/index.js')
})(graph)

然而在code中,我们同样需要调用require去获取其他模块导出的对象exports,所以require函数必须有导出对象。还要支持内部调用require函数,但是注意!!此require并非现在声明的require函数,因为我们观察之前编译出的代码,可以知道在code中,require函数传的参数是相对于当前module的路径。这时候,我们之前给每个module存的dependencies映射再次派上了用场。

(function(graph){
  function require(module){
    // 定义code内部使用的require函数 -> localRequire
    function localRequire(relativePath){
      return require(graph[module].dependencies[relativePath])
    }
    
    var exports = {};
    eval(graph[module].code)
    return exports;
  }
  require('./src/index.js')
})(graph)

为为了覆盖当前作用域链中的require变量,我们在eval外面包一层立即执行函数,将localRequireexportscode作为参数传入,这样也保证了eval中代码相关函数名字的对应。

(function(graph){
  function require(module){
    // 定义code内部使用的require函数 -> localRequire
    function localRequire(relativePath){
      return require(graph[module].dependencies[relativePath])
    }
    var exports = {};
    (function(require, exports, code){
      eval(code);
    })(localRequire, exports, graph[module].code)
    return exports;
  }
  require('./src/index.js')
})(graph);

由此一个bundler就写完了,最终生成的代码,也是可以直接在浏览器中运行的。

但是,我依然非常好奇,webpack打包后的结果究竟长什么样。

webpack打包结果分析

我们用同样的文件,使用webpack做一次打包,对结果进行一些分析(鉴于代码还挺长,建议自己去打包看)。

先从最外层开始分析:

(function (modules) {
  // ...
})({
  './src/index.js': function (module, __webpack_exports__, __webpack_require__) {
    'use strict';
    eval(
      // ...
    );
  },

  './src/message.js': function (module, __webpack_exports__, __webpack_require__) {
    'use strict';
    eval(
      '__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _word_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./word.js */ "./src/word.js");\n\n\nconst message = `say ${_word_js__WEBPACK_IMPORTED_MODULE_0__["word"]}`;\n\n/* harmony default export */ __webpack_exports__["default"] = (message);\n\n\n//# sourceURL=webpack:///./src/message.js?'
    );
  },

  './src/word.js': function (module, __webpack_exports__, __webpack_require__) {
    'use strict';
    eval(
      // ...
    );
  },
});

根据我们精密的观察,发现webpack同样将importexport做了转换,上面所说的在code内部的require函数和exports对象,变成了__webpack_require____webpack_exports__

更加智能的是,在如今的每个模块的code中,__webpack_require__的参数已经不再是之前的相对于该模块的路径,而是全部被转化为了相对于bundler的路径。

除此之外,graph每个key所对应的value变成了一个函数,和我们自己写的在eval代码外面包的那一层立即执行函数非常相似。

第二步,进入其中,分析核心逻辑:

// The module cache
var installedModules = {}
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] = {
			i: moduleId,
			l: false,
			exports: {}
    }
    // Execute the module function
    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
}

// ...

return __webpack_require__(__webpack_require__.s = './src/index.js')
//...

返回结果是拿webpack.config.js里配的entry作为moduleId,去调了声明的__webpack_require__函数。

__webpack_require__函数内部优先返回缓存里找对应模块的exports对象;如果不存在,则先在缓存中声明该模块的信息module,再拿moduleIdgraph里找对应的函数,传入刚声明的module.exports当this环境,其他参数也一一对应,非常自然,运行完后,返回module.exports结果。

这里有一个细节问题,虽然我们绑定了module.exports进行了call调用,但实际上在我们模块中最外层使用this,在编译成graph时,被转化为了undefined

感觉对webpack有个粗略的了解了。