天天看点

阅读 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 源码中整个打包流程如下,具体的过程下面进行介绍。

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

打开 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

函数分发了

beforeCompile

compile

事件,然后就执行了

his.newCompilation

函数,创建了一个 compilation 对象,接着继续分发

make

finishMake

事件,

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 是在哪个阶段进行依赖的分析和收集的,根据上面的流程,通过排除法可以知道最有可能的就是在

make

finishMake

阶段。

翻到上面看下

compile

函数的代码,你会发现

make

finishMake

中间并没有做什么操作,根据

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

是什么,这里才知道它里面是如何处理的。根据下面的代码我们可以看到它的处理函数即

processor

this._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

函数,我们可以注意到在这里有对

source

ast

进行初始化。接着我们进入到

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 创建文件,并写到硬盘上。

继续阅读