阅读 webpack 源码简单了解打包过程

简介: 通过阅读 webpack 源码简单了解打包过程~

我们先看一下 webpack 官方对 webpack 的定义:

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler) 。
当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph) ,
其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

webpack 作为一个打包器,相信大部分前端开发者是有使用过的,我也不例外。我所在的项目组是开发 H5 游戏的,随着所使用的引擎(egret)增加了对 webpack 的支持,我们项目也从原来官方的打包方式升级为用 webpack 打包。

之前项目从头开始编译得三十秒左右,后面每次改动后再编译也得十几秒左右。升级为 webpack 之后,除了第一次编译比较久,大概得一分钟左右,后面每次修改代码后只需要两三秒就完成热更新了,和之前的对比起来体验好了很多。

改用 webpack 打包对项目编译速度提升这么大,这也让我好奇它具体的一个执行流程是如何的,接下来便开始本篇文章的正题,通过阅读 webpack 的源码来简单地了解一下打包的过程。

注:webpack 版本为 v5.10.1,webpack-cli 版本为 4.2.0

一 入口

当我们使用 webpack-cli 对项目进行打包的时候,实际上调用的就是当前项目中的 node_modules 中的 webpack-cli 中的 /bin/cli.js,我们将 webpack-cli 的代码下载到本地上,打开 cli.js 可以看到它走了 runCLI 的逻辑。

if (packageExists('webpack')) {
    runCLI(rawArgs);
} else {...
}

跳转之后我们可以发现 runCLI 中执行了 const cli = new WebpackCLI() 并调用了 cli.run, 然后执行了 createCompiler 创建 compiler 对象,然后就进入到 webpack 的打包流程中了。

createCompiler(options, callback) {
    let compiler;
    try {
        compiler = webpack(options, callback);
    } catch (error) {...
    }
    return compiler;
}

二 打包流程的前置知识

在开始打包流程之前,我们需要了解一个叫 Tapable 的库,这个库是 webpack 团队为了 webpack 而写的一个事件库。因为 webpack 本质上是一个分发各种事件的架子,然后通过不同的 plugin 监听这些事件再分别执行对应的操作。

Tapable 用法

定义一个事件:this.hooks.eventName = new SyncHook([arg]);

监听一个事件:this.hooks.eventName.tap(reason, fn);

分发一个事件:this.hooks.eventName.call(arg);

三 打包流程

webpack 源码中整个打包流程如下,具体的过程下面进行介绍。

image.png

打开 webpack 的源码,进入到 package.json 中我们可以发现 main 指向的是 lib/index.js,那么我们的入口就是 lib/index.js 了。

进入到 index.js 中,将代码折叠起来后我们可以发现最重要的函数应该是 fn 函数。fn 函数的作用是去请求当前目录下 webpack.js。

const fn = lazyFunction(() => require("./webpack"));
module.exports = mergeExports(fn, {...
});

webpack.js

进入到 webpack.js 中,根据上面入口的代码 compiler = webpack(options, callback); 可知,在有无 callback 的判断中,会进入有的判断中,然后执行 create 函数,create 函数中我们的 options 是对象,所以是走 else 的逻辑,执行了 createCompiler 函数。

const webpack = /** @type {WebpackFunctionSingle & WebpackFunctionMulti} */ ((
    options,
    callback
) => {
    const create = () => {
        ...
        if (Array.isArray(options)) {...
        } else {
            /** @type {Compiler} */
            compiler = createCompiler(options);
            watch = options.watch;
            watchOptions = options.watchOptions || {};
        }
        return { compiler, watch, watchOptions };
    };
    if (callback) {
        try {
            const { compiler, watch, watchOptions } = create();
            if (watch) {
                compiler.watch(watchOptions, callback);
            } else {
                compiler.run((err, stats) => {
                    compiler.close(err2 => {
                        callback(err || err2, stats);
                    });
                });
            }
            return compiler;
        } catch (err) {...
        }
    } else {...
    }
});

createCompiler 函数这里就开始我们上面那张图的内容了,new Compiler(options.context); 中进行了一系列参数的初始化,有兴趣的可以自己去看一下。然后就开始进行事件的挂载了,事件都是挂载在 Compiler 上的,Compiler 的代码在 compiler.js 中。

可以看到按照 enviroment -> afterEnviroment -> initialize 这个顺序进行事件的分发,然后再在对应的地方进行监听和响应,要了解具体的响应的话可以在代码中直接全局进行搜索 事件名.tap 例如 enviroment.tap。这些不是特别关键的步骤就不进行细看了。

