likes
comments
collection
share

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

作者站长头像
站长
· 阅读数 14

前言

最近一直在看 webpack 相关的知识,无论是相关的打包流程,还是一些更深层次的原理,都想以我的角度,一种更为浓缩的方式分享给大家,思来想去,手写 webpack 似乎是一个不错的方式,在这里,也可以先看一下笔者之前发布的两篇文章,可以作为一个铺垫,

相信笔者,看完整篇,你会对 webpack 有更深刻的认识。

实现 mini webpack 的前置知识

webpack 是什么

首先,也是第一步,让我们浅浅的思考一下,webpack 是什么,让我们先看一下官网的描述,

webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

正如官网所描述,webpack 是一个 Javasript 应用程序的静态打包工具,它将应用程序看作一组相互依赖的模块,其中每个模块都是一个 Javascript 文件。在处理应用程序时,它会递归地构建一个依赖关系图,然后使用此图来生成一个或多个 bundle。在生成 bundle 的过程中,会执行各种优化操作,如代码压缩、去重、按需加载等,以提高应用程序的性能和加载速度。

这里解释一下何为静态打包,可以从两个方面去理解

  1. 这种打包方式是静态的,因为在编译时就已经确定了所有的依赖关系和最终输出结果,而不需要在运行时再去动态地加载和解析依赖关系。

  2. 使用 webpack 打包 Js,生成的一个或多个 bundle, 它们均称之为静态资源。除了 Javascript 外,webpack 还支持各种资源,如字体,图片,css等,它们也可以被打包成静态资源来使用。

webpack 核心概念

  • entry: entry 是 webpack 构建项目时的入口点,它定义了整个应用程序的起点,webpack 将基于 entry 分析出项目中的所有依赖,并将其打包为最终的静态资源文件。

  • output: output 是指打包后生成的文件输出路径和文件名规则配置。通过 output 配置,可以定义 webpack 构建项目时最终生成的文件的名称、路径等信息

  • loader: loader 本质上是导出函数的 JavaScript 模块。所导出的函数,可用于实现内容转换,由于 webpack 只能识别 Js 和 Json,因此其它类型的模块,如 css,图片等,必须借助 loader来处理,说的更明确点,就是将不同类型的文件转换为 webpack 可识别的模块。

  • plugin: plugin 为 webpack 的支柱功能,webpack 在构建打包的过程中,会广播出对应的钩子事件,实现不同的功能,可以监听不同的事件,在不同的编译时期对打包过程做出自定义处理。

webpack 构建流程

webpack 的构建流程,其它博主想必都已经总结的很详细了,这里简单说一下我的理解,同时呼应下文,等会实现的 mini webpack 的打包流程也大概如下。

  • 初始化参数:解析文件或者 shell 语句中读取并合并参数。

  • 开始编译:初始化 compiler 对象,注册所有的插件,其中有开发者引入的插件,还有 webpack 的内置插件。接着开始执行 compiler.run() 函数开始编译。

  • 编译模块:从入口文件出发,解析文件,构建 AST 语法树,找出依赖路径,进行递归,同时根据文件后缀,匹配相对应的 loader 对模块进行转换,直到项目中的所有文件都经过的处理。

  • 完成编译并输出:产出打包结果,并输出到 output 目录。

当然,这些只是极为简单的描述,中间还有很多过程,如 webpack 会在特定的时间点广播出特定的事件,插件如果注册过该事件,将会在事件中执行自定义的逻辑,来改变打包结果。

webpack 的打包结果是什么

先看看一个简单的小例子,搭建一个小项目。

npm install webpack webpack-cli --save-dev

项目结构

├── dist 
│ └── bundle.js 
├── src 
│ └── foo.js 
│ └── main.js
├── package-lock.json 
├── package.json
└── webpack.config.js

foo.js

export function foo() {
  console.log('foo');
}

main.js

import { foo } from './foo.js';

foo();

webpack.config.js

const path = require('path');

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, './src/main.js'),
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'bundle.js',
  },
}

执行打包命令后,让我们看一下 /dist/bundle.js 的内容,已抽离注释。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

