如何实现一个 Webpack 的 Bundler 打包机制 ?

前言

image

我想这两年,应该是「Webpack」受冲击最明显的时间段。前有「Snowpack」基于浏览器原生ES Module 提出,后有「Vite」站在「Vue3」肩膀上的迅猛发展,真的是后浪推前浪,前浪....javascript

而且,「Vite」主推的实现技术不是一点点新,典型的一点使用「esbuild」来充当「TypeScript」的解释器,这一点是和目前社区内绝大多数打包工具是不一样的。java

在下一篇文章,我将会介绍什么是「esbuild」,以及其带来的价值。

可是,虽然说后浪确实很强,不过起码近两年来看「Webpack」所处的地位是仍然不可撼动的。因此,更好地了解「Webpack」相关的原理,能够增强咱们的我的竞争力。node

那么,回到今天的正题,咱们就来从零实现一个「Webpack」的 Bundler 打包机制。segmentfault

1 Bundler 打包背景

Bundler 打包背景,即它是什么?Bundler 打包指的是咱们能够将模块化的代码经过构建模块依赖图解析代码执行代码等一系列手段来将模块化的代码聚合成可执行的代码浏览器

在日常的开发中,咱们常常使用的就是 ES Module 的形式进行模块间的引用。那么,为了实现一个 Bundler 打包,咱们准备这样一个例子:babel

目录模块化

|—— src
    |-- person.js
    |-- introduce.js
    |-- index.js    ## 入口
|—— bundler.js      ## bundler 打包机制

代码函数

// person.js
export const person = 'my name is wjc'
// introduce.js
import { person } from "./person.js";

const introduce = `Hi, ${person}`;
export default introduce;
// index.js
import introduce from "./introduce.js";

console.log(introduce);

除开 bundler.js 打包机制实现文件,另外咱们建立了三个文件,它们分别进行了模块间的引用,最终它们会被 Bundler 打包机制解析生成可执行的代码。工具

接下来,咱们就来一步步地实现 Bundler 打包机制。学习

2 单模块解析

Bundler 的打包实现第一步,咱们须要知道每一个模块中的代码,而后对模块中的代码进行依赖分析、代码转化,从而保证代码的正常执行。

首先,从入口文件 index.js 开始,获取其文件的内容(代码):

const fs = require("fs")

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, 'utf-8')
}

获取到模块的代码后,咱们须要知道它依赖了哪些模块?这个时候,咱们须要借助两个 babel 的工具:@babel/parser@babel/traverse。前者负责将代码转化为「抽象语法树 AST」,后者能够根据模块的引用构建依赖关系。

@babel/parser 将模块的代码解析成「抽象语法树 AST」:

const rawCode = fs.readFileSync(file, 'utf-8')
const ast = babelParser(rawCode, {
  sourceType: "module"
})

@babel/traverse 根据模块的引用标识 ImportDeclaration 来构建依赖:

const dependencies = {};
traverse(ast, {
  ImportDeclaration({ node }) {
    const dirname = path.dirname(file);
    const absoulteFile = `./${path
      .join(dirname, node.source.value)
      .replace("\\", "/")}`;
    dependencies[node.source.value] = absoulteFile;
  },
});

这里,咱们经过 @babel/traverse 来将入口 index.js 依赖的模块放到 dependencies 中:

// dependencies
{ './intro.js' : './src/intro.js' }

可是,此时 ast 中的代码仍是初始 ES6 的代码,因此,咱们须要借助 @babel/preset-env 来将其转为 ES5 的代码:

const { code } = babel.transformFromAst(ast, null, {
  presets: ["@babel/preset-env"],
});

index.js 转化后的代码:

"use strict";
var _introduce = _interopRequireDefault(require("./introduce.js "));
function _interopRequireDefault(obj) { 
  return obj && obj.__esModule ?
    obj : {
        "default": obj
    };
}
console.log(_introduce["default"]);

到此,咱们就完成了对单模块的解析,完整的代码以下:

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, "utf-8");
  const ast = babelParser.parse(rawCode, {
    sourceType: "module",
  });
  const dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const absoulteFile = `./${path
        .join(dirname, node.source.value)
        .replace("\\", "/")}`;
      dependencies[node.source.value] = absoulteFile;
    },
  });
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });

  return {
    file,
    dependencies,
    code,
  };
};

接下来,咱们就开始模块依赖图的构建。

2 构建模块依赖图

众所周知,「Webpack」的打包过程会构建一个模块依赖图,它的造成无非就是从入口文件出发,经过它的引用模块,进入该模块,继续单模块的解析,不断重复这个过程。大体的逻辑图以下:

因此,在代码层面,咱们须要从入口文件出发,先调用 moduleParse() 解析它,而后再遍历获取其对应的依赖 dependencies,以及调用 moduleParse()