const createCompiler = rawOptions => {
    ...
    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();
    compiler.hooks.initialize.call();
    return compiler;
};

createCompiler 函数执行完后回到 create 函数中,判断当前有无 watch,再决定执行 compiler.watch 还是 compiler.run。因为这是第一次编译,所以肯定不是处于 watch 状态,就进入到 compiler.run 也即 compiler.js 中了。

compiler.js

compiler.js 中的 run 函数如下所示,执行了函数里面自己定义的 run 箭头函数,又开始了各种事件的分发,同事调用了 readRecords 进行文件的读取,然后调用了 this.compile 并把 onCompiled 做为回调函数传了进去。

run(callback) {
    const onCompiled = (err, compilation) => {...}
    const run = () => {
        this.hooks.beforeRun.callAsync(this, err => {
            ...
            this.hooks.run.callAsync(this, err => {
                ...
                this.readRecords(err => {
                    ...
                    this.compile(onCompiled);
               });
            });
          });
    };

    if (this.idle) {
        ...
    run();
    });
    } else {
        run();
    }
}

compile 函数分发了 beforeCompilecompile 事件,然后就执行了 his.newCompilation 函数,创建了一个 compilation 对象,接着继续分发 makefinishMake 事件,make 是编译的第一个过程,这个需要留意一下。

然后调用了 compilation.finish 并在其回调里面调用了 compilation.seal,这两个我们后面再看里面的具体逻辑。接着分发了一个 afterCompile 事件表明编译阶段结束了,然后调用了 callback 即上面 run 函数中的 onCompiled 函数。

compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err => {
        ...
        this.hooks.compile.call(params);
        const compilation = this.newCompilation(params);
        this.hooks.make.callAsync(compilation, err => {
            this.hooks.finishMake.callAsync(compilation, err => {
                process.nextTick(() => {
                    compilation.finish(err => {
                        compilation.seal(err => {
                            this.hooks.afterCompile.callAsync(compilation, err => {...
                            });
                        });
                    });
                });
            });
        });
    });
}

onCompiled 函数里面分发了 shouldEmit 事件,然后进入 nextTick 函数中,执行了 this.emitAssets 函数,this.emitAssets 函数里面分发了一个 emit 事件然后读取了要打包的文件并定义了一个输出的文件。

接着执行 this.emitRecords 然后分发 done 事件,表示打包结束。

const onCompiled = (err, compilation) => {
    if (err) return finalCallback(err);
        if (this.hooks.shouldEmit.call(compilation) === false) {...
        }
        process.nextTick(() => {
            this.emitAssets(compilation, err => {
                if (compilation.hooks.needAdditionalPass.call()) {...
            }
            this.emitRecords(err => {
                ...
                this.hooks.done.callAsync(stats, err => {...
                }
            });
        });
    });
};

然后我们回到 compilation.finsih 函数中,它分发了 finishModules 事件对模块进行了一些操作,然后对每个模块都执行了 callback 函数。

finish(callback) {
    const { modules } = this;
    this.hooks.finishModules.callAsync(modules, err => {...
        for (const module of modules) {...
            callback();
        }
    });
}

再看看 compilation.seal 函数,在这里创建了 chunkGraph 对象,这个对象就是用来收集所有模块的关系的,接着就是分发各种事件。

seal(callback) {
    const chunkGraph = new ChunkGraph(this.moduleGraph);
    this.chunkGraph = chunkGraph;

    for (const module of this.modules) {
        ChunkGraph.setChunkGraphForModule(module, chunkGraph);
    }

    this.hooks.seal.call();
    this.hooks.afterOptimizeDependencies.call(this.modules);
    this.hooks.beforeChunks.call();
    for (const [name, { dependencies, includeDependencies, options }] of this.entries) {
        const chunk = this.addChunk(name);
        ...
    }
    buildChunkGraph(this, chunkGraphInit);
    this.hooks.afterChunks.call(this.chunks);
    this.hooks.optimize.call();

小结

到这里打包流程我们就大概的都看了一遍,整个流程比较重要的内容就是上面那张包含事件和函数的图,主要的流程包括 env -> init -> run -> beforeCompile -> compile -> compilation -> make -> finishMake -> afterCompile -> emit -> afterEmit -> done 这些事件。

看完上面的内容你可能还是一脸迷茫,不知道到底 webpack 打包过程中做了哪些内容,下面我们通过回答另外一个问题来加深对源码的理解。

webpack 是如何收集和分析依赖的?

首先我们需要知道 webpack 是在哪个阶段进行依赖的分析和收集的,根据上面的流程,通过排除法可以知道最有可能的就是在 makefinishMake 阶段。

翻到上面看下 compile 函数的代码,你会发现 makefinishMake 中间并没有做什么操作,根据 Table 库的知识点可知,我们需要找到监听 make 事件的地方即 make.tap

搜索之后你会发现有九个结果,一个个看了之后我们可以发现 EntryPlugin.js 中的监听函数是最符合我们的预期的,EntryPlugin.createDependency 顾名思义就是创建依赖的地方,然后执行 compilation.addEntry 将依赖添加到编译中。

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
    const { entry, options, context } = this;

    const dep = EntryPlugin.createDependency(entry, options);
    compilation.addEntry(context, dep, options, err => {
            callback(err);
    });
});

