webpack watch 篇(一)

3,397 阅读8分钟
原文链接: github.com

本个系列的文章会被分成两篇文章

(一)主要描述下问题的表现,并 dive into webpack watch system (二)解决问题,从根本上解决 webpack 的 bug


最近做一个内部工具时碰到了一个很有意思的问题

多次 rebuild 的现象

当首次动态创建 webpack 入口文件后,入口文件新增依赖时,会导致数十次的重新编译过程。

搜了下,发现 webpack 可追溯的 issue 记录为 Files created right before watching starts make watching go into a loop

该问题不论你是在使用 webpack-dev-middleware 或者 webpack --watch 又或者 webpack-dev-server 都可以复现。

webpack 作者 @sokra 对其解释为:

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.

白话理解为:确实有问题,但是呢,最关键的 compilation hash 不会变,所以上层使用时,自己内部处理下这个逻辑。

但实际情况呢, webpack-dev-server 等作者不认这一说!

粗暴的解决方案

至于不想刨根问底,这里也有狗皮膏药的解决方案:

// Webpack startup recompilation fix. Remove when @sokra fixes the bug.
// https://github.com/webpack/webpack/issues/2983
// https://github.com/webpack/watchpack/issues/25
const timefix = 11000;
compiler.plugin('watch-run', (watching, callback) => {
  watching.startTime += timefix;
  callback()
});
compiler.plugin('done', (stats) => {
  stats.startTime -= timefix
})

刨根问底

当然狗皮膏药并不是本文的重点,刚好借此一窥,webpack 中整体的 watch 机制。

如果不想看那么多代码片段,也可以看我在梳理代码逻辑时做的笔记,笔记中红色流程为初始化时的调用链路,蓝色部分为文件变更后事件回调链路。

img_4830

首先我们可以确定一点的是,不管是 webpack 自身的 cli 工具还是 webpack-dev-middleware 和 webpack-dev-server 都是通过 Compiler.prototype.watch 来实现了 watch 的功能,进而来实现调试阶段的高性能需求。

为了比较清晰的知道整一个流程,我们从创建一个 Compiler 实例开始说起

webpack Compiler 实例的创建

总所周知我们通过 const compiler = webpack(webpackConfig); 这种方式来创建一个 Compiler 的实例,一般也叫做 webpack 的实例,compiler 实例对象中包含着和打包相关的所有参数,plugins loaders 等等。这种情况下 webpack 并不会默认进行构建编译的过程,如果想要启动编译则需要执行一下 compiler.run(callback)。 另外我们也可以通过 webpack(webpackConfig, callback); 默认来启动构建编译流程。

对于今天我们想要了解的 watch 过程我们这边只需要知道,当构建参数中含有明确开启 watch 配置项时整个流程的走向是 compiler.watch(watchOptions, callback); 而非 compiler.run(callback);

题外话: 或许你比较好奇 compilation 是什么,它包含着 chunks modules 等信息,构建依赖文件变更时都会重新生成 compilation,而 compiler 只有一个。

源码追溯

compiler.watch 中创建 watch 服务

// compiler 的 watch 方法
class Compiler extends Tapable {
  watch(watchOptions, handler) {
    ...
    const watching = new Watching(this, watchOptions, handler);
        return watching;
  }
}
// Watch 类
class Watching {
    constructor(compiler, watchOptions, handler) {
        this.startTime = null;
        ...
        this.compiler = compiler;
        this.compiler.readRecords(err => {
            if(err) return this._done(err);

            this._go();
        });
    }
}

在这边需要注意的是 startTime 每次编译执行时 _go 方法将被调用,调用时会赋值编译启动时间,该时刻在认定文件是否需要再次编译或者是否变更时非常非常重要!

源码追溯 1 源码追溯 2

首次编译初始化

当如上 this._go() 被执行时,即开始了首次的编译过程

_go() {
    this.startTime = Date.now();
    this.running = true;
    this.invalid = false;
    this.compiler.applyPluginsAsync("watch-run", this, err => {
        if(err) return this._done(err);
        const onCompiled = (err, compilation) => {
          ...
          this.compiler.emitAssets(compilation, err => {
            ...
            return this._done(null, compilation);
          });
        };
        this.compiler.compile(onCompiled);
    });
}

