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 机制。
如果不想看那么多代码片段,也可以看我在梳理代码逻辑时做的笔记,笔记中红色流程为初始化时的调用链路,蓝色部分为文件变更后事件回调链路。
首先我们可以确定一点的是,不管是 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
方法将被调用,调用时会赋值编译启动时间,该时刻在认定文件是否需要再次编译或者是否变更时非常非常重要!
首次编译初始化
当如上 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,绑定 add
,addDir
, change
,unlink
,unlinkDir
,error
等事件。
并执行了 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 源码非常让人值得学习的一点就是插件机制应用的如火纯情。
“如火纯情” -> “炉火纯青” ?
你好,想咨询下,我在一台电脑上修改vue文件会自动刷新,换另一台电脑不行,应该如何调试webpack这块的源码呢