Node、node-watch、Chokidar实现文件监听封装思路解析

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类。
  • 在继承的基础上重写了watchFilewatchDirectory函数,实现了文件和文件夹的监听事件。
  • 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类。

  1. 首先会先检查是否可以使用fsevents

    const canUseFsEvents = FsEventsHandler.canUse();
    if (!canUseFsEvents) opts.useFsEvents = false;
    
  2. 根据不同的运行环境使用不同的方案,提高性能

    // Initialize with proper watcher.
    if (opts.useFsEvents) {
      this._fsEventsHandler = new FsEventsHandler(this); // MacOS环境使用fsevents
    } else {
      this._nodeFsHandler = new NodeFsHandler(this);  // 其他环境使用fs原生的API
    }
    
  3. 动态添加监听的文件

    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;
    }
    
  4. 如果是在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);
        })
      };
    }
    
  5. 如果在其他环境下,使用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.nodeNative API,实现file watch,文件的监听关系是属于一对一,假如目录下有多个文件,会遍历目录,给每一个文件单独执行观察者模式。fsevents.node是使用C语言写的二进制系统操作指令。
  • 如果是Linux或者Window系统环境,使用Node下fs模块的watchwatchFile。如果是目录,会递归目录,给每个文件添加观察者模式。
  • chokidar会根据不同环境使用不同文件监听方案,对症下药,相比于node-watch,性能会更好,主要体现在CPU上。其次不需要创建临时文件,空间复杂度更优。
  • chokidar在Linux或者window系统下解决调用两次的问题,解决方案是使用_throttle节流方法,30毫秒内的change只执行一次。

总结

热更新是我们开发期间最常用的功能,能够大大提高开发的效率,只要编译器保存一下就可以更新项目。比如我们咱们公司很多前端项目都是使用webpack打包工具,其中的热更新是使用HRM插件,比如vue3推荐使用的vite,文件更新正是使用的Chokidar,vite使用Chokidar的地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容