🚀🚀🚀面试官:你能简单实现一个webpack吗???我...

1,309 阅读5分钟

往期精彩文章推荐

🔥🔥🔥微前端无界(wujie)源码浅析——子应用加载与js、css沙箱实现
🏠🏠🏠微前端我劝你千万别无脑冲qiankun
一个js库就把你的网页的底裤🩲都扒了——import-html-entry
🧶创建js沙箱都有哪些方式
两分钟快速了解css样式隔离方案有哪几种
这一次再也不怕webpack面试了【webpack配置、处理资源】
🏝️🏝️🏝️一个babel插件让项目中所有antd table实现拖拽控制列宽

github仓库地址: mini-webpack

Webpack 是一个功能强大的打包工具,可以将项目中的各个模块打包成一个浏览器可以执行的文件。虽然 Webpack 本身功能非常复杂,但它的核心原理相对简单。本文将带你用不到 100 行代码实现一个简单版的 Webpack,实现以下功能:

  • 支持自定义 Plugin 插件体系
  • 支持自定义 Loader 加载体系
  • 支持 JavaScript 模块的依赖打包
  • 在不同文件中引入相同模块时实现缓存

接下来逐步讲解每个功能的实现,并展示如何构建一个简单的打包工具。

1. Webpack 的基本架构

Webpack 的核心工作是解析项目中的模块及其依赖,然后将它们打包成一个文件。在这个过程中,它会应用各种插件(Plugin)和加载器(Loader)来处理不同类型的文件。我们先从项目的入口文件开始,读取它的内容,解析模块依赖,再递归处理这些依赖。

2. 构建基本的 Webpack 类

首先,我们定义一个基本的 Webpack 类,并在构造函数中接受一些配置项,如入口文件路径、输出路径、插件和加载器。以下是代码实现:

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

class Webpack {
  constructor(options) {
    this.entry = options.entry;          // 入口文件
    this.output = options.output;        // 输出配置
    this.plugins = options.plugins || []; // 插件
    this.loaders = options.module?.rules || []; // 加载器
    this.cache = {};                     // 模块缓存

    // 初始化插件
    this.initPlugins();
  }

  // 初始化插件
  initPlugins() {
    this.plugins.forEach(plugin => {
      if (typeof plugin.apply === 'function') {
        plugin.apply(this);
      }
    });
  }
}

这段代码初始化了 Webpack 类,并在构造函数中接收配置项。initPlugins 方法用于初始化插件。

3. 解析文件内容

接下来,我们编写 readModule 方法来读取文件内容并解析它。这个方法会通过 Babel 将代码解析成 AST(抽象语法树),遍历 AST 找到所有的模块依赖,并转换代码以便打包。

readModule(filename) {
  const relativeFilename = './' + path.relative(process.cwd(), filename);

  if (this.cache[relativeFilename]) {
    return this.cache[relativeFilename];
  }

  let content = fs.readFileSync(filename, 'utf-8');

  // 应用 loaders
  for (const loader of this.loaders) {
    if (loader.test.test(filename)) {
      const loaderFn = require(loader.use);
      content = loaderFn(content);
    }
  }

  const ast = parser.parse(content, {
    sourceType: 'module',
  });

  const dependencies = [];

  // 遍历 AST 获取模块依赖
  traverse(ast, {
    ImportDeclaration({ node }) {
      dependencies.push(node.source.value);
    },
  });

  // 使用 Babel 转换代码
  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ['@babel/preset-env'],
  });

  const moduleInfo = {
    filename: relativeFilename,  // 使用相对路径作为 filename
    dependencies,
    code,
  };

  // 缓存模块
  this.cache[relativeFilename] = moduleInfo;

  return moduleInfo;
}

这里我们用 Babel 来解析代码的 AST,并找到所有的 import 声明,从而提取出模块依赖。同时,我们利用 Babel 将代码转换成兼容的 JavaScript 代码。

4. 构建依赖图

在拿到入口模块的信息后,我们还需要递归处理它的所有依赖模块。我们通过 buildDependencyGraph 方法构建依赖图。

