深入理解 Webpack 核心机制与编译流程

🤖 作者简介:水煮白菜王,一位前端劝退师 👻

👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧和知识归纳总结✍。

感谢支持💕💕💕

目录

Webpack核心机制

Webpack 本质上是一个高度可配置且可扩展的模块捆绑器,它采用了一种基于事件流的编程范例。Webpack 的运作依赖于一系列插件来完成各种任务,从简单的文件转换到复杂的构建优化。

Webpack 主要使用 Compiler 和 Compilation 两个类来控制整个生命周期。它们都继承自 Tapable 并利用它注册构建过程中的各个阶段所需触发的事件。

Tapable:Webpack 插件系统的"心脏"

Tapable 是一个类似于 Node.js 的 EventEmitter 的库,主要用于管理钩子函数的发布与订阅。在 Webpack 的插件系统中,Tapable 扮演着核心调度者的角色。

Tapabel提供的钩子及示例

Tapable 提供了多种类型的钩子(Hook)以便挂载,适用于不同的执行场景(同步 / 异步、串行 / 并发、是否支持熔断等):

javascript 复制代码
const {
  SyncHook,                   // 同步钩子:依次执行所有订阅者
  SyncBailHook,               // 同步熔断钩子:一旦某个订阅者返回非 undefined 值则停止执行
  SyncWaterfallHook,          // 同步流水钩子:前一个订阅者的返回值作为参数传给下一个
  SyncLoopHook,               // 同步循环钩子:重复执行订阅者直到返回 undefined
  AsyncParallelHook,          // 异步并发钩子:并行执行所有订阅者(不关心顺序)
  AsyncParallelBailHook,      // 异步并发熔断钩子:任意一个订阅者返回非 undefined 则立即结束
  AsyncSeriesHook,            // 异步串行钩子:按顺序依次执行每个订阅者
  AsyncSeriesBailHook,        // 异步串行熔断钩子:同 SyncBailHook,但为异步模式
  AsyncSeriesWaterfallHook    // 异步串行流水钩子:同 SyncWaterfallHook,但为异步模式
} = require("tapable");

Tabpack 提供了同步&异步绑定钩子的方法对比如下:

类型 绑定方法 执行方法
同步 (Sync) .tap(name, fn) .call(args...)
异步 (Async) .apAsync(name, fn) /.tapPromise(name, fn) .callAsync(args..., cb) / .promise(args...)

Tabpack 同步简单示例:

javascript 复制代码
const { SyncHook } = require("tapable");

// 创建一个带有三个参数的同步钩子
const demohook = new SyncHook(["arg1", "arg2", "arg3"]);

// 注册监听函数 绑定事件到webpack事件流
demohook.tap("hook1", (arg1, arg2, arg3) => {
  console.log("接收到参数:", arg1, arg2, arg3);
});

// 触发钩子 执行绑定的事件
demohook.call(1, 2, 3);
// 输出: 接收到参数:1 2 3

源码解读

  1. 初始化启动之Webpack的入口文件
    ● 追本溯源,第一步我们要找到Webpack的入口文件。
    ● 当通过命令行启动Webpack后,npm会让命令行工具进入node_modules.bin 目录。
    ● 然后查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在,就执行它们,不存在就会抛出错误。
    ● 实际的入口文件是:node_modules/webpack/bin/webpack.js,让我们来看一下里面的核心函数。
javascript 复制代码
// node_modules/webpack/bin/webpack.js
// 正常执行返回
process.exitCode = 0;    
// 运行某个命令                               
const runCommand = (command, args) => {...}
// 判断某个包是否安装
const isInstalled = packageName => {...}
// webpack可用的CLI:webpacl-cli和webpack-command
const CLIs = {...}
// 判断是否两个CLI是否安装了
const installedClis = CLIs.filter(cli=>cli.installed);
// 根据安装数量进行处理
if (installedClis.length === 0) {...} else if 
  (installedClis.length === 1) {...} else {...}

启动后,Webpack最终会找到 webpack-cli /webpack-command的 npm 包,并且 执行 CLI。

  1. webpack-cli
    搞清楚了Webpack启动的入口文件后,接下来让我们把目光转移到webpack-cli,看看它做了哪些动作。
    ● 引入 yargs,对命令行进行定制分析命令行参数,对各个参数进行转换,组成编译配置项引用webpack,根据配置项进行编译和构建
    ● webpack-cli 会处理不需要经过编译的命令。
javascript 复制代码
// node_modules/webpack-cli/bin/cli.js
const {NON_COMPILATION_ARGS} = require("./utils/constants");
const NON_COMPILATION_CMD = process.argv.find(arg => {
  if (arg === "serve") {
    global.process.argv = global.process.argv.filter(a => a !== "serve");
    process.argv = global.process.argv;
  }
  return NON_COMPILATION_ARGS.find(a => a === arg);
});
if (NON_COMPILATION_CMD) {
  return require("./utils/prompt-command")(NON_COMPILATION_CMD,...process.argv);
}

webpack-cli提供的不需要编译的命令如下

javascript 复制代码
// node_modules/webpack-cli/bin/untils/constants.js
const NON_COMPILATION_ARGS = [
  "init",                 // 创建一份webpack配置文件
  "migrate",              // 进行webpack版本迁移
  "add",                  // 往webpack配置文件中增加属性
  "remove",               // 往webpack配置文件中删除属性
  "serve",                // 运行webpack-serve
  "generate-loader",      // 生成webpack loader代码
  "generate-plugin",      // 生成webpack plugin代码
  "info"                  // 返回与本地环境相关的一些信息
];

webpack-cli 使用命令行工具包yargs

javascript 复制代码
// node_modules/webpack-cli/bin/config/config-yargs.js
const {
  CONFIG_GROUP,
  BASIC_GROUP,
  MODULE_GROUP,
  OUTPUT_GROUP,
  ADVANCED_GROUP,
  RESOLVE_GROUP,
  OPTIMIZE_GROUP,
  DISPLAY_GROUP
} = GROUPS;

● webpack-cli对配置文件和命令行参数进行转换最终生成配置选项参数 options,最终会根据配置参数实例化webpack对象,然后执行构建流程。

● 除此之外,让我们回到node_modules/webpack/lib/webpack.js里来看一下Webpack还做了哪些准备工作。

javascript 复制代码
// node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {
  ...
  options = new WebpackOptionsDefaulter().process(options);
  compiler = new Compiler(options.context);
  new NodeEnvironmentPlugin().apply(compiler);
  ...
  compiler.options = new WebpackOptionsApply().process(options, compiler);
  ...
  webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter;
  webpack.WebpackOptionsApply = WebpackOptionsApply;
  ...
  webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin;
}

WebpackOptionsDefaulter的功能是设置一些默认的Options(代码比较多,可自行查看node_modules/webpack/lib/WebpackOptionsDefaulter.js)

javascript 复制代码
// node_modules/webpack/lib/node/NodeEnvironmentPlugin.js
class NodeEnvironmentPlugin {
  apply(compiler) {
    ...		
    compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
      if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
    });
  }
}

从上面的代码我们可以知道,NodeEnvironmentPlugin插件监听了beforeRun钩子,它的作用是清除缓存。

  1. WebpackOptionsApply
    WebpackOptionsApply会将所有的配置options参数转换成webpack内部插件。
    使用默认插件列表:
    ● output.library -> LibraryTemplatePlugin
    ● externals -> ExternalsPlugin
    ● devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin
    ● AMDPlugin, CommonJsPlugin
    ● RemoveEmptyChunksPlugin
javascript 复制代码
// node_modules/webpack/lib/WebpackOptionsApply.js
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
  1. EntryOptionPlugin
    下来让我们进入EntryOptionPlugin插件,看看它做了哪些动作。