由上可以看出。

  • webpack的打包结果是一个立即执行函数(IIFE),在函数内部定义了一个缓存对象,__webpack_module_cache__ 和一个私有函数 __webpack_require__

  • 首先会使用 __webpack_require__ 加载入口模块 /src/main.js

  • 当调用 __webpack_require__(filename) 函数时,webpack 会检查缓存中是否已经存在指定模块的导出值,如果存在,则直接返回;否则,会加载该模块并执行它的代码。

如何实现 mini webpack

目标需求

  • 实现 plugin 体系,允许接入开发者自定义 plugin。

  • 实现 loader 体系,允许接入开发者自定义 loader。

  • 实现 Javascript 模块打包。

  • 在不同文件具有相同的引入,实现缓存。

捋一下实现的大概思路

  1. 首先根据配置文件找到打包入口。

  2. 解析入口文件,运用 babel 等插件,构建 AST 语法树,抽出依赖,同时将 es6 代码转换为 es5 代码,并收集依赖。

  3. 递归寻找依赖的关系,生成依赖图。

  4. 将所有的文件打包成一个文件。

实现部分

搭建项目

npm init -y

接下来分析一下我们要用到的依赖包

  • tapable:webpack 插件的核心,它提供了一种插件机制,允许开发者在 webpack 构建过程中注入自定义逻辑,以满足特定的需求。

  • @babel/parser:babel 提供的 Javascript 代码解析器。它可以将 Javascript 代码转换为 ast(抽象语法树),方便后续的代码处理和转换。

  • @babel/traverse:用于对输入的抽象语法树(ast)进行遍历。

  • @babel/core:babel 的核心库,它提供了 babel 的编译器和 API 接口,用于将源代码转换为目标代码。

  • @babel/preset-env:可根据配置的目标浏览器或者运行环境来自动将 ES6 + 的代码转换为 ES5。

  • ejs: 用于生成模板代码或者读取模板文件。

npm install @babel/parser @babel/traverse @babel/core @babel/preset-env ejs tapable --save-dev

让我们先来看一下项目结构

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

这里解释一下 webpack 配置文件结尾为什么要用 cjs 结尾,是因为我将 examples/package.jsontype 字段的值改为了module,在 examples 目录下的所有 Js 文件都要使用 esm 规范,但为了模拟配置文件中 require 导入,于是将结尾改成了 cjs。在该文件下可以使用 cjs 规范。

webpack.config.cjs

const { webpack } = require('../lib/webpack.js');
const HtmlWebpackPlugin = require('./plugins/html-webpack-plugin.cjs');
const jsonLoader = require('./loaders/json-loader.cjs');

const path = require('path');

const config = {
  entry: path.resolve(__dirname, './src/main.js'),
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.json$/,
        use: jsonLoader,
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'template.html'
    }),
  ]
} 

webpack(config);

解析配置

首先思考,我们已经定义了配置项,是不是要把配置项传入我们的 Compiler 类,在生成实例的时候,初始化参数,将一些关键的配置挂载在实例上,方便使用。

让我们来看一下两段代码。

const { Compiler } = require('./Compiler.js');

function webpack(config) {
  const compiler = new Compiler(config);

  compiler.run();
}

module.exports = {
  webpack,
}
class Compiler {
  constructor(config) {
    this.entry = config.entry;
    this.output = config.output;
    this.module = config.module;
    this.plugins = config.plugins;
    ...
    
    this.initPlugins();
  }
  
  run() {
    ...
    this.hooks.make.callAsync(compilation, () => {
      console.log('make 钩子')
    });
    ...
  }
  ...
}

如解析入口文件,获取 AST。

const entryModule = compilation.buildModule(this.entry);
buildModule(filename) {
  let sourceCode = fs.readFileSync(filename, {
    encoding: 'utf-8'
  });
  ...
}
const ast = parseBabel.parse(sourceCode, {
  sourceType: 'module',
});

源码转为 ast,抽离依赖,并转换代码

这里我们创建一个 utils 文件,文件里面定义一个 parse 函数,包含使用 @babel/parser 将源码转 ast,使用 @babel/traverse对 ast 历,将依赖抽离,存储在数组中,以及使用 babel-core 和 babel-preset-env 将 ES6+ 转换为 ES5。

utils.js

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


