Node文件變更監聽
前言
文件監聽是很多業務場景中常用的功能,簡單的探索一下文件監聽工具的差異。
場景
在學習rollup過程中初始化了一個node項目,希望做到每次文件變更的時候都能夠監聽得到具體是哪個文件的變更,根據這個需求,我首選了node自帶的watch API
。
項目結構
|____bundle.js // 構建出來的包
|____index.js // 開發文件入口
|____README.md
|____main.js // 構建入口文件
|____package-lock.json
|____package.json
|____utils.js // 工具函數
這里只用到三個文件,分別是:
utiles.js
是幾個函數
export const foo = function () {
console.log("foo");
};
export const bar = function () {
console.log("bar");
};
export const name = "光環助手";
export const sayHi = function () {
console.log(`Hi ${name}`);
};
main.js
是入口文件,負責收集所有執行的內容
import { foo, bar, sayHi } from "./utils.js";
const unused = "我用不著";
foo();
sayHi();
index.js
是rollup構建函數中心
const rollup = require("rollup");
const fs = require("fs");
rollup
.rollup({
input: "main.js",
})
.then(async (bundle) => {
await bundle.write({
file: "bundle.js",
});
});
fs.watchFile
監聽單個文件,每當訪問文件時會觸發回調,保存文件后有可能不會及時觸發回調,因為使用的輪詢機制。官網地址
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("開始監聽啦~~");
fs.watchFile(filePath, (curr, prev) => {
console.log(`the current mtime is: ${curr.mtime}`);
console.log(`the previous mtime was: ${prev.mtime}`);
});
});
執行以上文件內容后會生成bundle.js
,并且會啟動文件監聽,控制臺打印如下:
開始監聽
現在還沒有變更,所以沒有變化,接下來改變點東西再次保存,打印如下:
開始監聽
the current mtime is: Wed Aug 18 2021 15:37:12 GMT+0800 (中國標準時間)
the previous mtime was: Wed Aug 18 2021 15:31:22 GMT+0800 (中國標準時間)
接下來,不做任何變化,直接保存文件,打印如下:
開始監聽
the current mtime is: Wed Aug 18 2021 15:37:12 GMT+0800 (中國標準時間)
the previous mtime was: Wed Aug 18 2021 15:31:22 GMT+0800 (中國標準時間)
the current mtime is: Wed Aug 18 2021 15:38:20 GMT+0800 (中國標準時間)
the previous mtime was: Wed Aug 18 2021 15:37:12 GMT+0800 (中國標準時間)
發現不做任何變更,也會被觸發。其次它只支持單個文件。官網也是說不建議使用watchFile
,它并不高效,建議使用watch
。
fs.watch
可以監聽整個目錄下的文件,官網地址。回調函數有兩個參數
- 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("開始監聽~~")
const filePath = "./";
fs.watch(filePath, (event, filename) => {
console.log("更新了~~~", event, filename);
});
});
執行以上文件內容后會生成bundle.js
,并且會啟動文件監聽,控制臺打印如下:
開始監聽~~
更新了~~~ change bundle.js
接下來改變點東西再次保存,打印如下:
始監聽~~
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
文件更新了兩次,接下來,不做任何變化,直接保存文件,打印如下:
始監聽~~
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
更新了~~~ change bundle.js
同樣文件更新了兩次。
其次有個比較明顯的差異是,相應比較快,相比于watchFile
的輪詢效率更高。
這里有一個問題是每次更新都觸發了兩次回調,這個不符合預期,可以通過文件對比的方式進行差異化檢查,這里我用到了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("開始監聽");
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);
});
});
不改變內容的情況下保存文件,不會打印"更新",改變內容的情況下保存文件,會打印"更新",符合預期。
node-watch
node-watch是對上面的fs.watch
的封裝和增強。它解決了以下問題:
- 編輯器會生成臨時的文件,導致回調函數會被觸發兩次
- 在觀察單個文件保存時,回調函數只會觸發一次
- 解決Linux和舊版本node不支持遞歸的問題
使用方法如下:
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);
});
});
每次保存文件都會觸發更新,不論文件內容是否有變更。
思路
執行
const watch = require("node-watch");
let watcher = watch("./", { recursive: true });
這個監聽就啟動了,根據源碼入口找到了lib/watch.js
文件,從中找到watch
函數,核心代碼如下:
function watch(fpath, options, fn) {
var watcher = new Watcher(); // 實例一個事件觸發器
// 省略一些代碼,主要是負責檢查傳入的fpath類型是否正確,文件是否存在
// 是數組,則遞歸觀察文件樹
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過濾不需要監聽的文件
var w = watch(f, options); // 遞歸
if (is.func(fn)) {
w.on('change', filterDups(fn));
}
return w;
}));
}
// 監聽文件
if (is.file(fpath)) {
watcher.watchFile(fpath, options, fn);
emitReady(watcher);
}
// 監聽目錄
else if (is.directory(fpath)) {
var counter = semaphore(function () {
emitReady(watcher);
});
watcher.watchDirectory(fpath, options, fn, counter);
}
return watcher.expose();
}
一開始實例一個Watcher
事件觸發器,后面則是根據這個實例,注冊所有的事件,我們看看Watcher
構造函數做了什么工作。
const events = require("events")
const util = require("util")
// 構造函數
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 */}
// 監聽文件
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(); // 解決回調兩次的問題
this.on('change', fn);
}
}
// 監聽文件夾
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(); // 解決回調兩次的問題
self.on('change', fn);
}
if (options.recursive && !has) {
getSubDirectories(dir, function(d) {
if (shouldNotSkip(d, options.filter)) { // 過濾需要忽略的文件
self.watchDirectory(d, options, null, counter); // 遞歸
}
}, counter());
}
});
}
簡單概括就是繼承了EventEmitter
的屬性,實現了文件、文件夾的監聽事件。
小結
- 執行
watch
會創建一個events事件觸發器,其中主要是繼承了EventEmitter
類。 - 在繼承的基礎上重寫了
watchFile
和watchDirectory
函數,實現了文件和文件夾的監聽事件。 -
watch
支持數組,遇到數組使用遞歸進行處理。 - 通過判斷
fn
調用的次數來解決元素fs.watch
回調被多次調用的問題,只有調用次數為1時才執行回調。 -
hasNativeRecursive
函數負責解決linux和舊版本Node遞歸的問題,解決思路是根據不同環境動態創建臨時文件或者文件夾實現當前環境所支持的監聽事件。文件監聽依舊使用的是fs.watch
。當監聽結束之后會自動把臨時文件清除。
根據對源碼的解讀,能夠大體了解封裝的思路,以及如何解決原生遺留的問題。
Chokidar
Chokidar 是一個極簡高效的跨平臺文件查看器。我第一次了解到Chokidar是在看vite源碼的時候,vite的文件更新監聽使用的正是Chokidar。除此之外,使用到Chokidar的還有 Microsoft's Visual Studio Code, gulp,karma, PM2, browserify, webpack, BrowserSync, and many others,在開發環境下都有它的身影。
Chokidar本質上是做了系統區分,在OS X系統中依賴原生fsevents API實現文件監控,在Window、Linux等系統中依賴node的fs.watch()
和fs.watchFile()
實現文件監控,相比于前面的node-watch,Chokidar封裝的更加強壯、穩定,性能更好,有更好的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);
});
});
.
代表的是監聽當前目錄下所有的問題,包括node_modules
依賴文件,所以需要使用ignored
對不需要監聽的文件進行過濾。
運行后,每次保存文件都會觸發更新,不論文件內容是否有變更。
探索思路
根據chokidar項目package.json找到入口文件為index.js
,順著使用中首先需要實例watch
的思路,找到如下源碼:
const watch = (paths, options) => {
const watcher = new FSWatcher(options);
watcher.add(paths);
return watcher;
};
封裝的watch
函數非常簡單,估計核心代碼都在FSWatcher
類下面,順藤摸瓜找FSWatcher
類。
-
首先會先檢查是否可以使用fsevents
const canUseFsEvents = FsEventsHandler.canUse(); if (!canUseFsEvents) opts.useFsEvents = false;
-
根據不同的運行環境使用不同的方案,提高性能
// Initialize with proper watcher. if (opts.useFsEvents) { this._fsEventsHandler = new FsEventsHandler(this); // MacOS環境使用fsevents } else { this._nodeFsHandler = new NodeFsHandler(this); // 其他環境使用fs原生的API }
-
動態添加監聽的文件
add(paths_, _origAdd, _internal) { const {cwd, disableGlobbing} = this.options; let paths = unifyPaths(paths_); // 處理單文件、數組、目錄,返回一個路徑數組 // 根據不同環境,使用不同方法進行處理 if (this.options.useFsEvents && this._fsEventsHandler) { // fsevents if (!this._readyCount) this._readyCount = paths.length; if (this.options.persistent) this._readyCount *= 2; // 遍歷數組,給每一個文件都添加觀察者模式 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 => { // 遍歷數組,給每一個文件都添加觀察者模式 const res = await this._nodeFsHandler._addToNodeFs(path, !_internal, 0, 0, _origAdd); // 文件觀察模式啟動 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系統中,
fsevents-handler.js
負責調用原生的watch
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; const info = fsevents.getInfo(fullPath, flags); cont.listeners.forEach(list => { list(fullPath, flags, info); }); cont.rawEmitter(info.event, fullPath, info); }) }; }
-
如果在其他環境下,使用Node原生的API
function createFsWatchInstance(path, options, listener, errHandler, emitRaw) { const handleEvent = (rawEvent, evPath) => { listener(path); emitRaw(rawEvent, evPath, {watchedPath: path}); // 監聽回調 }; try { return fs.watch(path, options, handleEvent); // 使用fs依賴下的watch } catch (error) { errHandler(error); } } // 給文件列表監聽事件 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執行的流程了。下面詳細講解一下fsevents。
fsevents
fsevents是Chokidar的一個依賴,用于替代Node的fs
模塊來訪問MacOS系統文件,它僅僅支持MacOS。
先來看看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則無法執行
const info = fsevents.getInfo(fullPath, flags);
cont.listeners.forEach(list => { // 遍歷目錄,給每一個文件添加觀察者模式
list(fullPath, flags, info);
});
cont.rawEmitter(info.event, fullPath, info);
})
};
}
fsevents最核心的是寫了專門針對MacOS的二進制操作源碼,是用C語言寫的,在fsevents源碼下的fsevents.node
文件。
const Native = require("./fsevents.node");
利用封裝好的操作指令,實現了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;
};
}
// 輸出監聽信息(只針對單個文件)
function getInfo(path, flags) {
return {
path,
flags,
event: getEventType(flags),
type: getFileType(flags),
changes: getFileChanges(flags),
};
}
以上就是fsevents所做的主要工作了。
小結
- 執行
watch
,根據封裝好的FSWatcher
類,實例一個watch
對象。 -
FSWatcher
類構造函數會初始化基本信息,其中最重要是判斷當前執行的系統環境,是MacOS則使用fsevents,是其他系統則使用Node。 - 確定了執行的系統環境,給用戶需要監聽的文件(單個文件、目錄、或者globs匹配路徑)添加監聽事件。
- 如果是MacOS系統環境,使用的是fsevents封裝好的
fsevents.node
Native API,實現file watch
,文件的監聽關系是屬于一對一,假如目錄下有多個文件,會遍歷目錄,給每一個文件單獨執行觀察者模式。fsevents.node
是使用C語言寫的二進制系統操作指令。 - 如果是Linux或者Window系統環境,使用Node下fs模塊的
watch
和watchFile
。如果是目錄,會遞歸目錄,給每個文件添加觀察者模式。 - chokidar會根據不同環境使用不同文件監聽方案,對癥下藥,相比于
node-watch
,性能會更好,主要體現在CPU上。其次不需要創建臨時文件,空間復雜度更優。 - chokidar在Linux或者window系統下解決調用兩次的問題,解決方案是使用
_throttle
節流方法,30毫秒內的change
只執行一次。
總結
熱更新是我們開發期間最常用的功能,能夠大大提高開發的效率,只要編譯器保存一下就可以更新項目。比如我們咱們公司很多前端項目都是使用webpack打包工具,其中的熱更新是使用HRM插件,比如vue3推薦使用的vite,文件更新正是使用的Chokidar,vite使用Chokidar的地址。