Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webpack watch 篇(一) #6

Open
soda-x opened this issue Oct 9, 2017 · 4 comments
Open

webpack watch 篇(一) #6

soda-x opened this issue Oct 9, 2017 · 4 comments
Assignees
Labels

Comments

@soda-x
Copy link
Owner

soda-x commented Oct 9, 2017

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

(一)主要描述下问题的表现,并 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();
	}
}
@dzyhenry
Copy link

dzyhenry commented Mar 8, 2018

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

“真是” -> “正是” ?

@dzyhenry
Copy link

dzyhenry commented Mar 8, 2018

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

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

@soda-x
Copy link
Owner Author

soda-x commented Mar 14, 2018

@dzyhenry 谢谢指正

@lht1936
Copy link

lht1936 commented Dec 25, 2019

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants