webpack核心流程解析与简单实现

时至今日,webpack仍然是最火和最稳定的前端打包构建工具。但是在平常的业务开发中我们很少接触到其内部原理,最多也仅仅停留在使用常用的配置层面,对webpack整个工作没有一个清晰的认知,所以本文实现一个简易的webpack,旨在了解webpack其和核心流程与思想。

Tapable

webpack内部使用了tapable.

  • tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但更专注于自定义事件的触发和处理
  • webpack 通过 tapable 将实现与流程解耦,所有具体实现通过插件的形式存在

大致像这样

class SyncHook {
  constructor() {
    this.taps = [];}
  tap(name, fn) {
    this.taps.push(fn);}
  call() {
    this.taps.forEach((tap) => tap());}
}
​
let hook = new SyncHook();
hook.tap("some name", () => {
  console.log("some name");
});
​
class Plugin {
  apply() {
    hook.tap("Plugin", () => {
      console.log("Plugin ");
  });}
}
new Plugin().apply();
hook.call(); 

webpack编译流程梳理

1.初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
2.用上一步得到的参数初始化 Compiler 对象
3.加载所有配置的插件
4.执行对象的 run 方法开始执行编译
5.根据配置中的entry找出入口文件
6.从入口文件出发,调用所有配置的Loader对模块进行编译
7.再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
9.再把每个 Chunk 转换成一个单独的文件加入到输出列表
10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,webpack插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果

具体流程实现

创建目录

src/entry1.js

const title = require('./title');
console.log('entry1', title); 

src/entry2.js

const title = require('./title');
console.log('entry2', title); 

src/title.js

module.exports = 'title'; 

debugger.js(我们最后执行这个文件来调用自己实现的webpack)

const webpack = require('./webpack');
const webpackConfig = require('./webpack.config');
//这是编译器对象代表整个编译过程
const compiler = webpack(webpackConfig);
//4.执行对象的 run 方法开始执行编译
compiler.run((err, stats) => {
  console.log(err);
  //stats是一个对象,记录了整个编译 过程 和产出的内容
  console.log(
    stats.toJson({
      assets: true, //输出打包出来的文件或者说资源 main.js
      chunks: true, //生成的代码块
      modules: true, //打包的模块
  }));
});
​ 

webpack.config.js

const path = require('path');
const RunPlugin = require('./plugins/run-plugin');
const DonePlugin = require('./plugins/done-plugin');
module.exports = {
  mode: 'production',
  devtool: false,
  entry: {
    entry1: './src/entry1.js',
    entry2: './src/entry2.js'},
  output: {
    path: path.resolve('dist'),
    filename: '[name].js'},
  resolve: {
    extensions: ['.js', '.json']},
  module: {
    rules: [
    {
        test: /.js$/,
        use: [
          path.resolve(__dirname, 'loaders/logger1-loader.js'),
          path.resolve(__dirname, 'loaders/logger2-loader.js')
      ]
    }
  ]},
  plugins: [
    new RunPlugin(),//希望在编译开始的时候运行run插件
    new DonePlugin()//在编译 结束的时候运行done插件]
} 

webpack.js

const Compiler = require('./Compiler');
function webpack(options) {
  //1.初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置对象
  const argv = process.argv.slice(2);
  const shellOptions = argv.reduce((memo, options) => {
    const [key, value] = options.split('=');
    memo[key.slice(2)] = value;
    return memo;}, {});
  const finalOptions = { ...options, ...shellOptions };
  //2.用上一步得到的参数初始化 Compiler 对象
  const compiler = new Compiler(finalOptions);
  //3. 加载所有配置的插件
  const { plugins = [] } = finalOptions;
  for (const plugin of plugins) {
    plugin.apply(compiler);}
  return compiler;
}
​
module.exports = webpack; 

Compiler.js

const { SyncHook } = require('tapable');
const path = require('path');
const fs = require('fs');
const Compilation = require('./Complication');
const fileDependencySet = new Set();
class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      run: new SyncHook(), //会在开始编译的时候触发
      emit: new SyncHook(), // 输出 asset 到 output 目录之前执行 (写入文件之前)
      done: new SyncHook(), //会在结束编译的时候触发
  };}
  // 4.执行Compiler对象的run方法开始执行编译
  run(callback) {
    this.hooks.run.call();
    // 5.根据配置中的entry找出入口文件
    const onCompiled = (err, stats, fileDependencies) => {
      const { assets } = stats;
      // 在输出文件前调用emit钩子
      this.hooks.emit.call();
      for (const filename in assets) {
        //10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
        // 先判断目录是否存在
        if (!fs.existsSync(this.options.output.path)) {
          fs.mkdirSync(this.options.output.path);
      }
        let filePath = path.join(this.options.output.path, filename);
        fs.writeFileSync(filePath, assets[filename], 'utf8');
    }
      callback(err, {
        toJson: () => stats,
    });
      // 遍历依赖的文件,对这些文件进行监听,当这些文件发生变化后会重新开始一次新的编译
    [...fileDependencies].forEach(fileDependency => {
        if (!fileDependencySet.has(fileDependency)) {
          fs.watch(fileDependency, () => this.compile(onCompiled));
          fileDependencySet.add(fileDependency);
      }
    });
      // 结束之后触发钩子
      this.hooks.done.call();
  };
    // 调用this.compile方法开始真正的编译,编译成功后会执行onCompiled回调
    this.compile(onCompiled);}
  // 每次调用compile方法,都会创建一个新的Compilation
  compile(callback) {
    const compilation = new Compilation(this.options);
    //调用compilation的build方法开始编译
    compilation.build(callback);}
}
module.exports = Compiler;
​ 

