webpack打包原理

804 阅读7分钟

ast语法树

有三个操作ast语法树的api

  • esprima:将源代码转化为抽象语法树
  • estraverse:遍历抽象语法树,修改书上的语法节点
  • escodegen:将抽象语法树生成代码
let esprima = require('esprima');
let estraverse = require('estraverse');
let escodegen = require('escodegen');
let sourceCode = 'function ast(){}';
let ast = esprima.parse(sourceCode);
let indent = 0;
let visitor = {
    enter(node, parent) {
        console.log(node.type);
        if (node.type === 'FunctionDeclaration') {
            node.id.name = 'newFunction';
        }
        indent++;
    },
    leave(node, parent) {
        indent--;
        console.log(node.type);
    }
}
estraverse.traverse(ast,visitor);

//重新生成源代码,经过上面的步骤,已经将函数名转化为newFunction了
let newSourceCode = escodegen.generate(ast);
console.log(newSourceCode);

webpack打包流程

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

初始化参数

function webpack(options) {

    // [ '--mode=development']
    const argv = process.argv.slice(2);

    // 获取shell配置,返回一个对象 { mode: development }
    const shellOptions = argv.reduce((shellOptions, option) => {
        let [key, value] = option.split('=');
        shellOptions[key.slice(2)] = value;
        return shellOptions;
    }, {});

    // 合并用户配置和shell配置,得到最终的配置对象finalOptions
    const finalOptions = { ...options, ...shellOptions };

    //2.用上一步得到的参数初始化 Compiler 对象
    let compiler = new Compiler(finalOptions);

    //3.加载所有配置的插件,调用插件对象的apply方法,并将compiler实例传进去
    finalOptions.plugins.forEach(plugin => plugin.apply(compiler));
    return compiler;
}

tapable

// webpack中使用的是tapable,这里起到发布订阅的功能
//let { SyncHook } = require('tapable');
class SyncHook {
    constructor(args) {
        
        // 参数
        this.args = args;

        // 事件池
        this.taps = [];
    }

    // 注册事件
    tap(name, fn) {
        this.taps.push(fn);
    }

    // 触发事件
    call(...args) {
        this.taps.forEach((tap) => tap(...args));
    }
} 
let syncHook = new SyncHook(['name', 'age']);

// 注册事件、等同于on
syncHook.tap('监听器的名字1(没有实际意义)', (name, age) => {
    console.log(name, age);
})
syncHook.tap('监听器的名字2(没有实际意义)', (name, age) => {
    console.log(name, age);
})

// 触发事件执行、等同于emit
syncHook.call('myName', 13);

编译器Compiler

/**
 * 代表整个编译对象,负责整个编译的过程,里面会保存所有的编译的信息
 * Compiler类的实例全局唯一
 * 会将compiler实例作为参数传递给所有插件的apply方法
 */
class Compiler {
    constructor(options) {
        this.options = options;

        //存的是当前的Compiler上面的所有的钩子
        this.hooks = {
            //开始编译事件
            run: new SyncHook(),
            //编译结束事件
            done: new SyncHook() 
        }
    }

    //4.执行对象的 run 方法开始执行编译
    run(callback) {

        //在执行Compiler的run方法开头触发run这个钩子
        this.hooks.run.call();
        const onCompiled = (err, stats, fileDependencies) => {
            //10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
            for (let filename in stats.assets) {
                let filePath = path.join(this.options.output.path, filename);
                fs.writeFileSync(filePath, stats.assets[filename], 'utf8');
            }
            callback(null, {
                toJson: () => stats
            });
            fileDependencies.forEach(fileDependency => {
                fs.watch(fileDependency, () => this.compile(onCompiled));
            });
        }
        this.compile(onCompiled);
        //编译过程....
        this.hooks.done.call();
    }
    compile(onCompiled) {
        //以后每次开启一次新的编译 ,都会创建一个新的Compilation类的实例
        let compilation = new Compilation(this.options);
        compilation.build(onCompiled);
    }
}

