1 引言
本期精讀的文章是:How to Watch for Files Changes in Node.js,探討如何監聽檔案的變化。
如果想使用現成的庫,推薦 chokidar 或 node-watch,如果想了解實現原理,請往下閱讀。
2 概述
使用 fs.watchfile
使用 fs
內建函式 watchfile
似乎可以解決問題:
fs.watchFile(dir, (curr, prev) => {});
但你可能會發現這個回撥執行有一定延遲,因為 watchfile
是通過輪詢檢測檔案變化的,它並不能實時作出反饋,而且只能監聽一個檔案,存在效率問題。
使用 fs.watch
使用 fs
的另一個內建函式 watch
是更好的選擇:
fs.watch(dir, (event, filename) => {});
watch
通過作業系統提供的檔案更改通知機制,在 Linux 作業系統使用 inotify,在 macOS 系統使用 FSEvents,在 windows 系統使用 ReadDirectoryChangesW,而且可以用來監聽目錄的變化,在監聽資料夾的場景中,比建立 N 個 fs.watchfile
效率高出很多。
$ node file-watcher.js
[2018-05-21T00:55:52.588Z] Watching for file changes on ./button-presses.log
[2018-05-21T00:56:00.773Z] button-presses.log file Changed
[2018-05-21T00:56:00.793Z] button-presses.log file Changed
[2018-05-21T00:56:00.802Z] button-presses.log file Changed
[2018-05-21T00:56:00.813Z] button-presses.log file Changed
但當我們修改一個檔案時,回撥卻執行了 4 次!原因是檔案被寫入時,可能觸發多次寫操作,即使只儲存了一次。但我們不需要這麼敏感的回撥,因為通常認為一次儲存就是一次修改,系統底層寫了幾次檔案我們並不關心。
因而可以進一步判斷是否觸發狀態是 change
:
fs.watch(dir, (event, filename) => {
if (filename && event === "change") {
console.log(`${filename} file Changed`);
}
});
這樣做可以一定程度解決問題,但作者發現 Raspbian 系統不支援 rename
事件,如果歸類為 change
,會導致這樣的判斷毫無意義。
作者要表達的意思是,在不同平臺下,
fs.watch
的規則可能會不同,原因是fs.watch
分別使用了各平臺提供的 api,所以無法保證這些 api 實現規則的統一性。
優化方案一:對比檔案修改時間
基於 fs.watch
,增加了對修改時間的判斷:
let previousMTime = new Date(0);
fs.watch(dir, (event, filename) => {
if (filename) {
const stats = fs.statSync(filename);
if (stats.mtime.valueOf() === previousMTime.valueOf()) {
return;
}
previousMTime = stats.mtime;
console.log(`${filename} file Changed`);
}
});
log 由 4 個變成了 3 個,但依然存在問題。我們認為檔案內容變化才算有修改,但作業系統考慮的因素更多,所以我們再嘗試對比檔案內容是否變化。
筆者補充:另外一些開源編輯器可能先清空檔案再寫入,也會影響到觸發回撥的次數。
優化方案二:校驗檔案 md5
只有檔案內容變化了,才認為觸發了改動,這下總可以了吧:
let md5Previous = null;
fs.watch(dir, (event, filename) => {
if (filename) {
const md5Current = md5(fs.readFileSync(buttonPressesLogFile));
if (md5Current === md5Previous) {
return;
}
md5Previous = md5Current;
console.log(`${filename} file Changed`);
}
});
log 終於由 3 個變成了 2 個,為什麼多出一個?可能的原因是,在檔案儲存過程中,系統可能會觸發多個回撥事件,也許存在中間態。
優化方案三:加入延遲機制
我們嘗試延遲 100 毫秒進行判斷,也許能避開中間狀態:
let fsWait = false;
fs.watch(dir, (event, filename) => {
if (filename) {
if (fsWait) return;
fsWait = setTimeout(() => {
fsWait = false;
}, 100);
console.log(`${filename} file Changed`);
}
});
這下 log 變成一個了。很多 npm 包在這裡使用了 debounce 函式控制觸發頻率,才將觸發頻率修正。
而且我們需要結合 md5 與延遲機制共同作用,才能得到相對精準的結果:
let md5Previous = null;
let fsWait = false;
fs.watch(dir, (event, filename) => {
if (filename) {
if (fsWait) return;
fsWait = setTimeout(() => {
fsWait = false;
}, 100);
const md5Current = md5(fs.readFileSync(dir));
if (md5Current === md5Previous) {
return;
}
md5Previous = md5Current;
console.log(`${filename} file Changed`);
}
});
3 精讀
作者討論了一些實現資料夾監聽的基本方式,可以看出,使用了各平臺原生 API 的 fs.watch
並不那麼靠譜,但這也我們監聽檔案的唯一手段,所以需要基於它進行一系列優化。
而實際場景中,還需要考慮區分資料夾與檔案、軟連線、讀寫許可權等情況。
另外用在生產環境的庫,也基本使用 50 到 100 毫秒解決重複觸發的問題。
所以無論 chokidar 或 node-watch,都大量使用了文中提及的技巧,再加上對邊界條件的處理,對軟連線、許可權等情況處理,將所有可能情況都考慮到,才能提供較為準確的回撥。
比如判斷檔案寫入操作是否完畢,也需要通過輪詢的方式:
function awaitWriteFinish() {
// ...省略
fs.stat(
fullPath,
function(err, curStat) {
// ...省略
if (prevStat && curStat.size != prevStat.size) {
this._pendingWrites[path].lastChange = now;
}
if (now - this._pendingWrites[path].lastChange >= threshold) {
delete this._pendingWrites[path];
awfEmit(null, curStat);
} else {
timeoutHandler = setTimeout(
awaitWriteFinish.bind(this, curStat),
this.options.awaitWriteFinish.pollInterval
);
}
}.bind(this)
);
// ...省略
}
可以看出,第三方 npm 庫都採取不信任作業系統回撥的方式,根據檔案資訊完全重寫了判斷邏輯。
可見,信任作業系統的回撥,就無法抹平所有作業系統間的差異,唯有統一重寫檔案的 “寫入”、“刪除”、“修改” 等邏輯,才能保證在全平臺的相容性。
4 總結
利用 nodejs 監聽資料夾變化很容易,但提供準確的回撥卻很難,主要難在兩點:
- 抹平作業系統間的差異,這需要在結合
fs.watch
的同時,增加一些額外校驗機制與延時機制。 - 分清楚作業系統預期與使用者預期,比如編輯器的額外操作、作業系統的多次讀寫都應該被忽略,使用者的預期不會那麼頻繁,會忽略極小時間段內的連續觸發。
另外還有相容性、許可權、軟連線等其他因素要考慮,fs.watch
並不是一個開箱可用的工程級別 api。
5 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。