javascript 复制代码
// node_modules/webpack/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {
  apply(compiler) {
    compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
      if (typeof entry === "string" || Array.isArray(entry)) {
        itemToPlugin(context, entry, "main").apply(compiler);
      } else if (typeof entry === "object") {
        for (const name of Object.keys(entry)) {
          itemToPlugin(context, entry[name], name).apply(compiler);
        }
      } else if (typeof entry === "function") {
        new DynamicEntryPlugin(context, entry).apply(compiler);
      }
      return true;
    });
  }
};

● 如果是数组,则转换成多个entry来处理,如果是对象则转换成一个个entry来处理。

● compiler实例化是在node_modules/webpack/lib/webpack.js里完成的。通过EntryOptionPlugin插件进行参数校验。通过WebpackOptionsDefaulter将传入的参数和默认参数进行合并成为新的options,创建compiler,以及相关plugin,最后通过

● WebpackOptionsApply将所有的配置options参数转换成Webpack内部插件。

● 再次来到我们的node_modules/webpack/lib/webpack.js中

javascript 复制代码
if (options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
  const watchOptions = Array.isArray(options)
    ? options.map(o => o.watchOptions || {})
    : options.watchOptions || {};
  return compiler.watch(watchOptions, callback);
}
compiler.run(callback);

实例compiler后会根据options的watch判断是否启动了watch,如果启动watch了就调用compiler.watch来监控构建文件,否则启动compiler.run来构建文件。

编译构建

compile

首先会实例化NormalModuleFactory和ContextModuleFactory。然后进入到run方法。

javascript 复制代码
// node_modules/webpack/lib/Compiler.js
run(callback) { 
  ...
  // beforeRun 如上文NodeEnvironmentPlugin插件清除缓存
  this.hooks.beforeRun.callAsync(this, err => {
    if (err) return finalCallback(err);
    // 执行run Hook开始编译
    this.hooks.run.callAsync(this, err => {
      if (err) return finalCallback(err);
      this.readRecords(err => {
        if (err) return finalCallback(err);
        // 执行compile
        this.compile(onCompiled);
      });
    });
  });
}

在执行this.hooks.compile之前会执行this.hooks.beforeCompile,来对编译之前需要处理的插件进行执行。紧接着this.hooks.compile执行后会实例化Compilation对象

javascript 复制代码
// node_modules/webpack/lib/compiler.js
compile(callback) {
  const params = this.newCompilationParams();
  this.hooks.beforeCompile.callAsync(params, err => {
    if (err) return callback(err);
    // 进入compile阶段
    this.hooks.compile.call(params);
    const compilation = this.newCompilation(params);
    // 进入make阶段
    this.hooks.make.callAsync(compilation, err => {
      if (err) return callback(err);
      compilation.finish(err => {
        if (err) return callback(err);
        // 进入seal阶段
        compilation.seal(err => {
          if (err) return callback(err);
          this.hooks.afterCompile.callAsync(compilation, err => {
            if (err) return callback(err);
            return callback(null, compilation);
          })
        })
      })
    })
  })
}

make

● 一个新的Compilation创建完毕,将从Entry开始读取文件,根据文件类型和配置的Loader对文件进行编译,编译完成后再找出该文件依赖的文件,递归的编译和解析。

● 我们来看一下make钩子被监听的地方。

● 如代码中注释所示,addEntry是make构建阶段真正开始的标志

javascript 复制代码
// node_modules/webpack/lib/SingleEntryPlugin.js
compiler.hooks.make.tapAsync(
  "SingleEntryPlugin",
  (compilation, callback) => {
    const { entry, name, context } = this;
    cosnt dep = SingleEntryPlugin.createDependency(entry, name);
    // make构建阶段开始标志 
    compilation.addEntry(context, dep, name, callback);
  }
)

addEntry实际上调用了_addModuleChain方法,_addModuleChain方法将模块添加到依赖列表中去,同时进行模块构建。构建时会执行如下函数:

javascript 复制代码
// node_modules/webpack/lib/Compilation.js
// addEntry -> addModuleChain
_addModuleChain(context, dependency, onModule, callback) {
  ...
  this.buildModule(module, false, null, null, err => {
    ...
  })
    ...
    }

