咱们在平常使用 webpack
或者是在以它为基础开发的时候,可能更多的时候关注的是配置以及配置的插件开发。在平常的开发过程当中,会发现 watch
状态下的编译流程有一个规律是,第一次会较为缓慢,后续的编译会很快速,看起来像是有缓存的控制,那么具体内部的缓存流程存在哪些节点呢?下面进行一些探索总结,但愿能为平常的插件 plugin
、loader
开发起到帮助。javascript
对于 cache 使用的入口,其实在咱们平常构建中,大可能是借助 webpack
启动一个构建 watch 服务
。java
最普通的相比于 webpack
不带参数直接执行的方式, webpack --watch
的执行逻辑存在较为明显的区别。node
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
除了 webpack --watch
调用,这里还能够关联一下在平常使用中很日常的 webpack-dev-middleware 模块。git
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
方法进行文件的监听编译。印证了前面的github
大可能是借助
webpack
启动一个构建watch 服务
web
更准确的说法是,经过 compiler.watch
来建立 watch
服务。npm
如图对应上文不一样调用方式之间的差别。数组
上面小结的内容,在整个 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
三个函数循环调用的过程。衔接初始化截图,大体以下图。
后续主要对 监听
和 触发
两个部分所涉及的一些细节进行深刻。
由上面内容看出对于 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
层,只负责对文件(夹)的变化的事件监听
经过多个层级的划分,解耦逻辑,方便函数进行调整和功能横向扩展。
由上面 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
完成对底层逻辑的处理封装。
紧接着 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
实例的生成。
在 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
就会等待文件的改动,进行编译的再次触发。
目前 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
的编译启动流程。
在上面有提到
在
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
中的实现再一次的依赖 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
。
在上文中提过
Watching.prototype.watch
经过compiler.watchFileSystem
的watch
方法实现,能够大体看出在变化触发编译后,会执行传递的回调函数,最终会调用Watching.prototype.invalidate
进行编译触发
从调用开始,经过最底层的 chokidar
完成文件(夹)监听事件的触发,经过事件传递的方式,又回到调用处,进行从新编译。
回顾整个触发流程,纵向 4 个逻辑层级之间进行传递,
DirectoryWatcher
:完成对文件(夹)的监听实现,以及初步监听数据加工
watchpack
:完成触发底层逻辑的封装,实现上层逻辑跟触发逻辑解耦
NodeWatchFileSystem
:完成对监听数据业务逻辑处理,进行最后回调处理
Compiler
:完成最终业务响应
watch
流程利用事件模型,采用多个逻辑层的设计,对复杂的触发流程进行解耦拆分,实现了比较清晰可维护的代码结构。
在完成 watch
流程,触发从新编译后,与 run
流程相不一样的是,webpack
为了提升编译速度,下降编译的时间消耗与提升编译性能,在从新编译的不少环节中都设置了缓存机制,让二次编译的速度获得大大提升。下一篇文章主要对 cache 的状况进行描述。