function parse(sourceCode) {

  const dependencies = [];

  const ast = parseBabel.parse(sourceCode, {
    sourceType: 'module',
  });

  traverse.default(ast, {
    ImportDeclaration({ node }) {
      dependencies.push(node.source.value);
    },

    CallExpression ({ node }) {
      if (node.callee.name === 'require') {
        dependencies.push(node.arguments[0].value)
      }
    }
  })

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

  return {
    code,
    dependencies,
  }
}

module.exports = {
  parse
}

这段代码大概的逻辑大概分为三个方面。

  • 首先运用 @babel/parse 生成 ast,这里的 sourceType 的值必须要为 module,只有这样才能解析 esm 模块。

  • 接下来用 @babel/traverse 则用来遍历和修改节点,有一个问题,我们如何从文件内容分析我们 import 引入了哪些模块。上面已经说到了源码已经被转为了 ast,ast 是源代码的一种抽象表达形式,使用树结构来表示代码的语法结构,在 ast 中,每个节点代表一种语句、表达式或声明等。我们思考一下,我们只需要找到表示 import 的节点类型,取到具体的导入内容,有一个网站,可以帮助我们分析 ast 的语法结构,AST explorer

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

由此可见 source 属性中的 value 就是我们要找的依赖路径。使用一个数组将该源码下的所有 import 的路径全部收集到。

上述代码也做了一个兼容,也支持收集使用 require 的引入路径。来看一下使用 require 的 ast 语法树。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

遍历 CallExpression 节点类型,同时可看出,require 的文件引入路径是 arguments[0] 中的 value。

CallExpression ({ node }) {
  if (node.callee.name === 'require') {
    dependencies.push(node.arguments[0].value)
  }
}
  • 最后就是转换我们的 es6 代码,transformFromAst 方法是 babel 提供的一个方法,用于将 ast 抽象语法树)转换为代码。它接受两个参数:第一个参数是待转换的 ast,第二个参数是一组 babel 插件和插件选项。它会按照插件列表中指定的顺序,对 ast 进行生成转换后的代码。使用 bable-preset-env 插件不仅能将 es6 转为 es5,同时能将 import 转为 require,这是一个知识点,后续会创建一个私有函数 require,围绕这个点进行展开,让我们来看一下被转换的代码。

源代码

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

转换后的代码

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

构建依赖图谱

这里先展示封装的一个插件。

EntryPlugin.js

const path = require('path');

class EntryPlugin {
  constructor({ entry }) {
    this.entry = entry;
  }

  apply(compiler) {
    compiler.hooks.make.tapAsync('EntryPlugin', (compilation, callback) => {
      const moduleQueue = [];
      const visitedAsset = {};

      const entryModule = compilation.buildModule(this.entry);
      moduleQueue.push(entryModule);

      for(let i = 0; i < moduleQueue.length; i++) {
        const module = moduleQueue[i];

        module.dependencies.forEach(dependency => {

          const childDependency = path.resolve(path.dirname(this.entry), dependency);

          if(visitedAsset[childDependency]) {
            const sameModule = moduleQueue.find((item) => item.filename === childDependency);
            module.mapping[dependency] = sameModule.id;
          } else {
            const childModule = compilation.buildModule(childDependency);
            module.mapping[dependency] = childModule.id;
            visitedAsset[childDependency] = childModule.id;
            moduleQueue.push(childModule);
          }
        });
      }

      compilation.graph = moduleQueue;

      callback();
    })
  }
}

module.exports = EntryPlugin;

这个插件也是模仿 webpack 源码中的设计。当初始化 compiler 实例,在注册开发者引入插件的同时,也会注册 webpack 本身的插件。当触发对应的钩子,如 make 钩子,则会开始构建依赖。

  initPlugins() {
    const compiler = this;

    if(Array.isArray(this.plugins)) {
      this.plugins.forEach(plugin => {
        if(typeof plugin === 'function') {
          plugin.call(compiler, compiler);
        } else {
          plugin.apply(compiler);
        }
      });

      new EntryPlugin({
        entry: this.entry,
      }).apply(compiler);
    }
  }