如果模块构建完成,会触发finishModules。

javascript 复制代码
// node_modules/webpack/lib/Compilation.js
finish(callback) {
  const modules = this.modules;
  this.hooks.finishModules.callAsync(modules, err => {
    if (err) return callback(err);
    for (let index = 0; index < modules.length; index++) {
      const module = modules[index];			
      this.reportDependencyErrorsAndWarnings(module, [module]);
    }
    callback();
  })
}
1. Module

● Module包括NormalModule(普通模块)、ContextModule(./src/a ./src/b)、ExternalModule(module.exports=jQuery)、DelegatedModule(manifest)以及MultiModule(entry:['a', 'b'])。

● 本文以NormalModule(普通模块)为例子,看一下构建(Compilation)的过程。

使用 loader-runner 运行 loadersLoader转换完后,使用 acorn 解析生成AST使用 ParserPlugins 添加依赖

2. loader-runner
javascript 复制代码
// node_modules/webpack/lib/NormalModule.js
const { getContext, runLoaders } = require("loader-runner");
doBuild(){
  ...
  runLoaders(
    ...
  )
    ...
    }
...
try {
  const result = this.parser.parse()
}

doBuild会去加载资源,doBuild中会传入资源路径和插件资源去调用loader-runner插件的runLoaders方法去加载和执行loader

3. acorn
javascript 复制代码
// node_modules/webpack/lib/Parser.jsconst acorn = require("acorn");

使用acorn解析转换后的内容,输出对应的抽象语法树(AST)。

javascript 复制代码
// node_modules/webpack/lib/Compilation.js
this.hooks.buildModule.call(module);
...
if (error) {
  this.hooks.failedModule.call(module, error);
  return callback(error);
}
this.hooks.succeedModule.call(module);
return callback();

● 成功就触发succeedModule,失败就触发failedModule。

● 最终将上述阶段生成的产物存放到Compilation.js的this.modules = [];上。

完成后就到了seal阶段。

这里补充介绍一下Chunk生成的算法

4. Chunk生成算法

● webpack首先会将entry中对应的module都生成一个新的chunk。

● 遍历module的依赖列表,将依赖的module也加入到chunk中。

● 如果一个依赖module是动态引入的模块,会根据这个module创建一个新的chunk,继续遍历依赖。

● 重复上面的过程,直至得到所有的chunk。

eal

● 所有模块及其依赖的模块都通过Loader转换完成,根据依赖关系开始生成Chunk。

● seal阶段也做了大量的的优化工作,进行了hash的创建以及对内容进行生成(createModuleAssets)。

javascript 复制代码
// node_modules/webpack/lib/Compilation.jsthis.createHash();
this.modifyHash();
this.createModuleAssets();
javascript 复制代码
// node_modules/webpack/lib/Compilation.js
createModuleAssets(){
  for (let i = 0; i < this.modules.length; i++) {
    const module = this.modules[i];
    if (module.buildInfo.assets) {
      for (const assetName of Object.keys(module.buildInfo.assets)) {
        const fileName = this.getPath(assetName);
        this.assets[fileName] = module.buildInfo.assets[assetName];
        this.hooks.moduleAsset.call(module, fileName);
      }
    }
  }
}

seal阶段经历了很多的优化,比如tree shaking就是在这个阶段执行。最终生成的代码会存放在Compilation的assets属性上

emit

将输出的内容输出到磁盘,创建目录生成文件,文件生成阶段结束。

javascript 复制代码
// node_modules/webpack/lib/compiler.js
this.hooks.emit.callAsync(compilation, err => {
  if (err) return callback(err);
  outputPath = compilation.getPath(this.outputPath);
  this.outputFileSystem.mkdirp(outputPath, emitFiles);
})

总结

Webpack在启动阶段对配置参数和命令行参数以及默认参数进行了合并,并进行了插件的初始化工作。完成初始化的工作后调用Compiler的run开启Webpack编译构建过程,构建主要流程包括compile、make、build、seal、emit等阶段。