接下来我们继续往里面看,经过 compilation.addEntry -> this._addEntryItem -> this.addModuleChain -> this.handleModuleCreation -> this.factorizeModule 的跳转之后,我们可以发现最后就是往 factorizeQueue 中添加了一个任务,到这里就没了。

我们需要了解一下 factorizeQueue 是什么,这里才知道它里面是如何处理的。根据下面的代码我们可以看到它的处理函数即 processorthis._factorizeModule。处理函数里面主要就是执行了 factory.create,到这里发现前面又没有路了。那么我们需要找到这个 factory 是什么,这个寻找的过程比较麻烦,这里就省略不讲了,最后寻找的结果是 factory 对应 NormalModuleFactory

this.factorizeQueue = new AsyncQueue({
    name: "factorize",
    parallelism: options.parallelism || 100,
    processor: this._factorizeModule.bind(this)
});

NormalModuleFactory 中找到 create 函数,可以看到 factorize 进行了事件监听,搜索 factorize.tap 可以看到里面进行了一系列事件的监听: reslove -> afterResolve -> createModule,由此可知 factory.create 最终创建了一个 module 对象。

this.factorizeModule 执行完后就到它的回调函数里面,回调函数中调用了 this.addModule 函数,这个函数里面和 this.factorizeModule 了类似,也是往一个队列里面添加了一个任务之类,进入到它的 processor 中,可以知道它的作用就是把 module 添加到 compilation.modules 中,同时会检查 id 防止重复添加。

接着到 this.addModule 的回调函数中,它在里面调用了 this.buildModule,从名称我们可以推测依赖很大可能就是在这个函数里面进行收集的。this.buildModule 中也是和上面类似的操作,它的 processor 就在下面的 _buildModule 函数中。_buildModule 中调用了 module.needBuild,而 module.needBuild 里面调用了 module.build,这个 module 由上面的 NormalModuleFactory 可知它对应的代码应该在 NormalModule.js 中。

NormalModule.js 中搜索 build 函数,我们可以注意到在这里有对 sourceast 进行初始化。接着我们进入到 doBuild 函数中。