当我们使用入口路径(this.entry)来构建 entryModule,我们接下来要寻找 entryModule 的依赖。entryModule 的依赖有三个,以此类推,我们构建完 fooModule,再寻找 fooModule 的依赖,递归下去,直到找完所有的依赖,这样我们就能得知,webpack 的构建打包为什么相比于其它的构建工具,如 vite,无论冷启动还是热启动,都相对较慢,这是底层架构决定的。当全部构建完,我们就可以获取完整的依赖关系啦。

上述插件中的设计需要注意三点,

  • 首先我们 import 使用的是相对路径,module.dependencies 存储的路径,是相对于 entryModule 的路径,因此我们要转换成绝对路径。

  • 关于 mapping 对象,此对象存储了该模块引入路径与依赖的 id 的映射关系。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

  • 关于缓存相同依赖的问题,当不同文件有相同的引入路径,代表此前有一个模块已被构建过,可直接将该模块代表 id 映射到这个引入路径上。如 1 模块和 2 模块引入了相同的 4 模块,此时没有为 2 模块的 ../src/bar.js 重新构建依赖,直接将前一个已经构建好的 4 模块映射到 2 模块上。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

构造模板文件,生成 bundle

先看一下模板文件的代码。

template.ejs

;(function(modules) {

  let cache = {};

  function require(id) {

    const [fn, mapping] = modules[id];

    if(cache[id]) {
      return cache[id].exports;
    }

    const module = {
      exports: {},
    }

    // 相对路径转为模块 id
    function localRequire(filePath) {
      const id = mapping[filePath];
      return require(id);
    }

    cache[id] = module;

    // fn 内部模块被赋值
    fn(localRequire, module, module.exports)

    // 模块导出
    return module.exports;
  }

  require(0);
})({
  <% Object.keys(data).forEach((id) => { %>
    <%= id %>: [function(require, module, exports) {
      <%- data[id].code %>
    }, <%- JSON.stringify(data[id].mapping) %>],
  <% }); %>
})

乍一看,代码有些复杂,且听我缓缓道来。上文我们可以知道,import 已经被转换成 require,但是浏览器是不识别require,exports,module 的,因此我们在立即执行函数中,重新定义了一个私有函数 require,类似于 webpack 的 __webpack_require__函数。因此我们需要一个函数包裹我们编译后的代码,提供入参,将编译后的代码作为这个函数的函数体去执行。上述代码中有一个点要注意,在前文中,我们已处理了缓存相同依赖的问题,因此在这里如有相同的映射,则直接可以将缓存抛出去。

if(cache[id]) {
  return cache[id].exports;
}

以下面这张图举例,我们之前在 mapping 对象中填入路径与模块的关系,作用就体现出来了,通过路径来映射另一个模块。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

这里解释一下 webpack 的实现,webpack 是使用文件路径作为模块的表示,是一个 (key, value)形式,key 是路径,value 是执行的代码,这里就不需要多一个 mapping 对象存储映射和 localRequire 函数了,更加直观,不过这样的一个麻烦之处在于,要统一文件路径,如 require 的引入是相对路径,而 key 经过 path.resolve 转换,变成了绝对路径,因此是取不到的,类似于这种情况,引用一下崔大直播课的代码,链接

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

所以要做路径统一,想了解 webpack 怎么实现的小伙伴可以去扒一下源码看看。

嵌入 loader

loader 在前文中也说到了,由于 webpack 只识别 Js 和 Json,想识别 css,图片,字体等,都需要 loader 的支持。开发 loader 也简单,它就是一个输入输出的函数,我们要明白一个原理,loader 像是管道,将源码塞进去,通过一根根不同的管道,将它转为另一种 webpack 识别的形式。我们开发最简单的一个 json-loader 来举个例子。

function jsonLoader(source) {

  this.addDeps("jsonLoader");

  const value = typeof source === "string" ? JSON.stringify(source) : source;

  return `export default ${value}`;
}

让我们看一下,mini webpack 是如何处理 loader 的。

  buildModule(filename) {
    let sourceCode = fs.readFileSync(filename, {
      encoding: 'utf-8'
    });

    if(Array.isArray(this.loaders)) {
      this.loaders.forEach((loader) => {
        const { test, use } = loader;

        const loaderContext = {
          addDeps(dep) {
            console.log('addDeps', dep);
          },
        }

        if(test.test(filename)) {
          if(Array.isArray(use)) {
            use.traverse().forEach((fn) => {
              sourceCode = fn.call(loaderContext, sourceCode)
            })
          } else {
            sourceCode = use.call(loaderContext, sourceCode);
          }
        }
      });
    }

    ...
  }

