webpack 中的 watch & cache (上)

我们在日常使用 webpack 或者是在以它为基础开发的时候,可能更多的时候关注的是配置以及配置的插件开发。在日常的开发过程中,会发现 watch 状态下的编译流程有一个规律是,第一次会较为缓慢,后续的编译会很快速,看起来像是有缓存的控制,那么具体内部的缓存流程存在哪些节点呢?下面进行一些探索总结,希望能为日常的插件 pluginloader 开发起到帮助。

webpack –watch

对于 cache 使用的入口,其实在我们日常构建中,大多是借助 webpack 启动一个构建 watch 服务

入口

最普通的相比于 webpack 不带参数直接执行的方式, webpack --watch 的执行逻辑存在较为明显的区别。

webpack/bin/webpack.js:

if(options.watch) {
  var primaryOptions = !Array.isArray(options) ? options : options[0];
  var watchOptions = primaryOptions.watchOptions || primaryOptions.watch || {};
  if(watchOptions.stdin) {
    process.stdin.on('end', function() {
      process.exit(0); // eslint-disable-line
    });
    process.stdin.resume();
  }
  compiler.watch(watchOptions, compilerCallback);
} else
  compiler.run(compilerCallback);

从执行文件中 webpack/bin/webpack.js 找到 --watch 逻辑,相比于直接 webpack 不带参数执行对应的是 compiler.run 方法,--watch 则对应的是 compiler.watch 方法。

除了 webpack --watch 调用,这里还可以关联一下在日常使用中很平常的 webpack-dev-middleware 模块。

webpack-dev-middleware/middleware.js:

if(!options.lazy) {
  var watching = compiler.watch(options.watchOptions, function(err) {
    if(err) throw err;
  });
}

从代码可以看到,在非 lazylazy 模式指的是根据请求来源情况来直接调用 compiler.run 进行构建)模式下,实际上也是同样通过 compiler.watch 方法进行文件的监听编译。印证了前面的

大多是借助 webpack 启动一个构建 watch 服务

更准确的说法是,通过 compiler.watch 来创建 watch 服务。

《webpack 中的 watch & cache (上)》

如图对应上文不同调用方式之间的差异。

watch 编译生命周期

上面小结的内容,在整个 webpack 的过程中,是处在完成 compiler = webpack(config) 函数调用之后,得到一个 Compiler 实例之后,进行正式编译流程之前的节点,详细的编译流程文章推荐 [][]Webpack 源码(二)—— 如何阅读源码细说 webpack 之流程篇 ,后续我们也会不断输出一些细节实现的文章。

对于 watch 这种需要不断进行触发编译的流程的情况,会出现不断重复地经历几个相同流程,可以称之为 watch 的 生命周期,而 cache 的出现和使用同样也融入了在这个生命周期中。

  1. 生成 Watching 实例 watching,将编译流程控制交给 watching

    webpack/lib/Compiler.js
    
    Compiler.prototype.watch = function(watchOptions, handler) {
      this.fileTimestamps = {};
      this.contextTimestamps = {};
      var watching = new Watching(this, watchOptions, handler);
      return watching;
    };

    无论是 webpack --watch,还是 webpack-dev-middleware 模块,都是调用 compiler.watch 方法进行初始化 watch 流程,在 Compiler.prototype.watch 逻辑中,与 Compiler.prototype.run 在方法中完成具体编译流程不同的是,会通过生成 watching 实例来接管具体编译流程

    1. 构造实例,进行第一次编译初始化
      watching 作为 watch 监听流程中的最上层对象,满足了 watch 流程在逻辑最上层的各个阶段衔接。

      webpack/lib/Compiler.js
      
      function Watching(compiler, watchOptions, handler) {
        this.startTime = null;
        this.invalid = false;
        this.error = null;
        this.stats = null;
        this.handler = handler;
        if(typeof watchOptions === "number") {
          this.watchOptions = {
            aggregateTimeout: watchOptions
          };
        } else if(watchOptions && typeof watchOptions === "object") {
          this.watchOptions = Object.create(watchOptions);
        } else {
          this.watchOptions = {};
        }
        this.watchOptions.aggregateTimeout = this.watchOptions.aggregateTimeout || 200;
        this.compiler = compiler;
        this.running = true;
        this.compiler.readRecords(function(err) {
          if(err) return this._done(err);
      
          this._go();
        }.bind(this));
      }

      对于 Watching 构造函数,其实可以分成两个部分

      1. 基础属性设置

        1. startTime:执行每次编译时(Watching.prototype._go 方法调用) ,会赋值编译启动时间,在后续文件是否需要再次编译时,作为重要根据之一

        2. invalid:表明现在 watching 的调用状态,例如在 this.runing 为 true 时,表明运行正常,会赋值该属性为 true

        3. error:存放编译过程的错误对象,完成每次编译后会回传给 handler 回调

        4. stats :存放编译过程中的各个数值,同样也是会在每次编译后会回传给 handler 回调

        5. handler:指的是,每次编译完执行的回调函数,一个常见的例子是每次编译完在命令行中出现的资源列表就是通过这个函数实现

        6. watchOptionswatch 调用参数设置,其中 aggregateTimeout 参数代表的是每一次文件(夹)变化后在 aggregateTimeout 值内的变化都会进行合并发送

        7. compiler:生成 watching 对象的 Compiler 实例

        8. runningwatching 实例的运行状态

      2. 执行初始化编译
        this._go 调用开始,就会进入 编译 -> watch监听编译 -> 文件变更触发编译 -> 编译 的循环

    2. 执行编译
      作为执行编译的入口 Watching.prototype._go 函数的结构与 Compiler.prototype.run 的结构类似,都是调用 Compiler 提供的诸如 this.compile 、this.emitAssets 等方法完成编译过程。
      《webpack 中的 watch & cache (上)》
      run 类似,_go 函数同样会调用 compiler.compile 方法进行编译,同时在完成 emitAssets (资源输出)、emitRecords (记录输出) 后,也就是完成这一次编译后,会调用 this.done 方法进行 watch 循环的最后一步

    3. 调用文件监听
      在完成编译后,为了在不重复启动编译进程的情况下,文件改动会自动重新编译。会在 Watching.prototype._done 中实时监听文件操作进行编译。

      Watching.prototype._done = function(err, compilation) {
       // 省略部分流程(结束状态值设置、结束事件触发等)
       if(!this.error)
           this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
      };

      这里在 _done 的最后一个步骤,会调用 Watching.prototype.watch 来进行文件监听:

      Watching.prototype.watch = function(files, dirs, missing) {
       this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, function(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) {
           this.watcher = null;
           if(err) return this.handler(err);
      
           this.compiler.fileTimestamps = fileTimestamps;
           this.compiler.contextTimestamps = contextTimestamps;
           this.invalidate();
       }.bind(this), function() {
           this.compiler.applyPlugins("invalid");
       }.bind(this));
      };

    Watching.prototype.watch 通过 compiler.watchFileSystemwatch 方法实现,可以大致看出在文件(夹)变化触发编译后,会执行传递的回调函数,最终会调用 Watching.prototype.invalidate 进行编译触发:

    Watching.prototype.invalidate = function() {
        if(this.watcher) {
            this.watcher.pause();
            this.watcher = null;
        }
        if(this.running) {
            this.invalid = true;
            return false;
        } else {
            this._go();
        }
    };

    到了 Watching.prototype.invalide 这个方法后,又去从 Watching.prototype._go 函数开始进行新一轮的编译,到这里整个 watch 的流程就串起来了。

