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

2,599 阅读5分钟

原文首发于 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);

该方法初始化一些属性后,new 一个 Watching 实例并返回。

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

//Watching.js
this.compiler.readRecords((err) => {
  //...
  this._go();
});

_goCompiler 里的 run 很类似。 在 _go 里,触发 compiler.hooks:watchRun,执行插件 CachePlugin 设置 this.watching = true。与 webpack 普通构建一致,在钩子 watchRun 回调里执行 compiler.compile 开始构建,在资源构建结束后执行 onCompiled

// Watching.js
_go(){
  //...
  this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
    //...
    const onCompiled = (err, compilation) => {
  //...
    };
  this.compiler.compile(onCompiled);
  }
}

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

//... Watching.js
_done(err, compilation){
  //...
  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 设置后,compiler.hooks: done 的回调里执行 this.handler(实际与 finalCallback 功能一致) 即 compilerCallback,在 cli 里打印出构建相关的信息。到此,初始化构建完毕。

添加监听

接着执行 this.watch 并传入 fileDependencies, contextDependencies, missingDependenciescompilation.sealthis.summarizeDependencies 生成) 这些需要监听的文件和目录。

this.watch 即执行 this.compiler.watchFileSystem.watchNodeWatchFileSystem 的实例 watch 方法( 文件 webpack/lib/node/NodeWatchFileSystem.jsNodeEnvironmentPlugin 里所设置),方法里先对参数进行了格式判断后,实例化了 WatchpackWatchpack 继承了 events 模块的 EventEmitter,然后在 this.watcherWatchpack 实例) 上注册了 change,aggregated 事件后,执行 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.js
var directory = path.dirname(p);
return this.getDirectoryWatcher(directory, options).watch(p, startTime);

其中 getDirectoryWatcher 根据文件对应目录路径 directory,实例化不同的 DirectoryWatcher 并执行 watch 方法。

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 来进行文件夹的监听,然后根据不同操作(增加,删除,修改等)绑定事件后,执行 this.doInitialScan 读取该 path(文件对应的文件夹路径 directory)下的所有文件及文件夹,如果是文件则执行 this.setFileTime 根据是否是首次 watch 来收集该文件的修改时间;如果是文件夹则执行 this.setDirectory 记录所有子路径。

因为 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 实例。

然后回到:

//watchpack.js
//...
return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime)); // watcherManager.watchFile 返回一个 watcher 实例

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

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

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

修改文件触发监听

修改文件后,触发 chokidarchange 事件,即 this.onChange,在方法里对 path 进行验证后,执行 this.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,即执行在 _fileWatcher 里注册的事件:

//watchpack.js
this._onChange(file, mtime, file, type);

方法里执行:

//watchpack.js
this.emit('change', file, mtime); // 触发 `this.compiler.watchFileSystem.watch` 里的回调:this.compiler.hooks.invalid.call(fileName, changeTime)
if (this.aggregateTimeout) clearTimeout(this.aggregateTimeout);
if (this.aggregatedChanges.indexOf(item) < 0) this.aggregatedChanges.push(item);
this.aggregateTimeout = setTimeout(this._onTimeout, this.options.aggregateTimeout);

函数防抖(debounce),通过设置配置项 options.aggregateTimeout 可以设置间隔时间,间隔时间越长,性能越好。

执行 this._onTimeout 里触发 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 可大大提升编译过程。