Node、node-watch、Chokidar實(shí)現(xiàn)文件監(jiān)聽(tīng)封裝思路解析

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ě)了watchFilewatchDirectory函數(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類。

  1. 首先會(huì)先檢查是否可以使用fsevents

    const canUseFsEvents = FsEventsHandler.canUse();
    if (!canUseFsEvents) opts.useFsEvents = false;
    
  2. 根據(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
    }
    
  3. 動(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;
    }
    
  4. 如果是在MacOS系統(tǒng)中,fsevents-handler.js負(fù)責(zé)調(diào)用原生的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);
        })
      };
    }
    
  5. 如果在其他環(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模塊的watchwatchFile。如果是目錄,會(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的地址。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容