buildDependencyGraph(entry) {
  const entryPath = path.resolve(entry);
  const entryModule = this.readModule(entryPath);
  const graph = [entryModule];

  for (const module of graph) {
    module.mapping = {};
    const dirname = path.dirname(module.filename);

    module.dependencies.forEach((relativePath) => {
      // 获取依赖模块的绝对路径
      const absolutePath = path.resolve(path.dirname(entryPath), dirname, relativePath);
      // 解析为相对于项目根目录的相对路径
      const childModule = this.readModule(absolutePath);
      const relativeChildPath = './' + path.relative(process.cwd(), absolutePath);

      module.mapping[relativePath] = relativeChildPath;

      graph.push(childModule);
    });
  }

  return graph;
}

buildDependencyGraph 方法从入口文件开始,递归读取所有依赖模块并构建一个包含所有模块的依赖图。

5. 实现模块打包和缓存

有了依赖图后,我们就可以将所有模块打包到一个文件中。我们通过 bundle 方法将模块包装在一个立即执行函数中,并实现一个简单的缓存机制。

bundle() {
  const graph = this.buildDependencyGraph(this.entry);

  const modules = graph.map(module => {
    return `
      '${module.filename}': function (require, module, exports) {
        ${module.code}
      }
    `;
  }).join(',');

  // 生成最终的打包文件
  const result = `
    (function(modules) {
      const cache = {};
      
      function require(filename) {
        if (cache[filename]) {
          return cache[filename].exports;
        }

        const module = cache[filename] = {
          exports: {}
        };

        modules[filename](require, module, module.exports);

        return module.exports;
      }

      require('./${path.relative(process.cwd(), this.entry)}');
    })({${modules}});
  `;

  // 输出到指定的文件
  fs.writeFileSync(path.resolve(this.output.path), result, 'utf-8');
}

在这里,我们用一个立即执行函数将所有模块封装起来,并为每个模块提供 require 函数。require 函数会检查模块是否已经加载,如果是,则直接从缓存中返回,否则执行模块代码。

6. 运行打包

最后,我们通过 run 方法来启动整个打包过程:

run() {
  this.bundle();
}

这段代码会触发 bundle 方法,生成最终的打包文件。

7. 测试我们的 Webpack

假设有以下项目结构:

/src
  |-- index.js
  |-- base.js

index.js 文件内容如下:

import base from './base.js';
console.log(base, 'index.js');

base.js 文件内容如下:

export default 'base';

通过配置我们的 Webpack, webpack.config.js中内容如下:

const path = require('path');
const ExamplePlugin = require('./ExamplePlugin.js');

module.exports = {
  entry: './index.js', // 入口文件
  output: {
    path: path.resolve(__dirname, '../dist/bundle.js'), // 输出文件路径
  },
  plugins: [
    new ExamplePlugin(),
  ],
  module: {
    rules: [
    ],
  },
};

run.js中内容:

const Webpack = require('./mini-webpack.js');
const config = require('./webpack.config.js');

const compiler = new Webpack(config);
compiler.run();

运行run.js

node  run.js

运行后会在 dist 目录下生成一个 bundle.js 文件,里面包含打包后的代码。

8、打包产物

打包产物如下:

(function (modules) {
  const cache = {};

  function require(filename) {
    if (cache[filename]) {
      return cache[filename].exports;
    }

    const module = (cache[filename] = {
      exports: {},
    });

    modules[filename](require, module, module.exports);

    return module.exports;
  }

  require('./index.js');
})({
  './index.js': function (require, module, exports) {
    "use strict";

    var _base = _interopRequireDefault(require("./base.js"));
    function _interopRequireDefault(e) {
      return e && e.__esModule ? e : { default: e };
    }
    console.log(_base.default, 'index.js');
  },
  './base.js': function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
    exports.default = void 0;
    var base = 'base';
    var _default = (exports.default = base);
  },
});

上面产物代码可以直接复制在浏览器控制台中运行

image.png