聊聊webpack的那些事

7,569 阅读13分钟

hello,大家好,我是德莱问,又和大家见面了。

前端构建工具的发展已经很久了,从最开始的不进行编译;到后来的grunt、gulp,流式的进行编译;到现在的webpack;在现在看来,webpack俨然成为前端打包编译的趋势,而且由于webpack的社区比较强大,发展也是非常迅猛;webpack经过这几年的发展,已经更新迭代到了5版本,较之前版本也是增加了很多功能,包括webpack本身的一些plugin的实现和钩子函数的调用方式,发生了比较大的区别。本文将会对webpack的主流程、tapable功能、chunks的使用等方面进行讲解,最后会给出一份webpack的优化指南供大家参考。

如有讲解出错,欢迎指出,欢迎探讨。

webpack和rollup

提到webpack,也就不得不提rollup。

webpack是什么?根据webpack产出的默认文件名《bundle.js》,我们可以很清晰的知道webpack其实就是一个打包器,它会根据你的入口找到JavaScript模块以及一些浏览器不能直接运行的语言(scss/less,typescript等),把它打包编译为浏览器可以运行的语言,以供浏览器去使用。

rollup是什么?rollup当前也是非常流行的一个打包工具,rollup旨在使用ES6的语法对代码进行编译处理。

webpack和rollup都是非常优秀的打包工具,都是对模块化支持特别好的打包工具;rollup的使用一般是在一些框架库(例如vue、react)的打包上面,webpack则是相当于面向大众、实际项目当中去使用;两者都支持tree-shaking,不过tree-shaking的概念是由rollup首先提出来的,所以在tree-shaking方面,rollup做的更好,webpack支持tree-shaking是需要通过一些压缩js的工具来实现的,例如UglifyJsPlugin和TerserPlugin。

两个打包工具都很优秀,webpack的插件功能非常强大,在npm上面有非常多的插件。在对打包工具做选择的时候,还是遵循上面的原则:

  • 框架库使用rollup,打包出来的代码会体积更加小;
  • 业务、项目中使用webpack,webpack更加面向浏览器。

webpack主流程

说完webpack和rollup的区别,我们现在来看下webpack对一个项目是如何进行编译的,这部分可能会涉及到配置方面的东西,不过关于webpack的配置和使用,我们不做讲解;webpack-cli部分对命令行输入的命令参数等的校验,我们不做讲解;我们直接从webpack函数开始讲起。

webpack的参数校验

webpack函数调用一开始,就会调用内部的一个函数对我们的传入进去的options(也就是我们webpack.config.js的内容)进行校验。

const webpackOptionsValidationErrors = validateSchema(
    webpackOptionsSchema,
    options
);
if (webpackOptionsValidationErrors.length) {
    throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
}

webpackOptionsSchema是一个json的配置,options也就是我们上面讲到的传进来的options;调用validateSchema方法去对进行参数校验。此处其实webpack调用了一个校验的库来实现参数的校验功能;ajv(Another JSON Schema Validator),文档地址。当然webpack对其进行浅封装,会返回一个error的数组,如果有error产生,,则会调用WebpackOptionsValidationError,编译报错。

webpack的compiler生成

webpack是支持传递一个数组的options进去的,不过在这里我们不做讨论,我们只讨论单个的编译。首先会对options进行处理。

options = new WebpackOptionsDefaulter().process(options);

此处会调用WebpackOptionsDefaulter,WebpackOptionsDefaulter继承自OptionsDefaulter,WebpackOptionsDefaulter的构造函数里面会为调用父类的set方法去设置一些默认的属性进去,确保了webpack的开箱即用。

// OptionsDefaulter
set(name, config, def) {
    if (def !== undefined) {
        this.defaults[name] = def;
        this.config[name] = config;
    } else {
        this.defaults[name] = config;
        delete this.config[name];
    }
}

接下来调用了process函数,process是父类OptionsDefaulter的方法,此方法接受的参数也就是上面讲到的经过了校验的options;

process(options) {
    options = Object.assign({}, options);
    for (let name in this.defaults) {
        switch (this.config[name]) {
            case undefined:
                if (getProperty(options, name) === undefined) {
                    setProperty(options, name, this.defaults[name]);
                }
                break;
            case "call":
                setProperty(
                    options,
                    name,
                    this.defaults[name].call(this, getProperty(options, name), options)
                );
                break;
            case "make":
                if (getProperty(options, name) === undefined) {
                    setProperty(options, name, this.defaults[name].call(this, options));
                }
                break;
            case "append": {
                let oldValue = getProperty(options, name);
                if (!Array.isArray(oldValue)) {
                    oldValue = [];
                }
                oldValue.push(...this.defaults[name]);
                setProperty(options, name, oldValue);
                break;
            }
            default:
                throw new Error(
                    "OptionsDefaulter cannot process " + this.config[name]
                );
        }
    }
    return options;
}

