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 的运行结果

image-20220626164638506.png

具体流程实现

创建目录

image-20220626162531496.png

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两个文件

image-20220626162726949.png

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

image-20220626162754440.png

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

猜你喜欢

转载自juejin.im/post/7113471451478360071