Webpack打包机制

webpack是一个打包模块化 JavaScript 的工具,在 webpack里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件。webpack专注于构建模块化项目。

简单版打包模型步骤

从简单的入手看,当 webpack 的配置只有一个出口时,不考虑分包的情况,其实我们只得到了一个bundle.js的文件,这个文件里包含了我们所有用到的js模块,可以直接被加载执行。那么,我可以分析一下它的打包思路,大概有以下4步:

  • 利用babel完成代码转换及解析,并生成单个文件的依赖模块Map
  • 从入口开始递归分析,并生成整个项目的依赖图谱
  • 将各个引用模块打包为一个立即执行函数
  • 将最终的bundle文件写入bundle.js

单个文件的依赖模块Map

我们会可以使用这几个包:

  • @babel/parser:负责将代码解析为抽象语法树
  • @babel/traverse:遍历抽象语法树的工具,我们可以在语法树中解析特定的节点,然后做一些操作,如ImportDeclaration获取通过import引入的模块,FunctionDeclaration获取函数
  • @babel/core:代码转换,如ES6的代码转为ES5的模式

由这几个模块的作用,其实已经可以推断出应该怎样获取单个文件的依赖模块了,转为Ast->遍历Ast->调用ImportDeclaration。代码如下:

javascript 复制代码
// exportDependencies.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

const exportDependencies = (filename)=>{
  const content = fs.readFileSync(filename,'utf-8')
  // 转为Ast
  const ast = parser.parse(content, {
    sourceType : 'module'//babel官方规定必须加这个参数,不然无法识别ES Module
  })

  const dependencies = {}
  //遍历AST抽象语法树
  traverse(ast, {
    //调用ImportDeclaration获取通过import引入的模块
    ImportDeclaration({node}){
      const dirname = path.dirname(filename)
      const newFile = './' + path.join(dirname, node.source.value)
      //保存所依赖的模块
      dependencies[node.source.value] = newFile
    }
  })
  //通过@babel/core和@babel/preset-env进行代码的转换
  const {code} = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
  })
  return{
    filename,//该文件名
    dependencies,//该文件所依赖的模块集合(键值对存储)
    code//转换后的代码
  }
}
module.exports = exportDependencies

试跑工作:

javascript 复制代码
//info.js
const a = 1
export a
// index.js
import info from'./info.js'
console.log(info)

//testExport.js
const exportDependencies = require('./exportDependencies')
console.log(exportDependencies('./src/index.js'))

单个文件的依赖模块Map

有了获取单个文件依赖的基础,我们就可以在这基础上,进一步得出整个项目的模块依赖图谱了。首先,从入口开始计算,得到entryMap,然后遍历entryMap.dependencies,取出其value(即依赖的模块的路径),然后再获取这个依赖模块的依赖图谱,以此类推递归下去即可,代码如下:

javascript 复制代码
const exportDependencies = require('./exportDependencies')

//entry为入口文件路径
const exportGraph = (entry)=>{
  const entryModule = exportDependencies(entry)
  const graphArray = [entryModule]
  for(let i = 0; i < graphArray.length; i++){
    const item = graphArray[i];
    //拿到文件所依赖的模块集合,dependencies的值参考exportDependencies
    const { dependencies } = item;
    for(let j in dependencies){
      graphArray.push(
        exportDependencies(dependencies[j])
      )//关键代码,目的是将入口模块及其所有相关的模块放入数组
    }
  }
  //接下来生成图谱
  const graph = {}
  graphArray.forEach(item => {
    graph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code
    }
  })
  //可以看出,graph其实是 文件路径名:文件内容 的集合
  return graph
}
module.exports = exportGraph

输出立即执行函数

首先,我们的代码被加载到页面中的时候,是需要立即执行的。所以输出的bundle.js实质上要是一个立即执行函数。我们主要注意以下几点:

  • 我们写模块的时候,用的是 import/export.经转换后,变成了require/exports
  • 我们要让require/exports能正常运行,那么我们得定义这两个东西,并加到bundle.js里
  • 在依赖图谱里,代码都成了字符串。要执行,可以使用eval

