阅读 1311

源码解读系列之 chokidar

目的

许多工具(vs code,webpack,gulp)都带有监控文件变化然后执行自动处理的功能。有时候会想到,这些工具都是如何优雅地实现文件变化的呢?为什么我的开发环境在某些工具的 watch 模式下,cpu 会疯狂飙高,而换一个操作系统却又不会出现这些问题?本着好奇心,借此了解 NodeJs 监控文件变化的细节以及现有的一些问题,chokidar 又是如何解决这些问题的

chokidar 介绍

chokidar 是什么?

chokidar 是封装 Node.js 监控文件系统文件变化功能的库

Node.js 原生的监控功能不好用吗?为什么要进行这样的封装?

Node.js 原生的监控功能还真有问题,根据 chokidar 的介绍,有如下问题:

Node.js fs.watch

  • 在 MacOS 上不报告文件名变化
  • 在 MacOS 上使用 Sublime 等编辑器时,不报告任何事件
  • 经常报告两次事件
  • 把多数事件通知为 rename
  • 没有便捷的方式递归监控文件树

Node.js fs.watchFile

  • 事件处理有大量问题
  • 不提供递归监控文件树功能
  • 导致 CPU 占用高

chokidar 解决了上面的这些问题,并且在大量开源项目,生产环境上得到了检验

版本

3.1.0

项目结构

解释

  • index:程序入口,包含了程序主逻辑,默认使用 node 提供的 fs.watchfs.watchFile对文件资源进行监控,如果是 OS X 系统,则会通过自定义的 fsevents-handler对文件资源进行监控
  • nodefs-handler:基于 nodejs 的 fs.watchfs.watchFile 接口扩展的文件资源监控器
  • fsevents-handler:自制的文件资源监控器,同样使用了 fs 模块,但是没有使用 watch 和 watchFile 接口

关键流程

index:

  1. 入口逻辑:
/**
 * Instantiates watcher with paths to be tracked.
 * @param {String|Array<String>} paths file/directory paths and/or globs
 * @param {Object=} options chokidar opts
 * @returns an instance of FSWatcher for chaining.
 */
const watch = (paths, options) => {
  const watcher = new FSWatcher(options);
  watcher.add(watcher._normalizePaths(paths));
  return watcher;
};

exports.watch = watch;
复制代码
const chokidar = require('chokidar');

// One-liner for current directory
chokidar.watch('.').on('all', (event, path) => {
  console.log(event, path);
});
复制代码

向外暴露 watch 方法,watch 方法会创建一个 FSWatcher 实例,将输入的监控路径 paths 进行格式化(转换成数组)后,传入给 FSWatcher 实例进行监控

  1. FSWatcher 实例化过程
/**
 * Watches files & directories for changes. Emitted events:
 * `add`, `addDir`, `change`, `unlink`, `unlinkDir`, `all`, `error`
 *
 *     new FSWatcher()
 *       .add(directories)
 *       .on('add', path => log('File', path, 'was added'))
 */
class FSWatcher extends EventEmitter
复制代码
this._emitRaw = (...args) => this.emit('raw', ...args);
this._readyEmitted = false;
this.options = opts;
// Initialize with proper watcher.
  if (opts.useFsEvents) {
    this._fsEventsHandler = new FsEventsHandler(this);
  } else {
    this._nodeFsHandler = new NodeFsHandler(this);
  }
复制代码

在处理完配置参数后,关键点在于根据最终情况决定使用 FsEventsHandler 还是 NodeFsHandler

由于 FSWatcher 扩展自 EventEmitter,所以 FSWatcher 的实例有 on 和 emit 方法实现事件发射与监听,同时将 _emitRaw 方法传入到两个 handler 的实例中,使得 handler 获得向外 emit 事件的能力

  1. 关键方法:add
/**
 * Adds paths to be watched on an existing FSWatcher instance
 * @param {Path|Array<Path>} paths_
 * @param {String=} _origAdd private; for handling non-existent paths to be watched
 * @param {Boolean=} _internal private; indicates a non-user add
 * @returns {FSWatcher} for chaining
 */
add(paths_, _origAdd, _internal) {
  const {cwd, disableGlobbing} = this.options;
  this.closed = false;

复制代码
  if (this.options.useFsEvents && this._fsEventsHandler) {
    if (!this._readyCount) this._readyCount = paths.length;
    if (this.options.persistent) this._readyCount *= 2;
    paths.forEach((path) => this._fsEventsHandler._addToFsEvents(path));
  } else {
    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));
      });
    });
  }