可以看到,这部分的代码主要是做合并操作,对不同的属性调用不同的case,关于某个属性到底是调用哪个case,这部分是在WebpackOptionsDefaulter构造函数里面调用父类OptionsDefaulter的set方法来决定的。最后会把处理完成后的新的一个options对象返回。

  • 像我们熟知的entry调用的是第一个case,也就是undefined,直接赋值;
  • 像devtool,则会调用第二个case,也就是make,则会调用默认的函数来处理;
  • 像output,则会调用call case来处理,通过默认函数的方式返回一个新的经过处理后的对象;
  • append case主要是用来添加数组类型的配置。不过并没有在webpack的默认配置里面看到这方面的使用。

接下来webpack就会调用生成最主要的compiler,其实compiler的生成主要涉及到的地方是当前所需要编译的项目的地址,也就是options.context,不过这个context是不能通过配置来更改的,就是当前执行webpack命令的文件夹(process.pwd());同时把上面返回的options赋值给compiler实例。

compiler = new Compiler(options.context);
compiler.options = options;
new NodeEnvironmentPlugin().apply(compiler);

到这里,compiler的生成其实已经算是结束了,接下来的部分是属于挂载一些plugin的阶段。webpack最强大的功能其实就是模块化和plugin。

说明:compiler是webpack的主要引擎,完整记录了webpack的环境信息,webpack从开始到结束只生成一次compiler,同时在compiler上面可以获取到webpack的所有config信息

NodeEnvironmentPlugin

我们在这里先说下webpack的缓存处理,,这个插件是最先加载的,也就是上面的NodeEnvironmentPlugin。先看下这个插件的源码:

const NodeWatchFileSystem = require("./NodeWatchFileSystem");
const NodeOutputFileSystem = require("./NodeOutputFileSystem");
const NodeJsInputFileSystem = require("enhanced-resolve/lib/NodeJsInputFileSystem");
const CachedInputFileSystem = require("enhanced-resolve/lib/CachedInputFileSystem");

class NodeEnvironmentPlugin {
	apply(compiler) {
		compiler.inputFileSystem = new CachedInputFileSystem(
			new NodeJsInputFileSystem(),
			60000
		);
		const inputFileSystem = compiler.inputFileSystem;
		compiler.outputFileSystem = new NodeOutputFileSystem();
		compiler.watchFileSystem = new NodeWatchFileSystem(
			compiler.inputFileSystem
		);
		compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
			if (compiler.inputFileSystem === inputFileSystem) 
				inputFileSystem.purge();
		});
	}
}
module.exports = NodeEnvironmentPlugin;
  • NodeJsInputFileSystem这个对象是对《graceful-fs》的封装,《graceful-fs》又是对fs的封装,使用这个对象来处理输入,包括了stat、statSync、readFile、readFileSync、readlink、readlinkSync、readdir、readdirSync;
  • CachedInputFileSystem是用来做缓存处理的;
  • NodeOutputFileSystem是用来输出文件的,内部很简单,就是fs的一些方法的绑定到当前对象;
  • NodeWatchFileSystem是用来监听文件变化的;
  • 此plugin是在compiler的beforeRun阶段进行广播调用。相当于每次run之前会先把之前的cache进行清空处理。 关于NodeWatchFileSystem,盗图一张: 下面我们来简单看下CachedInputFileSystem这个对象。