敲黑板: 注意此时 startTime 被正式赋值为 首次构建编译开始的时间,同时 compile 的执行标志着首次编译的开始。

此次文章并不会涉及 webpack 的事件流,以及编译过程中 loaders 和 plugins 等的流转过程,这边我们只需要知道,执行 compile 后进入了编译流程即可。

由代码可以看出在正常流程下正常编译流程完毕后,调用 _done 方法。

_done(err, compilation) {
  ...
    const stats = compilation ? this._getStats(compilation) : null;
  ...
    this.compiler.applyPlugins("done", stats);
    ...
    if(!this.closed) {
        this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
    }
}

在 compilation 对象中我们可以获取到和构建相关所有的依赖,而这些依赖真是需要去监听的内容。

源码追溯

正式开启文件监听

上个过程中我们可以看到最后我们把构建依赖,传递给了 watch 的方法。

watch(files, dirs, missing) {
    this.pausedWatcher = null;
    this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, (err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) => {
        ...
        this.invalidate();
    }, (fileName, changeTime) => {
        this.compiler.applyPlugins("invalid", fileName, changeTime);
    });
}

这里我们注意到 watch 实际调用的是 compiler.watchFileSystem.watch。看过源码的可能会很好奇,因为在 Compiler 的源码中没有定义过这个原型链上的方法。原因很简单,因为在 webpack(webpackConfig) 的阶段中,webpack 注入很多内部的自有插件,webpack 源码非常让人值得学习的一点就是插件机制应用的如火纯情。具体我们可以看到这 webpack.js,而通过这个线索我们找到了 NodeEnvironmentPlugin,开始有所眉目我们看到了熟悉的 watch 字眼 NodeWatchFileSystem,通过它进而我们终于找到了 NodeWatchFileSystem 兴奋之余 watch 服务最终的启动者 watchpack 也浮出水面。

题外话: 这边比较有趣的是 NodeEnvironmentPlugin 这个 plugin,在这个 plugin 中默认设置了 NodeOutputFileSystem NodeJsInputFileSystem CachedInputFileSystem,以 NodeOutputFileSystem 为例,在 webpack 默认情况下编译完成后文件内容都会通过 io 输出到实际的文件目录中,但是毕竟涉及 io 操作这种性能并不能满足调试的需求,所以在 webpack-dev-middleware 中会将 NodeOutputFileSystem 原本默认的 fs 替换为 memory-fs 进而 boost performance。另外 CachedInputFileSystem 等也是通过本地构建的缓存文件物理加速。由于这些内容并不是本文重点,所以不再展开,有兴趣的同学可以继续深挖。

const Watchpack = require("watchpack");

class NodeWatchFileSystem {
    constructor(inputFileSystem) {
        this.inputFileSystem = inputFileSystem;
        this.watcherOptions = {
            aggregateTimeout: 0
        };
        this.watcher = new Watchpack(this.watcherOptions);
    }
    watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
      ...
      const oldWatcher = this.watcher;
        this.watcher = new Watchpack(options);
        ...
        if(callbackUndelayed)
            this.watcher.once("change", callbackUndelayed);
        this.watcher.once("aggregated", (changes, removals) => {
          ...
          const times = this.watcher.getTimes();
            callback(null,
                changes.filter(file => files.indexOf(file) >= 0).sort(),
                changes.filter(file => dirs.indexOf(file) >= 0).sort(),
                changes.filter(file => missing.indexOf(file) >= 0).sort(), times, times);
    });
    ...
    this.watcher.watch(files.concat(missing), dirs.concat(missing), startTime);

        if(oldWatcher) {
            oldWatcher.close();
        }
        ...
    }
}

基于 webpack 的源码不难发现最终 watch 交由的是 Watchpack 实例的 watch 方法。

接下来我们看到

Watchpack.prototype.watch = function watch(files, directories, startTime) {
    this.paused = false;
    var oldFileWatchers = this.fileWatchers;
    var oldDirWatchers = this.dirWatchers;
    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);
    oldFileWatchers.forEach(function(w) {
        w.close();
    }, this);
    oldDirWatchers.forEach(function(w) {
        w.close();
    }, this);
};

这边对 webpack 不是很熟悉的同学可能会比较困惑为什么 file 和 dir 需要进行区分 watch,默认情况下,通过 webpack resolve 后我们能拿到每个模块精确的路径地址,但是在一些特别的用法下,比如使用 require.context(path) 就会对该 path 所对应的目录加以监听。

