webpack学习(1)起步——模块是怎样打包的

87 阅读8分钟

webpack是怎样分析、整理、优化代码的呢?

用了几年vue/cli,也就磕磕绊绊地学了几年的webpack,每次都是兴致勃勃的拿起来,又悄无声息地放回去,是什么导致自己总是黑瞎子掰苞米,掰一棒,扔一棒呢,到最后好像还是什么都没学呢?

每次都是从基础配置开始,entry、output、module、plugins开始,走着走着就被淹没在版本的兼容路上,被各种冒出来的error和warning给搞得心烦意乱。

究其本质,webpack实现原理一窍不通,__webpack_require方法到底是干啥的,一看就绕进去了。

这次呢,从一个一直被忽略的地方开始,一个webpack官网上列举的基础学习项目开始,研究一下webpack的最基础实现,是否能够从下往上带来一些触动和启发。

一个webpack官网项目

我们有个实例应用目录如下:

image.png

如上目录,我们将要通过三个方法组成的极简打包工具来分析示例项目目录example下的三个文件,得到相应的应用内文件依赖图,从而处理后得到简单webpack打包代码,本示例讲解webpack官网的起步项目:一个简单打包工具的详细说明

createAsset函数

大家都知道,webpack的核心是构建应用中各个文件之间的依赖关系,构建出整个应用所有模块(webpack中一个文件就是一个模块)的依赖图。那么webpack是怎么分析我们代码的呢,它怎么就知道我们的代码中有哪些外部模块依赖呢。

有两种方法:

(1)用字符串匹配,也就是用前端常用的模块引入关键字import、require等来查找匹配相应代码,项目注释里说它是一个笨拙的漂亮方法,确实是,而且我竟然在自己的低代码项目这么用了,欲哭无泪啊!!!

(2)用babylon来解析我们的代码,也可以加上一些选项,以便满足我们的需求,比如有如下文件entry.js

import message from './message.js';

console.log(message);

我们想解析出这段代码中的模块引用关系,我们可以这么做,在我们的minipack.js中写入如下代码:

const babylon = require('babylon');


const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
 	sourceType: 'module',
 });

得到了一个如下结构,(由于篇幅有限,只粘贴部分出来

{
  type: 'File',
  start: 0,
  end: 62,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 4, column: 0 }   
  },
  program: Node {
    type: 'Program',
    start: 0,
    end: 62,
    loc: SourceLocation { start: [Position], end: [Position] },
    sourceType: 'module',
    body: [ [Node], [Node] ],
    directives: []
  },
  comments: [],
  tokens: [
    Token {
      type: [KeywordTokenType],
      value: 'import',
      start: 0,
      end: 6,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'message',
      start: 7,
      end: 14,
      loc: [SourceLocation]
    },
   
  ]
}

上面这种结构是通过分析javascript代码得到的,这种结构叫做AST(abstract syntax tree),通过这种结果可以帮我们更好的分析js代码中都依赖了哪些模块,使用了哪些import语句,大家如果对AST感兴趣的话可以查看AST Explorer,了解AST特性。

当我们得到AST之后,遍历这个AST来获取import依赖,因为js的ES6规定了我们的import只能静态地引入,不可以引入一个变量,或者是有条件地引入,也就是不能在if else中引入,这样当我们在任何时候发现import 声明时,都可以把这个声明当做一个外部依赖。

想要遍历这个AST,需要借助外部工具babel-traverse,代码如下:

const traverse = require('babel-traverse').default;

const dependencies = []
traverse(ast, {
    ImportDeclaration: ({ node }) => {
	dependencies.push(node.source.value);
    },
});

通过遍历AST得到的依赖数组如下:

[ './message.js' ]

通过AST得到了依赖,这时候还有一个问题,为了让我们的代码能够兼容更多的浏览器,我们使用babel来转换代码,以便可以在大多数的浏览器中正常运行,可以登录babel 官网学习babel相关知识。用babel转译代码时,需要为参数presets传入正确的选项,这样babel就可以按照我们的指定的规则来转译代码,这里我们使用babel-preset-env来指定规则。

代码如下:

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

得到entry.js代码如下:

'"use strict";\n' +
    '\n' +
    'var _message = require("./message.js");\n' +
    '\n' +
    'var _message2 = _interopRequireDefault(_message);\n' +
    '\n' +
    'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
    '\n' +
    'console.log(_message2.default);'

上面三部分可以得到一个模块中外部依赖,转译后的代码,还有filename,这里还需要唯一的标识符,这里需要设置一个全局变量ID来自增运算,用来为我们的模块计数。

为这个方法取个名字createAsset,createAsset帮助我们分析一个模块内部的依赖项,和转译模块代码。就得到了如下方法

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

let ID = 0;
function createAsset(filename) {
	const ast = babylon.parse(content, {
		sourceType: 'module',
	});
	traverse(ast, {
		ImportDeclaration: ({ node }) => {
			// We push the value that we import into the dependencies array.
			dependencies.push(node.source.value);
		},
	});
	const id = ID++;
	const { code } = transformFromAst(ast, null, {
		presets: ['env'],
	});
	return {
		id,
		filename,
		dependencies,
		code,
	};
}