class CachedInputFileSystem {
	// fileSystem = NodeJsInputFileSystem的实例,duration为60000
	constructor(fileSystem, duration) {
		this.fileSystem = fileSystem;
        // 为每一种文件读取方式类型创建一个Storage的实例。
		this._statStorage = new Storage(duration);
		this._readdirStorage = new Storage(duration);
		this._readFileStorage = new Storage(duration);
		this._readJsonStorage = new Storage(duration);
		this._readlinkStorage = new Storage(duration);
		// 为this._*系列赋值为fileSystem对应的方法;
		this._stat = this.fileSystem.stat ? this.fileSystem.stat.bind(this.fileSystem) : null;
		if(!this._stat) this.stat = null;

		this._statSync = this.fileSystem.statSync ? this.fileSystem.statSync.bind(this.fileSystem) : null;
		if(!this._statSync) this.statSync = null;

		this._readdir = this.fileSystem.readdir ? this.fileSystem.readdir.bind(this.fileSystem) : null;
		if(!this._readdir) this.readdir = null;

		this._readdirSync = this.fileSystem.readdirSync ? this.fileSystem.readdirSync.bind(this.fileSystem) : null;
		if(!this._readdirSync) this.readdirSync = null;

		this._readFile = this.fileSystem.readFile ? this.fileSystem.readFile.bind(this.fileSystem) : null;
		if(!this._readFile) this.readFile = null;

		this._readFileSync = this.fileSystem.readFileSync ? this.fileSystem.readFileSync.bind(this.fileSystem) : null;
		if(!this._readFileSync) this.readFileSync = null;
		// 自己实现readJson方法
		if(this.fileSystem.readJson) {
			this._readJson = this.fileSystem.readJson.bind(this.fileSystem);
		} else if(this.readFile) {
			this._readJson = (path, callback) => {
				this.readFile(path, (err, buffer) => {
					if(err) return callback(err);
					let data;
					try {
						data = JSON.parse(buffer.toString("utf-8"));
					} catch(e) {
						return callback(e);
					}
					callback(null, data);
				});
			};
		} else {
			this.readJson = null;
		}
		if(this.fileSystem.readJsonSync) {
			this._readJsonSync = this.fileSystem.readJsonSync.bind(this.fileSystem);
		} else if(this.readFileSync) {
			this._readJsonSync = (path) => {
				const buffer = this.readFileSync(path);
				const data = JSON.parse(buffer.toString("utf-8"));
				return data;
			};
		} else {
			this.readJsonSync = null;
		}

		this._readlink = this.fileSystem.readlink ? this.fileSystem.readlink.bind(this.fileSystem) : null;
		if(!this._readlink) this.readlink = null;

		this._readlinkSync = this.fileSystem.readlinkSync ? this.fileSystem.readlinkSync.bind(this.fileSystem) : null;
		if(!this._readlinkSync) this.readlinkSync = null;
	}
	// 对外暴露的文件读取方法,会调用_*Storage的provide*方法来实现缓存。
	stat(path, callback) {
		this._statStorage.provide(path, this._stat, callback);
	}
	readdir(path, callback) {
		this._readdirStorage.provide(path, this._readdir, callback);
	}
	readFile(path, callback) {
		this._readFileStorage.provide(path, this._readFile, callback);
	}
	readJson(path, callback) {
		this._readJsonStorage.provide(path, this._readJson, callback);
	}
	readlink(path, callback) {
		this._readlinkStorage.provide(path, this._readlink, callback);
	}
	statSync(path) {
		return this._statStorage.provideSync(path, this._statSync);
	}
	readdirSync(path) {
		return this._readdirStorage.provideSync(path, this._readdirSync);
	}
	readFileSync(path) {
		return this._readFileStorage.provideSync(path, this._readFileSync);
	}
	readJsonSync(path) {
		return this._readJsonStorage.provideSync(path, this._readJsonSync);
	}
	readlinkSync(path) {
		return this._readlinkStorage.provideSync(path, this._readlinkSync);
	}
    // 清楚所有_*Storage;what可以接受三种类型:所有false类型的值,字符串,数组;
	purge(what) {
		this._statStorage.purge(what);
		this._readdirStorage.purge(what);
		this._readFileStorage.purge(what);
		this._readlinkStorage.purge(what);
		this._readJsonStorage.purge(what);
	}
}
  • fileStream是我们上面通过NodeJsInputFileSystem生成的;duration为60000;
  • _statStorage、_readdirStorage、_readFileStorage、_readJsonStorage、_readlinkStorage都是通过调用Storage来生成的;
  • 对_stat、_readdir、_readFile、_readlink以及各自的sync方法进行了this的绑定,_readJson和_readJsonSync进行了自定义;
  • 对上面的方法进行了内部封装,每个方法调用对应的Storage的provide方法。关于Storage的源码,暂时不做讲解,只关注主流程。

主流程:

webpack两个核心,一个核心就是上面的compiler,另外一个就是compilation;这两个都继承自Tapable。关于Tapable,我们后面讲。

进入hooks调用前,上面讲到的new NodeEnvironmentPlugin().apply(compiler)后,webpack会根据用户传入的plugins,依次进行apply的插件的注册;

if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
            plugin.apply(compiler);
        } else {
            plugin.apply(compiler);
        }
    }
}

上面也就是为什么当实现一个webpack的plugin的时候要实现一个apply方法的原因了。

