soda-x/blog

webpack watch 篇(一)

soda-x opened this issue · 4 comments

本个系列的文章会被分成两篇文章

(一)主要描述下问题的表现,并 dive into webpack watch system
(二)解决问题,从根本上解决 webpack 的 bug


最近做一个内部工具时碰到了一个很有意思的问题

多次 rebuild 的现象

当首次动态创建 webpack 入口文件后,入口文件新增依赖时,会导致数十次的重新编译过程。

搜了下,发现 webpack 可追溯的 issue 记录为 Files created right before watching starts make watching go into a loop

该问题不论你是在使用 webpack-dev-middleware 或者 webpack --watch 又或者 webpack-dev-server 都可以复现。

webpack 作者 @sokra 对其解释为:

The watching may loop in a unlucky case, but this should not result in a different compilation hash. I. e. the webpack-dev-server doesn't trigger a update if the hash is equal.

白话理解为:确实有问题,但是呢,最关键的 compilation hash 不会变,所以上层使用时,自己内部处理下这个逻辑。

但实际情况呢, webpack-dev-server 等作者不认这一说!

粗暴的解决方案

至于不想刨根问底,这里也有狗皮膏药的解决方案:

// Webpack startup recompilation fix. Remove when @sokra fixes the bug.
// https://github.com/webpack/webpack/issues/2983
// https://github.com/webpack/watchpack/issues/25
const timefix = 11000;
compiler.plugin('watch-run', (watching, callback) => {
  watching.startTime += timefix;
  callback()
});
compiler.plugin('done', (stats) => {
  stats.startTime -= timefix
})

刨根问底

当然狗皮膏药并不是本文的重点,刚好借此一窥,webpack 中整体的 watch 机制。

如果不想看那么多代码片段,也可以看我在梳理代码逻辑时做的笔记,笔记中红色流程为初始化时的调用链路,蓝色部分为文件变更后事件回调链路。

img_4830

首先我们可以确定一点的是,不管是 webpack 自身的 cli 工具还是 webpack-dev-middleware 和 webpack-dev-server 都是通过 Compiler.prototype.watch 来实现了 watch 的功能,进而来实现调试阶段的高性能需求。

为了比较清晰的知道整一个流程,我们从创建一个 Compiler 实例开始说起

webpack Compiler 实例的创建

总所周知我们通过 const compiler = webpack(webpackConfig); 这种方式来创建一个 Compiler 的实例,一般也叫做 webpack 的实例,compiler 实例对象中包含着和打包相关的所有参数,plugins loaders 等等。这种情况下 webpack 并不会默认进行构建编译的过程,如果想要启动编译则需要执行一下 compiler.run(callback)。 另外我们也可以通过 webpack(webpackConfig, callback); 默认来启动构建编译流程。

对于今天我们想要了解的 watch 过程我们这边只需要知道,当构建参数中含有明确开启 watch 配置项时整个流程的走向是 compiler.watch(watchOptions, callback); 而非 compiler.run(callback);

题外话: 或许你比较好奇 compilation 是什么,它包含着 chunks modules 等信息,构建依赖文件变更时都会重新生成 compilation,而 compiler 只有一个。

源码追溯

compiler.watch 中创建 watch 服务

// compiler 的 watch 方法
class Compiler extends Tapable {
  watch(watchOptions, handler) {
    ...
    const watching = new Watching(this, watchOptions, handler);
		return watching;
  }
}
// Watch 类
class Watching {
	constructor(compiler, watchOptions, handler) {
		this.startTime = null;
		...
		this.compiler = compiler;
		this.compiler.readRecords(err => {
			if(err) return this._done(err);

			this._go();
		});
	}
}

在这边需要注意的是 startTime 每次编译执行时 _go 方法将被调用,调用时会赋值编译启动时间,该时刻在认定文件是否需要再次编译或者是否变更时非常非常重要!

源码追溯 1 源码追溯 2

首次编译初始化

当如上 this._go() 被执行时,即开始了首次的编译过程

_go() {
	this.startTime = Date.now();
	this.running = true;
	this.invalid = false;
	this.compiler.applyPluginsAsync("watch-run", this, err => {
		if(err) return this._done(err);
		const onCompiled = (err, compilation) => {
		  ...
		  this.compiler.emitAssets(compilation, err => {
		    ...
		    return this._done(null, compilation);
		  });
		};
		this.compiler.compile(onCompiled);
	});
}

敲黑板: 注意此时 startTime 被正式赋值为 首次构建编译开始的时间,同时 compile 的执行标志着首次编译的开始。

