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

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

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

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

初次构建

资源构建

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

1
2
//...
compiler.watch(watchOptions, compilerCallback);

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

1
2
//...
return new Watching(this, watchOptions, handler); //handler即compilerCallback

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

1
2
//...Watching.js
this._go();

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

1
2
3
4
5
// Watching.js
const onCompiled = (err, compilation) => {
//...
};
this.compiler.compile(onCompiled);

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

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

1
2
3
4
5
6
7
8
9
//... 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 方法里,先对参数进行了格式判断后,然后执行:

1
2
3
//NodeWatchFileSystem.js
const oldWatcher = this.watcher;
this.watcher = new Watchpack(options);

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

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

1
this.watcher.watch(cachedFiles.concat(missing), cachedDirs.concat(missing), startTime);

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

1
2
3
4
5
6
7
//...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 中执行:

1
2
3
//watcherManager.js
var directory = path.dirname(p);
return this.getDirectoryWatcher(directory, options).watch(p, startTime);

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

1
2
3
4
5
6
7
8
9
10
11
12
//...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,在实例化的过程中执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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 来进行文件的监听,然后根据不同操作(增加,删除,修改等)绑定一些事件:

1
2
3
4
5
6
7
//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 类的原型方法上。然后执行:

1
2
//DirectoryWatcher.js
this.doInitialScan();

即执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//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 来收集该文件的修改时间:

1
2
//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 方法,方法里执行:

1
2
//...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。然后回到:

1
2
//...DirectoryWatcher.js
return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));

执行 this._fileWatcher 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 进行验证后,执行:

1
this.setFileTime(filePath, mtime, false, 'change');

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

1
2
3
4
5
6
//DirectoryWatcher.js
if (this.watchers[withoutCase(filePath)]) {
this.watchers[withoutCase(filePath)].forEach(function (w) {
w.emit('change', mtime, type);
});
}

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

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

方法里执行:

1
2
3
4
5
//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 里的回调:

1
2
//Watching.js
this.compiler.hooks.invalid.call(fileName, changeTime);

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

执行 this._onTimeout

1
2
//watchpack.js
this.emit('aggregated', changes, removals);

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

1
2
//NodeWatchFileSystem.js
const times = objectToMap(this.watcher.getTimes());

得到 times

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
//...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 后,执行:

1
this._invalidate();

方法里执行:

1
this._go();

开启新一轮的构建。

watch 优化

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

1
const addModuleResult = this.addModule(module);

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

1
2
3
4
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 可大大提升编译过程。

---- 本文结束,感谢您的阅读 ----