webpack工作流

82 阅读1分钟

webpack工作流

如何调试

  • 创建个js文件,然后在内部引入webpack和配置文件,可以在文件中写debugger,然后执行小爬虫即可
const webpack = require("./webpack");
const webpackConfig = require("./webpack.config");
debugger;
const compiler = webpack(webpackConfig);
// 调用compiler的run方法开始启动编译
compiler.run((err, stats) => {
  if (err) {
    console.log(err);
  } else {
    // stats代表统计结果对象
    console.log(
      stats.toJson({
        files: true, // 代表打包后生成的文件
        assets: true, // 其它是一个代码块到文件的对应关系
        chunks: true, // 从入口模块出发,找到此入口模块依赖的模块,或者依赖的模块依赖的模块,合在一起组成一个代码块
        modules: true, // 打包的模块
      })
    );
  }
});

loader

  • 因为webpack只识别js和json,所以我们需要将其他它不认识的文件类型转换成它认识的
  • 我们想对某些文件进行处理,也可以使用loader
  • loader的本质是一个函数,它最大的作用是修改语法树的
  • loader从后到前依次,后面的会拿到前面的执行结果执行

自定义一个玩具loader

  • 新建两个文件,存储loader
  • 修改配置文件
// webpack.config.js
 ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          // 最左则的loader需要返回合法的JS
          // path.resolve(__dirname, "loaders/loader2.js"),
          // 最右侧的loader拿到的是源代码
          path.resolve(__dirname, "loaders/loader2.js"),
          path.resolve(__dirname, "loaders/loader1.js"),
        ],
      },
    ],
  },
  ...

// loaders/loader1.js
function loader1(source) {
  return "偷梁换柱";
}
module.exports = loader1;

// loaders/loader2.js
function loader2(source) {
  return source + "第二步骤";
}
module.exports = loader2;

// 现在执行打包,结果为 偷梁换柱第二步骤

让它识别其他文件

  • 不认识的文件也能打包,编译不报错,一旦执行就挂了
  • 随便自定义搞一个格式,写一段内容,然后编译下
// 新建xxx.alg文件,自己随便写的
asdadasd
// 在index.js中导入
import xx from "./xx.alg";
console.log(xx);

// 去掉刚才对js的操作
// 增加对.alg的操作
  {
    test: /\.alg$/,
    use: path.resolve(__dirname, "loaders/loader1.js"),
  },

// 修改babel1.js为
function loader1(source) {
  return `module.exports = "${source}"`;// 手动补一个module.export
}
module.exports = loader1;

// 打包,正常运行
// 可以看到只要按照webpack的逻辑执行就ok
// 那么我们把这个babel去掉,在浏览器执行就会报错是因为里面执行了一段浏览器识别不了的东西,或异常操作
(() => {asdadasd}) // 这段逻辑本身就是错误的

plugins

  • plugins就是我们在webpack中使用的插件集合,有多个,所以使用数组形式存储
  • 规范:
    • 都有一个apply方法,该方法接收一个compiler形参,代表当前webpack进程对象
    • 可以通过compiler.hooks.xxx.tap向进程中指定的钩子注册事件,方法接收两个参数,name与callback,name没用,callback则为事件回调
    • 相同周期的钩子看谁先注册,那么谁执行
    • 事件的挂载是在启动前全部挂载,后续到某个生命周期,依次执行对应的钩子
      • run为执行前
      • done为执行后
// plugins/done-plugin.js
class DonePlugin {
  apply(compiler) {
    compiler.hooks.done.tap("DonePlugin", () => {
      console.log("done:结束编译");
    });
  }
}

module.exports = DonePlugin;

// plugins/run-plugin.js
class RunPlugin {
  apply(compiler) {
    compiler.hooks.run.tap("RunPlugin", () => {
      console.log("run:开始编译");
    });
  }
}

module.exports = RunPlugin;

// 在webpack中导入
const RunPlugin = require("./plugins/run-plugin");
const DonePlugin = require("./plugins/done-plugin");
...
  plugins: [
    // 插件的挂载或者说监听是在编译启动前全部挂载的
    new DonePlugin(),
    new RunPlugin(),
  ]
...
// 执行编译,结果为run:开始编译 done:结束编译
  • 可以抽象成这样
  • tap可以理解为是注册事件