此次文章并不会涉及 webpack 的事件流,以及编译过程中 loaders 和 plugins 等的流转过程,这边我们只需要知道,执行 compile 后进入了编译流程即可。

由代码可以看出在正常流程下正常编译流程完毕后,调用 _done 方法。

_done(err, compilation) {
  ...
	const stats = compilation ? this._getStats(compilation) : null;
  ...
	this.compiler.applyPlugins("done", stats);
	...
	if(!this.closed) {
		this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
	}
}

在 compilation 对象中我们可以获取到和构建相关所有的依赖,而这些依赖正是需要去监听的内容。

源码追溯

正式开启文件监听

上个过程中我们可以看到最后我们把构建依赖,传递给了 watch 的方法。

watch(files, dirs, missing) {
	this.pausedWatcher = null;
	this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, (err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) => {
		...
		this.invalidate();
	}, (fileName, changeTime) => {
		this.compiler.applyPlugins("invalid", fileName, changeTime);
	});
}

这里我们注意到 watch 实际调用的是 compiler.watchFileSystem.watch。看过源码的可能会很好奇,因为在 Compiler 的源码中没有定义过这个原型链上的方法。原因很简单,因为在 webpack(webpackConfig) 的阶段中,webpack 注入很多内部的自有插件,webpack 源码非常让人值得学习的一点就是插件机制应用的炉火纯青。具体我们可以看到这 webpack.js,而通过这个线索我们找到了NodeEnvironmentPlugin,开始有所眉目我们看到了熟悉的 watch 字眼 NodeWatchFileSystem,通过它进而我们终于找到了 NodeWatchFileSystem 兴奋之余 watch 服务最终的启动者 watchpack 也浮出水面。

题外话: 这边比较有趣的是 NodeEnvironmentPlugin 这个 plugin,在这个 plugin 中默认设置了 NodeOutputFileSystem NodeJsInputFileSystem CachedInputFileSystem,以 NodeOutputFileSystem 为例,在 webpack 默认情况下编译完成后文件内容都会通过 io 输出到实际的文件目录中,但是毕竟涉及 io 操作这种性能并不能满足调试的需求,所以在 webpack-dev-middleware 中会将 NodeOutputFileSystem 原本默认的 fs 替换为 memory-fs 进而 boost performance。另外 CachedInputFileSystem 等也是通过本地构建的缓存文件物理加速。由于这些内容并不是本文重点,所以不再展开,有兴趣的同学可以继续深挖。

const Watchpack = require("watchpack");

class NodeWatchFileSystem {
	constructor(inputFileSystem) {
		this.inputFileSystem = inputFileSystem;
		this.watcherOptions = {
			aggregateTimeout: 0
		};
		this.watcher = new Watchpack(this.watcherOptions);
	}
	watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
	  ...
	  const oldWatcher = this.watcher;
		this.watcher = new Watchpack(options);
		...
		if(callbackUndelayed)
			this.watcher.once("change", callbackUndelayed);
		this.watcher.once("aggregated", (changes, removals) => {
		  ...
		  const times = this.watcher.getTimes();
			callback(null,
				changes.filter(file => files.indexOf(file) >= 0).sort(),
				changes.filter(file => dirs.indexOf(file) >= 0).sort(),
				changes.filter(file => missing.indexOf(file) >= 0).sort(), times, times);
    });
    ...
    this.watcher.watch(files.concat(missing), dirs.concat(missing), startTime);

		if(oldWatcher) {
			oldWatcher.close();
		}
		...
	}
}

基于 webpack 的源码不难发现最终 watch 交由的是 Watchpack 实例的 watch 方法。

接下来我们看到

Watchpack.prototype.watch = function watch(files, directories, startTime) {
	this.paused = false;
	var oldFileWatchers = this.fileWatchers;
	var oldDirWatchers = this.dirWatchers;
	this.fileWatchers = files.map(function(file) {
		return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));
	}, this);
	this.dirWatchers = directories.map(function(dir) {
		return this._dirWatcher(dir, watcherManager.watchDirectory(dir, this.watcherOptions, startTime));
	}, this);
	oldFileWatchers.forEach(function(w) {
		w.close();
	}, this);
	oldDirWatchers.forEach(function(w) {
		w.close();
	}, this);
};

这边对 webpack 不是很熟悉的同学可能会比较困惑为什么 file 和 dir 需要进行区分 watch,默认情况下,通过 webpack resolve 后我们能拿到每个模块精确的路径地址,但是在一些特别的用法下,比如使用 require.context(path) 就会对该 path 所对应的目录加以监听。