因此,我们要做这些工作:

  • 定义一个require函数,require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
  • 获取整个项目的依赖图谱,从入口开始,调用require方法。完整代码如下:
javascript 复制代码
const exportGraph = require('./exportGraph')
// 写入文件,可以用fs.writeFileSync等方法,写入到output.path中
const exportBundle = require('./exportBundle')

const exportCode = (entry)=>{
  //要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object]
  const graph = JSON.stringify(exportGraph(entry))
  exportBundle(`
        (function(graph) {
            //require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
            function require(module) {
                //localRequire的本质是拿到依赖包的exports变量
                function localRequire(relativePath) {
                    return require(graph[module].dependencies[relativePath]);
                }
                var exports = {};
                (function(require, exports, code) {
                    eval(code);
                })(localRequire, exports, graph[module].code);
                return exports;
                //函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
            }
            require('${entry}')
        })(${graph})`)
}
module.exports = exportCode

至此,简单打包完成,跑出结果。bundle.js的文件内容为:

javascript 复制代码
(function(graph) {
  //require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
  function require(module) {
    //localRequire的本质是拿到依赖包的exports变量
    function localRequire(relativePath) {
      returnrequire(graph[module].dependencies[relativePath]);
    }
    var exports = {};
    (function(require, exports, code) {
      eval(code);
    })(localRequire, exports, graph[module].code);
    return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
  }
  require('./src/index.js')
})({"./src/index.js":{"dependencies":{"./info.js":"./src/info.js"},"code":"\"use strict\";\n\nvar _info = _interopRequireDefault(require(\"./info.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_info[\"default\"]);"},"./src/info.js":{"dependencies":{"./name.js":"./src/name.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\nvar _name = require(\"./name.js\");\n\nvar info = \"\".concat(_name.name, \" is beautiful\");\nvar _default = info;\nexports[\"default\"] = _default;"},"./src/name.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.name = void 0;\nvar name = 'winty';\nexports.name = name;"}})

webpack打包流程概括

输出 编译 初始化 输出资源 输出完成 开始编译 确定入口 编译模块 完成模块编译 初始化参数

webpack的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  • 初始化参数:Webpack启动时,依据命令行参数和配置文件设置编译所需的各项基本参数,确保准备好开始构建。
  • 开始编译: 用上一步得到的参数初始Compiler对象,加载所有配置的插件,通 过执行对象的run方法开始执行编译
  • 确定入口: 根据配置中的 Entry 找出所有入口文件
  • 编译模块: 从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  • 完成模块编译: 在经过第4步使用 Loader 翻译完所有模块后, 得到了每个模块被编译后的最终内容及它们之间的依赖关系
  • 输出资源 :根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再将每个 Chunk 转换成一个单独的文件加入输出列表中,这是可以修改输出内容的最后机会
  • 输出完成: 在确定好输出内容后,根据配置确定输出的路径和文件名,将文件的内容写入文件系统中。

在以上过程中, Webpack 会在特定的时间点广播特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,井且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。其实以上7个步骤,可以简单归纳为初始化、编译、输出,三个过程,而这个过程其实就是前面说的基本模型的扩展。

实现一个丐版Webpack

该工具可以实现以下两个功能

● 将 ES6 转换为 ES5

● 支持在 JS 文件中 import CSS 文件

通过这个工具的实现,可以更好地理解打包工具背后的运行原理。

开始

由于需要将 ES6 转换为 ES5,我们首先需要安装一些 Babel 相关的依赖包:

javascript 复制代码
yarn add babylon babel-traverse babel-core babel-preset-env

接下来我们将这些工具引入文件中

javascript 复制代码
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')

第一步:首先,实现如何使用 Babel 解析并转换代码