在进入 watchFileSystem 之前,回顾上面的整个流程,webpack 中的 watch 流程大致就是 Watching.prototype._go -> Watching.prototype.watch -> Watching.prototype.invalidate 三个函数循环调用的过程。衔接初始化截图,大致如下图。

《webpack 中的 watch & cache (上)》

后续主要对 监听触发 两个部分所涉及的一些细节进行深入。

watchFileSystem

由上面内容看出对于 Watching.prototype.watch 实现文件监听的核心是 compiler.watchFileSystem 对象的 watch 方法。 watchFileSystemwebpack 中通过 NodeEnvironmentPlugin 来进行加载

webpack/lib/node/NodeEnvironmentPlugin.js

var NodeWatchFileSystem = require("./NodeWatchFileSystem");

NodeEnvironmentPlugin.prototype.apply = function(compiler) {
    compiler.inputFileSystem = new NodeJsInputFileSystem();
    var inputFileSystem = compiler.inputFileSystem = new        CachedInputFileSystem(compiler.inputFileSystem, 60000);
    compiler.resolvers.normal.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.context.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.loader.fileSystem = compiler.inputFileSystem;
    compiler.outputFileSystem = new NodeOutputFileSystem();
    compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
    compiler.plugin("run", function(compiler, callback) {
        if(compiler.inputFileSystem === inputFileSystem)
            inputFileSystem.purge();
        callback();
    });
};

这里会设置很多的 fileSystem ,而这样做的好处可以关联到前面的 webpack-dev-middleware 模块,在本地调试等对编译性能有较高要求的场景下,需要尽量利用缓存的速度,而 webpack-dev-middleware 将物理 io 切换成缓存设置,通过修改 fileSystem 来实现。

webpack-dev-middleware/middleware.js

var fs = new MemoryFileSystem();

// the base output path for web and webworker bundles
var outputPath;

compiler.outputFileSystem = fs;
outputPath = compiler.outputPath;
    

compileroutputFileSystem 设置成内存 (MemoryFileSystem) 的方式,将资源编译文件不落地输出,大大提高编译性能。在 webpack 中存在文件系统的抽象处理,方便一些优秀的文件系统处理模块功能(例如读取缓存、内存读写)接入利用。

《webpack 中的 watch & cache (上)》

例如 webpack 默认采用的是 graceful-fs,本身基于 Node.js 中的 fs 模块进行了许多优化,而 webpack-dev-middleware 则是采用内存读取的 memory-fs

对照 NodeEnvironmentPlugin 的代码,可以看到 watchFileSystem 指向的是同目录下的 NodeWatchFileSystem.js 导出的构造函数生成的实例。

webpack/lib/node/NodeWatchFileSystem.js

var Watchpack = require("watchpack");

function NodeWatchFileSystem(inputFileSystem) {
    this.inputFileSystem = inputFileSystem;
    this.watcherOptions = {
        aggregateTimeout: 0
    };
    this.watcher = new Watchpack(this.watcherOptions);
}