调用compiler的run方法

  • 调用compiler.run
const webpack = require('./webpack');
const options = require('./webpack.config');
const compiler = webpack(options);
//4.执行编译器对象的 run 方法开始执行编译 run 方法开始执行编译
compiler.run((err, stats) => {
    console.log(err);
    console.log(stats.toJson());
});
  • Compiler
class Compiler {
    constructor(options) {
        this.options = options;
        this.hooks = {
            run: new SyncHook(), 
            done: new SyncHook()
        }
    }
    run(callback) {

        // 编译开始,触发run钩子
        this.hooks.run.call();

        // 编译结束的回调
        const onCompiled = (err, stats, fileDependencies) => {
            // 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
            // TODO...
        }

        // 进行编译
        this.compile(onCompiled);

        // 编译结束后触发done钩子
        this.hooks.done.call();
    }
    compile(onCompiled) {

        //每次新的新的编译 ,都会创建一个新的Compilation类的实例
        let compilation = new Compilation(this.options);

        // 开始构建
        compilation.build(onCompiled);
    }
}

调用compilation的build方法

const path = require('path').posix;
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 = toUnixPath(process.cwd());
function toUnixPath(filePath) {
    return filePath.replace(/\\/g, '/');
}
class Compilation {
    constructor(options) {

        // 配置对象
        this.options = options;

        //存放本次编译的所有的模块
        this.modules = [];

        //当前编译依赖的文件
        this.fileDependencies = [];
        
        //里面放置所有的chunk
        this.chunks = [];

        // 输出资源
        this.assets = {};
    }

    // 构建
    build(onCompiled) {
    
        // 5. 根据配置中的entry找出入口文件
        let entry = {};
    
        // 兼容entry的值是对象和字符串的情况
        if (typeof this.options.entry === 'string') {
            entry.main = this.options.entry;
        } else {
            entry = this.options.entry;
        }

        // 遍历入口对象 {main: './src/title.js'}
        for (let entryName in entry) {

            //获取到了所有的入口文件的绝对路径
            let entryPath = path.join(baseDir, entry[entryName]);

            // 当前的入口路径添加到文件依赖中
            this.fileDependencies.push(entryPath);

            // 6. 从入口文件出发,调用所有配置的Loader对模块进行编译
            let entryModule = this.buildModule(entryName, entryPath);
            //8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
            let chunk = {
                name: entryName,//代码块名称是入口名称
                entryModule,///入口模块
                //这个入口代码块中包含哪些模块
                modules: this.modules.filter(module => module.names.includes(entryName))
            }
            this.chunks.push(chunk);
            //9.再把每个 Chunk 转换成一个单独的文件加入到输出列表
            this.chunks.forEach(chunk => {
                let filename = this.options.output.filename.replace('[name]', chunk.name);
                this.assets[filename] = getSource(chunk);
            });
        }

        // 编译完成、触发回调,根据参数生成输出文件
        onCompiled(null, {
            modules: this.modules,
            chunks: this.chunks,
            assets: this.assets
        }, this.fileDependencies);
    }
    /**
     * 编译模块
     * @param {*} name 入口的名称 entry1 entry2
     * @param {*} modulePath 模块的路径
     */
    buildModule(name, modulePath) {
        //6.从入口文件出发,调用所有配置的Loader对模块进行编译
        // 读取源代码的内容
        let sourceCode = fs.readFileSync(modulePath, 'utf8');
        // 匹配此模块需要使用的loader
        let { rules } = this.options.module;
        let loaders = [];

        // 遍历规则数组
        rules.forEach(rule => {

            //如果正则匹配上了,则把此rule对应的loader添加到loaders数组里
            if (modulePath.match(rule.test)) {
                loaders.push(...rule.use);
            }
        });

        // 使用loader对文件进行处理(loader顺序=>从右向左),得到最终的代码 sourceCode
        sourceCode = loaders.reduceRight((sourceCode, loader) => {
            return require(loader)(sourceCode);
        }, sourceCode);


        // 7. 再找出该模块依赖的模块,再递归本步骤(buildModule)直到所有入口依赖的文件都经过了本步骤的处理
        // "./src/title.js" 每个模块都有一个ID,   id: './src/entry1.js',
        // 模块ID就是相对于项目根目录的相对路径
        let moduleId = "./" + path.relative(baseDir, modulePath);

        //创建一个模块对象,moduleId是相对于项目根目录的相对路径 dependencies表示此模块依赖的模块 
        //names表示此模块添几个入口依赖了,入口的名称 [entry1,entry2]
        let module = { id: moduleId, dependencies: [], names: [name] };

        // 将源代码转化为ast语法树
        let ast = parser.parse(sourceCode, { sourceType: 'module' });

        // 对语法树进行分析处理
        traverse(ast, {
            CallExpression: ({ node }) => {
                if (node.callee.name === 'require') {
                    let depModuleName = node.arguments[0].value;//./title
                    //获取当前模块所有的目录 => xxx/xxx/src
                    let dirname = path.dirname(modulePath);

                    // xxx/xxx/src/title
                    let depModulePath = path.join(dirname, depModuleName);

                    //获取当前支持扩展名
                    let extensions = this.options.resolve.extensions;

                    //获取依赖的模块的绝对路径(尝试添加拓展名)
                    depModulePath = tryExtensions(depModulePath, extensions);

                    //把此依赖文件添加到依赖数组里,当文件变化了,会重新启动编译 ,创建一个新的Compilation
                    this.fileDependencies.push(depModulePath);

                    //获取依赖模块的模块iD,也就是相对于根目录的相对路径
                    let depModuleId = './' + path.relative(baseDir, depModulePath);

                    //修改AST语法对,把require方法的参数变成依赖的模块ID
                    node.arguments = [types.stringLiteral(depModuleId)];

                    //把依赖信息添加到依赖数组里
                    module.dependencies.push({ depModuleId, depModulePath });
                }
            }
        });

        // 生成新的代码
        let { code } = generator(ast);
        module._source = code;
        module.dependencies.forEach(({ depModuleId, depModulePath }) => {
            let buildedModule = this.modules.find(module => module.id === depModuleId);
            if (buildedModule) {
                //title这个module.names = [entry1,entry2];
                buildedModule.names.push(name);
            } else {
                let depModule = this.buildModule(name, depModulePath);
                this.modules.push(depModule);
            }
        });
        return module;
    }
}

