咱們在平常使用 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 的狀況進行描述。