NodeWatchFileSystem.js 中的实现再一次的依赖 watchpack 完成。通过封装 watchpack 的监听逻辑,完成绑定相应的文件变更事件,进行上层 compiler.invalidate 方法调用,触发再次编译流程。

webpack/lib/node/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略异常处理
  
    if(callbackUndelayed)
        this.watcher.once("change", callbackUndelayed);

    this.watcher.once("aggregated", function(changes) {
        // 省略具体流程
        callback(...);
    }.bind(this));
      
      this.watcher.watch(files.concat(missing), dirs, startTime);
     // 省略返回
}

这里的 callback 就是 Watching.prototype.watch 方法中调用 this.compiler.watchFileSystem.watch 传递的回调函数,当用户触发了 watchpack 提供的文件(夹)变化事件,那么就会通过 callback 回调中 Watching.prototype.invalidate 进行再次编译。在进入 watchpack 细节之前总结一下 watch 调用层级。

《webpack 中的 watch & cache (上)》

webpack 中的 watch 调用,每一层都叫做 watch 方法,在每一个 watch 方法中,都通过逐步对下一层的依赖调用,完成从 watching 实例与 watcher 实例的衔接解耦。

  • watching 层,完成对重新编译的回调绑定

  • watchfileSystem 层,完成对下层监听文件(夹)触发逻辑之后信息返回的过滤处理,以及对上层回调的调用

  • watcer 层,只负责对文件(夹)的变化的事件监听

通过多个层级的划分,解耦逻辑,方便函数进行调整和功能横向扩展。

watchpack 监听

由上面 NodeWatchFileSystem.js 的代码截断中可以看到,对应的 watch 方法,核心逻辑是 watchpack 的实例 watcher 对应的 watch 方法。直接找到对应的 Watchpack.prototype.watch 方法

watchpack/lib/watchpack.js

var watcherManager = require("./watcherManager");
Watchpack.prototype.watch = function watch(files, directories, startTime) {
    this.paused = false;
    // 省略 old watchers 处理
  
    this.fileWatchers = files.map(function(file) {
        return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));
    }, this);
    this.dirWatchers = directories.map(function(dir) {
        return this._dirWatcher(dir, watcherManager.watchDirectory(dir, this.watcherOptions, startTime));
    }, this);

};

衔接上一层在 NodeWatchFileSystem.jsthis.watcher.watch(files.concat(missing), dirs, startTime); 的调用,在 watchpack 实例的 watch 方法中可以看到会针对 文件文件夹 类型分别调用 watcherManager.watchFilewatcherManager.watchDirectory进行监听。

watchpack/lib/watcherManager.js

WatcherManager.prototype.watchFile = function watchFile(p, options, startTime) {
    var directory = path.dirname(p);
    return this.getDirectoryWatcher(directory, options).watch(p, startTime);
};
WatcherManager.prototype.watchDirectory = function watchDirectory(directory, options, startTime) {
    return this.getDirectoryWatcher(directory, options).watch(directory, startTime);
};

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都传递了同类型的参数调用了 this.getDirectoryWatcher ,并在随后调用了返回实例的 watch 方法,并将 watch 方法的返回结果继续往上层 watchpack.jsthis._fileWatcherthis._dirWatcher 方法进行传递。

watchpack/lib/watcherManager.js

WatcherManager.prototype.getDirectoryWatcher = function(directory, options) {
 var DirectoryWatcher = require("./DirectoryWatcher");
 options = options || {};
 var key = directory + " " + JSON.stringify(options);
 if(!this.directoryWatchers[key]) {
  this.directoryWatchers[key] = new DirectoryWatcher(directory, options);
  this.directoryWatchers[key].on("closed", function() {
   delete this.directoryWatchers[key];
  }.bind(this));
 }
 return this.directoryWatchers[key];
};

getDirectoryWatcher 的具体实现,则是创建一个由 ./DirectoryWatcher 导出的构造函数所构造出来的实例。这里可以看到以文件夹路径(directory) 和配置 (options)两个属性作为实例的 key 并且在函数最后,将实例进行返回。

《webpack 中的 watch & cache (上)》

整个逻辑通过 watchManager 进行底层逻辑创建,通过 _dirWatcher_fileWatcher 完成对底层逻辑的处理封装。

DirectoryWatcher 实例创建

紧接着 wacthManagerwatchFilewatchDirectorygetDirectoryWatcher 调用完成后,则调用实例的 watch 方法,逻辑就走到了 DirectoryWatcher.js 文件。关联在 getDirectoryWatcher 的实例生成过程,对应 DirectoryWatcher 的构造函数

watchpack/lib/DirectoryWatcher.js

var chokidar = require("chokidar");

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.path = directoryPath;
    this.files = {};
    this.directories = {};
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        usePolling: options.poll ? true : undefined,
        interval: typeof options.poll === "number" ? options.poll : undefined
    });
    this.watcher.on("add", this.onFileAdded.bind(this));
    this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
    this.watcher.on("change", this.onChange.bind(this));
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
    this.watcher.on("error", this.onWatcherError.bind(this));
    this.initialScan = true;
    this.nestedWatching = false;
    this.initialScanRemoved = [];
    this.doInitialScan();
    this.watchers = {};
    this.refs = 0;
}