所以在一般业务场景下只会涉及到 this._fileWatcher

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
    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;
};

根据如上代码我们可以获知 watcherManager.watchFile(file, this.watcherOptions, startTime) 返回了 一个 watcher
_fileWather 根本上是对返回的 watcher 做了一次事件绑定。

那我们看看 watcherManager.watchFile(file, this.watcherOptions, startTime) 到底创建了一个怎么样的 watcher。

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];
};

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);
};

Step1: this.getDirectoryWatcher(directory, options)
如上所知不管是传入的内容是 file 路径还是 directory 路径,都会被转到 getDirectoryWatcher

言下之意就是一个目录下所有的文件都会被对应到一个 directoryWatcher。

在新建一个 DirectoryWatcher 的实例时

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.options = options;
    this.path = directoryPath;
    this.files = Object.create(null);
    this.directories = Object.create(null);
    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: 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 = Object.create(null);
}

可以发现,webpack watch 文件夹变更的能力实际输出者为 chokidar
并且对 directoryPath 对应的 chokidar watcher,绑定 addaddDirchangeunlinkunlinkDirerror 等事件。
并执行了 this.doInitialScan();

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));
};

根据如上代码我们可以获知,在执行首次扫描时,会把当前文件夹下的内容读取出来。对文件则进行 this.setFileTime(itemPath, +stat.mtime, true);

这边不对 setFileTime 做过多阐述,他有两种使用场景。

一种来源于 initialScan 会把所有的文件的最新修改时间全部读取出来,为之后判断文件变更触发更新提供依据。另外一个场景就是触发更新了。

Step2: directoryWatcher.watch((p, startTime))

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() {
        var idx = this.watchers[withoutCase(filePath)].indexOf(watcher);
        this.watchers[withoutCase(filePath)].splice(idx, 1);
        if(this.watchers[withoutCase(filePath)].length === 0) {
            delete this.watchers[withoutCase(filePath)];
            if(this.path === filePath)
                this.setNestedWatching(false);
        }
        if(--this.refs <= 0)
            this.close();
    }.bind(this));
    this.watchers[withoutCase(filePath)].push(watcher);
    var data;
    if(filePath === this.path) {
        this.setNestedWatching(true);
        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) {
            var ts = data[0] === data[1] ? data[0] + FS_ACCURACY : data[0];
            if(ts >= startTime)
                watcher.emit("change", data[1]);
        } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
            watcher.emit("remove");
        }
    }.bind(this));
    return watcher;
};

该代码记录了一个 filepath 创建一个 Watcher 的过程,最后返回了该 wathcer。

所以再反观

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
    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;
};

我们就可以知道,这边是对每个文件绑定了一个 change 和 remove 事件。

文件发生变更后,最初会被 directoryWatcher 监听到,进而触发对应的 fileWatcher 的 change 事件。

_onChange 会被调用

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 事件, 该事件由在 NodeWatchFileSystem 中绑定。

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

this.watcher.once("aggregated", (changes, removals) => {
    changes = changes.concat(removals);
    if(this.inputFileSystem && this.inputFileSystem.purge) {
        this.inputFileSystem.purge(changes);
    }
    const times = this.watcher.getTimes();
    callback(null,
        changes.filter(file => files.indexOf(file) >= 0).sort(),
        changes.filter(file => dirs.indexOf(file) >= 0).sort(),
        changes.filter(file => missing.indexOf(file) >= 0).sort(), times, times);
});

那如何触发重编译呢?答案在 aggregated 事件中。

function example(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) => {
        this.pausedWatcher = this.watcher;
        this.watcher = null;
        if(err) return this.handler(err);

        this.compiler.fileTimestamps = fileTimestamps;
        this.compiler.contextTimestamps = contextTimestamps;
        this.invalidate();
}

触发 invalidate 事件,因为 _go 事件再次被执行。

invalidate(callback) {
    if(callback) {
        this.callbacks.push(callback);
    }
    if(this.watcher) {
        this.pausedWatcher = this.watcher;
        this.watcher.pause();
        this.watcher = null;
    }
    if(this.running) {
        this.invalid = true;
        return false;
    } else {
        this._go();
    }
}