const buildDependenceGraph = (entry) => {
  const entryModule = moduleParse(entry);
  const rawDependenceGraph = [entryModule];
  for (const module of rawDependenceGraph) {
    const { dependencies } = module;
    if (Object.keys(dependencies).length) {
      for (const file in dependencies) {
        rawDependenceGraph.push(moduleParse(dependencies[file]));
      }
    }
  }
  // 优化依赖图
  const dependenceGraph = {};
  rawDependenceGraph.forEach((module) => {
    dependenceGraph[module.file] = {
      dependencies: module.dependencies,
      code: module.code,
    };
  });

  return dependenceGraph;
};

最终,咱们构建好的模块依赖图会放到 dependenceGraph。如今,对于咱们这个例子,构建好的依赖图会是这样:

{ 
  './src/index.js':
   { 
     dependencies: { './introduce.js': './src/introduce.js' },
     code: '"use strict";\n\nvar...'     
    },
  './src/introduce.js':{ 
    dependencies: { 
      './person.js': './src/person.js' 
    },
    code: '"use strict";\n\nObject.defineProperty(exports,...' 
  },
  './src/person.js':
   { 
     dependencies: {},
     code: '"use strict";\n\nObject.defineProperty(exports,...' 
    } 
}

3 生成可执行代码

构建完模块依赖图后,咱们须要根据依赖图将模块的代码转化成能够执行的代码。

因为 @babel/preset-env 处理后的代码用到了两个不存在的变量 requireexports。因此,咱们须要定义好这两个变量。

require 主要作这两件事:

  • 根据模块名,获取对应的代码并执行。
eval(dependenceGraph[module].code)
  • 处理模块名,因为引用的时候是相对路径,这里须要转成绝对路径,而且递归执行依赖模块代码
function _require(relativePath) {
  return require(dependenceGraph[module].dependencies[relativePath]);
}

export 则用于存储定义的变量,因此咱们定义一个对象来存储。完整的生成代码函数 generateCode 定义:

const generateCode = (entry) => {
  const dependenceGraph = JSON.stringify(buildDependenceGraph(entry));
  return `
  (function(dependenceGraph){
    function require(module) {
      function localRequire(relativePath) {
        return require(dependenceGraph[module].dependencies[relativePath]);
      };
      var exports = {};
      (function(require, exports,  code) {
        eval(code);
      })(localRequire, exports, dependenceGraph[module].code);
      return exports;
    }
    require('${entry}');
  })(${dependenceGraph});
  `;
};

4 完整的 bundler 打包机制实现代码

完整的 Bunlder 打包实现代码:

const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, "utf-8");
  const ast = babelParser.parse(rawCode, {
    sourceType: "module",
  });
  const dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const absoulteFile = `./${path
        .join(dirname, node.source.value)
        .replace("\\", "/")}`;
      dependencies[node.source.value] = absoulteFile;
    },
  });
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });

  return {
    file,
    dependencies,
    code,
  };
};

const buildDependenceGraph = (entry) => {
  const entryModule = moduleParse(entry);
  const rawDependenceGraph = [entryModule];
  for (const module of rawDependenceGraph) {
    const { dependencies } = module;
    if (Object.keys(dependencies).length) {
      for (const file in dependencies) {
        rawDependenceGraph.push(moduleParse(dependencies[file]));
      }
    }
  }
  // 优化依赖图
  const dependenceGraph = {};
  rawDependenceGraph.forEach((module) => {
    dependenceGraph[module.file] = {
      dependencies: module.dependencies,
      code: module.code,
    };
  });
  return dependenceGraph;
};

const generateCode = (entry) => {
  const dependenceGraph = JSON.stringify(buildDependenceGraph(entry));
  return `
  (function(dependenceGraph){
    function require(module) {
      function localRequire(relativePath) {
        return require(dependenceGraph[module].dependencies[relativePath]);
      };
      var exports = {};
      (function(require, exports,  code) {
        eval(code);
      })(localRequire, exports, dependenceGraph[module].code);
      return exports;
    }
    require('${entry}');
  })(${dependenceGraph});
  `;
};

const code = generateCode("./src/index.js");

最终,咱们拿到的 code 就是 Bundler 打包后生成的可执行代码。接下来,咱们能够将它直接复制到浏览器的 devtool 中执行,查看结果。

写在最后

虽然,这个 Bundler 打包机制的实现,只是简易版的,它只是大体地实现了整个「Webpack」的 Bundler 打包流程,并非适用于全部用例。可是,在我看来不少东西的学习都应该是从易到难,这样的吸取效率才是最高的。

往期文章回顾

深度解读 Vue3 源码 | 组件建立过程

深度解读 Vue3 源码 | 内置组件 teleport 是什么“来头”?

深度解读 Vue3 源码 | compile 和 runtime 结合的 patch 过程

❤️爱心三连击

写做不易,若是你以为有收获的话,能够爱心三连击!!!

相关文章
相关标签/搜索