找到这里,可以看到,监听文件(夹)采用的是 chokidar 的能力。关联前面的逻辑,可以大致看出,通过 chokidar 绑定对应 directoryPath 的目录的 addaddDirchangeunlinkunlinkDir 的事件,通过对应的事件回调函数来向上层逻辑传递文件(夹)变更信息。

除了 watcher 对应 chokidar 对象,这里还有一些辅助的属性来完成监听处理逻辑

  • files:保存文件改变状态(mtime)

  • directories:保存文件夹监听状态,以及嵌套文件夹监听实例

  • initialScan:初次文件扫描标识

  • nestedWatching:是否存在嵌套文件夹监听

  • initialScanRemoved: 首次查看过程中删除的文件(夹),对在首次查看过程中对已删除文件(夹)的过滤

  • watchers:以监听路径(filePath) 为 key 的 watcher 数组为值的 map 对象

  • refswatchers 的数量

在属性复制完成后,会类似 Compiler.jsWatching 实例在实例创建时会进行首次编译一样,会进行首次文件夹的查看(doInitalScan) ,这里会进行初始数据(this.filesthis.directories)的生成。

DirectoryWatcher.prototype.doInitialScan = function doInitialScan() {
    fs.readdir(this.path, function(err, items) {
        if(err) {
            this.initialScan = false;
            return;
        }
        async.forEach(items, function(item, callback) {
            var itemPath = path.join(this.path, item);
            fs.stat(itemPath, function(err2, stat) {
                if(!this.initialScan) return;
                if(err2) {
                    callback();
                    return;
                }
                if(stat.isFile()) {
                    if(!this.files[itemPath])
                        this.setFileTime(itemPath, +stat.mtime, true);
                } else if(stat.isDirectory()) {
                    if(!this.directories[itemPath])
                        this.setDirectory(itemPath, true, true);
                }
                callback();
            }.bind(this));
        }.bind(this), function() {
            this.initialScan = false;
            this.initialScanRemoved = null;
        }.bind(this));
    }.bind(this));
};

这里是一个 async.forEach 撑起的函数结构,主要对传入 directoryPath 下的文件(夹)通过 setFileTimesetDirectory 进行 DirectoryWatcher 实例的 filesdirectories 属性赋值。

  • 对于文件情况 (stat.isFiletrue) :

    调用 `setFileTime` 函数传入文件最后修改时间( `stat.mtime`),函数本身分为两个步骤,而这里主要是**存储文件的变更记录**,而另一部则是**变更事件的触发**,在后面的内容也会提到。
    
    DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      var now = Date.now();
      var old = this.files[filePath];
      this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      // 省略变更触发
    };

    这里会以数组的形式,存储 变更流程执行时间点文件最后修改时间点
    一般 setFileTime 的调用的时候,就认为触发了文件触发了变更,进行文件变更记录更新,而对于初始化情况,主要目的是为了初始化数据,并不为变更而调用 setFileTime,所以对于初始化的返回是进行比较 Math.min(now, mtime) 而不是直接返回当前时间。

  • 对于文件夹情况(stat.isDirectorytrue

    调用 setDirectory 来进行子文件夹标记,方便后续进行子文件夹监听的创建:

    DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial) {
    var old = this.directories[directoryPath];
    if(!old) {
      if(exist) {
    if(this.nestedWatching) {
      this.createNestedWatcher(directoryPath);
    } else {
      this.directories[directoryPath] = true;
    }
      }
    } 
    // 省略文件夹删除事件触发
    }

    doInitalScan 的场景下,会判断 nestedWatching 的情况,如果为 false 则赋值 this.directories[directoryPath]true,表示文件夹没有创建对应的监听;或者是通过 this.createNestedWatcher 进行子文件夹监听的创建,最终也会赋值到 this.directories[directoryPath] 上的则是对应的内嵌 Watcher 实例。而这里的子文件夹的状态在后续也是可能发生变化的。
    完成赋值过程后, 会将 this.initialScan 设置成 false 表示首次查看结束,设置 this.initialScanRemovednull ,表示在首次查看过程中就删除的文件(夹)的处理也结束。

《webpack 中的 watch & cache (上)》

在完成基础 this.watcher 文件系统监听逻辑(chokidar )创建,基础属性 this.filesthis.directories 初始化后,则完成了整个 DirectoryWatcher 实例的生成。

搭建监听通道(创建内部 Watcher 实例)

getDirectoryWatcher 完成调用返回 DirectoryWatcher 的实例之后,调用实例的 watch 方法,传入文件(夹)路径。对最上层 Compiler 传入的 filesmissings 文件,dirs 文件夹进行循环调用,进行监听流程。watch 方法通过三个阶段完成底层到上层的监听信息通道的搭建。

