阅读 159

webpack 4 源码主流程分析(十三):watch

原文首发于 blog.flqin.com。如有错误,请联系笔者。分析码字不易,转载请表明出处,谢谢!

前面分析了 webpack 的普通主流程构建,另外,通过设置 watch 模式,webpack 可以监听文件变化,当它们修改后会重新编译。文档

webpack-dev-serverwebpack-dev-middlewareWatch 模式默认开启。

接下来设置 cli 命令加上 --watch 之后 对 watch 模式下的主流程进行分析(mode = development)。

初次构建

资源构建

代码执行后,跟主流程类似,然后执行到之前文章介绍到的 编译前的准备 -> 回到 cli.js 里,读取到 options.watchOptionswatch 配置后, 走 compiler.watch

//...
compiler.watch(watchOptions, compilerCallback);
复制代码

complier 里的 watch 方法里,new 一个 Watching 实例:

//...
return new Watching(this, watchOptions, handler); //handler即compilerCallback
复制代码

来到文件 Watching.js,在 Watching 实例化的过程中,先对 watchOptions 进行了处理后,在 compiler.readRecords 的回调里执行 _go

//...Watching.js
this._go();
复制代码

_go 方法与 Compiler 里的 run 很类似。 在 _go 里,触发 compiler.hooks:watchRun,执行插件 CachePlugin,即 CachePlugin 里的 this.watching = true,在钩子 watchRun 回调里执行:

// Watching.js
const onCompiled = (err, compilation) => {
  //...
};
this.compiler.compile(onCompiled);
复制代码

与普通 webpack 构建一致,即执行 compiler.compile 开始构建,在资源构建结束后执行 onCompiled

onCompiled 方法与 compiler.run 里的 onCompiled 大致一致,不同点是所有回调由 finalCallback 改为 _done,并且将 stats 统计信息相关处理也放到了 _done 里,执行 _done

//... Watching.js
this.compiler.hooks.done.callAsync(stats, () => {
  this.handler(null, stats); // compilerCallback
  if (!this.closed) {
    this.watch(Array.from(compilation.fileDependencies), Array.from(compilation.contextDependencies), Array.from(compilation.missingDependencies));
  }
  for (const cb of this.callbacks) cb();
  this.callbacks.length = 0;
});
复制代码

在该方法里对 stats 设置后,先执行 handler(实际与 finalCallback 执行一致) 即 compilerCallback,在 cli 里打印出构建相关的信息。到此,初始化构建完毕。

添加监听

然后执行 watch 方法并传入在之前 compilation.sealthis.summarizeDependencies 方法里生成的 this.fileDependencies, this.contextDependencies, this.missingDependencies 这些需要监听的文件和目录。

Watching 的实例 watch 方法里仅仅执行 this.compiler.watchFileSystem.watchwatchFileSystem 即是在前文 NodeEnvironmentPlugin 里所设置的 NodeWatchFileSystem 的实例。

NodeWatchFileSystem 的实例 watch 方法里,先对参数进行了格式判断后,然后执行:

//NodeWatchFileSystem.js
const oldWatcher = this.watcher;
this.watcher = new Watchpack(options);
复制代码

this.watcherNodeWatchFileSystem 实例化的时候已经创建了一个 Watchpack 的实例,这里相当于重新创建了一个实例。

Watchpack 继承了 events 模块的 EventEmitter,所以接下来分别在 this.watcherWatchpack 实例) 上注册了 changeaggregated 事件,然后执行:

this.watcher.watch(cachedFiles.concat(missing), cachedDirs.concat(missing), startTime);
复制代码

即执行 watchpack 的实例方法 watch,在方法里执行:

//...watchpack.js
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);
复制代码

这里循环对每一个 file 进行执行 this._fileWatcher 方法。

一般情况的监听只会涉及 this._fileWatchers,目录类的 this._dirWatchers 会在 require.context 的情况下被监听。

这里先执行 watcherManager.watchFile,在类 WatcherManager 的实例方法 watchFile 中执行:

//watcherManager.js
var directory = path.dirname(p);
return this.getDirectoryWatcher(directory, options).watch(p, startTime);
复制代码

获取到文件对应路径 directory 后(文件路径 -> 目录路径),this.getDirectoryWatcher 里执行:

//...watcherManager.js
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];
复制代码

this.directoryWatchers 是一个 key 为目录路径,valueDirectoryWatcher 实例的对象。

可见 this.getDirectoryWatcher 返回了一个参数为目录路径和配置的 DirectoryWatcher 实例。

DirectoryWatcherWatchpack 一样,也 继承了 events 模块的 EventEmitter,在实例化的过程中执行:

//DirectoryWatcher.js
this.watcher = chokidar.watch(directoryPath, {
  ignoreInitial: true,
  persistent: true,
  followSymlinks: false,
  depth: 0,
  atomic: false,
  alwaysStat: true,
  ignorePermissionErrors: true,
  ignored: options.ignored,
  usePolling: options.poll ? true : undefined,
  interval: interval, // 即 options.poll 文件系统轮询的时间间隔,越大性能越好
  binaryInterval: interval,
  disableGlobbing: true
});
复制代码

webpack 采用 npmchokidar 来进行文件的监听,然后根据不同操作(增加,删除,修改等)绑定一些事件:

//DirectoryWatcher.js
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));
复制代码

这些事件是挂载在 DirectoryWatcher 类的原型方法上。然后执行:

//DirectoryWatcher.js
this.doInitialScan();
复制代码

即执行:

//DirectoryWatcher.js
fs.readdir(
  this.path,
  function(err, items) {
    //...
    async.forEach(
      items,
      function(item, callback) {
        var itemPath = path.join(this.path, item);
        fs.stat(
          itemPath,
          function(err2, stat) {
            //...
            if (stat.isFile()) {
              if (!this.files[itemPath]) this.setFileTime(itemPath, +stat.mtime || +stat.ctime || 1, 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)
);
复制代码

即读取该 path(上文对应的文件对应文件夹路径 directory)下的所有文件及文件夹,如果是文件则执行 this.setFileTime,在该方法里根据是否是首次 watch 来收集该文件的修改时间:

//DirectoryWatcher.js
this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
复制代码

如果是文件夹则执行 this.setDirectory 记录所有子路径。

因为 fs.readdir 为异步,所以 fs.readdir 的回调里先不执行,转而先执行 this.getDirectoryWatcher(directory, options).watch(p, startTime)watch 方法,方法里执行:

//...DirectoryWatcher.js
var watcher = new Watcher(this, filePath, startTime);
复制代码

Watcher 依旧继承了 events 模块的 EventEmitter。这里实例化了一个 watcher,然后订阅了他的 close 方法后,将该 watcher pushthis.watchers,然后返回一个 watcher,即执行 watcherManager.watchFile(file, this.watcherOptions, startTime) 返回了一个 watcher。然后回到:

//...DirectoryWatcher.js
return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));
复制代码

执行 this._fileWatcher 方法:

watcher.on(
  'change',
  function(mtime, type) {
    this._onChange(file, mtime, file, type);
  }.bind(this)
);
watcher.on(
  'remove',
  function(type) {
    this._onRemove(file, file, type);
  }.bind(this)
);
return watcher;
复制代码

即给对应的 watcher 订阅了 changeremove 事件。最终 this.fileWatchers 得到一个 watcher 数组。

然后回到 NodeWatchFileSystem 实例的 watch 方法执行 oldWatcher.close() 删除旧的 Watchpack 实例。

然后回到 _done 里,这一轮代码执行结束。

然后转而执行之前在 doInitialScan 里的 fs.readdir 的异步回调,收集文件修改时间(前文已解释),到此 webpack watch 的初次构建结束,文件正在被监听。

修改文件触发监听

修改文件后,触发 chokidarchange 事件,即对应路径在 DirectoryWatcher 实例化里设置的 onChange 事件,在方法里对 path 进行验证后,执行:

this.setFileTime(filePath, mtime, false, 'change');
复制代码

再次调用了 setFileTime 方法。在方法里更新 this.files[filePath] 里对应的最新修改时间后,执行:

//DirectoryWatcher.js
if (this.watchers[withoutCase(filePath)]) {
  this.watchers[withoutCase(filePath)].forEach(function(w) {
    w.emit('change', mtime, type);
  });
}
复制代码

判断该文件是否在 this.watchers 即在被监听之列后,对该文件的每一个 watcher 触发其 change 事件,即执行:

//watchpack.js
this._onChange(file, mtime, file, type);
复制代码

方法里执行:

//watchpack.js
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);
复制代码

this.emit('change', file, mtime) 用于触发 this.compiler.watchFileSystem.watch 里的回调:

//Watching.js
this.compiler.hooks.invalid.call(fileName, changeTime);
复制代码

然后剩下的部分是一个标准的函数防抖(debounce),通过设置配置项 options.aggregateTimeout 可以设置间隔时间,间隔时间越长,性能越好。

执行 this._onTimeout

//watchpack.js
this.emit('aggregated', changes, removals);
复制代码

主要作用触发 aggregated 事件即在 NodeWatchFileSystem 里注册,执行:

//NodeWatchFileSystem.js
const times = objectToMap(this.watcher.getTimes());
复制代码

得到 times

{
  //...map结构
  0: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  },
  1: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  },
  2: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  },
  3: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  },
  4: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  },
  5: {
    "key": "/Users/github/webpack-demo/src/a.js",
    "value": "1578382937093"
  }
}
复制代码

得到每个文件的最新修改时间后,执行回调 callback,即 Watching.jsthis.compiler.watchFileSystem.watch 方法的倒数第二个参数方法,在方法里将 fileTimestampstimes 赋给 this.compiler.fileTimestamps 后,执行:

this._invalidate();
复制代码

方法里执行:

this._go();
复制代码

开启新一轮的构建。

watch 优化

在构建过程中,依旧从入口开始构建,但在 moduleFactory.create 的回调里(包括 addModuleDependencies 里的 factory.create),执行:

const addModuleResult = this.addModule(module);
复制代码

该方法除了判断 module 已加载之外,还判断了如果在 compilationthis.cache 存在该模块的话,则执行:

let rebuild = true;
if (this.fileTimestamps && this.contextTimestamps) {
  rebuild = cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);
}
复制代码

在方法 needRebuild 里判断模块修改时间 fileTimestamps.get(file) 与 模块构建时间 this.buildTimestamp(在 module.build 时取得)的先后来决定是否需要重新构建模块,若修改时间大于构建时间,则需要 rebuild,否则跳过 build 这步直接执行 afterBuild 即递归解析构建依赖。这样在监听时只 rebuild 修改过的 module 可大大提升编译过程。