我们在日常使用 webpack
或者是在以它为基础开发的时候,可能更多的时候关注的是配置以及配置的插件开发。在日常的开发过程中,会发现 watch
状态下的编译流程有一个规律是,第一次会较为缓慢,后续的编译会很快速,看起来像是有缓存的控制,那么具体内部的缓存流程存在哪些节点呢?下面进行一些探索总结,希望能为日常的插件 plugin
、loader
开发起到帮助。
webpack --watch
对于 cache 使用的入口,其实在我们日常构建中,大多是借助 webpack
启动一个构建 watch 服务
。
入口
最普通的相比于 webpack
不带参数直接执行的方式, webpack --watch
的执行逻辑存在较为明显的区别。
webpack/bin/webpack.js:
if(options.watch) {
var primaryOptions = !Array.isArray(options) ? options : options[0];
var watchOptions = primaryOptions.watchOptions || primaryOptions.watch || {};
if(watchOptions.stdin) {
process.stdin.on('end', function() {
process.exit(0); // eslint-disable-line
});
process.stdin.resume();
}
compiler.watch(watchOptions, compilerCallback);
} else
compiler.run(compilerCallback);
从执行文件中 webpack/bin/webpack.js
找到 --watch
逻辑,相比于直接 webpack
不带参数执行对应的是 compiler.run
方法,--watch
则对应的是 compiler.watch
方法。
除了 webpack --watch
调用,这里还可以关联一下在日常使用中很平常的 webpack-dev-middleware 模块。
webpack-dev-middleware/middleware.js:
if(!options.lazy) {
var watching = compiler.watch(options.watchOptions, function(err) {
if(err) throw err;
});
}
从代码可以看到,在非 lazy
(lazy
模式指的是根据请求来源情况来直接调用 compiler.run
进行构建)模式下,实际上也是同样通过 compiler.watch
方法进行文件的监听编译。印证了前面的
大多是借助
webpack
启动一个构建watch 服务
更准确的说法是,通过 compiler.watch
来创建 watch
服务。
如图对应上文不同调用方式之间的差异。
watch 编译生命周期
上面小结的内容,在整个 webpack
的过程中,是处在完成 compiler = webpack(config)
函数调用之后,得到一个 Compiler
实例之后,进行正式编译流程之前的节点,详细的编译流程文章推荐 [][]Webpack 源码(二)—— 如何阅读源码、细说 webpack 之流程篇 ,后续我们也会不断输出一些细节实现的文章。
对于 watch
这种需要不断进行触发编译的流程的情况,会出现不断重复地经历几个相同流程,可以称之为 watch 的 生命周期
,而 cache 的出现和使用同样也融入了在这个生命周期
中。
-
生成
Watching
实例watching
,将编译流程控制交给watching
。webpack/lib/Compiler.js Compiler.prototype.watch = function(watchOptions, handler) { this.fileTimestamps = {}; this.contextTimestamps = {}; var watching = new Watching(this, watchOptions, handler); return watching; };
无论是
webpack --watch
,还是 webpack-dev-middleware 模块,都是调用compiler.watch
方法进行初始化watch
流程,在Compiler.prototype.watch
逻辑中,与Compiler.prototype.run
在方法中完成具体编译流程不同的是,会通过生成watching
实例来接管具体编译流程。-
构造实例,进行第一次编译初始化
watching
作为watch
监听流程中的最上层对象,满足了watch
流程在逻辑最上层的各个阶段衔接。webpack/lib/Compiler.js function Watching(compiler, watchOptions, handler) { this.startTime = null; this.invalid = false; this.error = null; this.stats = null; this.handler = handler; if(typeof watchOptions === "number") { this.watchOptions = { aggregateTimeout: watchOptions }; } else if(watchOptions && typeof watchOptions === "object") { this.watchOptions = Object.create(watchOptions); } else { this.watchOptions = {}; } this.watchOptions.aggregateTimeout = this.watchOptions.aggregateTimeout || 200; this.compiler = compiler; this.running = true; this.compiler.readRecords(function(err) { if(err) return this._done(err); this._go(); }.bind(this)); }
对于
Watching
构造函数,其实可以分成两个部分-
基础属性设置
startTime
:执行每次编译时(Watching.prototype._go
方法调用) ,会赋值编译启动时间,在后续文件是否需要再次编译时,作为重要根据之一invalid
:表明现在watching
的调用状态,例如在this.runing
为 true 时,表明运行正常,会赋值该属性为true
error
:存放编译过程的错误对象,完成每次编译后会回传给handler
回调stats
:存放编译过程中的各个数值,同样也是会在每次编译后会回传给handler
回调handler
:指的是,每次编译完执行的回调函数,一个常见的例子是每次编译完在命令行中出现的资源列表就是通过这个函数实现watchOptions
:watch
调用参数设置,其中aggregateTimeout
参数代表的是每一次文件(夹)变化后在aggregateTimeout
值内的变化都会进行合并发送compiler
:生成watching
对象的Compiler
实例running
:watching
实例的运行状态
执行初始化编译
从this._go
调用开始,就会进入编译
->watch监听编译
->文件变更触发编译
->编译
的循环
-
执行编译
作为执行编译的入口Watching.prototype._go
函数的结构与Compiler.prototype.run
的结构类似,都是调用Compiler
提供的诸如this.compile
、this.emitAssets
等方法完成编译过程。
与run
类似,_go
函数同样会调用compiler.compile
方法进行编译,同时在完成emitAssets
(资源输出)、emitRecords
(记录输出) 后,也就是完成这一次编译后,会调用this.done
方法进行watch
循环的最后一步-
调用文件监听
在完成编译后,为了在不重复启动编译进程的情况下,文件改动会自动重新编译。会在Watching.prototype._done
中实时监听文件操作进行编译。Watching.prototype._done = function(err, compilation) { // 省略部分流程(结束状态值设置、结束事件触发等) if(!this.error) this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies); };
这里在
_done
的最后一个步骤,会调用Watching.prototype.watch
来进行文件监听:Watching.prototype.watch = function(files, dirs, missing) { this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, function(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) { this.watcher = null; if(err) return this.handler(err); this.compiler.fileTimestamps = fileTimestamps; this.compiler.contextTimestamps = contextTimestamps; this.invalidate(); }.bind(this), function() { this.compiler.applyPlugins("invalid"); }.bind(this)); };
Watching.prototype.watch
通过compiler.watchFileSystem
的watch
方法实现,可以大致看出在文件(夹)变化触发编译后,会执行传递的回调函数,最终会调用Watching.prototype.invalidate
进行编译触发:Watching.prototype.invalidate = function() { if(this.watcher) { this.watcher.pause(); this.watcher = null; } if(this.running) { this.invalid = true; return false; } else { this._go(); } };
到了
Watching.prototype.invalide
这个方法后,又去从Watching.prototype._go
函数开始进行新一轮的编译,到这里整个 watch 的流程就串起来了。 -
在进入 watchFileSystem
之前,回顾上面的整个流程,webpack
中的 watch
流程大致就是 Watching.prototype._go
-> Watching.prototype.watch
-> Watching.prototype.invalidate
三个函数循环调用的过程。衔接初始化截图,大致如下图。
后续主要对 监听
和 触发
两个部分所涉及的一些细节进行深入。
watchFileSystem
由上面内容看出对于 Watching.prototype.watch
实现文件监听的核心是 compiler.watchFileSystem
对象的 watch
方法。 watchFileSystem
在 webpack
中通过 NodeEnvironmentPlugin
来进行加载
webpack/lib/node/NodeEnvironmentPlugin.js
var NodeWatchFileSystem = require("./NodeWatchFileSystem");
NodeEnvironmentPlugin.prototype.apply = function(compiler) {
compiler.inputFileSystem = new NodeJsInputFileSystem();
var inputFileSystem = compiler.inputFileSystem = new CachedInputFileSystem(compiler.inputFileSystem, 60000);
compiler.resolvers.normal.fileSystem = compiler.inputFileSystem;
compiler.resolvers.context.fileSystem = compiler.inputFileSystem;
compiler.resolvers.loader.fileSystem = compiler.inputFileSystem;
compiler.outputFileSystem = new NodeOutputFileSystem();
compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
compiler.plugin("run", function(compiler, callback) {
if(compiler.inputFileSystem === inputFileSystem)
inputFileSystem.purge();
callback();
});
};
这里会设置很多的 fileSystem
,而这样做的好处可以关联到前面的 webpack-dev-middleware 模块,在本地调试等对编译性能有较高要求的场景下,需要尽量利用缓存的速度,而 webpack-dev-middleware
将物理 io 切换成缓存设置,通过修改 fileSystem
来实现。
webpack-dev-middleware/middleware.js
var fs = new MemoryFileSystem();
// the base output path for web and webworker bundles
var outputPath;
compiler.outputFileSystem = fs;
outputPath = compiler.outputPath;
将 compiler
的 outputFileSystem
设置成内存 (MemoryFileSystem
) 的方式,将资源编译文件不落地输出,大大提高编译性能。在 webpack
中存在文件系统的抽象处理,方便一些优秀的文件系统处理模块功能(例如读取缓存、内存读写)接入利用。
例如 webpack
默认采用的是 graceful-fs,本身基于 Node.js 中的 fs 模块进行了许多优化,而 webpack-dev-middleware
则是采用内存读取的 memory-fs
对照 NodeEnvironmentPlugin
的代码,可以看到 watchFileSystem
指向的是同目录下的 NodeWatchFileSystem.js
导出的构造函数生成的实例。
webpack/lib/node/NodeWatchFileSystem.js
var Watchpack = require("watchpack");
function NodeWatchFileSystem(inputFileSystem) {
this.inputFileSystem = inputFileSystem;
this.watcherOptions = {
aggregateTimeout: 0
};
this.watcher = new Watchpack(this.watcherOptions);
}
在 NodeWatchFileSystem.js
中的实现再一次的依赖 watchpack 完成。通过封装 watchpack
的监听逻辑,完成绑定相应的文件变更事件,进行上层 compiler.invalidate
方法调用,触发再次编译流程。
webpack/lib/node/NodeWatchFileSystem.js
NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
// 省略异常处理
if(callbackUndelayed)
this.watcher.once("change", callbackUndelayed);
this.watcher.once("aggregated", function(changes) {
// 省略具体流程
callback(...);
}.bind(this));
this.watcher.watch(files.concat(missing), dirs, startTime);
// 省略返回
}
这里的 callback
就是 Watching.prototype.watch
方法中调用 this.compiler.watchFileSystem.watch
传递的回调函数,当用户触发了 watchpack
提供的文件(夹)变化事件,那么就会通过 callback
回调中 Watching.prototype.invalidate
进行再次编译。在进入 watchpack
细节之前总结一下 watch
调用层级。
在 webpack
中的 watch
调用,每一层都叫做 watch
方法,在每一个 watch
方法中,都通过逐步对下一层的依赖调用,完成从 watching
实例与 watcher
实例的衔接解耦。
在
watching
层,完成对重新编译的回调绑定在
watchfileSystem
层,完成对下层监听文件(夹)触发逻辑之后信息返回的过滤处理,以及对上层回调的调用在
watcer
层,只负责对文件(夹)的变化的事件监听
通过多个层级的划分,解耦逻辑,方便函数进行调整和功能横向扩展。
watchpack 监听
由上面 NodeWatchFileSystem.js
的代码截断中可以看到,对应的 watch
方法,核心逻辑是 watchpack
的实例 watcher
对应的 watch
方法。直接找到对应的 Watchpack.prototype.watch
方法
watchpack/lib/watchpack.js
var watcherManager = require("./watcherManager");
Watchpack.prototype.watch = function watch(files, directories, startTime) {
this.paused = false;
// 省略 old watchers 处理
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);
};
衔接上一层在 NodeWatchFileSystem.js
中 this.watcher.watch(files.concat(missing), dirs, startTime);
的调用,在 watchpack
实例的 watch
方法中可以看到会针对 文件 、文件夹 类型分别调用 watcherManager.watchFile
、watcherManager.watchDirectory
进行监听。
watchpack/lib/watcherManager.js
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);
};
在 watcherManager.js
文件中的 watchFile
以及 watchDirectory
都传递了同类型的参数调用了 this.getDirectoryWatcher
,并在随后调用了返回实例的 watch
方法,并将 watch
方法的返回结果继续往上层 watchpack.js
的 this._fileWatcher
与 this._dirWatcher
方法进行传递。
watchpack/lib/watcherManager.js
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];
};
而 getDirectoryWatcher
的具体实现,则是创建一个由 ./DirectoryWatcher
导出的构造函数所构造出来的实例。这里可以看到以文件夹路径(directory
) 和配置 (options
)两个属性作为实例的 key
并且在函数最后,将实例进行返回。
整个逻辑通过 watchManager
进行底层逻辑创建,通过 _dirWatcher
、_fileWatcher
完成对底层逻辑的处理封装。
DirectoryWatcher 实例创建
紧接着 wacthManager
的 watchFile
与 watchDirectory
中 getDirectoryWatcher
调用完成后,则调用实例的 watch
方法,逻辑就走到了 DirectoryWatcher.js
文件。关联在 getDirectoryWatcher
的实例生成过程,对应 DirectoryWatcher
的构造函数
watchpack/lib/DirectoryWatcher.js
var chokidar = require("chokidar");
function DirectoryWatcher(directoryPath, options) {
EventEmitter.call(this);
this.path = directoryPath;
this.files = {};
this.directories = {};
this.watcher = chokidar.watch(directoryPath, {
ignoreInitial: true,
persistent: true,
followSymlinks: false,
depth: 0,
atomic: false,
alwaysStat: true,
ignorePermissionErrors: true,
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 = {};
this.refs = 0;
}
找到这里,可以看到,监听文件(夹)采用的是 chokidar 的能力。关联前面的逻辑,可以大致看出,通过 chokidar 绑定对应 directoryPath
的目录的 add
、addDir
、change
、unlink
、unlinkDir
的事件,通过对应的事件回调函数来向上层逻辑传递文件(夹)变更信息。
除了 watcher
对应 chokidar 对象,这里还有一些辅助的属性来完成监听处理逻辑
files
:保存文件改变状态(mtime)directories
:保存文件夹监听状态,以及嵌套文件夹监听实例initialScan
:初次文件扫描标识nestedWatching
:是否存在嵌套文件夹监听initialScanRemoved
: 首次查看过程中删除的文件(夹),对在首次查看过程中对已删除文件(夹)的过滤watchers
:以监听路径(filePath
) 为 key 的watcher
数组为值的 map 对象refs
:watchers
的数量
在属性复制完成后,会类似 Compiler.js
中 Watching
实例在实例创建时会进行首次编译一样,会进行首次文件夹的查看(doInitalScan)
,这里会进行初始数据(this.files
、this.directories
)的生成。
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));
};
这里是一个 async.forEach
撑起的函数结构,主要对传入 directoryPath
下的文件(夹)通过 setFileTime
、setDirectory
进行 DirectoryWatcher
实例的 files
、directories
属性赋值。
-
对于文件情况 (
stat.isFile
为true
) :调用 `setFileTime` 函数传入文件最后修改时间( `stat.mtime`),函数本身分为两个步骤,而这里主要是**存储文件的变更记录**,而另一部则是**变更事件的触发**,在后面的内容也会提到。
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) { var now = Date.now(); var old = this.files[filePath]; this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime]; // 省略变更触发 };
这里会以数组的形式,存储
变更流程执行时间点
、文件最后修改时间点
。
一般setFileTime
的调用的时候,就认为触发了文件触发了变更,进行文件变更记录更新,而对于初始化情况,主要目的是为了初始化数据,并不为变更而调用setFileTime
,所以对于初始化的返回是进行比较Math.min(now, mtime)
而不是直接返回当前时间。 -
对于文件夹情况(
stat.isDirectory
为true
)调用
setDirectory
来进行子文件夹标记,方便后续进行子文件夹监听的创建:DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial) { var old = this.directories[directoryPath]; if(!old) { if(exist) { if(this.nestedWatching) { this.createNestedWatcher(directoryPath); } else { this.directories[directoryPath] = true; } } } // 省略文件夹删除事件触发 }
在
doInitalScan
的场景下,会判断nestedWatching
的情况,如果为false
则赋值this.directories[directoryPath]
为true
,表示文件夹没有创建对应的监听;或者是通过this.createNestedWatcher
进行子文件夹监听的创建,最终也会赋值到this.directories[directoryPath]
上的则是对应的内嵌Watcher
实例。而这里的子文件夹的状态在后续也是可能发生变化的。
完成赋值过程后, 会将this.initialScan
设置成false
表示首次查看结束,设置this.initialScanRemoved
为null
,表示在首次查看过程中就删除的文件(夹)的处理也结束。
在完成基础 this.watcher
文件系统监听逻辑(chokidar )创建,基础属性 this.files
、this.directories
初始化后,则完成了整个 DirectoryWatcher
实例的生成。
搭建监听通道(创建内部 Watcher 实例)
在 getDirectoryWatcher
完成调用返回 DirectoryWatcher
的实例之后,调用实例的 watch
方法,传入文件(夹)路径。对最上层 Compiler
传入的 files
、missings
文件,dirs
文件夹进行循环调用,进行监听流程。watch
方法通过三个阶段完成底层到上层的监听信息通道的搭建。
-
生成
Watcher
实例
第一个部分是针对传入的路径生成对应的Watcher
实例,最终通过WatcherManager
的watchFile
、watchDirectory
返回到上层watchpack
中的watch
方法中this._fileWatcher
、this._dirname
调用的返回结果,就是这个内部Watcher
实例。watchpack/lib/DirectoryWatcher.js function Watcher(directoryWatcher, filePath, startTime) { EventEmitter.call(this); this.directoryWatcher = directoryWatcher; this.path = filePath; this.startTime = startTime && +startTime; this.data = 0; } 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() { // 省略 closed 事件处理 }.bind(this)); this.watchers[withoutCase(filePath)].push(watcher); // 省略设置子文件内嵌监听 // 省略已有数据处理 return watcher; };
这里内部
Watcher
实例主要是通过继承EventEmitter
来实现实例的事件支持,那么传递回上层例如watchpack
时,就可以绑定该Watcher
实例的事件,底层的文件改动触发实例的事件,上层对事件处理,通过这个对象建立数据传递的通道,完成监听数据的传递。在完成watcher
实例创建后,会将实例push
进this.watchers
中以filePath
为 key 的watcher
数组,并将实例返回。 -
设置子文件夹内嵌监听
watch
方法的另一部分,则是进行设置内嵌监听setNestedWatching
watchpack/lib/DirectoryWatcher.js DirectoryWatcher.prototype.watch = function watch(filePath, startTime) { // 省略内部 Watcher 实例生成 var data; if(filePath === this.path) { this.setNestedWatching(true); } // 省略已有数据处理 }; DirectoryWatcher.prototype.setNestedWatching = function(flag) { if(this.nestedWatching !== !!flag) { this.nestedWatching = !!flag; if(this.nestedWatching) { Object.keys(this.directories).forEach(function(directory) { this.createNestedWatcher(directory); }, this); } else { Object.keys(this.directories).forEach(function(directory) { this.directories[directory].close(); this.directories[directory] = true; }, this); } } };
在处理
filePath == this.path
的时候,也就是DirectoryWatcher.prototype.watch
传入的路径与Directory
生成实例的路径相同的时候(watchManager.js
中的watchDirectory
方法的调用this.getDirectoryWatcher(directory, options).watch(directory, startTime)
满足此条件)会在watch
中调用DirectoryWatcher.prototype.setNestedWatching
进行子文件夹的监听的创建。watchpack/lib/DirectoryWatcher.js DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) { this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1); this.directories[directoryPath].on("change", function(filePath, mtime) { if(this.watchers[withoutCase(this.path)]) { this.watchers[withoutCase(this.path)].forEach(function(w) { if(w.checkStartTime(mtime, false)) { w.emit("change", filePath, mtime); } }); } }.bind(this)); };
子文件夹的监听同样是通过上层
watchManager.js
中的watchManager.watchDirectory
的调用实现,同时这里会多绑定一次change
事件,实现当子文件夹变化的时候触发父文件夹的change
事件。 -
处理已有数据
在完成watcher
实例创建之后,会针对在watch
实例创建过程中发生的文件(夹)变动进行处理,保证文件的变动能完备更新watchpack/lib/DirectoryWatcher.js DirectoryWatcher.prototype.watch = function watch(filePath, startTime) { // 省略内部 Watcher 实例生成 var data; if(filePath === this.path) { // 省略设置子文件内嵌监听 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) { if(data[0] > startTime) watcher.emit("change", data[1]); } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) { watcher.emit("remove"); } }.bind(this)); };
处理已有数据也是分成两个步骤
-
读取数据
这里对于文件、文件夹的处理,获取数据的方式也不同。
对于监听文件夹路径的情况: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);
可以从对
this.files
的循环看出,这里实际上是取到的是该文件夹下所有文件中的变更流程执行时间点
、文件最后修改时间点
的最大值。
对于单个文件路径的情况:data = this.files[filePath];
则是直接取到当前监听文件路径的数据。
-
触发事件
当数据完成获取后,就进入到触发事件
的阶段,这个阶段会将前面取到的变更流程执行时间点
与由Watching.prototype._go
中设置的编译开始时间startTime
进行比较:process.nextTick(function() { if(data) { if(data[0] > startTime) watcher.emit("change", data[1]); } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) { watcher.emit("remove"); } }.bind(this));
当
变更流程执行时间点
比startTime
时间晚的时候说明,在编译开始后,针对文件夹的情况是文件夹其中的文件发生了变化,对于单个文件的情况,则是该文件发生变化。则触发change
事件。
这里还会有一个判断是:if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) { watcher.emit("remove"); }
对于第一个条件
this.initialScan
,上面提到在完成doInitialScan
完成后会复制为false
。完成赋值过程后, 会将
this.initialScan
设置成false
表示首次查看结束,设置this.initialScanRemoved
为null
,表示在首次查看过程中就删除的文件(夹)的处理也结束则这条判断是在
watch
进行的同时,doInitialScan
也还在进行的时候生效。
对于第二个条件this.initialScanRemoved.indexOf(filePath)
,这里主要落脚点在于initialScanRemoved
对这个数组的操作watchpack/lib/DirectoryWatcher.js this.watcher.on("unlink", this.onFileUnlinked.bind(this)); this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this)); DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) { // 省略判断 if(this.initialScan) { this.initialScanRemoved.push(filePath); } }; DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) { // 省略判断 if(this.initialScan) { this.initialScanRemoved.push(directoryPath); } };
从事件绑定中可以看到,当在进行
doInitialScan
过程中,发生了文件(夹)删除的情况,则会将删除的路径push
到initialScanRemoved
数组中。
那么整合两个条件,在初始扫描的场景下,监听文件(夹)发生删除的情况时,则触发remove
事件,避免增加无效的监听。
-
在整个数据监听通道的流程中,都是围绕 Watcher
实例进行开展,通过 Watcher
承上启下衔接上下逻辑的作用。
触发流程
在完成了从 Watchpack.prototype.watch
-> WatcherManager.prototype.watchFile
、WatcherManager.prototype.watchDirectory
-> Directory.prototype.watch
这条调用链之后,webpack --watch
就会等待文件的改动,进行编译的再次触发。
chokidar
目前 watchpack
中对文件(夹)的监听通过 chokidar
来实现,首先关联的逻辑就是 chokidar
的具体调用,关注到 DirectoryWatcher
中调用 chokidar
的部分
watchpack/lib/DirectoryWatcher.js
function DirectoryWatcher(directoryPath, options) {
EventEmitter.call(this);
this.watcher = chokidar.watch(directoryPath, {
ignoreInitial: true,
persistent: true,
followSymlinks: false,
depth: 0,
atomic: false,
alwaysStat: true,
ignorePermissionErrors: true,
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));
}
首先是 chokidar
的初始化,
ignoreInitial
:默认为false
, 设置为true
,避免在chokidar
自身初始化的过程中触发add
、addDir
事件persistent
:默认为true
,设置为true
,保持文件监听,为false
的情况下,会在ready
事件后不再触发事件followSymlinks
:默认为true
,设置为false
,对 link 文件不监听真实文件内容的变化depth
: 设置为0
,表明对子文件夹不进行递归监听atomic
:默认为false
,设置为false
,关闭对同一文件删除后 100ms 内重新增加的行为触发change
事件,而不是unlink
、add
事件的默认行为alwaysStat
:默认为false
,设置为true
,保持传递fs.Stats
,即使可能存在不存在的情况ignorePermissionErrors
:默认为false
,设置为true
,忽略权限错误的提示usePolling
:默认为false
,根据实际配置来设置,是否开启polling
轮询模式interval
:轮询模式的周期时间,根据实际配置来设置,轮询模式的具体时间
其次绑定对应的文件(夹)事件 add
、addDir
、change
、unlink
、unlinkDir
完成初始化和事件绑定后,通过各个事件的回调函数来进行监听逻辑的触发和向上层传递。
文件时间精确度数值(FS_ACCURENCY
)确定
根据上面提到的 this.watcher.on("change", this.onChange.bind(this));
当文件内容发生变化时,进入绑定的 onChange
回调函数
watchpack/lib/DirectoryWatcher.js
var FS_ACCURENCY = 10000;
DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) {
if(filePath.indexOf(this.path) !== 0) return;
if(/[\\\/]/.test(filePath.substr(this.path.length + 1))) return;
var mtime = +stat.mtime;
if(FS_ACCURENCY > 1 && mtime % 1 !== 0)
FS_ACCURENCY = 1;
else if(FS_ACCURENCY > 10 && mtime % 10 !== 0)
FS_ACCURENCY = 10;
else if(FS_ACCURENCY > 100 && mtime % 100 !== 0)
FS_ACCURENCY = 100;
else if(FS_ACCURENCY > 1000 && mtime % 1000 !== 0)
FS_ACCURENCY = 1000;
else if(FS_ACCURENCY > 2000 && mtime % 2000 !== 0)
FS_ACCURENCY = 2000;
this.setFileTime(filePath, mtime, false, "change");
};
在 onChange
中,除了调用 this.setFileTime
进行文件变更数据更新、对应 watcher
实例事件触发之外,还会进行 FS_ACCURENCY
的校准逻辑。可以看到校准的规则是根据文件的修改时间取模的精度来确定值。关于这个变量值,这里从 issue 中找到 webpack
作者 sokra 的描述:
FS_ACCURENCY
should automatically adjust to your file system accuracy
With low fs accuracy files could have changed even if mime is equal
其中说到,在文件系统数据低精确度的情况,可能出现 mime
相同,但也发生了改变的情况。通过在后面的变更判断中通过加入精确值的度量值计算,起到平衡数值的作用(例如var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;
)。
watcher
实例事件触发
之前提到,watcher
实例是文件变更信息的通道,通过在 watcher
上的事件绑定,将 chokidar
监听到的文件(夹)变更信息,传递到 watchpack
层的逻辑。进入 this.setFileTime
后,则进行对应事件的触发
watchpack/lib/DirectoryWatcher.js
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
var now = Date.now();
var old = this.files[filePath];
this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
if(!old) {
if(mtime) {
if(this.watchers[withoutCase(filePath)]) {
// 文件事件触发具体逻辑
}
}
} else if(!initial && mtime && type !== "add") {
// 文件事件触发具体逻辑
} else if(!initial && !mtime) {
// 文件事件触发具体逻辑
}
if(this.watchers[withoutCase(this.path)]) {
// 文件目录事件触发
}
};
事件触发分为两个大的阶段,第一个阶段为对于 filePath
文件的事件触发,第二个阶段为对于当前 DirectoryWatcher
对应 path
属性文件夹的事件触发。
1.filepath
文件的事件触发
watchpack/lib/DirectoryWatcher.js
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
var now = Date.now();
var old = this.files[filePath];
this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
if(!old) {
if(mtime) {
if(this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function(w) {
if(!initial || w.checkStartTime(mtime, initial)) {
w.emit("change", mtime);
}
});
}
}
} else if(!initial && mtime && type !== "add") {
if(this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function(w) {
w.emit("change", mtime);
});
}
} else if(!initial && !mtime) {
if(this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function(w) {
w.emit("remove");
});
}
}
// 省略文件夹触发
};
文件事件触发,实际会涉及到三个逻辑,单纯已有文件改变的触发,对应第二个逻辑
-
对于
filePath
之前没有数据设置的情况if(!old)
这里穿插到前面初始化的逻辑,在前面 `doIntialScan` 中 `initial` 的参数为 `true`, 则进入 `checkStartTime` 函数判断
watchpack/lib/DirectoryWatcher.js Watcher.prototype.checkStartTime = function checkStartTime(mtime, initial) { if(typeof this.startTime !== "number") return !initial; var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY; return startTime <= mtime; };
会去比较编译开始时间 `statrTime` 与文件最后修改时间 `mtime` 来判断是否需要触发事件,`doInitialScan` 场景下,默认 `FS_ACCURENCY` 的值是 `10000` ,意思是在编译前的 10s 范围内的改动都会触发 `change` 事件,那么这样是否会存在初始化时多触发一次编译呢?在上面提到 [issue](https://github.com/webpack/watchpack/issues/25) 中,作者同样给出了解释 > This may not happen fast enough if you have few files and the files are created unlucky on a timestamp modulo 10s > 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. 及时触发这样的 `unlucky case`,也只会在 `doInitailScan` 过程中文件内容真正发生变化导致 `hash` 变化的时候再次触发编译更新。 这条判断同样适用当有新增文件,触发 `add` 事件的情况。
-
对于已有文件变化(非
doInitial
过程中、add
新增文件事件触发,if(!initial && mtime && type !== "add")
)对应这种情况,则直接会触发 `change` 事件
if(this.watchers[withoutCase(filePath)]) { this.watchers[withoutCase(filePath)].forEach(function(w) { w.emit("change", mtime); }); }
找到对应文件的监听 `watcher` 触发 `change` 事件,对应上层逻辑逻辑进行响应。
-
mtime
不存在的情况(文件删除)watchpack/lib/DirectoryWatcher.js this.watcher.on("unlink", this.onFileUnlinked.bind(this)); DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) { // 省略其他操作 this.setFileTime(filePath, null, false, "unlink"); };
当文件删除触发 `unlink` 事件时,调用 `setFileTime` 时,则会传递 `mtime` 为 `null`。则事件触发逻辑与第二种情况方式相同,只是从 `change` 事件变成了 `remove` 事件。
2.DirectoryWatcher
对应 path
属性文件夹的事件触发
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
// 省略文件触发
if(this.watchers[withoutCase(this.path)]) {
this.watchers[withoutCase(this.path)].forEach(function(w) {
if(!initial || w.checkStartTime(mtime, initial)) {
w.emit("change", filePath, mtime);
}
});
}
};
因为是监听的是文件夹下的文件发生的变化,所以在完成了对应文件事件的触发之后,会进行监听文件夹(路径为实例化 DirectoryWatcher
时传入的 this.path
)的触发,这里除了会将文件的最后修改时间 mtine
传递,还会将对应的文件路径 this.filePath
也当做参数一起传递到绑定的事件回调参数中。
在通过 watcher
这个继承了 EventEmitter
对象的实例触发事件后,就完成了底层文件(夹)监听触发的功能,紧接着就是上层对象对于 watcher
实例的事件触发的对应处理,最终关联上 webpack
的编译启动流程。
上层响应
watchpack.js
在上面有提到
在
watcherManager.js
文件中的watchFile
以及watchDirectory
都传递了同类型的参数调用了this.getDirectoryWatcher
,并在随后调用了返回实例的watch
方法,并将watch
方法的返回继续往上层watchpack.js
的this._fileWatcher
与this._dirWatcher
方法。
则 watch
实例的上层响应的第一层在 watchpack.js
中的 Watchpack.prototype._fileWatcher
、Watchpack.prototype._dirWatcher
中完成,分别针对文件和文件夹的变更处理
watchpack/lib/watchpack.js
Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
watcher.on("change", this._onChange.bind(this, file));
return watcher;
};
Watchpack.prototype._dirWatcher = function _dirWatcher(item, watcher) {
watcher.on("change", function(file, mtime) {
this._onChange(item, mtime, file);
}.bind(this));
return watcher;
};
这里 _fileWatcher
和 _dirWatcher
对 change
的事件都是将逻辑导向了 Watchpack.prototype._onChange
中
watchpack/lib/watchpack.js
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
事件,传入触发的文件(夹)的路径,以及最后修改时间,供上层逻辑操作。
然后开始进行 aggregate
逻辑的触发,可以看到这里的大致含义是在文件(夹)发生变更 this.aggregateTimeout
后,进行 Watchpack.prototype._onTimeout
逻辑,在此之前,会将修改的文件(夹)路径暂存到 aggregatedChanges
数组中
watchpack/lib/watchpack.js
Watchpack.prototype._onTimeout = function _onTimeout() {
this.aggregateTimeout = 0;
var changes = this.aggregatedChanges;
this.aggregatedChanges = [];
this.emit("aggregated", changes);
};
而 Watchpack.prototype._onTimeout
则是当最后一次文件(夹)触发之后没有变更的 200ms 后,通过 this.aggregatedChanges
将接连不断的变更聚合通过 aggregated
事件传递给上层。
那么对应每一个变更,实际会牵涉触发一次 change
事件,以及关联一次 aggregated
事件,传给给上层,关联实际的编译重新触发逻辑。
NodeWatchFileSystem.js
前面提到
在
NodeWatchFileSystem.js
中的实现再一次的依赖 watchpack 完成。通过封装watchpack
的监听逻辑,完成绑定相应的文件变更事件,进行上层compiler.invalidate
方法调用,触发再次编译流程。
那么绑定 watchpack
实例的事件,来完成这一层的逻辑
webpack/lib/NodeWatchFileSystem.js
NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
// 省略参数合法性检测
this.watcher = new Watchpack(options);
if(callbackUndelayed)
this.watcher.once("change", callbackUndelayed);
this.watcher.once("aggregated", function(changes) {
//1.
if(this.inputFileSystem && this.inputFileSystem.purge) {
this.inputFileSystem.purge(changes);
}
//2.
var times = this.watcher.getTimes();
//3.
callback(null, changes.filter(function(file) {
return files.indexOf(file) >= 0;
}).sort(), changes.filter(function(file) {
return dirs.indexOf(file) >= 0;
}).sort(), changes.filter(function(file) {
return missing.indexOf(file) >= 0;
}).sort(), times, times);
}.bind(this));
this.watcher.watch(files.concat(missing), dirs, startTime);
// 省略返回
};
与上面 watchpack
触发事件一致,在 NodeWatchFileSystem
这一层逻辑中,其实对下一层 Watchpack
的就是通过绑定主要的 change
、aggregated
事件完成的。
对于 change
事件,会直接传递到上层的 callbackUndelayed
中
对于 aggregated
事件,
首先会调用
this.inputFileSystem.purge(changes)
,将文件系统中涉及到变更的文件的记录清空。其次调用
Watchpack
实例的getTimes()
方法获取监听文件(夹)的变更流程执行时间点
、文件最后修改时间点
的最大值,便于在后续判断是否需要进行重新编译,例如cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);
。最后在调用上层回调之前,会将变化的文件(夹)根据监听时传入参数通过挨个过滤的方式进行分发到每个参数中,完成之后,流程就会走到最后一层也是最初调用监听的一层
Compiler.js
。
Compiler.js
在上文中提过
Watching.prototype.watch
通过compiler.watchFileSystem
的watch
方法实现,可以大致看出在变化触发编译后,会执行传递的回调函数,最终会调用Watching.prototype.invalidate
进行编译触发
从调用开始,通过最底层的 chokidar
完成文件(夹)监听事件的触发,通过事件传递的方式,又回到调用处,进行重新编译。
回顾整个触发流程,纵向 4 个逻辑层级之间进行传递,
DirectoryWatcher
:完成对文件(夹)的监听实现,以及初步监听数据加工watchpack
:完成触发底层逻辑的封装,实现上层逻辑跟触发逻辑解耦NodeWatchFileSystem
:完成对监听数据业务逻辑处理,进行最后回调处理Compiler
:完成最终业务响应
总结 & 衔接
watch
流程利用事件模型,采用多个逻辑层的设计,对复杂的触发流程进行解耦拆分,实现了比较清晰可维护的代码结构。
在完成 watch
流程,触发重新编译后,与 run
流程相不同的是,webpack
为了提高编译速度,降低编译的时间消耗与提高编译性能,在重新编译的很多环节中都设置了缓存机制,让二次编译的速度得到大大提高。下一篇文章主要对 cache 的情况进行描述。