《webpack 中的 watch & cache (上)》

  1. 生成 Watcher 实例
    第一个部分是针对传入的路径生成对应的 Watcher 实例,最终通过 WatcherManagerwatchFilewatchDirectory 返回到上层 watchpack 中的 watch 方法中 this._fileWatcherthis._dirname调用的返回结果,就是这个内部 Watcher 实例。

    watchpack/lib/DirectoryWatcher.js
    
    function Watcher(directoryWatcher, filePath, startTime) {
        EventEmitter.call(this);
        this.directoryWatcher = directoryWatcher;
        this.path = filePath;
        this.startTime = startTime && +startTime;
        this.data = 0;
    }
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
      this.watchers[withoutCase(filePath)] = this.watchers[withoutCase(filePath)] || [];
      this.refs++;
      var watcher = new Watcher(this, filePath, startTime);
      
      watcher.on("closed", function() {
        // 省略 closed 事件处理
      }.bind(this));
      
      this.watchers[withoutCase(filePath)].push(watcher);
      // 省略设置子文件内嵌监听
      // 省略已有数据处理
      return watcher;  
    };

    这里内部 Watcher 实例主要是通过继承 EventEmitter 来实现实例的事件支持,那么传递回上层例如 watchpack 时,就可以绑定该 Watcher 实例的事件,底层的文件改动触发实例的事件,上层对事件处理,通过这个对象建立数据传递的通道,完成监听数据的传递。在完成 watcher实例创建后,会将实例 pushthis.watchers 中以 filePath 为 key 的 watcher 数组,并将实例返回。

  2. 设置子文件夹内嵌监听
    watch 方法的另一部分,则是进行设置内嵌监听 setNestedWatching

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略内部 Watcher 实例生成
        var data;
        if(filePath === this.path) {
            this.setNestedWatching(true);
        }
          // 省略已有数据处理
    };
    
    DirectoryWatcher.prototype.setNestedWatching = function(flag) {
        if(this.nestedWatching !== !!flag) {
            this.nestedWatching = !!flag;
            if(this.nestedWatching) {
                Object.keys(this.directories).forEach(function(directory) {
                    this.createNestedWatcher(directory);
                }, this);
            } else {
                Object.keys(this.directories).forEach(function(directory) {
                    this.directories[directory].close();
                    this.directories[directory] = true;
                }, this);
            }
        }
    };

    在处理 filePath == this.path 的时候,也就是 DirectoryWatcher.prototype.watch 传入的路径与 Directory 生成实例的路径相同的时候(watchManager.js 中的 watchDirectory 方法的调用 this.getDirectoryWatcher(directory, options).watch(directory, startTime) 满足此条件)会在 watch 中调用 DirectoryWatcher.prototype.setNestedWatching 进行子文件夹的监听的创建。

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) {
      this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1);
      this.directories[directoryPath].on("change", function(filePath, mtime) {
        if(this.watchers[withoutCase(this.path)]) {
          this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(w.checkStartTime(mtime, false)) {
              w.emit("change", filePath, mtime);
            }
          });
        }
      }.bind(this));
    };

    子文件夹的监听同样是通过上层watchManager.js 中的 watchManager.watchDirectory 的调用实现,同时这里会多绑定一次 change 事件,实现当子文件夹变化的时候触发父文件夹的 change 事件。

  3. 处理已有数据
    在完成 watcher 实例创建之后,会针对在 watch 实例创建过程中发生的文件(夹)变动进行处理,保证文件的变动能完备更新

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略内部 Watcher 实例生成
        var data;
          if(filePath === this.path) {
          // 省略设置子文件内嵌监听
          data = false;
          Object.keys(this.files).forEach(function(file) {
            var d = this.files[file];
            if(!data)
              data = d;
            else
              data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
          }, this);
        } else {
          data = this.files[filePath];
        }
        process.nextTick(function() {
          if(data) {
            if(data[0] > startTime)
              watcher.emit("change", data[1]);
          } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
            watcher.emit("remove");
          }
        }.bind(this));
    };

    处理已有数据也是分成两个步骤

    1. 读取数据
      这里对于文件、文件夹的处理,获取数据的方式也不同。
      对于监听文件夹路径的情况:

      Object.keys(this.files).forEach(function(file) {
      var d = this.files[file];
      if(!data)
        data = d;
      else
        data = [Math.max(data[0], d[0]), Math.max(data[1], d[1])];
      }, this);

      可以从对 this.files 的循环看出,这里实际上是取到的是该文件夹下所有文件中的变更流程执行时间点文件最后修改时间点 的最大值。
      对于单个文件路径的情况:

       data = this.files[filePath];

      则是直接取到当前监听文件路径的数据。

    2. 触发事件
      当数据完成获取后,就进入到 触发事件 的阶段,这个阶段会将前面取到的 变更流程执行时间点 与由 Watching.prototype._go 中设置的编译开始时间 startTime 进行比较:

      process.nextTick(function() {
      if(data) {
        if(data[0] > startTime)
       watcher.emit("change", data[1]);
      } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }
      }.bind(this));

      变更流程执行时间点startTime 时间晚的时候说明,在编译开始后,针对文件夹的情况是文件夹其中的文件发生了变化,对于单个文件的情况,则是该文件发生变化。则触发 change 事件。
      这里还会有一个判断是:

      if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }

      对于第一个条件 this.initialScan,上面提到在完成 doInitialScan 完成后会复制为 false

      完成赋值过程后, 会将 this.initialScan 设置成 false 表示首次查看结束,设置 this.initialScanRemovednull ,表示在首次查看过程中就删除的文件(夹)的处理也结束

      则这条判断是在 watch 进行的同时,doInitialScan 也还在进行的时候生效。
      对于第二个条件 this.initialScanRemoved.indexOf(filePath) ,这里主要落脚点在于 initialScanRemoved 对这个数组的操作

      watchpack/lib/DirectoryWatcher.js
       
      this.watcher.on("unlink", this.onFileUnlinked.bind(this));
      this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
       
      DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
        // 省略判断
        if(this.initialScan) {
         this.initialScanRemoved.push(filePath);
        }
      };
       
      DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) {
        // 省略判断
        if(this.initialScan) {
         this.initialScanRemoved.push(directoryPath);
        }
      };

      从事件绑定中可以看到,当在进行 doInitialScan 过程中,发生了文件(夹)删除的情况,则会将删除的路径 pushinitialScanRemoved 数组中。
      那么整合两个条件,在初始扫描的场景下,监听文件(夹)发生删除的情况时,则触发 remove 事件,避免增加无效的监听。

