从minipack看打包原理

从minipack看打包原理

前端有很多的打包工具如webpack等,但是打包工具的原理是什么呢?

minipack是一个小型的打包工具,作者ronami,用来解析打包工具的基本原理。代码中有相当多的注释,理解起来也非常容易。

先放其中测试的代码文件,代码文件只有三个,每个都只有一两行代码:

// 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';

在模块化编程中,开发人员将程序分解为离散的功能块,称为模块

三个代码文件就是三个模块,它们之间存在依赖关系。

然后是正文,这是实现打包功能用到的库:

// fs读取文件
const fs = require('fs');
// path库解析文件路径
const path = require('path');
// babylon用于AST解析,构造AST语法树
const babylon = require('babylon');
// travers对AST语法树进行遍历
const traverse = require('babel-traverse').default;
// transformFromAst将语法树转换为代码
const {transformFromAst} = require('babel-core');

createAsset获取模块信息

第一个函数是createAsset()函数,获取模块的信息。主要包括四个部分:

  • id:每个模块的唯一标识符;
  • filename:模块的文件名;
  • dependencies:模块的依赖列表,数据结构为数组;
  • code:模块中的代码。
let ID = 0;

// 接受一个文件参数,为模块创建一个抽象语法树,
// 遍历该树,得到模块的信息对象,属性包括id,文件名,依赖,代码
function createAsset(filename) {
  // 获取文件内容,编码格式utf-8
  const content = fs.readFileSync(filename, 'utf-8');

  // JavaScript解析器会生成抽象语法树
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });

  const dependencies = [];
  // 遍历抽象语法树,从导入声明中获取依赖列表
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    },
  });

  // 获取id
  const id = ID++;

  // 从AST解析获取代码
  const {code} = transformFromAst(ast, null, {
    presets: ['env'],
  });

  return {
    id,
    filename,
    dependencies,
    code,
  };
}

如entry.js文件经过该函数处理后,会得到以下数据:

{
  id: 0,
  filename: './example/entry.js',
  dependencies: ['./message.js'],
  code: '"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);'
}

构建依赖图

第二个函数createGraph()接受一个文件,从该文件开始向前遍历,直到处理完所有的模块。

// 函数接受一个入口文件,从入口文件开始,向前递归寻找依赖文件,最后返回一个包含所有模块的数组
function createGraph(entry) {
  // 从入口文件开始获取依赖项
  const mainAsset = createAsset(entry);

  // 创建一个数组类型的队列,起始队列中只有入口文件一个元素
  const queue = [mainAsset];
  // 使用for..of...循环遍历队列,添加一个mapping对象,将依赖项的相对地址改为绝对地址
  for (const asset of queue) {
    asset.mapping = {};

    const dirname = path.dirname(asset.filename);
    // 将每个依赖项的相对地址转为绝对地址,获取到依赖项的全部信息后,
    asset.dependencises.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath);

      const child = createAsset(absolutePath);

      // 为mapping添加relativePath属性,属性值为依赖项的id
      asset.mapping[relativePath] = child.id;
      // 将child推到依赖项队列中
      queue.push(child);
    });
  }

  return queue;
}

在第二个函数中,为模块对象添加了一个mapping属性,用于保存依赖的模块的相对路径和模块id的映射。如{'./message.js': 1}。遍历完成后,函数返回一个依赖图数组。返回结果大致如下:

[
  {
    id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    code: '',
    mapping: { './message.js': 1 }
  },
  {
    id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    code: '',
    mapping: { './name.js': 2 }
  },
  {
    id: 2,
    filename: 'example/name.js',
    dependencies: [],
    code: '',
    mapping: {}
  }
]

mapping属性解决的问题是,当依赖列表中出现相同的文件时,可以使用唯一标识符id进行区分。

bundle函数

bundle()函数首先对参数进行处理,对每一个模块进行处理,将所有的模块转换成key:value形式,key为模块的唯一标识符id,value是一个二值数组,第一个值是模块的代码,第二个值是mapping。代码如下:

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;
}

const graph = createGraph('./example/entry.js');
const result = bundle(graph);

console.log(result);

中间这段代码与webpack的runtime函数很像。Runtime函数帮助模块顺利地执行模块的导入、导出和执行。

这里的runtime函数,接受依赖图作为参数,但是数据结构已经不同。runtime定义了一个require函数,运行require(0),表示从入口文件开始解析,由于id具有唯一性,所以将id作为参数。

为了实现模块化,runtime构造了函数作用域,模块内的代码被包裹在函数内。minipack项目运行结果中modules为:

{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';
      },
      {},
    ],}

总结

打包的基本过程为:

  • 从entry开始生成AST,从导入声明中获取依赖列表
  • 获取entry模块的全部信息
  • 对entry的依赖文件重复上述操作,直到遍历完成
  • 生成依赖图数组
  • 构建runtime函数
  • 将依赖图传递给runtime函数,生成代码
posted @ 2020-06-23 21:33  不作声  阅读(297)  评论(0编辑  收藏  举报