從模塊流可以看出,這個NodeWatchFileSystem模塊非常深,這裡暫時不會深入到chokidar模塊,有點太偏離本系列文章了,從WatcherManager開始講解。 流程如圖: 源碼非常簡單,包括一個工廠函數與兩個原型方法,整理如下: 包含一個容器類和三個實例方法,每一次調用watchF ...
從模塊流可以看出,這個NodeWatchFileSystem模塊非常深,這裡暫時不會深入到chokidar模塊,有點太偏離本系列文章了,從WatcherManager開始講解。
流程如圖:
源碼非常簡單,包括一個工廠函數與兩個原型方法,整理如下:
var path = require("path"); class WatcherManager { constructor() { // 監視容器 this.directoryWatchers = {}; }; getDirectoryWatcher(directory, options) { var DirectoryWatcher = require("./DirectoryWatcher"); options = options || {}; // 目錄路徑拼接參數 這個有夠厲害的 // 假設directory為lib options不傳 拼接後為'lib {}' var key = directory + " " + JSON.stringify(options); if (!this.directoryWatchers[key]) { // 根據監視路徑生成一個DirectoryWatcher實例 this.directoryWatchers[key] = new DirectoryWatcher(directory, options); // 監聽監視關閉事件 this.directoryWatchers[key].on("closed", function() { delete this.directoryWatchers[key]; }.bind(this)); } // 返回對應的實體類 return this.directoryWatchers[key]; }; // 路徑 參數 開始事件 watchFile(p, options, startTime) { // 返回目錄名作為根目錄 // lib/newFile.js => lib var directory = path.dirname(p); // 生成實例並調用watch方法 // 由於上面返回的是實體類 這裡可以進行鏈式調用 return this.getDirectoryWatcher(directory, options).watch(p, startTime); }; watchDirectory(directory, options, startTime) { return this.getDirectoryWatcher(directory, options).watch(directory, startTime); }; } module.exports = new WatcherManager();
包含一個容器類和三個實例方法,每一次調用watchFile或watchDirectory方法時會在容器中添加一個目錄監視信息,在關閉監視事會刪除對應的信息。
主流方法還是引用的DirectoryWatcher模塊,從構造函數開始詳細看源碼:
function DirectoryWatcher(directoryPath, options) { // 繼承EventEmitter EventEmitter.call(this); // 獲取配置 this.options = options; // 根目錄 this.path = directoryPath; // 根目錄下的文件信息 this.files = Object.create(null); // 根目錄下的文件夾信息 this.directories = Object.create(null); // 目錄下的文件所有監聽器容器 this.watchers = Object.create(null); // 初始化監視器 跳過 this.watcher = chokidar.watch(directoryPath, { /*options*/ }); // 事件監聽 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; // 對整個文件夾進行監視 僅在傳入監視路徑為文件夾時置true this.nestedWatching = false; this.initialScanRemoved = []; // 初始化掃描 this.doInitialScan(); // 記錄watchers中監聽器數量 this.refs = 0; }
這裡可以分為幾塊內容:
1、繼承nodejs的事件模塊
2、獲取傳進來的路徑與配置參數
3、根據參數初始化一個watcher對象,並對文件操作做事件監聽
4、初始化掃描
watcher對象的生成過程暫時不考慮,太深入會偏離主線任務。
初始化掃描
在構造函數中會對傳進來的路徑進行掃描,源碼如下:
DirectoryWatcher.prototype.doInitialScan = function doInitialScan() { // 讀取根目錄 fs.readdir(this.path, function(err, items) { // 即使報錯仍然置initialScan標記為false if (err) { this.initialScan = false; return; } // items為到根目錄下所有文件的文件名組成的數組 // 同時包含文件與文件夾 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() { // 回調函數中處理標記initialScan標記 this.initialScan = false; this.initialScanRemoved = null; }.bind(this)); }.bind(this)); };
代碼十分易懂,基本上都是fs模塊的方法,主要分為以下幾步:
1、讀取指定根目錄下所有文件
2、將文件名與當前路徑進行拼接獲取完整路徑,然後嘗試獲取文件信息
3、分別處理文件與文件夾
這裡的流程可以用一個案例測試,首先目錄如圖:
a.js是執行JS文件,lib是用來測試的文件夾,包含幾個js文件和一個空文件夾。
測試代碼如下:
// a.js const fs = require('fs'); const async = require('async'); const path = require('path'); // 讀取文件夾 fs.readdir('./lib', (err, items) => { // 這裡沒有傳路徑 所以用process.cwd()模擬 // 這裡需要拼接一下路徑 const absPath = path.join(process.cwd(), 'lib'); // items => ['DirectoryWatcher.js','fileDirectory',...,'watchpack.js'] async.forEach(items, (item, callback) => { // 第一個元素拼接後為d:\workspace\doc\lib\DirectoryWatcher.js const itemPath = path.join(absPath, item); fs.stat(itemPath, (err2, stat) => { // 處理文件 if (stat.isFile()) { console.log('Find file,the name is: ' + item); } // 處理文件夾 else if (stat.isDirectory()) { console.log('Find directory,the name is: ' + item); } }); }); });
執行JS文件後輸出如圖:
可以看到,通過該方法可以區別開文件與文件夾,然後分類處理。
下麵看兩種處理方法。
setFileTime
// this.setFileTime(itemPath, +stat.mtime, true); // itemPath => 路徑 // +stat.mtime => 修改時間 // 是否初始化 => true DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) { // 獲取當前時間 var now = Date.now(); var old = this.files[filePath]; // 初始化取文件修改時間與當前時間的較小值 // 否則files = {path:[now,mtime]} // 鍵為文件路徑 值為數組 包含當前時間與上一次修改時間 this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime]; // 這裡的FS_ACCURACY是假設操作可能的運行時間 // 嘗試通過加一點點來更精確修改時間 if (mtime) mtime = mtime + FS_ACCURACY; if (!old) { if (mtime) { if (this.watchers[withoutCase(filePath)]) { /**/ } } } else if (!initial && mtime && type !== "add") { /**/ } else if (!initial && !mtime) { /**/ } // 初始化不會有watchers if (this.watchers[withoutCase(this.path)]) { /**/ } };
從名字也能看出這個方法的作用就是設置時間,在初始化的情況下,會在files容器中註冊,鍵為文件路徑,值為當前時間與修改時間。
由於watchers對象此時為null,所以後面的代碼並不會進入,後面再討論。
setDirectory
// this.setDirectory(itemPath, true, true); DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial, type) { if (directoryPath === this.path) { if (!initial && this.watchers[withoutCase(this.path)]) { /**/ } } else { var old = this.directories[directoryPath]; // 初次掃描 if (!old) { if (exist) { // 預設為false if (this.nestedWatching) { this.createNestedWatcher(directoryPath); } else { // 根目錄在監聽器容器中的值預設設置為true this.directories[directoryPath] = true; } if (!initial && this.watchers[withoutCase(this.path)]) { /**/ } } } else { /**/ } } };
在初始化的掃描中,根目錄下所有的文件夾也會在對應的容器中註冊一個鍵,值為true。
其餘代碼在初始化並不會執行,後面再講。
在經過doInitialScan初始化之後,files、directories容器會被填充進對應的鍵值對,存儲文件與文件夾的路徑信息。
watch
無論是watchFile還是watchDirectory都在初始化後直接調用了watch方法對具體文件進行了監視,這裡分析該處源碼:
DirectoryWatcher.prototype.watch = function watch(filePath, startTime) { // 將路徑小寫 // 第一次監視指定路徑會初始化一個空數組 this.watchers[withoutCase(filePath)] = this.watchers[withoutCase(filePath)] || []; // 記數 this.refs++; // 生成一個內部輔助類 var watcher = new Watcher(this, filePath, startTime); // 監聽closed事件 watcher.on("closed", function() { // 刪除對應的watcher var idx = this.watchers[withoutCase(filePath)].indexOf(watcher); this.watchers[withoutCase(filePath)].splice(idx, 1); // 當對應watcher數組為空時直接刪除該鍵 if (this.watchers[withoutCase(filePath)].length === 0) { delete this.watchers[withoutCase(filePath)]; // 如果觸發了文件夾的closed事件 關閉文件夾的監視 if (this.path === filePath) this.setNestedWatching(false); } // 當watchers為空時調用類的close方法 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]; } // node中的非同步函數 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; }; class Watcher { constructor() { EventEmitter.call(this); this.directoryWatcher = directoryWatcher; this.path = filePath; this.startTime = startTime && +startTime; this.data = 0; }; // 也不知道檢測啥的 checkStartTime(mtime, initial) { if (typeof this.startTime !== "number") return !initial; var startTime = this.startTime; return startTime <= mtime; }; // 此方法觸發closed事件 close() { this.emit("closed"); }; }
內部的Watcher對象負責對應路徑文件的操作事件響應。
watch有兩種情形,一種是普通的文件監視,一種是對文件夾的監視。
如果是普通的文件監視,直接生成一個Watcher監聽器,然後將該監聽器加入已有目錄監視容器對應的watchers容器中。
如果是傳入的是文件夾,其實相當於一個初始化,會對根目錄下文件夾內容做監視,代碼如下:
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); } } }; DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) { // 文件夾信息容器的值設為一個DirectoryWatcher實例 // startTime設為1 this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1); // 實例監聽change事件 this.directories[directoryPath].on("change", function(filePath, mtime, type) { // 文件夾改變時觸發對應的監聽器 if (this.watchers[withoutCase(this.path)]) { this.watchers[withoutCase(this.path)].forEach(function(w) { if (w.checkStartTime(mtime, false)) { w.emit("change", filePath, mtime, type); } }); } }.bind(this)); };
fs.watch
下麵開始講解文件操時作觸發的事件處理,其中包括文件與文件夾的操作。
先簡要介紹下nodejs原生的watch系統,官方文檔:https://nodejs.org/dist/latest-v8.x/docs/api/fs.html#fs_fs_watch_filename_options_listener。
通過引入nodejs中的fs模塊,通過調用fs.watch方法可以對文件進行監視,具體的api如下:
const fs = reqire('fs'); fs.watch(filename /*文件名*/ , options /*配置參數 可忽略*/ , listener /*監聽器*/ )
這裡的filename可以是文件,也可以是一個目錄。
options有三個可選參數:
persistent:文件如果在被監視,進程是否應該繼續進行,預設為true
recursive:是否監視所有子目錄,預設為false
encoding:指定傳給監聽器文件名的字元編碼,預設為'uft-8'
監聽器則是一個函數,有兩個參數,分別為事件類型與對應的文件名。
這裡用了小案例來進行演示,代碼如下:
const fs = require('fs'); fs.watch('./lib', ((event, filename) => { console.log('event type is: ' + event); console.log('the relative filename is: ' + filename); }));
目錄結構可參考上圖,執行node指令後終端會被掛起,等待變化。
此時新建一個文件,如圖:
在新建成功的時候,會發現監聽器被觸發,列印信息如圖:
修改文件內容,列印信息如圖:
根據官方文檔,事件只有rename與change兩種,無論是添加、刪除、重命名都會觸發rename事件,而修改文件內容會觸發change事件。
所以很明顯,框架內部對事件類型進行了細粒度更大的劃分,將rename分解為增加文件/文件夾,刪除文件/文件夾四種情況。
實現的原理根據上面的代碼也很容易想到,可以根據文件名與files、directories容器中的鍵做比對,區分文件與文件夾,根據修改時間,區分是新建還是刪除。
下麵可以看構造函數中對特殊文件操作的監聽器。
add
// 增加文件時觸發的事件 this.watcher.on("add", this.onFileAdded.bind(this)); DirectoryWatcher.prototype.onFileAdded = function onFileAdded(filePath, stat) { // filePath => 文件路徑 // stat => fs.stat(...) // 檢測文件是否在監視目錄中 if (filePath.indexOf(this.path) !== 0) return; if (/[\\\/]/.test(filePath.substr(this.path.length + 1))) return; // 設置文件修改時間信息 this.setFileTime(filePath, +stat.mtime, false, "add"); };
可以看出,進行簡單的文件合法性檢測後,還是進入了setFileTime函數,不過這一次的init標記為false,並且有對應的eventType。
這一次setFileTime的流程如下:
DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) { var now = Date.now(); // 初始化的值會被獲取 var old = this.files[filePath]; // initial是false 所以值為[now,mtime] this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime]; // ... };
一句話概括就是,add情況下,只會在files容器中註冊該文件的信息。
addDir => 在directories容器中註冊該文件夾
change
DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) { // ... // 會根據mtime值修改FS_ACCURACY ensureFsAccuracy(mtime); // 仍然進入此函數 this.setFileTime(filePath, mtime, false, "change"); }; function ensureFsAccuracy(mtime) { if (!mtime) return; // 當mtime為小數時才會跳過 if (FS_ACCURACY > 1 && mtime % 1 !== 0) FS_ACCURACY = 1; // 0-9或非10的倍數 else if (FS_ACCURACY > 10 && mtime % 10 !== 0) FS_ACCURACY = 10; // 0-99或非100倍數 else if (FS_ACCURACY > 100 && mtime % 100 !== 0) FS_ACCURACY = 100; else if (FS_ACCURACY > 1000 && mtime % 1000 !== 0) FS_ACCURACY = 1000; else if (FS_ACCURACY > 2000 && mtime % 2000 !== 0) FS_ACCURACY = 2000; } DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) { // ... if (!old) { /*...*/ } // change else if (!initial && mtime && type !== "add") { if (this.watchers[withoutCase(filePath)]) { this.watchers[withoutCase(filePath)].forEach(function(w) { w.emit("change", mtime, type); }); } } // remove else if (!initial && !mtime) { /*...*/ } // 如果監視了根目錄 if (this.watchers[withoutCase(this.path)]) { this.watchers[withoutCase(this.path)].forEach(function(w) { // 根目錄觸發change事件 if (!initial || w.checkStartTime(mtime, initial)) { w.emit("change", filePath, mtime, initial ? "initial" : type); } }); } };
這裡有一個ensureFsAccuracy函數,這裡預設的FS_ACCURACY為10000,而mtime一般都是很大的整數,所以這個函數的作用有待研究。
可以看到change事件除了設置文件的時間信息,同時也對watchers中每個監聽器觸發了change事件。
最後,如果根目錄設置了監視,由於監視文件在根目錄中,所以根目錄必定也發生了改變,所以根目錄的所有監視器也會同時觸發change事件。
unlink
DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) { // ... // 註意第二個參數mtime為null this.setFileTime(filePath, null, false, "unlink"); // 記錄被刪除的文件路徑 if (this.initialScan) { this.initialScanRemoved.push(filePath); } }; DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) { // ... if (!old) { /**/ } // 觸發remove事件 else if (!initial && mtime && type !== "add") { if (this.watchers[withoutCase(filePath)]) { this.watchers[withoutCase(filePath)].forEach(function(w) { w.emit("change", mtime, type); }); } } else if (!initial && !mtime) { /**/ } if (this.watchers[withoutCase(this.path)]) { /**/ } };
當刪除文件時,傳入的mtime會置null,所以會對所有的watcher觸發remove。
另外,這裡被刪除的文件路徑會被記錄到initialScan中。
unlinkDir
DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) { // ... // 這裡調用文件夾的刪除 this.setDirectory(directoryPath, false, false, "unlink"); if (this.initialScan) { this.initialScanRemoved.push(directoryPath); } }; DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial, type) { if (directoryPath === this.path) { /**/ } // 刪除文件夾 else { var old = this.directories[directoryPath]; if (!old) { /**/ } else { if (!exist) { if (this.nestedWatching) this.directories[directoryPath].close(); // 刪除容器中的文件夾信息 delete this.directories[directoryPath]; if (!initial && this.watchers[withoutCase(this.path)]) { /*...*/ } } } } };
在nestedWatching參數為false的情況下,這裡是直接從文件夾信息容器中刪除對應信息,否則會調用watcher對應的close方法。
error
DirectoryWatcher.prototype.onWatcherError = function onWatcherError( /* err */ ) {};
源碼中,這個事件監聽器並沒有任何內容,需要自定義。
由於這節內容比較多,這裡做一個簡單的內容總結,也幫助自己複習:
watcherManager模塊
1、有一個directoryWatchers容器保存已監視目錄信息
2、getDirectoryWatcher方法會根據監視路徑與options參數生成容器鍵,如果存在對應的值直接返回,否則新建一個DirectoryWatcher實體類註冊到容器中,並監聽closed事件,觸發時自動刪除該鍵
3、WatchFile、WatchDirectory分別處理文件、文件夾的監視,會同時調用getDirectoryWatcher方法與返回實體類的watch方法
4、在WatchFile中,監視文件所在的文件夾會作為根目錄傳入實例化的參數中,且只會監視根目錄的該文件
5、若傳入文件夾,則該文件夾目錄下所有文件與文件夾都會被監視
DirectoryWatcher模塊
1、包含多個容器,分別為:
files:保存根目錄下所有文件信息
directories:保存根目錄下所有文件夾信息
initialScanRemoved:記錄已被刪除的文件或文件夾路徑
watchers:指定目錄下監聽器容器,其中鍵為監視文件的路徑,值為監聽器
2、內部細分了原生nodejs的rename、change事件,分別為add、addDir、change、unlink、unlinkDir
3、觸發了對應路徑文件的事件,會依次觸發watchers中對應路徑數組中所有監聽器
完結!