《webpack 中的 watch & cache (上)》

在整个数据监听通道的流程中,都是围绕 Watcher 实例进行开展,通过 Watcher 承上启下衔接上下逻辑的作用。

触发流程

在完成了从 Watchpack.prototype.watch -> WatcherManager.prototype.watchFileWatcherManager.prototype.watchDirectory -> Directory.prototype.watch 这条调用链之后,webpack --watch 就会等待文件的改动,进行编译的再次触发。

chokidar

目前 watchpack 中对文件(夹)的监听通过 chokidar 来实现,首先关联的逻辑就是 chokidar 的具体调用,关注到 DirectoryWatcher 中调用 chokidar 的部分

watchpack/lib/DirectoryWatcher.js

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        usePolling: options.poll ? true : undefined,
        interval: typeof options.poll === "number" ? options.poll : undefined
    });
    this.watcher.on("add", this.onFileAdded.bind(this));
    this.watcher.on("addDir", this.onDirectoryAdded.bind(this));
    this.watcher.on("change", this.onChange.bind(this));
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
    this.watcher.on("error", this.onWatcherError.bind(this));
}

首先是 chokidar 的初始化,

  • ignoreInitial:默认为false, 设置为 true ,避免在 chokidar 自身初始化的过程中触发 addaddDir 事件

  • persistent:默认为 true,设置为 true,保持文件监听,为 false 的情况下,会在 ready 事件后不再触发事件

  • followSymlinks:默认为 true,设置为 false,对 link 文件不监听真实文件内容的变化

  • depth: 设置为 0 ,表明对子文件夹不进行递归监听

  • atomic:默认为 false,设置为 false,关闭对同一文件删除后 100ms 内重新增加的行为触发 change 事件,而不是 unlinkadd 事件的默认行为

  • alwaysStat:默认为false,设置为 true,保持传递 fs.Stats,即使可能存在不存在的情况

  • ignorePermissionErrors:默认为 false,设置为 true,忽略权限错误的提示

  • usePolling:默认为 false,根据实际配置来设置,是否开启 polling 轮询模式

  • interval:轮询模式的周期时间,根据实际配置来设置,轮询模式的具体时间

其次绑定对应的文件(夹)事件 addaddDirchangeunlinkunlinkDir

完成初始化和事件绑定后,通过各个事件的回调函数来进行监听逻辑的触发和向上层传递。

文件时间精确度数值(FS_ACCURENCY)确定

根据上面提到的 this.watcher.on("change", this.onChange.bind(this)); 当文件内容发生变化时,进入绑定的 onChange 回调函数

watchpack/lib/DirectoryWatcher.js

var FS_ACCURENCY = 10000;

DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) {
    if(filePath.indexOf(this.path) !== 0) return;
    if(/[\\\/]/.test(filePath.substr(this.path.length + 1))) return;
    var mtime = +stat.mtime;
    if(FS_ACCURENCY > 1 && mtime % 1 !== 0)
        FS_ACCURENCY = 1;
    else if(FS_ACCURENCY > 10 && mtime % 10 !== 0)
        FS_ACCURENCY = 10;
    else if(FS_ACCURENCY > 100 && mtime % 100 !== 0)
        FS_ACCURENCY = 100;
    else if(FS_ACCURENCY > 1000 && mtime % 1000 !== 0)
        FS_ACCURENCY = 1000;
    else if(FS_ACCURENCY > 2000 && mtime % 2000 !== 0)
        FS_ACCURENCY = 2000;
    this.setFileTime(filePath, mtime, false, "change");
};

onChange 中,除了调用 this.setFileTime 进行文件变更数据更新、对应 watcher 实例事件触发之外,还会进行 FS_ACCURENCY 的校准逻辑。可以看到校准的规则是根据文件的修改时间取模的精度来确定值。关于这个变量值,这里从 issue 中找到 webpack 作者 sokra 的描述:

FS_ACCURENCY should automatically adjust to your file system accuracy

With low fs accuracy files could have changed even if mime is equal

其中说到,在文件系统数据低精确度的情况,可能出现 mime 相同,但也发生了改变的情况。通过在后面的变更判断中通过加入精确值的度量值计算,起到平衡数值的作用(例如var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;)。

watcher 实例事件触发