在webpack版本4.20.2上面,compiler.hooks的调用顺序如下:

  • 1.environment.call => 类型为SyncHook; -- webpack5将移出此hooks
  • 2.afterEnvironment.call => 类型为SyncHook; -- webpack5将移出此hooks
  • 上面2结束后会调用new WebpackOptionsApply().process(options, compiler)来添加一些webpack自身的plugin,大约50个插件将会被注册;
  • 3.entryOption.call => 类型为SyncBailHook;例如用来处理入口的EntryOptionPlugin; -- webpack5将移出此hooks
  • 4.afterPlugins.call => 类型为SyncHook; -- webpack5将移出此hooks
  • 5.afterResolvers.call => 类型为SyncHook; -- webpack5将移出此hooks
  • 6.beforeRun.callAsync => 类型为AsyncSeriesHook;编译运行之前,例如上面提到的NodeEnvironmentPlugin,就是在这个阶段执行的。
  • 7.run.callAsync => 类型为AsyncSeriesHook;编译运行时,例如WebpackDevMiddleware会在这个阶段执行插件的回调函数。
  • 8.normalModuleFactory.call => 类型为SyncHook;例如SideEffectsFlagPlugin副作用插件会在此阶段执行插件的回调函数。
  • 9.contextModuleFactory.call => 类型为SyncHook;
  • watchRun.callAsync => 类型为AsyncSeriesHook; -- 此hook只有在watch的时候才会调用,正常build情况下是不会调用的
  • 10.beforeCompile.callAsync => 类型为AsyncSeriesHook;编译开始前,例如上面在run阶段运行的WebpackDevMiddleware插件,也会在这个阶段调用对应的回调函数。
  • 11.compile.call => 类型为SyncHook;编译进行时,像我们熟悉的webpack的配置的externals所对应的插件ExternalsPlugin就会在这个阶段运行对应的callback。
  • 12.thisCompilation.call => 类型为SyncHook;建立编译器,这个所对应的插件很多,像MiniCssExtractPlugin、RuntimeChunkPlugin、SplitChunksPlugin等等。
  • 13.compilation.call => 类型为SyncHook;编译器开始工作前,这个阶段所对应的插件是更多的,像我们熟知的HtmlWebpackPlugin、LoaderPlugin等等就是在这个阶段去运行callback的。
  • 14.make.callAsync => 类型为AsyncParallelHook; 编译器工作阶段
  • 15.afterCompile.callAsync => 类型为AsyncSeriesHook; 编译完成后
  • 16.shouldEmit.callAsync => 类型为SyncBailHook;是否应该输出到文件,输出前
  • 17.emit.callAsync => 类型为AsyncSeriesHook;输出到文件
  • 18.afterEmit.callAsync => 类型为AsyncSeriesHook;输出到文件后
  • 19.done.callAsync => 类型为AsyncSeriesHook;webpack运行完成阶段
  • 20.additionalPass.callAsync => 类型为AsyncSeriesHook;

上面是在执行npm run build的时候所做的工作;当然watch的时候,会重复watchRun + 10 ~ 19的工作。上面就是整个的webpack的核心之一compiler的主流程,当然里面还会涉及到非常多的细节的流程部分,包括webpack核心的compilation。

说明:compilation是webpack的编译器,每次的编译都会生成一个实例,代表了一次单一的版本的构建和资源生成,举个例子,webpack在watch模式下工作时,每次检测到文件发生变化时,都会重新生成一个Compilation的实例,去完成本次的编译工作。也就是说每次webpack在watch模式下启动的时候,只有一个compiler,但是至少有一个compilation去完成了本次的编译工作。不过切记,每次的编译器(compilation)只会存在一个,不会存在多个,不然就乱了嘛。

Tapable

说到webpack,不得不提的就是Tapable,上面我们也讲到了Compiler和Compilation都继承自Tapable。 可以看到Tapable大致分为两类,一种是同步的,一种为异步的;

  • 同步的,Sync*Hook,通过tap来添加消费者;通过call来调用
  • 异步的,Async*Hook,通过tap、tapAsync、tapPromise来添加消费者;通过promise、callAsync来调用,为什么call用不了,我们后面讲到。
  • 所有的Hook都继承自Tapable的Hook,是所有Hook的基类。

Sync*Hook

我们先来下Sync*Hook。除了SyncWaterfallHook自定义了constructor外,其他的hook都是一样的实现。

const factory = new SyncHookCodeFactory(); //  for SyncHook
const factory = new SyncLoopHookCodeFactory(); //  for SyncLoopHook
const factory = new SyncBailHookCodeFactory(); //  for SyncBailHook
const factory = new SyncWaterfallHookCodeFactory(); //  for SyncWaterfallHook