所以在一般业务场景下只会涉及到 this._fileWatcher

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
	watcher.on("change", function(mtime, type) {
		this._onChange(file, mtime, file, type);
	}.bind(this));
	watcher.on("remove", function(type) {
		this._onRemove(file, file, type);
	}.bind(this));
	return watcher;
};

根据如上代码我们可以获知 watcherManager.watchFile(file, this.watcherOptions, startTime) 返回了 一个 watcher
_fileWather 根本上是对返回的 watcher 做了一次事件绑定。

那我们看看 watcherManager.watchFile(file, this.watcherOptions, startTime) 到底创建了一个怎么样的 watcher。

WatcherManager.prototype.getDirectoryWatcher = function(directory, options) {
	var DirectoryWatcher = require("./DirectoryWatcher");
	options = options || {};
	var key = directory + " " + JSON.stringify(options);
	if(!this.directoryWatchers[key]) {
		this.directoryWatchers[key] = new DirectoryWatcher(directory, options);
		this.directoryWatchers[key].on("closed", function() {
			delete this.directoryWatchers[key];
		}.bind(this));
	}
	return this.directoryWatchers[key];
};

WatcherManager.prototype.watchFile = function watchFile(p, options, startTime) {
	var directory = path.dirname(p);
	return this.getDirectoryWatcher(directory, options).watch(p, startTime);
};

WatcherManager.prototype.watchDirectory = function watchDirectory(directory, options, startTime) {
	return this.getDirectoryWatcher(directory, options).watch(directory, startTime);
};

Step1: this.getDirectoryWatcher(directory, options)
如上所知不管是传入的内容是 file 路径还是 directory 路径,都会被转到 getDirectoryWatcher

言下之意就是一个目录下所有的文件都会被对应到一个 directoryWatcher。

在新建一个 DirectoryWatcher 的实例时

function DirectoryWatcher(directoryPath, options) {
	EventEmitter.call(this);
	this.options = options;
	this.path = directoryPath;
	this.files = Object.create(null);
	this.directories = Object.create(null);
	this.watcher = chokidar.watch(directoryPath, {
		ignoreInitial: true,
		persistent: true,
		followSymlinks: false,
		depth: 0,
		atomic: false,
		alwaysStat: true,
		ignorePermissionErrors: true,
		ignored: options.ignored,
		usePolling: options.poll ? true : undefined,
		interval: typeof options.poll === "number" ? options.poll : undefined
	});
	this.watcher.on("add", this.onFileAdded.bind(this));
	this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
	this.watcher.on("change", this.onChange.bind(this));
	this.watcher.on("unlink", this.onFileUnlinked.bind(this));
	this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
	this.watcher.on("error", this.onWatcherError.bind(this));
	this.initialScan = true;
	this.nestedWatching = false;
	this.initialScanRemoved = [];
	this.doInitialScan();
	this.watchers = Object.create(null);
}

可以发现,webpack watch 文件夹变更的能力实际输出者为 chokidar
并且对 directoryPath 对应的 chokidar watcher,绑定 addaddDirchangeunlinkunlinkDirerror 等事件。
并执行了 this.doInitialScan();

DirectoryWatcher.prototype.doInitialScan = function doInitialScan() {
	fs.readdir(this.path, function(err, items) {
		if(err) {
			this.initialScan = false;
			return;
		}
		async.forEach(items, function(item, callback) {
			var itemPath = path.join(this.path, item);
			fs.stat(itemPath, function(err2, stat) {
				if(!this.initialScan) return;
				if(err2) {
					callback();
					return;
				}
				if(stat.isFile()) {
					if(!this.files[itemPath])
						this.setFileTime(itemPath, +stat.mtime, true);
				} else if(stat.isDirectory()) {
					if(!this.directories[itemPath])
						this.setDirectory(itemPath, true, true);
				}
				callback();
			}.bind(this));
		}.bind(this), function() {
			this.initialScan = false;
			this.initialScanRemoved = null;
		}.bind(this));
	}.bind(this));
};

根据如上代码我们可以获知,在执行首次扫描时,会把当前文件夹下的内容读取出来。对文件则进行 this.setFileTime(itemPath, +stat.mtime, true);

这边不对 setFileTime 做过多阐述,他有两种使用场景。

一种来源于 initialScan 会把所有的文件的最新修改时间全部读取出来,为之后判断文件变更触发更新提供依据。另外一个场景就是触发更新了。