之前提到,watcher 实例是文件变更信息的通道,通过在 watcher 上的事件绑定,将 chokidar 监听到的文件(夹)变更信息,传递到 watchpack 层的逻辑。进入 this.setFileTime 后,则进行对应事件的触发

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
               // 文件事件触发具体逻辑
            }
        }
    } else if(!initial && mtime && type !== "add") {
      // 文件事件触发具体逻辑
    } else if(!initial && !mtime) {
      // 文件事件触发具体逻辑
    }
    if(this.watchers[withoutCase(this.path)]) {
      // 文件目录事件触发
    }
};

事件触发分为两个大的阶段,第一个阶段为对于 filePath 文件的事件触发,第二个阶段为对于当前 DirectoryWatcher 对应 path 属性文件夹的事件触发。

1.filepath 文件的事件触发

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
                this.watchers[withoutCase(filePath)].forEach(function(w) {
                    if(!initial || w.checkStartTime(mtime, initial)) {
                        w.emit("change", mtime);
                    }
                });
            }
        }
    } else if(!initial && mtime && type !== "add") {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("change", mtime);
            });
        }
    } else if(!initial && !mtime) {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("remove");
            });
        }
    }
  
      // 省略文件夹触发
};

文件事件触发,实际会涉及到三个逻辑,单纯已有文件改变的触发,对应第二个逻辑

  • 对于 filePath 之前没有数据设置的情况 if(!old)

       这里穿插到前面初始化的逻辑,在前面 `doIntialScan` 中 `initial` 的参数为 `true`, 则进入 `checkStartTime` 函数判断
    
    watchpack/lib/DirectoryWatcher.js
    
    Watcher.prototype.checkStartTime = function checkStartTime(mtime, initial) {
    if(typeof this.startTime !== "number") return !initial;
    var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;
    return startTime <= mtime;
    };
      会去比较编译开始时间 `statrTime` 与文件最后修改时间 `mtime` 来判断是否需要触发事件,`doInitialScan` 场景下,默认 `FS_ACCURENCY` 的值是 `10000` ,意思是在编译前的 10s 范围内的改动都会触发 `change` 事件,那么这样是否会存在初始化时多触发一次编译呢?在上面提到  [issue](https://github.com/webpack/watchpack/issues/25) 中,作者同样给出了解释
       > This may not happen fast enough if you have few files and the files are created unlucky on a timestamp modulo 10s
    
       > The watching may loop in a unlucky case, but this should not result in a different compilation hash. I. e. the webpack-dev-server doesn't trigger a update if the hash is equal.
    
       及时触发这样的 `unlucky case`,也只会在 `doInitailScan` 过程中文件内容真正发生变化导致 `hash` 变化的时候再次触发编译更新。
    
       这条判断同样适用当有新增文件,触发 `add` 事件的情况。
    
  • 对于已有文件变化(非 doInitial 过程中、add 新增文件事件触发,if(!initial && mtime && type !== "add")

      对应这种情况,则直接会触发 `change` 事件
    if(this.watchers[withoutCase(filePath)]) {
      this.watchers[withoutCase(filePath)].forEach(function(w) {
    w.emit("change", mtime);
      });
    }
       找到对应文件的监听 `watcher` 触发 `change` 事件,对应上层逻辑逻辑进行响应。
    
  • mtime 不存在的情况(文件删除)

    watchpack/lib/DirectoryWatcher.js
    
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
      // 省略其他操作
      this.setFileTime(filePath, null, false, "unlink");
    };
 当文件删除触发 `unlink` 事件时,调用 `setFileTime` 时,则会传递 `mtime` 为 `null`。则事件触发逻辑与第二种情况方式相同,只是从 `change` 事件变成了 `remove` 事件。

2.DirectoryWatcher 对应 path 属性文件夹的事件触发

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      // 省略文件触发
  
    if(this.watchers[withoutCase(this.path)]) {
        this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(!initial || w.checkStartTime(mtime, initial)) {
                w.emit("change", filePath, mtime);
            }
        });
    }
};

因为是监听的是文件夹下的文件发生的变化,所以在完成了对应文件事件的触发之后,会进行监听文件夹(路径为实例化 DirectoryWatcher 时传入的 this.path)的触发,这里除了会将文件的最后修改时间 mtine 传递,还会将对应的文件路径 this.filePath 也当做参数一起传递到绑定的事件回调参数中。

在通过 watcher 这个继承了 EventEmitter 对象的实例触发事件后,就完成了底层文件(夹)监听触发的功能,紧接着就是上层对象对于 watcher 实例的事件触发的对应处理,最终关联上 webpack 的编译启动流程。

上层响应

watchpack.js

在上面有提到

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都传递了同类型的参数调用了 this.getDirectoryWatcher ,并在随后调用了返回实例的 watch 方法,并将 watch 方法的返回继续往上层 watchpack.jsthis._fileWatcherthis._dirWatcher 方法。

watch 实例的上层响应的第一层在 watchpack.js 中的 Watchpack.prototype._fileWatcherWatchpack.prototype._dirWatcher 中完成,分别针对文件和文件夹的变更处理

watchpack/lib/watchpack.js

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
    watcher.on("change", this._onChange.bind(this, file));
    return watcher;
};