Complication.js

const path = require('path');
const fs = require('fs');
const parser = require('@babel/parser');
const types = require('babel-types');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
function normalizePath(path) {
  return path.replace(/\/g, '/'); //统一成linux的路径分隔符
}
const baseDir = normalizePath(process.cwd());
class Compilation {
  constructor(options) {
    this.options = options;
    this.fileDependencies = new Set();
    this.modules = []; //存放着本次编译生产所有的模块 所有的入口产出的模块
    this.chunks = []; //代码块的数组
    this.assets = {}; //产出的资源}
  //这个才是编译最核心的方法
  build(callback) {
    //5.根据配置中的entry找出入口文件
    let entry = {};
    if (typeof this.options.entry === 'string') {
      entry.main = this.options.entry; //如果字符串,其实入口的名字叫main
  } else {
      entry = this.options.entry; //否则 就是一个对象
  }
    // 6.从入口文件出发,调用所有配置的Loader对模块进行编译
    for (let entryName in entry) {
      //找到入口文件的绝对路径
      const entryFilePath = path.posix.join(baseDir, entry[entryName]);
      this.fileDependencies.add(entryFilePath);
      const entryModule = this.buildModule(entryName, entryFilePath);
      // 8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
      const chunk = {
        name: entryName, //代码块的名称就是入口的名字
        entryModule, //入口模块
        modules: this.modules.filter(module => module.names.includes(entryName)),
    };
      this.chunks.push(chunk);
  }
    //9.再把每个 chunk 转换成一个单独的文件加入到输出列表
    this.chunks.forEach(chunk => {
      const filename = this.options.output.filename.replace('[name]', chunk.name);
      this.assets[filename] = getSource(chunk);
  });
    // 调用传入的回调函数
    callback(
      null,
    {
        chunks: this.chunks,
        module: this.modules,
        assets: this.assets,
    },
      this.fileDependencies
  );}
  /**
   * 编译模块
   * @param {*} name 模块所属于代码块或者说入口的名称
   * @param {*} modulePath 模块的绝对路径
   */
  buildModule = (name, modulePath) => {
    //读取模块的源代码
    const sourceCode = fs.readFileSync(modulePath, 'utf8');
    //读取配置的loader
    const { rules } = this.options.module;
    const loaders = [];
    rules.forEach(rule => {
      const { test } = rule;
      if (modulePath.match(test)) {
        loaders.push(...rule.use);
    }
  });
    //使用配置的loader 对源码进行转换,得到最后的结果
    const transformedSourceCode = loaders.reduceRight((sourceCode, loader) => {
      return require(loader)(sourceCode);
  }, sourceCode);
    //当前模块的模块ID
    const moduleId = './' + path.posix.relative(baseDir, modulePath);
    //入口模块和它依赖的模块组成一个代码块,entry1.js title.js entry1的代码块chunk
    //每个代码块会生成一个bundle,也就是一个文件entry1.js
    //因为一个模块可能会属于多个入口,多个代码块,而模块不想重复编译的,所以一个模块的names对应于它的代码块名称的数组
    const module = { id: moduleId, dependencies: [], names: [name] }; //names=['entry1']
    const ast = parser.parse(transformedSourceCode, { sourceType: 'module' });
    //7.再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
    traverse(ast, {
      CallExpression: ({ node }) => {
        //说明这是要依赖或者说加载别的模块了
        if (node.callee.name === 'require') {
           //获取依赖模块的相对路径 wepback打包后不管什么模块,模块ID都是相对于根目录的相对路径
          const depModuleName = node.arguments[0].value;
          //先找到当前模块所在目录
          const dirname = path.posix.dirname(modulePath);
          //得到依赖的模块的绝对路径
          const depModulePath = this.tryExtension(path.posix.join(dirname, depModuleName));
          this.fileDependencies.add(depModulePath);
          //模块ID不管是本地的还是第三方的,都会转成相对项目根目录的相对路径,而且是添加过后缀的
          const depModuleId = './' + path.posix.relative(baseDir, depModulePath);
          //修改ast语法树上的require节点
          node.arguments = [types.stringLiteral(depModuleId)]; // ./title => ./src/title.js
          //给当前的模块添加模块依赖
          module.dependencies.push({ depModuleId, depModulePath });
      }
    },
  });
    //根据改造后语法树重新生成源代码
    const { code } = generator(ast);
    // module._source属性指向此模块改造后的源码
    module._source = code;
    //找到这个模块依赖的模块数组,循环编译这些依赖
    module.dependencies.forEach(({ depModuleId, depModulePath }) => {
      //先在已经编译好的模块数组中找一找有没有这个模块
      const existModule = this.modules.find(module => module.id === depModuleId);
      if (existModule) {
        //如果已经编译过了,在名称数组添加当前的代码块的名字
        existModule.names.push(name);
    } else {
        let depModule = this.buildModule(name, depModulePath);
        this.modules.push(depModule);
    }
  });
    return module;};
  tryExtension = modulePath => {
    //如果文件存在,说明require模块的时候已经添加了后缀了,直接返回
    if (fs.existsSync(modulePath)) {
      return modulePath;
  }
    let extensions = ['.js'];
    if (this.options.resolve && this.options.resolve.extensions) {
      extensions = this.options.resolve.extensions;
  }
    for (let i = 0; i < extensions.length; i++) {
      let filePath = modulePath + extensions[i];
      if (fs.existsSync(filePath)) {
        return filePath;
    }
  }
    throw new Error(`${modulePath}未找到`);};
}
function getSource(chunk) {
  return `
    (() => {
    var modules = ({
      ${chunk.modules.map(
        module => `
        "${module.id}":(module,exports,require)=>{
          ${module._source}
        }
      `
    )}
    });
    var cache = {};
    function require(moduleId) {
      var cachedModule = cache[moduleId];
      if (cachedModule !== undefined) {
        return cachedModule.exports;
      }
      var module = cache[moduleId] = {
        exports: {}
      };
      modules[moduleId](module, module.exports, require);
      return module.exports;
    }
    var exports = {};
    ${chunk.entryModule._source}
    })()
    ;
  `;
}
module.exports = Compilation;
​ 

plugins/run-plugin.js

webpack插件都是一个类(类本质上都是funciton的语法糖),每个插件都必须存在一个apply方法

class RunPlugin {
  apply(compiler) {
    compiler.hooks.run.tap('RunPlugin', () => {
      console.log('RunPlugin');
  });}
}
module.exports = RunPlugin;
​ 

plugins/done-plugin.js

class DonePlugin {
  apply(compiler) {
    compiler.hooks.done.tap('DonePlugin', () => {
      console.log('DonePlugin');
  });}
}
module.exports = DonePlugin;
​ 

loaders/logger1-loader.js

loader本质上就是一个函数,接受我们的源代码作为入参同时返回处理后的结果

function loader(source) {
  return source + '//logger1';
}
module.exports = loader; 

loaders/logger2-loader.js

function loader(source) {
  return source + '//logger2';
}
module.exports = loader; 

测试打包结果

最后执行 debugger.js

node debugger.js 

可以看到生成了dist目录,且下面有entry1.js和entry2.js两个文件

entry1.js

(() => {
  var modules = {
    './src/title.js': (module, exports, require) => {
      module.exports = 'title'; //logger2//logger1
  },};
  var cache = {};
  function require(moduleId) {
    var cachedModule = cache[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
  }
    var module = (cache[moduleId] = {
      exports: {},
  });
    modules[moduleId](module, module.exports, require);
    return module.exports;}
  var exports = {};
  const title = require('./src/title.js');
​
  console.log('entry1', title); //logger2//logger1
})();
​ 

执行entry1.js

node entry1.js 

可以看到打包后的结果正确,至此webpack的大致流程就完成了!当然了,这里只是最简单的实现,webpack远比想象中的复杂得多,不过大致运行原理和思想都是相通的。

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340