build(options, compilation, resolver, fs, callback) {
    ...
    this._source = null;
    this._ast = null;
    ...
    return this.doBuild(options, compilation, resolver, fs, err => {...})

可以看到 doBuild 函数执行了 runLoaders,顾名思义这个就是执行所有的 loader,读取所有文件的源代码。runLoaders 执行完后回调里面调用了 processResult 函数,在 processResult 里面对 _source 进行了赋值,但是 _ast 此时还是空的,因为之前没有对它进行过处理,它的赋值应该是后面才进行的。

doBuild(options, compilation, resolver, fs, callback) {
    const loaderContext = this.createLoaderContext(...);
    const processResult = (err, result) => {
        ...
        this._source = this.createSource(...);
        this._ast =
            typeof extraInfo === "object" && 
            extraInfo !== null && 
            extraInfo.webpackAST !== undefined ? 
            extraInfo.webpackAST : null;
        ...
    }
    runLoaders({...}, (err, result) => {
        ...
        processResult(err, result.result);
    });

执行完 doBuild 函数我们再回到上面它的回调里面,可以看到在 try 中执行了 parse 的操作,对源码(this._source.source())进行了解析,这个 parser 是根据源码的不同去调用不同的 parser,例如解析 JavaScript 代码的话就去调用了 JavascriptParser.js 中的 parse 函数。

this.doBuild(options, compilation, resolver, fs, err => {
    ...
    let result;
    try {
        result = this.parser.parse(this._ast || this._source.source(), {...});
    } catch (e) {
        ...
    }
})

JavascriptParser.js 中的 parse 函数中调用了 this.blockPreWalkStatements(ast.body); 函数,这个函数就是进行依赖分析的地方。分析的过程就是将源码的每行代码都进行分析,如果发现当前的代码是引用相关的,就根据 statement.type 分别进行处理。

blockPreWalkStatements(statements) {
    for (let index = 0, len = statements.length; index < len; index++) {
        const statement = statements[index];
        this.blockPreWalkStatement(statement);
    }
}

blockPreWalkStatement(statement) {
    ...
    switch (statement.type) {
        case "ImportDeclaration":
            this.blockPreWalkImportDeclaration(statement);
            break;
        case "ExportAllDeclaration":
            this.blockPreWalkExportAllDeclaration(statement);
            break;
        case "ExportDefaultDeclaration":
            this.blockPreWalkExportDefaultDeclaration(statement);
            break;
        case "ExportNamedDeclaration":
            this.blockPreWalkExportNamedDeclaration(statement);
            break;
        case "VariableDeclaration":
            this.blockPreWalkVariableDeclaration(statement);
            break;
        case "ClassDeclaration":
            this.blockPreWalkClassDeclaration(statement);
            break;
    }
}

到这里我们可以回答上面提出的那个问题了,webpack 是如何收集和分析依赖的?

doBuild 中执行 runLoaders 将所有的文件转化为源码,然后在 doBuild 的回调中将源码进行 parse 转化为 AST,然后再根据 AST 对每一行代码进行分析,发现是引用相关的就将其进行处理。

后面的内容我们在这里就不具体分析了,大概描述下就是上面的引用相关会被添加到 module 的 dependencies 或 blocks 中,然后再 seal 阶段 webpack 将 module 转化为 chunk,并且可能会把多个 module 通过 codeGeneration 合并为一个 chunk,seal 结束之后为每个 chunk 创建文件,并写到硬盘上。

目录
相关文章
|
3月前
|
JavaScript
webpack打包TS
webpack打包TS
138 60
|
2月前
|
缓存 前端开发 JavaScript
Webpack 打包的基本原理
【10月更文挑战第5天】
|
2月前
|
前端开发 JavaScript
ES6模块化和webpack打包
【10月更文挑战第5天】
|
2月前
|
缓存 前端开发 JavaScript
深入了解Webpack:模块打包的革命
【10月更文挑战第11天】深入了解Webpack:模块打包的革命
|
3月前
|
JavaScript 测试技术 Windows
vue配置webpack生产环境.env.production、测试环境.env.development(配置不同环境的打包访问地址)
本文介绍了如何使用vue-cli和webpack为Vue项目配置不同的生产和测试环境,包括修改`package.json`脚本、使用`cross-env`处理环境变量、创建不同环境的`.env`文件,并在`webpack.prod.conf.js`中使用`DefinePlugin`来应用这些环境变量。
162 2
vue配置webpack生产环境.env.production、测试环境.env.development(配置不同环境的打包访问地址)
|
2月前
|
缓存 前端开发 JavaScript
Webpack技术深度解析:模块打包与性能优化
【10月更文挑战第13天】Webpack技术深度解析:模块打包与性能优化
|
2月前
|
前端开发 JavaScript 开发者
深入了解Webpack:现代JavaScript应用的打包利器
【10月更文挑战第11天】 深入了解Webpack:现代JavaScript应用的打包利器
|
3月前
|
缓存
webpack 打包多页面应用
webpack 打包多页面应用
26 1
|
3月前
webpack 打包多页面应用
webpack 打包多页面应用
|
3月前
|
前端开发 开发者
在前端开发中,webpack 作为一个强大的模块打包工具,为我们提供了丰富的功能和扩展性
【9月更文挑战第1天】在前端开发中,Webpack 作为强大的模块打包工具,提供了丰富的功能和扩展性。本文重点介绍 DefinePlugin 插件,详细探讨其原理、功能及实际应用。DefinePlugin 可在编译过程中动态定义全局变量,适用于环境变量配置、动态加载资源、接口地址配置等场景,有助于提升代码质量和开发效率。通过具体配置示例和注意事项,帮助开发者更好地利用此插件优化项目。
88 13
下一篇
DataWorks