模块打包器的实现(一)

什么是打包器

一个完整的 JavaScript 项目(比如各种前端SPA)由各种各样的资源模块(module)组成,包括 JavaScript 代码,CSS 样式以及图片等各种文件。打包器(module bundler)可以分析入口文件(entry)引用了哪些模块,找到对应的文件,将其合并到一起。这样执行输出文件(output)的时候,一个完整的项目会呈现出来。

单页面应用包含大量 JavaScript 代码,为了合理地管理代码,开发时会将代码拆分到不同文件里面。在各个模块的代码编写完成之后,bundler 可以帮我们把各个分散的 JS 文件合并起来,输出一个完整的 JS 文件。

对于样式文件和图片等资源,我们也可以指定如何处理它们。一般的处理方式是直接插入到 HTML 或者 JS 文件中,或者通过指定文件网络地址(public path),在需要该文件的时候浏览器会通过网络请求获取到这些资源。

功能完备的打包器可以把各种资源模块聚集到一起,生成完整的 web app。但本文作为开篇只讨论如何实现一个 JavaSript bundler。

过程分析

一个最简单的 JS bundler 可以帮助我们:

  • 找到 entry JavaScript 文件引用到的所有其他 JS 文件,并将其合并到目标 JS 文件(output)中。
  • 保证各个模块的 JavaScript 代码都在自己的作用域中执行,避免命名冲突。

为了保证每个模块的 JS 代码都在自己的作用域中执行,可以参考 Node 执行 JS 代码的方式。可以概括为 5 步:

  • resolve:通过 require 中的 string 定位到文件的真实地址。
  • load:加载这个文件。
  • wrap:将引入的代码包含在一个函数中,保证定义的变量只作用在本文件中。
  • execute:执行代码。
  • cache:缓存执行结果。

而打包的流程可以概括为:

  • 找到起始文件的依赖文件,将其加载并描述为一个资源模块(asset),其包含的信息包括:
    • id:唯一 id
    • filename:绝对文件路径
    • code:模块代码,将其包含在一个函数里面。并且需要把 ESM 的 import 和 export 改成 require 和 exports,这样可以执行函数参数里面的 require 和 exports,函数参数的 require 可以帮我们通过相对路径找到实际文件。
    • dependencies:引入的模块。
    • mapping:记录以来模块的相对路径和其模块 id 的对应关系。
  • 当依赖的文件有其他依赖的时候,继续加载依赖文件。最终生成一个依赖图(dependency graph),包含所有模块之间的依赖关系。
  • 拼凑一个完整 string,包含所有模块信息,并且执行起始文件。输出这个 string。

代码实现

转换 JS 文件为资源模块(asset):

const path = require('path');
const fs = require('fs');

const parser = require('babel-parser');
const { transformFromAst } = require('@babel/core');
const traverse = require('@babel/traverse').default;

let ID = 0;

function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, {
    sourceType: 'module',
  });
  
  const dependencies = [];
  traverse(ast, {
    importDelcaration: ({ node }) => {
      dependencies.push(node.source.value);
    }
  });
  
  const { code } = tranformFromAst(ast, null, {
    presets: ['@babel/preset-env'],
  });
  
  return {
    id: ID++,
    filename,
    dependencies,
    code
  };
}

生成依赖图:

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

合并 string:

function createBundle(entry) {
  const graph = createGraph(entry);
  
  let modules = '{';
  
  graph.forEach((asset) => {
    modules +=
      `${asset.id}: [function (requre, module, exports) { ${asset.code} }, ${JSON.stringify(asset.mapping)}],`;
  });
  
  modules += '}';
  
  const result = `function(modules) {
    function require(id) {
      const [fn, mapping] = modules[id];
      
      function localRequire(relativePath) {
        require(mapping[relativePath]));
      }
      
      const module = { exports: {} };
      
      fn(localRequire, module, exports);
      
      return moduele.exports;
    }
    
    require(0);
  }(${modules)`;
}

备注项目依赖

"dependencies": {
    "@babel/core": "7.9.6",
    "@babel/parser": "7.9.6",
    "@babel/preset-env": "7.9.6",
    "@babel/traverse": "7.9.6"
  }

附录

  • 本文的知识来源和代码 minipack
  • 一个正在施工中的 打包器

你可能感兴趣的:(模块打包器的实现(一))