const compiler = {
  hooks: {
    done: [()=>{'结束'}],
    run: [()=>{'开始'}],
  },
};

编译流程

  • 初始化参数,合并命令行参数与配置文件参数,得到配置对象,命令行参数高于配置文件
  • 用得到的参数初始化compiler对象
  • 加载所有配置的插件,将compiler传递给apply方法,然后apply内部会按照插件逻辑向compiler的生命周期事件池子中注册事件
  • 执行compiler对象的run方法,开始编译
  • 根据配置中的entry找到入口文件
  • 从入口文件出发,调用所有配置的loader对模块进行编译
  • 找出该模块依赖的模块,再递归本步骤知道所有入口依赖的文件都经过了本步骤的处理
  • 将每个入口与模块之间的依赖关系组装成一个个包含多个模块的chunk
  • 再把每个chunk转换成一个单独的文件加入到输出列表
  • 确定好输出内容后,根据配置确定出处的路径和文件名,将文件内容写入到文件系统
  • 在编译过程中,在特定的时间,会按照顺序执行注册的插件(生命周期钩子

实现

  • 初始化参数,合并命令行参数与配置文件行参数
  • 初始化Compiler实例,将合并好的参数传递过去
    • Compiler的事件注册依赖于tapable库,在this.hooks上创建事件池,当前实现了run与done
  • 每个plugin都有一个apply方法,会接收compiler实例
    • 依次执行plugin的apply方法,在compiler的hooks上的周期池子中注册事件
  • 执行compiler的run方法开始编译
    • run方法接收一个函数,函数第一个参数为异常,第二个参数为本次进程内容
    • 先执行run钩子
    • 执行编译,编译依赖于Compilation模块,每次编译都会创建一个新的Compilation实例
      • Compilation中会读取babel,然后先编译入口文件,然后将入口文件变为ast语法树,找到导入节点,进行递归编译,最后整合obj然后返回
      • 编译完成后,将内容写入文件中
      • 将依赖模块交给fs.watch监听,后续修改内容可重新编译
    • 执行done钩子

webpack.config.js

const path = require("path");
const Run1Plugin = require("./plugins/run-plugin");
// const Run2Plugin = require("./plugins/run2-plugin");
const DonePlugin = require("./plugins/done-plugin");
module.exports = {
  mode: "development",
  devtool: false,
  entry: {
    // chunk的名称  值是入口模块的路径
    entry1: "./src/entry1.js",
    entry2: "./src/entry2.js",
  },
  output: {
    clean: true,
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
  },
  resolve: {
    // 导入文件先找不带后缀的,找不到再依次补后缀进行查找
    extensions: [".js", ".jsx", ".ts", ".tsx"],
  },
  module: {
    rules: [],
  },
  plugins: [
    // 插件的挂载或者说监听是在编译启动前全部挂载的
    // new Run2Plugin(),
    new DonePlugin(),
    new Run1Plugin(),
  ],
};

webpack.js

const Compiler = require("./Compiler");
function webpack(options) {
  // argv 前两个参数不需要,一个是node所在地址、一个是当前执行文件地址
  const argv = process.argv.slice(2);
  // 拆解命令行参数
  const shellOptions = argv.reduce((shellOptions, options) => {
    const [key, value] = options.split("=");
    // webpack行间参数前面都有--,给它干掉
    // 如--mode、--watch、--entry
    shellOptions[key.slice(2)] = value;
    return shellOptions;
  }, {});
  // 合并参数,命令语句中的参数优先级高于配置文件
  const finalOptions = {
    ...options,
    ...shellOptions,
  };
  // 初始化compiler实例
  const compiler = new Compiler(finalOptions);
  // 加载所有配置的插件
  const { plugins } = finalOptions;
  for (let plugin of plugins) {
    // 上面有说过,每个plugin一定是一个类,类上一定要有apply方法,apply方法会接收compiler形参
    // 然后注册事件
    plugin.apply(compiler);
  }
  // 返回实例
  return compiler;
}

module.exports = webpack;

Compiler.js

const { SyncHook } = require("tapable");
const Compilation = require("./Compilation");
const fs = require("fs");
const path = require("path");
class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      run: new SyncHook(), // 在开始编译前执行
      done: new SyncHook(), // 在编译完成时执行
    };
    this.fileDependencies = new Set(); // 防止重复监听,在this上加一个依赖
  }
  run(callback) {
    // 编译开始前,执行run的钩子
    this.hooks.run.call();
    // 在编译的过程中会收集所有的依赖的模块或者说文件
    const onCompiled = (err, stats, fileDependencies) => {
      console.log("编译");
      for (let filename in stats.assets) {
        let filePath = path.join(this.options.output.path, filename);
        fs.writeFileSync(filePath, stats.assets[filename], "utf8");
      }
      callback(err, { toJson: () => stats });
      for (let fileDependency of fileDependencies) {
        // 监听依赖的文件变化,如果依赖的文件变化后会开始一次新的编译
        // 在开发阶段,我们的文件是在不停的修改,所以要保证编译的实时性
        if (!this.fileDependencies.has(fileDependency)) {
          // 如果没有监听过,再注册监听
          fs.watch(fileDependency, () => this.compile(onCompiled));
          this.fileDependencies.add(fileDependency);
        }
      }
    };
    // 调用compile方法进行编译
    this.compile(onCompiled);
    this.hooks.done.call(); // 编译完成,执行done的钩子
  }
  // 开启一次新的编译
  compile(callback) {
    // 每次编译都会创建一个新的Compilation实例,然后执行它的build方法
    let compilation = new Compilation(this.options, this);
    compilation.build(callback);
  }
}