Step2: directoryWatcher.watch((p, startTime))

DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
	this.watchers[withoutCase(filePath)] = this.watchers[withoutCase(filePath)] || [];
	this.refs++;
	var watcher = new Watcher(this, filePath, startTime);
	watcher.on("closed", function() {
		var idx = this.watchers[withoutCase(filePath)].indexOf(watcher);
		this.watchers[withoutCase(filePath)].splice(idx, 1);
		if(this.watchers[withoutCase(filePath)].length === 0) {
			delete this.watchers[withoutCase(filePath)];
			if(this.path === filePath)
				this.setNestedWatching(false);
		}
		if(--this.refs <= 0)
			this.close();
	}.bind(this));
	this.watchers[withoutCase(filePath)].push(watcher);
	var data;
	if(filePath === this.path) {
		this.setNestedWatching(true);
		data = false;
		Object.keys(this.files).forEach(function(file) {
			var d = this.files[file];
			if(!data)
				data = d;
			else
				data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
		}, this);
	} else {
		data = this.files[filePath];
	}
	process.nextTick(function() {
		if(data) {
			var ts = data[0] === data[1] ? data[0] + FS_ACCURACY : data[0];
			if(ts >= startTime)
				watcher.emit("change", data[1]);
		} else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
			watcher.emit("remove");
		}
	}.bind(this));
	return watcher;
};

该代码记录了一个 filepath 创建一个 Watcher 的过程,最后返回了该 wathcer。

所以再反观

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
	watcher.on("change", function(mtime, type) {
		this._onChange(file, mtime, file, type);
	}.bind(this));
	watcher.on("remove", function(type) {
		this._onRemove(file, file, type);
	}.bind(this));
	return watcher;
};

我们就可以知道,这边是对每个文件绑定了一个 change 和 remove 事件。

文件发生变更后,最初会被 directoryWatcher 监听到,进而触发对应的 fileWatcher 的 change 事件。

_onChange 会被调用

Watchpack.prototype._onChange = function _onChange(item, mtime, file) {
	file = file || item;
	this.mtimes[file] = mtime;
	if(this.paused) return;
	this.emit("change", file, mtime);
	if(this.aggregateTimeout)
		clearTimeout(this.aggregateTimeout);
	if(this.aggregatedChanges.indexOf(item) < 0)
		this.aggregatedChanges.push(item);
	this.aggregateTimeout = setTimeout(this._onTimeout, this.options.aggregateTimeout);
};

进而触发了 Watchpack 实例的 change 事件, 该事件由在 NodeWatchFileSystem 中绑定。

// 片段
if(callbackUndelayed)
	this.watcher.once("change", callbackUndelayed);

this.watcher.once("aggregated", (changes, removals) => {
	changes = changes.concat(removals);
	if(this.inputFileSystem && this.inputFileSystem.purge) {
		this.inputFileSystem.purge(changes);
	}
	const times = this.watcher.getTimes();
	callback(null,
		changes.filter(file => files.indexOf(file) >= 0).sort(),
		changes.filter(file => dirs.indexOf(file) >= 0).sort(),
		changes.filter(file => missing.indexOf(file) >= 0).sort(), times, times);
});

那如何触发重编译呢?答案在 aggregated 事件中。

function example(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) => {
		this.pausedWatcher = this.watcher;
		this.watcher = null;
		if(err) return this.handler(err);

		this.compiler.fileTimestamps = fileTimestamps;
		this.compiler.contextTimestamps = contextTimestamps;
		this.invalidate();
}

触发 invalidate 事件,因为 _go 事件再次被执行。

invalidate(callback) {
	if(callback) {
		this.callbacks.push(callback);
	}
	if(this.watcher) {
		this.pausedWatcher = this.watcher;
		this.watcher.pause();
		this.watcher = null;
	}
	if(this.running) {
		this.invalid = true;
		return false;
	} else {
		this._go();
	}
}

在 compilation 对象中我们可以获取到和构建相关所有的依赖,而这些依赖真是需要去监听的内容。

“真是” -> “正是” ?

原因很简单,因为在 webpack(webpackConfig) 的阶段中,webpack 注入很多内部的自有插件,webpack 源码非常让人值得学习的一点就是插件机制应用的如火纯情。

“如火纯情” -> “炉火纯青” ?

@dzyhenry 谢谢指正

你好,想咨询下,我在一台电脑上修改vue文件会自动刷新,换另一台电脑不行,应该如何调试webpack这块的源码呢