class Sync*Hook extends Hook {
	// 只有SyncWaterfallHook自定义了constructor,其他的几个hook都没有显式声明constructor
	constructor(args) {
		super(args);
		if (args.length < 1)
			throw new Error("Waterfall hooks must have at least one argument");
	}
	tapAsync() {
		throw new Error("tapAsync is not supported on a SyncWaterfallHook");
	}
	tapPromise() {
		throw new Error("tapPromise is not supported on a SyncWaterfallHook");
	}
	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

SyncHook只支持通过tap方式注册,so SyncHook实现了tapAsync和tapPromise并报错,所有的compiler也都是一样的;区别在于factory是不一样的,上面代码我们看到了每个类型的Hook都是不同的factory。

但是所有的SyncHookCodeFactory都继承自HookCodeFactory,其实所有的AsyncHookCodeFactory也继承自HookCodeFactory。 每种类型的Sync*HookCodeFactory的区别是一样的,都是content方法的不同:

// 1.syncHookCodeFactory
content({ onError, onResult, onDone, rethrowIfPossible }) {
    return this.callTapsSeries({
        onError: (i, err) => onError(err),
        onDone,
        rethrowIfPossible
    });
}
// 2.SyncBailHookCodeFactory
content({ onError, onResult, onDone, rethrowIfPossible }) {
    return this.callTapsSeries({
        onError: (i, err) => onError(err),
        onResult: (i, result, next) =>
            `if(${result} !== undefined) {\n${onResult(
                result
            )};\n} else {\n${next()}}\n`,
        onDone,
        rethrowIfPossible
    });
}
// 3.SyncWaterfallHookCodeFactory
content({ onError, onResult, onDone, rethrowIfPossible }) {
    return this.callTapsSeries({
        onError: (i, err) => onError(err),
        onResult: (i, result, next) => {
            let code = "";
            code += `if(${result} !== undefined) {\n`;
            code += `${this._args[0]} = ${result};\n`;
            code += `}\n`;
            code += next();
            return code;
        },
        onDone: () => onResult(this._args[0]),
        rethrowIfPossible
    });
}
// 4.SyncLoopHookCodeFactory
content({ onError, onResult, onDone, rethrowIfPossible }) {
    return this.callTapsLooping({
        onError: (i, err) => onError(err),
        onDone,
        rethrowIfPossible
    });
}

可以看到上面前三个CodeFactory调用都是callTapsSeries方法;区别在于:

  • SyncBailHookCodeFactorysyncHookCodeFactory 多了一个onResult的方法, 都是调用了callTapsSeries
  • SyncWaterfallHookCodeFactorysyncHookCodeFactory 多了onResult和onDone, 都是调用了callTapsSeries
  • SyncLoopHookCodeFactorysyncHookCodeFactory相比,只是调用方法的不同,分别调用的是callTapsLoopingcallTapsSeries
  • HookCodeFactory是一个函数代码工厂,负责产出函数,其主要的作用就是当hooks注册了事件后,产出hooks广播事件时所调用的函数。 callTapsSeries属于连续调用,webpack自己实现了连续调用的code生成器,内部会调用底层的callTap方法;

callTapsLooping属于循环调用,webpack自己实现了循环调用的code生成器,会调用callTapsSeries方法,callTapsSeries也就是上面的连续调用,callTapsSeries内部再调用底层的callTap方法。

Async*Hook

我们先来下Async*Hook。除了AsyncSeriesWaterfallHook自定义了constructor外,其他的hook都是一样的实现。

const factory = new AsyncSeriesHookCodeFactory(); //  for AsyncSeriesHook
const factory = new AsyncSeriesBailHookCodeFactory(); //  for AsyncSeriesBailHook
const factory = new AsyncParallelHookCodeFactory(); //  for AsyncParallelHook
const factory = new AsyncParallelBailHookCodeFactory(); //  for AsyncParallelBailHook
const factory = new AsyncSeriesLoopHookCodeFactory(); //  for AsyncSeriesLoopHook
const factory = new AsyncSeriesWaterfallHookCodeFactory(); //  for AsyncSeriesWaterfallHook

class Async*Hook extends Hook {
	// 只有AsyncSeriesWaterfallHook自定义了constructor,其他的几个hook都没有显式声明constructor
	constructor(args) {
		super(args);
		if (args.length < 1)
			throw new Error("Waterfall hooks must have at least one argument");
	}
	compile(options) {
		factory.setup(this, options);
		return factory.create(options);
	}
}

Object.defineProperties(Async*Hook.prototype, {
	_call: { value: undefined, configurable: true, writable: true }
});

module.exports = Async*Hook;

AsyncHook支持通过tap、tapPromise、tapAsync等三种方式注册。**而且在下面定义个_call,value为undefined,也就是说通过call是消费不了AsyncHook类型的事件的**。

所有的compiler也都是一样的,与SyncHook的compile都一样;区别在于factory是不一样的,上面代码我们看到了每个类型的Hook都是不同的factory。关于上面几种AsyncHookCodeFactory的不同点,其实还是content方法的不同。

// AsyncSeriesHookCodeFactory
content({ onError, onDone }) {
    return this.callTapsSeries({
        onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
        onDone
    });
}
// AsyncSeriesBailHookCodeFactory, 细心地同学会发现,这个的content方法和SyncBailHookCodeFactory的content方法一模一样
content({ onError, onResult, onDone }) {
    return this.callTapsSeries({
        onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
        onResult: (i, result, next) =>
            `if(${result} !== undefined) {\n${onResult(
                result
            )};\n} else {\n${next()}}\n`,
        onDone
    });
}
// AsyncSeriesWaterfallHookCodeFactory
content({ onError, onResult, onDone }) {
    return this.callTapsSeries({
        onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
        onResult: (i, result, next) => {
            let code = "";
            code += `if(${result} !== undefined) {\n`;
            code += `${this._args[0]} = ${result};\n`;
            code += `}\n`;
            code += next();
            return code;
        },
        onDone: () => onResult(this._args[0])
    });
}
// AsyncSeriesLoopHookCodeFactory
content({ onError, onDone }) {
    return this.callTapsLooping({
        onError: (i, err, next, doneBreak) => onError(err) + doneBreak(true),
        onDone
    });
}
// AsyncParallelHookCodeFactory
content({ onError, onDone }) {
    return this.callTapsParallel({
        onError: (i, err, done, doneBreak) => onError(err) + doneBreak(true),
        onDone
    });
}
// AsyncParallelBailHookCodeFactory
content({ onError, onResult, onDone }) {
    let code = "";
    code += `var _results = new Array(${this.options.taps.length});\n`;
    code += "var _checkDone = () => {\n";
    code += "for(var i = 0; i < _results.length; i++) {\n";
    code += "var item = _results[i];\n";
    code += "if(item === undefined) return false;\n";
    code += "if(item.result !== undefined) {\n";
    code += onResult("item.result");
    code += "return true;\n";
    code += "}\n";
    code += "if(item.error) {\n";
    code += onError("item.error");
    code += "return true;\n";
    code += "}\n";
    code += "}\n";
    code += "return false;\n";
    code += "}\n";
    code += this.callTapsParallel({
        onError: (i, err, done, doneBreak) => {
            let code = "";
            code += `if(${i} < _results.length && ((_results.length = ${i +
                1}), (_results[${i}] = { error: ${err} }), _checkDone())) {\n`;
            code += doneBreak(true);
            code += "} else {\n";
            code += done();
            code += "}\n";
            return code;
        },
        onResult: (i, result, done, doneBreak) => {
            let code = "";
            code += `if(${i} < _results.length && (${result} !== undefined && (_results.length = ${i +
                1}), (_results[${i}] = { result: ${result} }), _checkDone())) {\n`;
            code += doneBreak(true);
            code += "} else {\n";
            code += done();
            code += "}\n";
            return code;
        },
        onTap: (i, run, done, doneBreak) => {
            let code = "";
            if (i > 0) {
                code += `if(${i} >= _results.length) {\n`;
                code += done();
                code += "} else {\n";
            }
            code += run();
            if (i > 0) code += "}\n";
            return code;
        },
        onDone
    });
    return code;
}

AsyncSeriesHookCodeFactoryAsyncSeriesBailHookCodeFactoryAsyncSeriesWaterfallHookCodeFactory调用的是callTapsSeries方法,AsyncSeriesLoopHookCodeFactory调用的是callTapsLooping方法,上面Sync*Hook已经讲过这两个,不做解释;

AsyncParallelHookCodeFactoryAsyncParallelBailHookCodeFactory调用的是callTapsParallel方法,此方法会对参数进行判断,如果taps长度为1,则直接调用callTapsSeries,否则会循环调用底层的callTap

Tapable的事件广播。

其实上面这些都是为了call、callAsync、promise等hooks的消费方法的调用来做准备的;

// tapable Hook 
_createCall(type) {
    return this.compile({
        taps: this.taps,
        interceptors: this.interceptors,
        args: this._args,
        type: type
    });
}
function createCompileDelegate(name, type) {
	return function lazyCompileHook(...args) {
		this[name] = this._createCall(type);
		return this[name](...args);
	};
}
Object.defineProperties(Hook.prototype, {
	_call: {
		value: createCompileDelegate("call", "sync"),
		configurable: true,
		writable: true
	},
	_promise: {
		value: createCompileDelegate("promise", "promise"),
		configurable: true,
		writable: true
	},
	_callAsync: {
		value: createCompileDelegate("callAsync", "async"),
		configurable: true,
		writable: true
	}
});

到了这里,我们就可以串起来了:

  • 当调用call、promise、callAsync的时候,会执行lazyCompileHook方法;
  • lazyCompileHook方法里面先调用_createCall方法生成函数;
  • _createCall方法则调用对应hook类型的实例的compile方法;
  • compile里面调用factory.setup(this, options);也就是运行instance._x = options.taps.map(t => t.fn);,在这里,会把我们在通过tap*(tap、tapAsync、tapPromise)注册的函数收集存储到hook实例的_x属性上面;
  • return factory.create(options);调用此方法的时候,就是根据类型产出函数的过程,并把产出的函数返回;
  • 回到lazyCompileHook函数,此时this[name]的值就是我们上面返回的函数;
  • 然后运行此函数:return this[name](...args); 一个hook的广播事件运行完成。

webpack的chunks

我们先来看下使用,webpack的chunks是通过webpack内部的一个插件来实现的,在3版本及以前使用的是CommonsChunkPlugin;在4版本后开始使用SplitChunksPlugin,我们对于3版本及以前的不做解释,着眼未来,我们来看SplitChunksPlugin;我们根据配置来看:

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

上面是webpack的默认配置,splitChunks就算你什么配置都不做它也是生效的,源于webpack有一个默认配置,这也符合webpack4的开箱即用的特性。

chunks意思为拆分模块的范围,有三个值:async、all和initial;三个值的区别如下:

  • async表示只从异步加载得模块里面进行拆分,也就是动态加载import();
  • initial表示只从入口模块进行拆分;
  • all的话,就是包含上面两者,都会去拆分; 上面还有几个参数:
  • minChunks,代表拆分之前,当前模块被共享的次数,上面是1,也就是一次及以上次引用,就会拆分;改为2的话,就是两次及以上的引用会被拆分;
  • minSize:生成模块的最小大小,单位是字节;也就是拆分出来的模块不能太小,太小的话进行拆分,多了一次网络请求,因小失大;
  • maxAsyncRequests:用来限制异步模块内部的并行最大请求数的,也就是是每个动态import()它里面的最大并行请求数量,需要注意的是:
    • import()本身算一个;
    • 只算js的,不算其他资源,例如css等;
    • 如果同时又两个模块满足cacheGroup的规则要进行拆分,但是maxInitialRequests的值只能允许再拆分一个模块,那尺寸更大的模块会被拆分出来;
  • maxInitialRequests:表示允许入口并行加载的最大请求数,之所以有这个配置也是为了对拆分数量进行限制,不至于拆分出太多模块导致请求数量过多而得不偿失,需要注意的是
    • 入口本身算一个请求,
    • 如果入口里面有动态加载的不算在内;通过runtimeChunk拆分出的runtime不算在内;只算js的,不算css的;
    • 如果同时又两个模块满足cacheGroup的规则要进行拆分,但是maxInitialRequests的值只能允许再拆分一个模块,那尺寸更大的模块会被拆分出来;
  • automaticNameDelimiter:这是个连接符,不用关注这个;
  • name:该属性主要是为了打包后文件的命名,使用原名字命名为true,按照默认配置,就会是文件名~文件名.**.js
  • cacheGroup:cacheGroups其实是splitChunks里面最核心的配置,splitChunks就是根据cacheGroups的配置去拆分模块的,
    • test:正则匹配路径,表示只筛选从node_modules文件夹下引入的模块,所以所有第三方模块才会被拆分出来。
    • priority:优先级,上面的default的优先级低于vendor;
    • minChunks:这个其实也是个例子,和父层的含义是一样的,不过会覆盖父层定义的值,拆分之前,模块被共享使用的次数;
    • reuseExistingChunk:是否使用以及存在的chunk,字面意思; 注意:
  • cacheGroups之外设置的约束条件比如说默认配置里面的chunks、minSize、minChunks等等都会作用于cacheGroups,cacheGroups里面的值覆盖外层的配置;
  • test、priority、reuseExistingChunk,这三个是只能定义在cacheGroup这一层的;
  • 如果cacheGroups下面的多个对象的priority相同时,先定义的会先命中;

SplitChunksPlugin是在compiler hooks的thisCompilation阶段注册的,也就是编译器建立时注册的,可以往上翻翻哦;

webpack的优化指南

自己整理了一份webpack的优化指南,分享给大家,欢迎补充哦

  • 使用webpack4+替换3及之前的版本,会大大提高webpack的构建速度

  • webpack在production模式下会默认进行代码的优化,减少代码体积;删除只在development下面才使用到的代码;