/**
 * 尝试给当前的路径添加扩展名
 * @param {*} modulePath 
 * @param {*} extensions 
 */
function tryExtensions(modulePath, extensions) {

    // 如果modulePath存在,不需要添加后缀
    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) {
    return `
   (() => {
    var modules = {
      ${chunk.modules.map(
        (module) => `
        "${module.id}": (module) => {
          ${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}
  })();
   `;
}

生成文件

class Compiler {
    constructor(options) {
        this.options = options;
        //存的是当前的Compiler上面的所有的钩子
        this.hooks = {
            run: new SyncHook(), //开始编译的时候触发
            done: new SyncHook() //编译结束的时候触发
        }
    }
    //4.执行对象的 run 方法开始执行编译
    run(callback) {
        //在执行Compiler的run方法开头触发run这个钩子
        this.hooks.run.call();
        const onCompiled = (err, stats, fileDependencies) => {

            //10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
            for (let filename in stats.assets) {
                let filePath = path.join(this.options.output.path, filename);
                fs.writeFileSync(filePath, stats.assets[filename], 'utf8');
            }
            callback(null, {
                toJson: () => stats
            });
            
            fileDependencies.forEach(fileDependency => {
                fs.watch(fileDependency, () => this.compile(onCompiled));
            });
        }
        this.compile(onCompiled);
        //编译过程....
        this.hooks.done.call();
    }
    compile(onCompiled) {
        //以后每次开启一次新的编译 ,都会创建一个新的Compilation类的实例
        let compilation = new Compilation(this.options);
        compilation.build(onCompiled);
    }
}