Watchpack.prototype._dirWatcher = function _dirWatcher(item, watcher) {
    watcher.on("change", function(file, mtime) {
        this._onChange(item, mtime, file);
    }.bind(this));
    return watcher;
};

这里 _fileWatcher_dirWatcherchange 的事件都是将逻辑导向了 Watchpack.prototype._onChange

watchpack/lib/watchpack.js

Watchpack.prototype._onChange = function _onChange(item, mtime, file) {
    file = file || item;
    this.mtimes[file] = mtime;
    if(this.paused) return;
    this.emit("change", file, mtime);
    if(this.aggregateTimeout)
        clearTimeout(this.aggregateTimeout);
    if(this.aggregatedChanges.indexOf(item) < 0)
        this.aggregatedChanges.push(item);
    this.aggregateTimeout = setTimeout(this._onTimeout, this.options.aggregateTimeout);
};

函数会首先触发 Watchpack 实例的 change 事件,传入触发的文件(夹)的路径,以及最后修改时间,供上层逻辑操作。

然后开始进行 aggregate 逻辑的触发,可以看到这里的大致含义是在文件(夹)发生变更 this.aggregateTimeout 后,进行 Watchpack.prototype._onTimeout 逻辑,在此之前,会将修改的文件(夹)路径暂存到 aggregatedChanges 数组中

watchpack/lib/watchpack.js

Watchpack.prototype._onTimeout = function _onTimeout() {
    this.aggregateTimeout = 0;
    var changes = this.aggregatedChanges;
    this.aggregatedChanges = [];
    this.emit("aggregated", changes);
};

Watchpack.prototype._onTimeout 则是当最后一次文件(夹)触发之后没有变更的 200ms 后,通过 this.aggregatedChanges 将接连不断的变更聚合通过 aggregated 事件传递给上层。

那么对应每一个变更,实际会牵涉触发一次 change 事件,以及关联一次 aggregated 事件,传给给上层,关联实际的编译重新触发逻辑。

NodeWatchFileSystem.js

前面提到

NodeWatchFileSystem.js 中的实现再一次的依赖 watchpack 完成。通过封装 watchpack 的监听逻辑,完成绑定相应的文件变更事件,进行上层 compiler.invalidate 方法调用,触发再次编译流程。

那么绑定 watchpack 实例的事件,来完成这一层的逻辑

webpack/lib/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略参数合法性检测
  
    this.watcher = new Watchpack(options);

    if(callbackUndelayed)
        this.watcher.once("change", callbackUndelayed);

    this.watcher.once("aggregated", function(changes) {
          //1.
        if(this.inputFileSystem && this.inputFileSystem.purge) {
            this.inputFileSystem.purge(changes);
        }
          //2.
        var times = this.watcher.getTimes();
          //3.
        callback(null, changes.filter(function(file) {
            return files.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return dirs.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return missing.indexOf(file) >= 0;
        }).sort(), times, times);
    }.bind(this));

    this.watcher.watch(files.concat(missing), dirs, startTime);
  
    // 省略返回
};

与上面 watchpack 触发事件一致,在 NodeWatchFileSystem 这一层逻辑中,其实对下一层 Watchpack 的就是通过绑定主要的 changeaggregated 事件完成的。

对于 change 事件,会直接传递到上层的 callbackUndelayed

对于 aggregated 事件,

  1. 首先会调用 this.inputFileSystem.purge(changes) ,将文件系统中涉及到变更的文件的记录清空。

  2. 其次调用 Watchpack 实例的 getTimes() 方法获取监听文件(夹)的 变更流程执行时间点文件最后修改时间点 的最大值,便于在后续判断是否需要进行重新编译,例如 cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);

  3. 最后在调用上层回调之前,会将变化的文件(夹)根据监听时传入参数通过挨个过滤的方式进行分发到每个参数中,完成之后,流程就会走到最后一层也是最初调用监听的一层 Compiler.js

Compiler.js

在上文中提过

Watching.prototype.watch 通过 compiler.watchFileSystemwatch 方法实现,可以大致看出在变化触发编译后,会执行传递的回调函数,最终会调用 Watching.prototype.invalidate 进行编译触发

从调用开始,通过最底层的 chokidar 完成文件(夹)监听事件的触发,通过事件传递的方式,又回到调用处,进行重新编译。

《webpack 中的 watch & cache (上)》

回顾整个触发流程,纵向 4 个逻辑层级之间进行传递,

  • DirectoryWatcher:完成对文件(夹)的监听实现,以及初步监听数据加工

  • watchpack:完成触发底层逻辑的封装,实现上层逻辑跟触发逻辑解耦

  • NodeWatchFileSystem:完成对监听数据业务逻辑处理,进行最后回调处理

  • Compiler:完成最终业务响应

总结 & 衔接

watch 流程利用事件模型,采用多个逻辑层的设计,对复杂的触发流程进行解耦拆分,实现了比较清晰可维护的代码结构。

在完成 watch 流程,触发重新编译后,与 run 流程相不同的是,webpack 为了提高编译速度,降低编译的时间消耗与提高编译性能,在重新编译的很多环节中都设置了缓存机制,让二次编译的速度得到大大提高。下一篇文章主要对 cache 的情况进行描述。

    原文作者:frankLife
    原文地址: https://segmentfault.com/a/1190000008111793
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