23.重学webpack——原理之webpack工作原理/工作流(高频面试题)

【重学webpack系列——webpack5.0】

1-15节主要讲webpack的使用,当然,建议结合《webpack学完这些就够了》一起学习。
从本节开始,专攻webpack原理,只有深入原理,才能学到webpack设计的精髓,从而将技术点运用到实际项目中。
可以点击上方专栏订阅哦。

以下是本节正文:


webpack工作流

1. webpack工作流步骤(高频面试点

  1. 初始化参数:从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
  2. 用上一步得到的参数初始化Compiler对象
  3. 加载所有配置的插件
  4. 执行Compiler对象的run方法开始执行编译
  5. 根据配置中的entry找出入口文件
  6. 从入口文件触发,调用所有配置的Loader对模块进行编译
  7. 再找出该模块依赖的模块,再递归这个步骤,知道所有入口依赖的文件都经过了这个步骤的处理,得到入口与模块之间的依赖关系
  8. 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk
  9. 再把每个Chunk转换成一个单独的文件加入到输出列表
  10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
  11. 以上过程中,webpack会在特性的时间点广播出特定的事件,插件在监听到感兴趣的事件后执行特定的逻辑,并且插件可以调用webpack提供的API改变webpack的运行结果。

面试可以按照上面的答,然后面试官会问每个步骤的细节,所以每个步骤怎么实现的,需要了解清楚,这就意味着需要总结下面写的代码,这块很重要哦~

23.重学webpack——原理之webpack工作原理/工作流(高频面试题)_第1张图片

2.实现webpack(精简版)

按照上面的步骤,一步步实现webpack,重要步骤均写了注释,可以打这个代码去debugger

有些许bug,但是不影响了解webpack的工作流

  • 首先准备好我们的webpack.config.js配置文件
const path = require('path');
const RunPlugin = require('./myPlugins/run-plugin');
const DonePlugin = require('./myPlugins/done-plugin');
const EmitPlugin = require('./myPlugins/emmit-plugin');
module.exports = {
  mode: 'production',
  devtool: false,
  context: process.cwd(), // 上下文,如果想改变根目录,可以改这边。默认值就是当前命令执行的时候所在的目录(不是webpack.config.js的目录,是执行时的目录)
  entry: {
    entry1: './src/entry1.js',
    entry2: './src/entry2.js'
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  resolve: {
    extensions: ['.js', '.jsx', '.json']
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          path.resolve(__dirname, 'loaders', 'logger1-loader.js'),
          path.resolve(__dirname, 'loaders', 'logger2-loader.js')
        ]
      }
    ]
  },
  plugins: [
    new RunPlugin(),
    new DonePlugin(),
    new EmitPlugin()
  ]
}
  • 然后开始手写webpack

    • 创建webpack.run.js文件,该文件主要用于手动跑webpack
      • webpack一般会返回一个compiler,这个compiler有一个run方法,调用该方法可以启动编译
      • compilercomplication的区别见下文
    const webpack = require('./myWebpack/myWebpack');
    const webpackOptions = require('./webpack.config');
    // compiler代表整个编译过程
    const compiler = webpack(webpackOptions);
    // 调用run方法,可以启动编译
    
    compiler.run((err, stats) => {
      console.log(err)
      console.log(stats.toJson())
    })
    
    • 然后编写我们自己的webpack,也就是上面代码中引入的myWebpack
      • 这里主要做:
        • 参数合并
        • 加载插件
        • 执行compiler的run方法
        • 将compiler返回
    const Compiler = require('./Compiler');
    
    function webpack(options){
      // 1. 初始化文件,从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
      // console.log(process.argv); // 获取命令参数,参数与webpack.config.js同名的时候,参数会覆盖配置文件,也就是说参数的优先级更高
      let shellOptions = process.argv.slice(2).reduce((shellConfig, item) => {
        let [key, value] = item.split('=')
        shellConfig[key.slice(2)] = value;
        return shellConfig;
      }, {})
      let finalConfig = {...options, ...shellOptions};
      // console.log(finalConfig)
      // 2. 用上一步得到的参数初始化Compiler对象
      let compiler = new Compiler(finalConfig);
      // 3. 加载所有配置的插件
      let { plugins } = finalConfig;
      for (let plugin of plugins) {
        plugin.apply(compiler); 
        // 注册所有插件的事件,是通过tapable的tap来注册的,然后就是等待着合适的时机去触发事件,也就是调用tapable的call函数
        // 由于触发的时机是固定的,所以不同时机触发的插件,在注册的时候谁先谁后都无所谓,那么webpack的plugins中写的谁先谁后其实都无所谓。但是如果多个插件是统一时机触发的,那么就是谁先注册谁就先触发。
      }
      // 4. 执行compiler对象的run方法开始编译,调用run在外面,具体的run方法在Compiler类中
    
      // 最后,需要将compiler对象返回
      return compiler;
    }
    module.exports = webpack;
    
    • 然后去实现Compiler类,该类主要是runcompiler方法。
    let { SyncHook } = require('tapable');
    let Complication = require('./complication');
    let fs = require('fs');
    
    class Complier{
      constructor(options){
        this.options = options;
        this.hooks = {// 类似run的钩子有四五十个,下面几个是比较典型的
          run: new SyncHook(), // 开始启动编译
          emit: new SyncHook(['assets']), // 会在将要写入文件的时候触发
          done: new SyncHook(), // 会在完成编译的时候触发 全部完成
        }
      }
      run(callback){ // 开始编译
        // 4. 执行compiler对象的run方法开始编译
        /* 下面都是编译过程 */
        // 首先是触发run钩子
        this.hooks.run.call();
        // 5. 根据配置中的entry找到入口文件
        // 开启编译
        this.compile(callback);
        // 监听入口文件的变化, 如果文件变化了,重新再开始编译
        Object.values(this.options.entry).forEach(entry => { // 考虑到多入口
          fs.watchFile(entry, () => this.compile(callback));
        })
        // 最后是触发done钩子
        this.hooks.done.call();
        /* 上面都是编译过程 */
        callback(null, {
          toJson(){
            return {
              files: [], // 产出哪些文件
              assets: [], // 生成哪些资源
              chunk: [], // 生成哪些代码块
              module: [], // 模块信息
              entries: [] // 入口信息
            }
          }
        })
      }
      compile(callback){
        let complication = new Complication(this.options, this.hooks);
        complication.make(callback);
      }
    }
    
    module.exports = Complier;
    
    • 在上面代码中,执行compier的时候,会调用Complication类,该类实现如下:
    const path = require('path');
    const fs = require('fs');
    const types = require('babel-types');
    const parser = require('@babel/parser'); // 编译
    const traverse = require("@babel/traverse").default; // 转换
    const generator = require('@babel/generator').default; // 生成 新源码
    const baseDir = toUnitPath(process.cwd()); // process.cwd()表示当前文件所在绝对路径,toUnitPath(路径)将路径分隔符统一转成正斜杠/
    
    class Complication{ // 一次编译会有一个complication,会存放所有入口
      constructor(options, hooks){
        this.options = options;
        this.hooks = hooks;
        // 下面这些 webpack4中都是数组  但是webpack5中都换成了set,优化了下,防止重复的资源编译,当然数组也可以做到防止重复资源编译
        this.entries = []; // 存放所有的入口
        this.modules = []; // 存放所有的模块
        this.chunks = []; // 存放所有的代码块
        this.assets = {}; // 对象,key为文件名,value为文件编译后的源码,存放所有的产出的资源, this.assets就是文件输出列表
        this.files = []; // 存放所有的产出的文件
      }
      make(callback){
        // 5. 根据配置中的entry找出入口文件
        let entry = {};
        if (typeof this.options.entry === 'string') {
          entry.main = this.options.entry;
        } else {
          entry = this.options.entry;
        }
        // entry = {entry1: "./src/entry1.js", entry2: './src/entry2.js'}
        for (let entryName in entry) {
          // 获取入口文件的绝对路径,这里的this.options.context默认是process.cwd(),这个默认值在这边没做处理
          let entryFilePath = toUnitPath(path.join(this.options.context, entry[entryName]))
          // 6.从入口文件出发,调用所有配置的Loader对模块进行编译,返回一个入口模块
          let entryModule = this.buildModule(entryName, entryFilePath);
          // // 把入口module也放到this.modules中去
          // this.modules.push(entryModule);
          // 8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk
          let chunk = {
            name: entryName,
            entryModule,
            modules: this.modules.filter(item => item.name === entryName)
          };
          this.entries.push(chunk); // 代码块会放到entries和chunks中
          this.chunks.push(chunk); // 代码块会放到entries和chunks中
          // 9.再把每个Chunk转换成一个单独的文件加入到输出列表
          this.chunks.forEach(chunk => {
            let filename = this.options.output.filename.replace('[name]', chunk.name);
            // 这个this.assets就是文件输出列表, key为文件名,value为chunk的源码
            this.assets[filename] = getSource(chunk); // assets中key为文件名,value为chunk的源码
          })
          // 10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
          this.files = Object.keys(this.assets);
          this.hooks.emit.call(this.assets)
          for(let fileName in this.assets){
            let filePath = path.join(this.options.output.path, fileName);
            fs.writeFileSync(filePath, this.assets[fileName], 'utf8');
          }
          // 最后调用callback,返回一个对象,对象内有toJSON方法
          callback(null, {
            toJson: () => {
              return {
                entries: this.entries,
                chunks: this.chunks,
                modules: this.modules,
                files: this.files,
                assets: this.assets
              }
            }
          });
    
        }
    
      }
      buildModule(name, modulePath){ // 参数第一个是代码块的名称,第二个是模块的绝对路径
        // 6.从入口文件出发,调用所有配置的Loader对模块进行编译,返回一个入口模块
        // 6.1 读取模块文件内容
        let sourceCode = fs.readFileSync(modulePath, 'utf-8');
        let rules = this.options.module.rules;
        let loaders = []; // 寻找匹配的loader
        for (let i = 0; i < rules.length; i++) {
          if (rules[i].test.test(modulePath)) { // 如果当前文件路径能够匹配上loader的正则的,那么就调用这个loader去处理
            loaders = [...loaders, ...rules[i].use]
          }
        }
        // 用loader进行转换,从右往左,从下往上,在这里就是数组从右往左
        for (let i = loaders.length - 1; i>= 0; i--) {
          let loader = loaders[i];
          sourceCode = require(loader)(sourceCode)
        }
        // 7. 再找出该模块依赖的模块,再递归这个步骤,知道所有入口依赖的文件都经过了这个步骤的处理,得到入口与模块之间的依赖关系
        let moduleId = './' + path.posix.relative(baseDir, modulePath); // 当前模块的id
        let module = {
          id: moduleId, // 模块id,也就是相对于项目根目录的相对路径
          dependencies: [], // 模块依赖
          name // 模块名称
        }
        let ast = parser.parse(sourceCode, {sourceType: 'module'}); // 生成语法树
        traverse(ast, {
          // CallExpression这个节点代表方法调用
          CallExpression: ({ node }) => {
            if (node.callee.name === 'require') {
              let moduleName = node.arguments[0].value; // 获取require函数的参数 './title',也就是模块的相对路径
              let dirname = path.posix.dirname(modulePath); // 获取模块的所在目录(title文件的父文件夹) path.posix相当于把路径都转成/,不论是什么系统,都是正斜杠,如果不用posix的话,linux是正斜杠,windows是反斜杠
              let depModulePath = path.posix.join(dirname, moduleName); // 模块的绝对路径,但是可能没有后缀,
              let extensions = this.options.resolve.extensions; // 如果options中没有配置resolve,需要做判断,这边就暂时不写了
              depModulePath = tryExtensions(depModulePath, extensions); // 生成依赖的模块绝对路径,已经包含了扩展名了
              // 找到引用的模块的id,引用的模块的id的特点是:相对于根目录的路径
              let depModuleId = './' + path.posix.relative(baseDir, depModulePath);
              node.arguments = [types.stringLiteral(depModulePath)]; // 这个就是参数这个节点要变,因为原来是require('./title'),现在要变成require('./src/title.js'),这个types.stringLiteral就是用来修改参数的
              let alreadyImportedModuleIds = this.modules.map(item => item.id); // 遍历出已有的modules的moduleId
              // 把依赖模块的绝对路径放到依赖数组里面
              // if (!alreadyImportedModuleIds.includes(dependency.depModuleId)) { // 如果不存在,才放进this.modules数组,这样防止已经编译过的模块重复放到this.modules中
                module.dependencies.push({depModuleId, depModulePath});
              // }
              
            }
          }
        })
        let { code } = generator(ast);
        console.log(code, toUnitPath(this.options.context));
        module._source = code.replace(toUnitPath(this.options.context), '.');// 模块源代码指向语法树转换后的新生村的源代码
        // 7. 再找出该模块依赖的模块,再递归这个步骤,知道所有入口依赖的文件都经过了这个步骤的处理,得到入口与模块之间的依赖关系 这时候需要开始递归了
        module.dependencies.forEach(dependency => {
          let depModule = this.modules.find(item => item.id === dependency.depModuleId);
          // 判断模块是否已经被编译过了,如果编译过了直接push,如果没有编译过,那么就先编译,编译完了再push
          if (depModule) {
            this.modules.push({...depModule, name}); // 重新改下名字
          } else {
            let dependencyModule = this.buildModule(name, dependency.depModulePath); // 这个name为啥是一样的??????
            this.modules.push(dependencyModule);
          }
        })
    
        return module;
      }
    }
    
    function tryExtensions(modulePath, extensions){
      extensions.unshift('');// 为什么要加一个空串,因为有可能用户写的路径是带后缀的,所以路径跟空串结合就是路径,如果不加空串,用户如果路径带了后缀,那判断就是title.js.js title.js.jsx title.js.json
      for(let i = 0; i < extensions.length; i++){
        let filePath = modulePath + extensions[i];
        if (fs.existsSync(filePath)) {
          return filePath;
        }
      }
      throw new Error('Module not found')
    }
    function toUnitPath(modulePath){
      return modulePath.replace(/\\/g, '/');
    }
    function getSource(chunk){
      return `
      (() => {
          var modules = ({
              ${chunk.modules.map(module => `
                      "${module.id}":(module,exports,require)=>{
                          ${module._source}
                      }
                  `).join(',')
              }
          });
          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 = Complication;
    

3. Compiler和Compilation的作用

webpack编译过程中有两个最重要的对象

  1. Compiler 生产产品的工厂,代表整个编译过程
  2. Compilation 代表一个生产过程,代表依次编译
  • 代码解释说明

    const fs = require("fs");
    const path = require("path");
    // 代表具体的一次编译,代表一次生产过程
    class Complication{
        build(){
            console.log('编译一次')
        }
    }
    // 代表整个编译
    class Compiler{
        run(){
            this.compile(); // 开始编译
            fs.watchFile(path.resolve(__dirname, './test.js'), () => {
                this.compile(); // 监听文件变化,一旦变化,我就开始编译
            })
        }
        compile(){
            let complication = new Complication();
            complication.build();
        }
    }
    
    const compiler = new Compiler()
    compiler.run()
    

4.webpack插件实现

webpack插件,主要是调用apply方法,apply方法是webpack会去调用的。

apply方法参数为compiler。

  • 实现最简单的一个emit-plugin
class EmitPlugin{
  apply(compiler){
    compiler.hooks.emit.tap('emit', (assets) => {
      assets['assets.md'] = Object.keys(assets).join('\n');
      console.log('这是发射文件之前触发')
    })
  }
}

module.exports = EmitPlugin;
  • webpack插件的实现,后面会有章节详细讲到。

4.其他知识

  • path.join('a', 'b', 'c')在windows结果是a\b\c,在linux中是a/b/c

  • path.posix.join('a', 'b', 'c')不管在哪个操作系统下,返回的都是a/b/c(都是linux的结果, 一般我们都希望用这个)

  • path.win32.join('a', 'b', 'c')不管在哪个操作系统下,返回的都是a\b\c(都是windows的结果)

  • process.cwd()表示当前文件所在绝对路径,路径分隔符是反斜杠\,一般来说我们需要手动转成linux下的正斜杠/,可以用正则replace替换

    • slash这个库,可以自动将反斜杠转成正斜杠

你可能感兴趣的:(webpack,工作原理,工作流,compiler,原理)