javascript 复制代码
function readCode(filePath) {
  // 读取文件内容
  const content = fs.readFileSync(filePath, 'utf-8')
  // 生成 AST
  const ast = babylon.parse(content, {
    sourceType: 'module'
  })
  // 寻找当前文件的依赖关系
  const dependencies = []
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value)
    }
  })
  // 通过 AST 将代码转为 ES5
  const { code } = transformFromAst(ast, null, {
    presets: ['env']
  })
  return {
    filePath,
    dependencies,
    code
  }
}

● 首先我们传入一个文件路径参数,通过 fs 模块读取其内容。

● 接下来我们通过 babylon 解析代码生成抽象语法树(AST),用于分析是否存在其他导入文件。

● 通过 babel-traverse 遍历 AST,提取出所有依赖路径。

● 通过 dependencies 来存储文件中的依赖,最终调用 transformFromAst 将 AST 转换为 ES5 代码。

● 最后函数返回了一个对象,对象中包含了当前文件路径、当前文件依赖和当前文件转换后的代码

接下来我们需要构建一个函数来处理整个依赖图谱,这个函数的功能有以下几点

● 调用 readCode 函数,传入入口文件

● 分析入口文件的依赖

● 识别 JS 和 CSS 文件

javascript 复制代码
function getDependencies(entry) {
  // 读取入口文件
  const entryObject = readCode(entry)
  const dependencies = [entryObject]
  // 遍历所有文件依赖关系
  for (const asset of dependencies) {
    // 获得文件目录
    const dirname = path.dirname(asset.filePath)
    // 遍历当前文件依赖关系
    asset.dependencies.forEach(relativePath => {
      // 获得绝对路径
      const absolutePath = path.join(dirname, relativePath)
      // CSS 文件逻辑就是将代码插入到 `style` 标签中
      if (/\.css$/.test(absolutePath)) {
        const content = fs.readFileSync(absolutePath, 'utf-8')
        const code = `
          const style = document.createElement('style')
          style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
          document.head.appendChild(style)
        `
        dependencies.push({
          filePath: absolutePath,
          relativePath,
          dependencies: [],
          code
        })
      } else {
        // JS 代码需要继续查找是否有依赖关系
        const child = readCode(absolutePath)
        child.relativePath = relativePath
        dependencies.push(child)
      }
    })
  }
  return dependencies
}

● 首先我们读取入口文件,然后创建一个数组,该数组的目的是存储代码中涉及到的所有文件

● 接下来我们遍历这个数组,一开始这个数组中只有入口文件,在遍历的过程中,如果入口文件有依赖其他的文件,那么就会被 push 到这个数组中

● 在遍历的过程中,我们先获得该文件对应的目录,然后遍历当前文件的依赖关系

● 在遍历当前文件依赖关系的过程中,首先生成依赖文件的绝对路径,然后判断当前文件是 CSS 文件还是 JS 文件

● 如果是 CSS 文件的话,我们就不能用 Babel 去编译了,只需要读取 CSS 文件中的代码,然后创建一个 <style> 标签,将代码插入进标签并且放入 head 中即可

● 如果是 JS 文件的话,我们还需要分析 JS 文件是否还有别的依赖关系

● 最后将读取文件后的对象 push 进数组中,此时已经获取一个包含所有依赖项的对象数组。

● 现在我们已经获取到了所有的依赖文件,接下来就是实现打包的功能了

第三步:打包依赖,模拟 CommonJS 运行环境

现在我们已经收集了完整的依赖图,下一步是将这些模块打包成一个可以在浏览器中运行的单文件。

javascript 复制代码
function bundle(dependencies, entry) {
  let modules = ''
  // 构建函数参数,生成的结构为
  // { './entry.js': function(module, exports, require) { 代码 } }
  dependencies.forEach(dep => {
    const filePath = dep.relativePath || entry
    modules += `'${filePath}': (
      function (module, exports, require) { ${dep.code} }
    ),`
  })
  // 构建 require 函数,目的是为了获取模块暴露出来的内容
  const result = `
    (function(modules) {
      function require(id) {
        const module = { exports : {} }
        modules[id](module, module.exports, require)
        return module.exports
      }
      require('${entry}')
    })({${modules}})
  `
  // 当生成的内容写入到文件中
  fs.writeFileSync('./bundle.js', result)
}