  • webpack在production模式下会默认开启代码的压缩,使用的是UglifyJsPlugin,不过webpack将会在5版本对其移出,需要用户在config.optimization.minimizer下面自定义压缩工具,建议使用terser-webpack-plugin;

  • 使用ES6语法,webpack能够进行 tree-shaking,不要意外地将ES模块编译成CommonJS模块。如果你使用Babel的时候,采用了babel-preset-env 或者 babel-preset-es2015,请检查这些预置的设置。默认情况下,它们会将ES的导入和导出转换为 CommonJS 的 require 和 module.exports,可以通过传递{ modules: false } 选项来禁用它

  • 配置externals,排除因为已使用<script>标签引入而不用打包的代码,noParse是排除没使用模块化语句的代码

  • 使用长期缓存:

    • [hash] 替换:可以用于在文件名中包含一个构建相关(build-specific)的 hash;
    • [chunkhash] 替换:在文件名中包含一个 chunk 相关(chunk-specific)的哈希,比[hash]替换更好;
    • [contenthash] 替换:会根据资源的内容添加一个唯一的 hash,当资源内容不变时,[contenthash] 就不会变
  • 当然还有上面提到的splitChunk,单页应用中使用 import().then() 对不关键的代码使用懒加载;

  • 使用resolve:

    • resolve.modules字段告诉webpack怎么去搜索文件,modules告诉webpack去哪些目录下寻找第三方模块,默认值为['node_modules'],会依次查找./node_modules、../node_modules、../../node_modules;我们设置为resolve.modules:[path.resolve(__dirname, 'node_modules')],路径写死。
    • resolve.mainFields字段告诉webpack使用第三方模块的哪个入口文件,由于大多数第三方模块都使用main字段描述入口文件的位置,所以可以设置单独一个main值,减少搜索;我们设置为resolve.mainFields: ["main"]
    • resolve.alias,别名,告诉webpack当引用此别名的时候,实际引用的是什么,对庞大的第三方模块设置resolve.alias, 使webpack直接使用库的min文件,避免库内解析,例如:
      resolve.alias:{
          'react':patch.resolve(__dirname, './node_modules/react/dist/react.min.js')
      }
      
    • resolve.extensions,合理配置resolve.extensions,减少webpack的文件查找;默认值:extensions:['.js', '.json'],当导入语句没带文件后缀时,Webpack会根据extensions定义的后缀列表进行文件查找,原则就是:列表的值尽量少;高频率出现的文件类型的后缀写在前端,比如js、ts等;源码中的导入语句尽可能的写上文件后缀,如require(./info)要写成require(./info.json)
  • module.noParse字段告诉Webpack不必解析哪些文件,可以用来排除对非模块化库文件的解析;例如上面的react我们已经使用alias指定了使用min文件来解析,因为react.min.js经过构建,已经是可以直接运行在浏览器的、非模块化的文件了;这么配置:module:{ noParse:[/react\.min\.js$/] };

  • 使用HappyPack开启多进程Loader转换,webpack的编译过程中,耗时最长的就是Loader对文件的转换操作,webpack运行在node上面是单线程的,也就是只能一个一个文件进行处理,不能并行处理。HappyPack可以将任务分解给多个子进程,最后将结果发给主进程。JS是单线程模型,只能通过这种多进程的方式提高性能。配置如下:

    const path = require('path');
    const HappyPack = require('happypack');
    module.exports = {
        module:{
            rules:[{
                    test:/\.js$/use:['happypack/loader?id=babel']
                    exclude:path.resolve(__dirname, 'node_modules')
                },{
                    test:/\.css/,
                    use:['happypack/loader?id=css']
                }],
            plugins:[
                new HappyPack({
                    id:'babel',
                    loaders:['babel-loader?cacheDirectory']
                }),
                new HappyPack({
                    id:'css',
                    loaders:['css-loader']
                })
            ]
        }
    }
    
  • 使用Prepack提前求值:Prepack是一个部分求值器,编译代码时提前将计算结果放到编译后的代码中,而不是在代码运行时才去求值;不过Prepack目前还不是很成熟,用到线上环境为时过早;

  • 使用url-loader把小图片转换成base64嵌入到JS或CSS中,减少加载次数;

  • 通过imagemin-webpack-plugin压缩图片,通过webpack-spritesmith制作雪碧图;

  • css压缩:使用css-loader?minimize来实现代码的压缩,同时还有mini-css-extract-plugin来进一步对css进行压缩;

  • 除了上面的这些优化外,可以再结合项目看下性能,找出耗时的地方进行优化:

    • 使用profile: true,可以看到构建过程中的耗时;

总结

性能优化不止,技术更新迭代太快,学无止境。想要真正做到极致,还是要结合源码来分析。

如有不对之处,欢迎指正。