复制代码

将 paths 进行遍历,根据条件分别通过 fsEventsHandler 或者 nodeFsHandler 进行文件状态的监听

nodedef-handler

从 index 的逻辑可以知道,该模块的关键入口方法为 _addToNodeFs

/**
 * Handle added file, directory, or glob pattern.
 * Delegates call to _handleFile / _handleDir after checks.
 * @param {String} path to file or ir
 * @param {Boolean} initialAdd was the file added at watch instantiation?
 * @param {Object} priorWh depth relative to user-supplied path
 * @param {Number} depth Child path actually targetted for watch
 * @param {String=} target Child path actually targeted for watch
 * @returns {Promise}
 */
async _addToNodeFs(path, initialAdd, priorWh, depth, target) {
  const ready = this.fsw._emitReady;
  if (this.fsw._isIgnored(path) || this.fsw.closed) {
    ready();
    return false;
  }

  let wh = this.fsw._getWatchHelpers(path, depth);
  if (!wh.hasGlob && priorWh) {
    wh.hasGlob = priorWh.hasGlob;
    wh.globFilter = priorWh.globFilter;
    wh.filterPath = entry => priorWh.filterPath(entry);
    wh.filterDir = entry => priorWh.filterDir(entry);
  }
复制代码

该方法的关键逻辑如下:

if (stats.isDirectory()) {
      const targetPath = follow ? await fsrealpath(path) : path;
      closer = await this._handleDir(wh.watchPath, stats, initialAdd, depth, target, wh, targetPath);
      // preserve this symlink's target path
      if (path !== targetPath && targetPath !== undefined) {
        this.fsw._symlinkPaths.set(targetPath, true);
      }
    } else if (stats.isSymbolicLink()) {
      const targetPath = follow ? await fsrealpath(path) : path;
      const parent = sysPath.dirname(wh.watchPath);
      this.fsw._getWatchedDir(parent).add(wh.watchPath);
      this.fsw._emit('add', wh.watchPath, stats);
      closer = await this._handleDir(parent, stats, initialAdd, depth, path, wh, targetPath);

      // preserve this symlink's target path
      if (targetPath !== undefined) {
        this.fsw._symlinkPaths.set(sysPath.resolve(path), targetPath);
      }
    } else {
      closer = this._handleFile(wh.watchPath, stats, initialAdd);
    }
复制代码

可以看出,这里涉及两个重要方法:_handleDir 和 _handleFile

_handleFile 处理具体文件路径

_handleDir 处理文件夹路径

通过阅读它们的源码,最终都会导向一个方法:_watchWithNodeFs

/**
 * Watch file for changes with fs_watchFile or fs_watch.
 * @param {String} path to file or dir
 * @param {Function} listener on fs change
 * @returns {Function} closer for the watcher instance
 */
_watchWithNodeFs(path, listener) {
  // createFsWatchInstance
  // setFsWatchFileListener
复制代码

抽象流程如下:

通过递归遍历目录,调用fs.watchFilefs.watch两个方法生成监听器并管理起来,实现文件以及目录的有效监控

fsevent-handler

主要入口是 _addToFsEvents

抽象结构如下:

可以看见,关键点在于 'fsevents.watch' 的调用

fsevents 模块来源于第三方依赖:

 "engines": {
    "node": ">= 8"
  },
  "dependencies": {
    "anymatch": "^3.1.0",
    "braces": "^3.0.2",
    "glob-parent": "^5.0.0",
    "is-binary-path": "^2.1.0",
    "is-glob": "^4.0.1",
    "normalize-path": "^3.0.0",
    "readdirp": "^3.1.1"
  },
  "optionalDependencies": {
    "fsevents": "^2.0.6"
  },
复制代码

fsevents 在 github 上的 readme 介绍为:

可知,fs-events 模块是 nodejs 的扩展模块,调用了 MacOS 的底层 API 以及相关文件监控事件,从而避免 nodejs fs 模块自带监控的问题

总结

  1. 应该说, chokidar 的代码仍然有很大的工程提升空间,应该可以写得更加简洁,模块耦合度更低 以及 拥有更好的方法、变量命名等;
  2. 通过本次分析,能够了解 chokidar 模块的大致结构,知道了不同环境下,监控事件的来源;尚有许多细节:事件过滤、事件合并、监听器的变化,相关内容会继续更新;