上边代码有两个需要注意的点,因为如果对一种后缀配置多个 loader,如 css,数组里面的 loader 是从后往前执行的,因此用 traverse 翻转一下数组顺序。而由于源码被这个 loader 处理,也要被下个 loader 处理,因此 sourceCode 会有变化,用 let 定义。

嵌入 plugin

上面讲了如何实现 Js 模块化打包,接下来讲一下如何实现插件体系,提起插件,自然少不了 tapable。在mini webpack 的不同编译时期,我们只需要触发不同的 tapable 钩子函数,注册的插件自然会在这个阶段执行,来改变打包结果。

class Compiler {
  constructor(config) {
    ...

    this.hooks = {
      compilation: new SyncHook(['compilation']),
      make: new AsyncParallelHook(['compilation']),
      emit: new AsyncSeriesHook(['compilation']),
      afterEmit: new AsyncSeriesHook(['compilation']),
    }

    this.initPlugins();
  }

  initPlugins() {
    const compiler = this;

    if(Array.isArray(this.plugins)) {
      this.plugins.forEach(plugin => {
        if(typeof plugin === 'function') {
          plugin.call(compiler, compiler);
        } else {
          plugin.apply(compiler);
        }
      });

      new EntryPlugin({
        entry: this.entry,
      }).apply(compiler);
    }
  }

  run() {
    const compilation = new Compilation({
      module: this.module,
      output: this.output,
    });

    this.hooks.compilation.call(compilation);

    this.hooks.make.callAsync(compilation, () => {
      console.log('make 钩子')
    });

    this.hooks.emit.callAsync(compilation, () => {
      console.log('emit 钩子');
    });

    this.emitAssets(compilation);

    this.hooks.afterEmit.callAsync(compilation, () => {
      console.log('afterEmit 钩子');
    })
  }

  emitAssets(compilation) {
    ...
  }
}

上述代码就是在初始化 compiler 时期,注册了所有的插件,在相应的编译时期触发,如我们想实现一个 mini 版的 HtmlWebpackPlugin 插件,在打包的过程中,讲 打包后的 bundle 引入到一个 html 文件上,这个 html 文件名称和 title 由我们定制。来看一下代码。

class HtmlWebpackPlugin {
  constructor(options) {
    const { filename, title } = options || {};
    this.filename = filename || 'index.html';
    this.title = title || '';
  }

  apply(compiler) {
    compiler.hooks.emit.tapAsync('HtmlWebpackPlugin', (compilation, callback) => {
      const templatePath = path.resolve(__dirname, './htmlTemplate.ejs');
      const outputPath = path.join(compilation.output.path, this.filename);
      const htmlOptions = {
        title: this.title,
        outputFilename: compilation.output.filename,
      };

      const template = fs.readFileSync(templatePath, {
        encoding: 'utf-8',
      });

      const code = ejs.render(template, {
        data: htmlOptions
      });

      fs.writeFileSync(outputPath, code);
      callback();
    })
  }
}

大概逻辑就是,使用 make 钩子注册插件,在 make 钩子被触发的时候,compilation 实例已被创建好,我们可以在插件中拿到 compilation 实例。使用 compilation 的属性来达到我们为您的目的,来看看效果。

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了

总结

笔者在实现 mini webapck 的时候,也想尽量实现的更加模块化,规范化,目录的划分也参照了 webpack 源码的组织结构,如创建了 Compiler.js,Compilation.js,EntryPlugin 文件,如在 Compiler.js 中读取配置,注册插件和创建 Compilation 对象,以及启动编译,使用 EntryPlugin 插件根据入口文件和依赖关系图谱,递归解析模块,而在 Compilation.js 中,则会使用 loader 处理不同模块,以及对代码进行转换。整体架构还是比较仿真的。

代码在这里,欢迎点个 star,感谢!!!

github地址

五千字长文,带你手写一个 mini webpack,再也不怕面试官问你 webpack 原理了