这段代码需要结合着 Babel 转换后的代码来看,这样大家就能理解为什么需要这样写了,

代码结构与 Babel 编译后的 CommonJS 代码相对应,目的是在浏览器端模拟模块化运行环境。

示例:Babel 转换后的代码如下

javascript 复制代码
// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {
  value: true
})
var a = 1
exports.default = a

Babel 将我们 ES6的模块化代码转换为了 CommonJS的代码,但是浏览器是不支持 CommonJS 的,所以如果这段代码需要在浏览器环境下运行的话,我们需要手动实现 CommonJS 相关的类似机制,这就是 bundle 函数做的大部分事情。bundle 函数正是为此而设计。

接下来我们再来逐行解析 bundle 函数

● 首先遍历所有依赖文件,构建出一个函数参数对象

● 对象的属性就是当前文件的相对路径,属性值是一个函数,函数体是当前文件下的代码,函数接受三个参数 module、exports、 require

○ module 参数对应 CommonJS 中的 module

○ exports 参数对应 CommonJS 中的 module.export

○ require 参数对应我们自己创建的 require 函数

● 接下来就是构造一个使用参数的函数了,函数做的事情很简单,就是内部创建一个 require函数,然后调用 require(entry),也就是 require('./entry.js'),这样就会从函数参数中找到 ./entry.js 对应的函数并执行,最后将导出的内容通过 module.export 的方式让外部获取到

● 最后再将打包出来的内容写入到单独的文件中

如果你对于上面的实现还有疑惑的话,可以阅读下打包后的部分简化代码

javascript 复制代码
;(function(modules) {
  function require(id) {
    // 构造一个 CommonJS 导出代码
    const module = { exports: {} }
    // 去参数中获取文件对应的函数并执行
    modules[id](module, module.exports, require)
    return module.exports
  }
  require('./entry.js')
})({
  './entry.js': function(module, exports, require) {
    // 这里继续通过构造的 require 去找到 a.js 文件对应的函数
    var _a = require('./a.js')
    console.log(_a2.default)
  },
  './a.js': function(module, exports, require) {
    var a = 1
    // 将 require 函数中的变量 module 变成了这样的结构
    // module.exports = 1
    // 这样就能在外部取到导出的内容了
    exports.default = a
  }
  // 省略
})

尽管这个"丐版 Webpack"仅用了不到百行代码实现,但它涵盖了现代打包工具的核心思想:

● 找出入口文件所有的依赖关系。

● 将不同类型的资源统一处理

● 然后通过构建 CommonJS 代码来获取 exports 导出的内容。

这为我们理解打包工具的工作原理提供了很好的入门视角。

如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

相关推荐
小堃学编程3 分钟前
前端学习(1)—— 使用HTML编写一个简单的个人简历展示页面
前端·javascript·html
hnlucky1 小时前
通俗易懂版知识点:Keepalived + LVS + Web + NFS 高可用集群到底是干什么的?
linux·前端·学习·github·web·可用性测试·lvs
懒羊羊我小弟1 小时前
使用 ECharts GL 实现交互式 3D 饼图:技术解析与实践
前端·vue.js·3d·前端框架·echarts
前端小巷子1 小时前
CSS3 遮罩
前端·css·面试·css3
运维@小兵1 小时前
vue访问后端接口,实现用户注册
前端·javascript·vue.js
雨汨1 小时前
web:InfiniteScroll 无限滚动
前端·javascript·vue.js
Samuel-Gyx2 小时前
前端 CSS 样式书写与选择器 基础知识
前端·css
天天打码2 小时前
Rspack:字节跳动自研 Web 构建工具-基于 Rust打造高性能前端工具链
开发语言·前端·javascript·rust·开源
字节高级特工3 小时前
【C++】”如虎添翼“:模板初阶
java·c语言·前端·javascript·c++·学习·算法
db_lnn_20213 小时前
【vue】全局组件及组件模块抽离
前端·javascript·vue.js