createGraph函数

有了createAsset方法,就可以解析一个模块,并得到我们需要的外部依赖关系和模块代码。那么我们要从入口文件,把应用涉及的所有模块都解析,从而生成相应的依赖图,该怎样做呢?

这时候我们的createGraph就该上场了,用它来创建应用的完整依赖图时,需要使用两个循环:

第一个循环

  • 因为我们构建依赖图是从入口模块开始,那么我们迭代模块的路径是个树形,那么该怎样设计这个循环呢?首先使用队列,通过for of循环这个队列,把通过createAsset解析入口文件得到的对象作为数组的第一个元素

  • 那么开始循环这个队列后,是怎样实现树形迭代的呢,这个会在第一个循环内的第二个循环做相应处理,当一个模块有外部依赖模块时,我们就把模块的外部依赖模块也放进队列中,这样相当于把复杂的依赖图逐步放在一个数组中来循环,知道所有依赖关系结束。

第二个循环

  • 这个循环在第一个循环内,因为从入口文件起,每个模块的外部依赖模块就可能不止一个模块,所以本循环就是把一个模块的外部依赖都遍历一遍,并且通过createAsset方法得到模块内容后,将模块对象放入第一个循环的队列中,这样第一个循环就会一直到所有模块依赖都遍历一遍后停下,

  • 并且本循环还会为createAsset方法得到的对象新增一个属性mapping,用来记录每个模块外部依赖路径和模块id的映射关系。

代码如下:

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);
            // createAsset获得的依赖路径是相对的 而它所需参数必须是 绝对路径+文件名
            const child = createAsset(absolutePath);
            //记录每个外部依赖所对应的模块ID
            asset.mapping[relativePath] = child.id;
            queue.push(child);
	});
    }
    return queue;
}

bundle函数

bundle英文里是打包的意思,把一些文件经过处理优化,转化为另一些我们需要的文件。

bundle函数以我们创建的依赖图为工作路径来处理应用模块,把应用打包成一个自调用的函数体,如(function(){})().这个函数只接受一个参数,一个包含依赖图中所有模块信息的object,通过模块ID与每个模块信息映射。用包含两个元素的数组表示每个模块的信息。当然这里构建出来的模块结构都是字符串,如下所示:

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

如上结构就是我们遍历依赖图中每个模块时,把每个模块的信息放到函数wrapper中,这样我们最终包含所有模块信息的对象就组合完成,用每个模块的id与每个每个模块数组相对应。

可以看到每个数组模块中有两个元素,第一个包含模块代码的函数wrapper,这样我们的代码放在一个函数中,相当于放在了局部作用域中,避免污染其他代码中的变量,或是全局变量。

我们的代码都经过了babel的转译,转化成了commenJS的运行方式,他们正常工作需要有三个变量:require、module、exports,它们不能正常在浏览器使用,所以需要用一个函数来包裹起来。

数组第二个元素是模块依赖的相对路径与模块id的映射,类似这种结构:{ './relative/path': 1 }。

我们转译后的代码,通过require方法来使用,require使用需要每个依赖的相对路径,这样我们的mapping就派上了用场,帮助我们知道每个模块依赖和它所依赖的模块id的对应关系。

现在,轮到最后一个组合结构上场:自调用函数主体,代码如下:

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}})
  `

我们通过创建一个require函数开始,这个函数接收模块id作为参数,用来在模块object——modules中查找所需模块。每个模块信息包含两部分,第一个是包含模块代码的函数,第二个是模块外部依赖相对路径与模块id的mapping。

为什么要用id作为key与包含两个模块信息的数组做映射来构建模块信息对象呢?模块代码通过require来引用外部依赖,靠相对路径来辨别所需模块位置,而我们定义的模块信息对象需要id来查找模块信息,再通过查找第二个元素所包含的依赖路径与id映射,则可以找到相应模块代码,这样当有多个依赖使用同一个模块时,也就是多个依赖路径应用同一个id时,我们只需要在代码中一个模块id和对应的代码即可,就达到了复用模块的目的,可以帮助减少优化我们的模块对象结构。

为了实现依赖和id的分离,在require函数内部定义了localRequire函数,输入依赖可以得到对应模块,把localRequire当做参数传递给包裹代码的wrapper函数,这样我们的自调用函数从require(0)就可以运行webpack转译后的代码了。

最后,当代码运行在commonJs规则时,当一个模块通过require被引用时,module可以暴露出已经被修改过的exports对象。这个exports对象已经被自定义的require函数的代码所修改,当引用时就会被返回给引用模块。

bundle函数完整代码如下:

function bundle(graph) {
    let modules = '';
    
    graph.forEach((mod) => {
        modules += `${mod.id}: [
          function (require, module, exports) {
            ${mod.code}
          },
          ${JSON.stringify(mod.mapping)},
        ],`;
    });
    
    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;
}

本文为学习webpack官网-基础概念内推荐入门项目的一个说明介绍,如有不准确之处请留言指教,感谢!

webpack官网-概念介绍模块-一个简单打包工具的详细说明

项目的GitHub地址