module.exports = Compiler;

Compilations.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;
const baseDir = normalizePath(process.cwd()); // 根目录
// 将\转为/
function normalizePath(path) {
  return path.replace(/\\/g, "/");
}
class Compilation {
  constructor(options, compiler) {
    this.options = options;
    // 将compiler传递过来,因为编译是在这里进行的,一些钩子也需要在编译阶段发布
    this.compiler = compiler;
    this.modules = []; // 本次编译涉及的所有模块
    this.chunks = []; // 本次编译所组装出的代码块
    this.assets = {}; // key是文件名,值是文件内容
    this.files = []; // 本次打包出来的文件
    this.fileDependencies = new Set(); // 本次编译依赖的文件(原始模块)
  }
  build(callback) {
    // 根据配置中的entry找到所有配置文件
    let entry = {};
    // 兼容string,单入口一般情况下只写一个
    if (typeof this.options.entry === "string") {
      entry.mian = this.options.entry;
    } else {
      entry = this.options.entry;
    }
    for (let entryName in entry) {
      // path.win32.sep   path.posix.sep  windows和linux的路径间隔符不一样,统一使用linux的
      // 基础路径 + 指定入口路径 = 真实路径
      let entryFilePath = path.posix.join(baseDir, entry[entryName]);
      // 将合并好的路径放到数组中
      this.fileDependencies.add(entryFilePath);
      // 从入口文件出发,调用所有配置的loader对模块进行编译
      let entryModule = this.buildModule(entryName, entryFilePath);
      // 放到modules里
      //   this.modules.push(entryModule);
      // 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的chunk
      let chunk = {
        name: entryName,
        entryModule,
        // 找到包含当前入口名的依赖
        modules: this.modules.filter((module) =>
          module.names.includes(entryName)
        ),
      };
      this.chunks.push(chunk);
    }
    // 输出文件
    this.chunks.forEach((chunk) => {
      // name可能为[name].js,那么我们需要将[name]替换为我们的入口name
      const filename = this.options.output.filename.replace(
        "[name]",
        chunk.name
      );
      this.files.push(filename);
      // 获取源代码
      this.assets[filename] = getSource(chunk);
    });
    callback(
      null,
      {
        modules: this.modules,
        chunks: this.chunks,
        assets: this.assets,
        files: this.files,
      },
      this.fileDependencies
    );
  }
  /**
   * 编译模块
   *    调用loader对模块进行编译
   * @param {*} name 入口(chunk)名称
   * @param {*} modulePath 文件路径
   */
  buildModule(name, modulePath) {
    // 读取文件
    let sourceCode = fs.readFileSync(modulePath, "utf8");
    let { rules } = this.options.module;
    let loaders = [];
    // 遍历rules
    rules.forEach((rule) => {
      if (modulePath.math(rule.test)) {
        // 如果路径匹配正则,那么将use中的loader放到loaders中
        let use = rule.use;
        // 兼容string
        if (!Array.isArray(use) && use) {
          use = [use];
        }
        if (use.length > 0) {
          loaders.push(...use);
        }
      }
    });
    // 从右到左依次执行loader
    sourceCode = loaders.reduceRight((sourceCode, loader) => {
      return require(loader)(sourceCode);
    }, sourceCode);
    // 声明当前模块的ID,相对与当前根目录的模块路径
    let moduleId = "./" + path.posix.relative(baseDir, modulePath);
    // 创建一个模块,id就是相对于根目录的相对路径,dependencies就是该模块依赖的模块
    // names就是模块所属的代码库名称,因为一个模块可能由多个模块引入,所以用[]存储
    // names为入口名字
    let module = { id: moduleId, dependencies: [], names: [name] };
    // 下面就得用babel将代码转换成语法树啦
    let ast = parser.parse(sourceCode, { sourceType: "module" });
    // 遍历语法树
    traverse(ast, {
      CallExpression: ({ node }) => {
        // require节点
        if (node.callee.name === "require") {
          // 拿到模块导入路径
          let depModuleName = node.arguments[0].value; //./title
          // 绝对路径,后续会赋值
          let depModulePath;
          // 以.开头代表是相对目录
          if (depModuleName.startsWith(".")) {
            // 获取当前文件的所在位置,因为文件是相对当前文件目录的,不是相对根目录的
            const currentDir = path.posix.dirname(modulePath);
            // 拼接绝对路径
            depModulePath = path.posix.join(currentDir, depModuleName);
            // 当前路径不一定有文件后缀,需要补齐
            const extensions = this.options.resolve.extensions;
            depModulePath = tryExtensions(depModulePath, extensions);
          } else {
            // 不是以.开头就是第三方,这里不考虑自定义前缀
            depModulePath = require.resolve(depModuleName);
          }
          // 放到监听依赖里,后续就会监听它的改动了
          this.fileDependencies.add(depModulePath);
          // 这个时候求出该文件相对于根目录的路径
          let depModuleId = "./" + path.posix.relative(baseDir, depModulePath);
          // 然后替换掉原本导入的路径,比如导入require('./title')会变成require('./src/title.js')
          node.arguments = [types.stringLiteral(depModuleId)];
          // 把依赖的模块id和依赖的模块路径放到当前模块的依赖数组中
          module.dependencies.push({
            depModuleId,
            depModulePath,
          });
        }
      },
    });
    // 使用修改后的语法树生成新的源代码
    let { code } = generator(ast);
    module._source = code;
    module.dependencies.forEach(({ depModuleId, depModulePath }) => {
      let existModule = this.modules.find(
        (module) => module.id === depModuleId
      );
      if (existModule) {
        // 判断模块是否已经存在,如果存在,就给它的name里添加一个依赖
        // 不存在才走递归打包逻辑
        existModule.names.push(name);
      } else {
        let depModule = this.buildModule(name, depModulePath);
        this.modules.push(depModule);
      }
    });
    return module;
  }
}
/**
 * 找文件逻辑
 *  不存在就报错
 * @param {*} modulePath 文件路径
 * @param {*} extensions 后缀集合
 * @returns 返回存在的路径
 */
function tryExtensions(modulePath, extensions) {
  // 如果文件存在,那么直接返回,因为有时候会写后缀
  if (fs.existsSync(modulePath)) {
    return modulePath;
  }
  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) {
  // 将打包后的产物复制过来,wbpack的方法我们不变,只改变对应的变量
  return `(() => {
    var __webpack_modules__ = {
        ${chunk.modules.map(
          (module) => `
        "${module.id}": (module) => {
           ${module._source}
          },`
        )}
      
    };
    var __webpack_module_cache__ = {};
    function require(moduleId) {
      var cachedModule = __webpack_module_cache__[moduleId];
      if (cachedModule !== undefined) {
        return cachedModule.exports;
      }
      var module = (__webpack_module_cache__[moduleId] = {
        exports: {},
      });
      __webpack_modules__[moduleId](module, module.exports, require);
      return module.exports;
    }
    var __webpack_exports__ = {};
    (() => {
     ${chunk.entryModule._source}
    })();
  })();
  `;
}
module.exports = Compilation;

debugger.js

  • 引入我们的webpack
  • 然后node debugger.js即可