Node文件變更監(jiān)聽(tīng)
前言
文件監(jiān)聽(tīng)是很多業(yè)務(wù)場(chǎng)景中常用的功能,簡(jiǎn)單的探索一下文件監(jiān)聽(tīng)工具的差異。
場(chǎng)景
在學(xué)習(xí)rollup過(guò)程中初始化了一個(gè)node項(xiàng)目,希望做到每次文件變更的時(shí)候都能夠監(jiān)聽(tīng)得到具體是哪個(gè)文件的變更,根據(jù)這個(gè)需求,我首選了node自帶的watch API。
項(xiàng)目結(jié)構(gòu)
|____bundle.js // 構(gòu)建出來(lái)的包
|____index.js // 開(kāi)發(fā)文件入口
|____README.md
|____main.js // 構(gòu)建入口文件
|____package-lock.json
|____package.json
|____utils.js // 工具函數(shù)
這里只用到三個(gè)文件,分別是:
utiles.js是幾個(gè)函數(shù)
export const foo = function () {
console.log("foo");
};
export const bar = function () {
console.log("bar");
};
export const name = "光環(huán)助手";
export const sayHi = function () {
console.log(`Hi ${name}`);
};
main.js是入口文件,負(fù)責(zé)收集所有執(zhí)行的內(nèi)容
import { foo, bar, sayHi } from "./utils.js";
const unused = "我用不著";
foo();
sayHi();
index.js是rollup構(gòu)建函數(shù)中心
const rollup = require("rollup");
const fs = require("fs");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
});
fs.watchFile
監(jiān)聽(tīng)單個(gè)文件,每當(dāng)訪問(wèn)文件時(shí)會(huì)觸發(fā)回調(diào),保存文件后有可能不會(huì)及時(shí)觸發(fā)回調(diào),因?yàn)槭褂玫妮喸儥C(jī)制。官網(wǎng)地址
const rollup = require("rollup");
const fs = require("fs");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
const filePath = "./bundle.js";
console.log("開(kāi)始監(jiān)聽(tīng)啦~~");
fs.watchFile(filePath, (curr, prev) => {
console.log(`the current mtime is: ${curr.mtime}`);
console.log(`the previous mtime was: ${prev.mtime}`);
});
});
執(zhí)行以上文件內(nèi)容后會(huì)生成bundle.js,并且會(huì)啟動(dòng)文件監(jiān)聽(tīng),控制臺(tái)打印如下:
開(kāi)始監(jiān)聽(tīng)
現(xiàn)在還沒(méi)有變更,所以沒(méi)有變化,接下來(lái)改變點(diǎn)東西再次保存,打印如下:
開(kāi)始監(jiān)聽(tīng)
the current mtime is: Wed Aug 18 2021 15:37:12 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
the previous mtime was: Wed Aug 18 2021 15:31:22 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
接下來(lái),不做任何變化,直接保存文件,打印如下:
開(kāi)始監(jiān)聽(tīng)
the current mtime is: Wed Aug 18 2021 15:37:12 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
the previous mtime was: Wed Aug 18 2021 15:31:22 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
the current mtime is: Wed Aug 18 2021 15:38:20 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
the previous mtime was: Wed Aug 18 2021 15:37:12 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間)
發(fā)現(xiàn)不做任何變更,也會(huì)被觸發(fā)。其次它只支持單個(gè)文件。官網(wǎng)也是說(shuō)不建議使用watchFile,它并不高效,建議使用watch。
fs.watch
可以監(jiān)聽(tīng)整個(gè)目錄下的文件,官網(wǎng)地址。回調(diào)函數(shù)有兩個(gè)參數(shù)
- eventType:事件類型
- filename:變更的文件名稱
const rollup = require("rollup");
const fs = require("fs");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
console.log("開(kāi)始監(jiān)聽(tīng)~~")
const filePath = "./";
fs.watch(filePath, (event, filename) => {
console.log("更新了~~~", event, filename);
});
});
執(zhí)行以上文件內(nèi)容后會(huì)生成bundle.js,并且會(huì)啟動(dòng)文件監(jiān)聽(tīng),控制臺(tái)打印如下:
開(kāi)始監(jiān)聽(tīng)~~
更新了~~~ change bundle.js
接下來(lái)改變點(diǎn)東西再次保存,打印如下:
始監(jiān)聽(tīng)~~
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
文件更新了兩次,接下來(lái),不做任何變化,直接保存文件,打印如下:
始監(jiān)聽(tīng)~~
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
同樣文件更新了兩次。
其次有個(gè)比較明顯的差異是,相應(yīng)比較快,相比于watchFile的輪詢效率更高。
這里有一個(gè)問(wèn)題是每次更新都觸發(fā)了兩次回調(diào),這個(gè)不符合預(yù)期,可以通過(guò)文件對(duì)比的方式進(jìn)行差異化檢查,這里我用到了md5插件。
代碼更新如下:
const rollup = require("rollup");
const fs = require("fs");
const md5 = require("md5");
let old = null;
let timer = null;
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
const filePath = "./";
console.log("開(kāi)始監(jiān)聽(tīng)");
fs.watch(filePath, (event, filename) => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
}, 100);
const temp = md5(fs.readFileSync(filePath + filename));
if (temp == old) return;
old = temp;
console.log("更新了", filename);
});
});
不改變內(nèi)容的情況下保存文件,不會(huì)打印"更新",改變內(nèi)容的情況下保存文件,會(huì)打印"更新",符合預(yù)期。
node-watch
node-watch是對(duì)上面的fs.watch的封裝和增強(qiáng)。它解決了以下問(wèn)題:
- 編輯器會(huì)生成臨時(shí)的文件,導(dǎo)致回調(diào)函數(shù)會(huì)被觸發(fā)兩次
- 在觀察單個(gè)文件保存時(shí),回調(diào)函數(shù)只會(huì)觸發(fā)一次
- 解決Linux和舊版本node不支持遞歸的問(wèn)題
使用方法如下:
const rollup = require("rollup");
const watch = require("node-watch");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
let watcher = watch("./", { recursive: true });
watcher.on("change", function (evt, name) {
// callback
console.log("更新了~~~", name);
});
});
每次保存文件都會(huì)觸發(fā)更新,不論文件內(nèi)容是否有變更。
思路
執(zhí)行
const watch = require("node-watch");
let watcher = watch("./", { recursive: true });
這個(gè)監(jiān)聽(tīng)就啟動(dòng)了,根據(jù)源碼入口找到了lib/watch.js文件,從中找到watch函數(shù),核心代碼如下:
function watch(fpath, options, fn) {
var watcher = new Watcher(); // 實(shí)例一個(gè)事件觸發(fā)器
// 省略一些代碼,主要是負(fù)責(zé)檢查傳入的fpath類型是否正確,文件是否存在
// 是數(shù)組,則遞歸觀察文件樹(shù)
if (is.array(fpath)) {
if (fpath.length === 1) {
return watch(fpath[0], options, fn);
}
var filterDups = createDupsFilter();
return composeWatcher(unique(fpath).map(function(f) { // unique過(guò)濾不需要監(jiān)聽(tīng)的文件
var w = watch(f, options); // 遞歸
if (is.func(fn)) {
w.on('change', filterDups(fn));
}
return w;
}));
}
// 監(jiān)聽(tīng)文件
if (is.file(fpath)) {
watcher.watchFile(fpath, options, fn);
emitReady(watcher);
}
// 監(jiān)聽(tīng)目錄
else if (is.directory(fpath)) {
var counter = semaphore(function () {
emitReady(watcher);
});
watcher.watchDirectory(fpath, options, fn, counter);
}
return watcher.expose();
}
一開(kāi)始實(shí)例一個(gè)Watcher事件觸發(fā)器,后面則是根據(jù)這個(gè)實(shí)例,注冊(cè)所有的事件,我們看看Watcher構(gòu)造函數(shù)做了什么工作。
const events = require("events")
const util = require("util")
// 構(gòu)造函數(shù)
function Watcher() {
events.EventEmitter.call(this);
this.watchers = {};
this._isReady = false;
this._isClosed = false;
}
util.inherits(Watcher, events.EventEmitter);
Watcher.prototype.expose = function(){/* do something */}
Watcher.prototype.add = function(){/* do something */}
// 監(jiān)聽(tīng)文件
Watcher.prototype.watchFile = function(){
// 核心代碼
var watcher = fs.watch(parent, opts);
this.add(watcher, {
type: 'file',
fpath: parent,
options: Object.assign({}, opts, {
encoding: options.encoding
}),
compareName: function(n) {
return is.samePath(n, file);
}
});
if (is.func(fn)) {
if (fn.length === 1) deprecationWarning(); // 解決回調(diào)兩次的問(wèn)題
this.on('change', fn);
}
}
// 監(jiān)聽(tīng)文件夾
Watcher.prototype.watchDirectory = function(file, options, fn){
// 兼容linux和舊版本
hasNativeRecursive(function(has) {
options.recursive = !!options.recursive;
// 核心代碼
var watcher = fs.watch(dir, opts);
self.add(watcher, {
type: 'dir',
fpath: dir,
options: options
});
if (is.func(fn)) {
if (fn.length === 1) deprecationWarning(); // 解決回調(diào)兩次的問(wèn)題
self.on('change', fn);
}
if (options.recursive && !has) {
getSubDirectories(dir, function(d) {
if (shouldNotSkip(d, options.filter)) { // 過(guò)濾需要忽略的文件
self.watchDirectory(d, options, null, counter); // 遞歸
}
}, counter());
}
});
}
簡(jiǎn)單概括就是繼承了EventEmitter的屬性,實(shí)現(xiàn)了文件、文件夾的監(jiān)聽(tīng)事件。
小結(jié)
- 執(zhí)行
watch會(huì)創(chuàng)建一個(gè)events事件觸發(fā)器,其中主要是繼承了EventEmitter類。 - 在繼承的基礎(chǔ)上重寫(xiě)了
watchFile和watchDirectory函數(shù),實(shí)現(xiàn)了文件和文件夾的監(jiān)聽(tīng)事件。 -
watch支持?jǐn)?shù)組,遇到數(shù)組使用遞歸進(jìn)行處理。 - 通過(guò)判斷
fn調(diào)用的次數(shù)來(lái)解決元素fs.watch回調(diào)被多次調(diào)用的問(wèn)題,只有調(diào)用次數(shù)為1時(shí)才執(zhí)行回調(diào)。 -
hasNativeRecursive函數(shù)負(fù)責(zé)解決linux和舊版本Node遞歸的問(wèn)題,解決思路是根據(jù)不同環(huán)境動(dòng)態(tài)創(chuàng)建臨時(shí)文件或者文件夾實(shí)現(xiàn)當(dāng)前環(huán)境所支持的監(jiān)聽(tīng)事件。文件監(jiān)聽(tīng)依舊使用的是fs.watch。當(dāng)監(jiān)聽(tīng)結(jié)束之后會(huì)自動(dòng)把臨時(shí)文件清除。
根據(jù)對(duì)源碼的解讀,能夠大體了解封裝的思路,以及如何解決原生遺留的問(wèn)題。
Chokidar
Chokidar 是一個(gè)極簡(jiǎn)高效的跨平臺(tái)文件查看器。我第一次了解到Chokidar是在看vite源碼的時(shí)候,vite的文件更新監(jiān)聽(tīng)使用的正是Chokidar。除此之外,使用到Chokidar的還有 Microsoft's Visual Studio Code, gulp,karma, PM2, browserify, webpack, BrowserSync, and many others,在開(kāi)發(fā)環(huán)境下都有它的身影。
Chokidar本質(zhì)上是做了系統(tǒng)區(qū)分,在OS X系統(tǒng)中依賴原生fsevents API實(shí)現(xiàn)文件監(jiān)控,在Window、Linux等系統(tǒng)中依賴node的fs.watch()和fs.watchFile()實(shí)現(xiàn)文件監(jiān)控,相比于前面的node-watch,Chokidar封裝的更加強(qiáng)壯、穩(wěn)定,性能更好,有更好的CPU使用率。
使用方法
const rollup = require("rollup");
const chokidar = require("chokidar");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
chokidar
.watch(".", {
ignored: ["**/node_modules/**", "**/.git/**"],
})
.on("all", (event, path) => {
console.log(event, path);
});
});
.代表的是監(jiān)聽(tīng)當(dāng)前目錄下所有的問(wèn)題,包括node_modules依賴文件,所以需要使用ignored對(duì)不需要監(jiān)聽(tīng)的文件進(jìn)行過(guò)濾。
運(yùn)行后,每次保存文件都會(huì)觸發(fā)更新,不論文件內(nèi)容是否有變更。
探索思路
根據(jù)chokidar項(xiàng)目package.json找到入口文件為index.js,順著使用中首先需要實(shí)例watch的思路,找到如下源碼:
const watch = (paths, options) => {
const watcher = new FSWatcher(options);
watcher.add(paths);
return watcher;
};
封裝的watch函數(shù)非常簡(jiǎn)單,估計(jì)核心代碼都在FSWatcher類下面,順藤摸瓜找FSWatcher類。
-
首先會(huì)先檢查是否可以使用fsevents
const canUseFsEvents = FsEventsHandler.canUse(); if (!canUseFsEvents) opts.useFsEvents = false; -
根據(jù)不同的運(yùn)行環(huán)境使用不同的方案,提高性能
// Initialize with proper watcher. if (opts.useFsEvents) { this._fsEventsHandler = new FsEventsHandler(this); // MacOS環(huán)境使用fsevents } else { this._nodeFsHandler = new NodeFsHandler(this); // 其他環(huán)境使用fs原生的API } -
動(dòng)態(tài)添加監(jiān)聽(tīng)的文件
add(paths_, _origAdd, _internal) { const {cwd, disableGlobbing} = this.options; let paths = unifyPaths(paths_); // 處理單文件、數(shù)組、目錄,返回一個(gè)路徑數(shù)組 // 根據(jù)不同環(huán)境,使用不同方法進(jìn)行處理 if (this.options.useFsEvents && this._fsEventsHandler) { // fsevents if (!this._readyCount) this._readyCount = paths.length; if (this.options.persistent) this._readyCount *= 2; // 遍歷數(shù)組,給每一個(gè)文件都添加觀察者模式 paths.forEach((path) => this._fsEventsHandler._addToFsEvents(path)); } else { // Node if (!this._readyCount) this._readyCount = 0; this._readyCount += paths.length; Promise.all( paths.map(async path => { // 遍歷數(shù)組,給每一個(gè)文件都添加觀察者模式 const res = await this._nodeFsHandler._addToNodeFs(path, !_internal, 0, 0, _origAdd); // 文件觀察模式啟動(dòng) if (res) this._emitReady(); return res; }) ).then(results => { if (this.closed) return; results.filter(item => item).forEach(item => { // 遞歸 this.add(sysPath.dirname(item), sysPath.basename(_origAdd || item)); }); }); } return this; } -
如果是在MacOS系統(tǒng)中,
fsevents-handler.js負(fù)責(zé)調(diào)用原生的watchconst createFSEventsInstance = (path, callback) => { const stop = fsevents.watch(path, callback); return {stop}; }; function setFSEventsListener(path, realPath, listener, rawEmitter) { // 省略代碼 cont = { watcher: createFSEventsInstance(watchPath, (fullPath, flags) => { if (!cont.listeners.size) return; const info = fsevents.getInfo(fullPath, flags); cont.listeners.forEach(list => { list(fullPath, flags, info); }); cont.rawEmitter(info.event, fullPath, info); }) }; } -
如果在其他環(huán)境下,使用Node原生的API
function createFsWatchInstance(path, options, listener, errHandler, emitRaw) { const handleEvent = (rawEvent, evPath) => { listener(path); emitRaw(rawEvent, evPath, {watchedPath: path}); // 監(jiān)聽(tīng)回調(diào) }; try { return fs.watch(path, options, handleEvent); // 使用fs依賴下的watch } catch (error) { errHandler(error); } } // 給文件列表監(jiān)聽(tīng)事件 const setFsWatchFileListener = (path, fullPath, options, handlers) => { cont = { watcher: fs.watchFile(fullPath, options, (curr, prev) => { foreach(cont.rawEmitters, (rawEmitter) => { rawEmitter(EV_CHANGE, fullPath, {curr, prev}); }); const currmtime = curr.mtimeMs; if (curr.size !== prev.size || currmtime > prev.mtimeMs || currmtime === 0) { foreach(cont.listeners, (listener) => listener(path, curr)); } }) }; FsWatchFileInstances.set(fullPath, cont); };
以上就是chokidar執(zhí)行的流程了。下面詳細(xì)講解一下fsevents。
fsevents
fsevents是Chokidar的一個(gè)依賴,用于替代Node的fs模塊來(lái)訪問(wèn)MacOS系統(tǒng)文件,它僅僅支持MacOS。
先來(lái)看看chokidar/lib/fsevents-handler.js使用的例子:
const createFSEventsInstance = (path, callback) => {
const stop = fsevents.watch(path, callback);
return {stop};
};
function setFSEventsListener(path, realPath, listener, rawEmitter) {
// 省略代碼
cont = {
watcher: createFSEventsInstance(watchPath, (fullPath, flags) => {
if (!cont.listeners.size) return; // 如果不是MacOS則無(wú)法執(zhí)行
const info = fsevents.getInfo(fullPath, flags);
cont.listeners.forEach(list => { // 遍歷目錄,給每一個(gè)文件添加觀察者模式
list(fullPath, flags, info);
});
cont.rawEmitter(info.event, fullPath, info);
})
};
}
fsevents最核心的是寫(xiě)了專門針對(duì)MacOS的二進(jìn)制操作源碼,是用C語(yǔ)言寫(xiě)的,在fsevents源碼下的fsevents.node文件。
const Native = require("./fsevents.node");
利用封裝好的操作指令,實(shí)現(xiàn)了watch操作,代碼如下:
const Native = require("./fsevents.node");
const events = Native.constants;
function watch(path, since, handler) {
let instance = Native.start(Native.global, path, since, handler);
if (!instance) throw new Error(`could not watch: ${path}`);
return () => {
const result = instance ? Promise.resolve(instance).then(Native.stop) : Promise.resolve(undefined);
instance = undefined;
return result;
};
}
// 輸出監(jiān)聽(tīng)信息(只針對(duì)單個(gè)文件)
function getInfo(path, flags) {
return {
path,
flags,
event: getEventType(flags),
type: getFileType(flags),
changes: getFileChanges(flags),
};
}
以上就是fsevents所做的主要工作了。
小結(jié)
- 執(zhí)行
watch,根據(jù)封裝好的FSWatcher類,實(shí)例一個(gè)watch對(duì)象。 -
FSWatcher類構(gòu)造函數(shù)會(huì)初始化基本信息,其中最重要是判斷當(dāng)前執(zhí)行的系統(tǒng)環(huán)境,是MacOS則使用fsevents,是其他系統(tǒng)則使用Node。 - 確定了執(zhí)行的系統(tǒng)環(huán)境,給用戶需要監(jiān)聽(tīng)的文件(單個(gè)文件、目錄、或者globs匹配路徑)添加監(jiān)聽(tīng)事件。
- 如果是MacOS系統(tǒng)環(huán)境,使用的是fsevents封裝好的
fsevents.nodeNative API,實(shí)現(xiàn)file watch,文件的監(jiān)聽(tīng)關(guān)系是屬于一對(duì)一,假如目錄下有多個(gè)文件,會(huì)遍歷目錄,給每一個(gè)文件單獨(dú)執(zhí)行觀察者模式。fsevents.node是使用C語(yǔ)言寫(xiě)的二進(jìn)制系統(tǒng)操作指令。 - 如果是Linux或者Window系統(tǒng)環(huán)境,使用Node下fs模塊的
watch和watchFile。如果是目錄,會(huì)遞歸目錄,給每個(gè)文件添加觀察者模式。 - chokidar會(huì)根據(jù)不同環(huán)境使用不同文件監(jiān)聽(tīng)方案,對(duì)癥下藥,相比于
node-watch,性能會(huì)更好,主要體現(xiàn)在CPU上。其次不需要?jiǎng)?chuàng)建臨時(shí)文件,空間復(fù)雜度更優(yōu)。 - chokidar在Linux或者window系統(tǒng)下解決調(diào)用兩次的問(wèn)題,解決方案是使用
_throttle節(jié)流方法,30毫秒內(nèi)的change只執(zhí)行一次。
總結(jié)
熱更新是我們開(kāi)發(fā)期間最常用的功能,能夠大大提高開(kāi)發(fā)的效率,只要編譯器保存一下就可以更新項(xiàng)目。比如我們?cè)蹅児竞芏嗲岸隧?xiàng)目都是使用webpack打包工具,其中的熱更新是使用HRM插件,比如vue3推薦使用的vite,文件更新正是使用的Chokidar,vite使用Chokidar的地址。