webpack5源码指南系列之watch系统是如何运转的

1,313 阅读7分钟

最近在看webpack5的源码,此文作为笔记用于记录。内容仅限于watch过程中的事件流转及代码的调用过程,具体实现以及如何编译代码,暂且略过。目的是让需要看源码的同学可以快速厘清整个Watch源码的脉络,后续会补上watch的编译流程

watch部分源码大概有2500行左右,鄙人花了2天时间才看完,看得我头痛。这还只是冰山一角

在了解webpackWatch系统之前,先抛出几个问题让大家思考:

  1. 如果让你来设计watch机制(文件更新 -> 触发回调),你会怎么做?(要求高可用,稳定强,兼容性强)
  2. 你和webpack作者的设计差别在哪?有哪些不足、优点?

对于问题1,我想你可以先把页面关了,花一点时间思考下。这对你的提升将是巨大的,因为这样可以让你深刻体会到自己和大神的差距,是一个很好的锻炼过程。

把心中预想的设计,先简单写下来:

先预想10分钟~~


自行设计watch系统


假设,我这样设计,简单明了,这不是很好吗,浪费那么多那细胞写那几千行代码干啥?


class Watcher{
    constructor(options={},callback){
        this.options = options;
        this.callback = callback;
    }
    watchfile(path){
       this.getFiles(path).forEach(file=>{
           let watcher = fs.watch(file);
           watcher.on('change',this.callback);
       })
    }
    getFiles(path){
        retrun ['./file1.js','../../xxx.peg','./xxx.js','...'];
    }
}

Compiler.watch = (compilation,options)=>{
    let callback = ()=>{
        /*...*/
        compilation.rebuild() 
    };
    return new Watcher(options,callback).watchfile(path);
}

Compiler.watch()

接下来看,我们的设计有没有覆盖以下的场景:

  1. 监听文件的依据是什么,比如工程目录下有各种各样的文件,如何保证只监听需要的文件(不仅仅是js文件)。这里可能想到用到配置,如果用户没配置呢?用默认配置,那默认配置如何设计呢?
  2. 是否考虑平台的兼容性问题,Wath?node.js还有兼容问题?答案是:的确有。fs.watch有的系统不支持
  3. 超大型的目录,需要watch的文件可能有千上万个,是否考虑了其性能问题?
  4. 如何对接其他模块,提供怎样的接口, watch触发回调之后的处理,callback传入什么样的参数。

很明显,以上的设计还有许多需要完善的地方。


解读webpackwatch设计


我们来欣赏下webpack的设计吧~

核心思想是通过继承EventEmitter类,通过订阅发布模型来实现。

主要有类有 Watching,Watchpack,DirectoryWatcher

还有几个辅助类 NodeWatchFileSystem,DirectWatcher,WatcherManager,Watcher ... 主要是起到桥梁的作用。

为了简单起见,我们将主要的类组织起来,大概就是这样:

Watching用于处理文件更新后的各种hooks回调

class Watching {
    constructor(){
        this.startTime = Date.now()
    }
    watch(files){
        let watcher = new Watchpack();
        let callback =  (changes, removals)=>{
             // 执行各种hooks  bala bala
            this.compiler.hooks.watchRun.callAsync(compiler,()=>{
                let onCompiled = ()=>{ // 执行各种hooks  bala bala                  
                    process.nextTick(() => {
                        if (!this.closed) {
                            this.watch(
                                compilation.fileDependencies,
                                compilation.contextDependencies,
                                compilation.missingDependencies
                            );
                        }
                    });
                }
                this.compiler.compile(onCompiled);
            })
        }
        // 源码这里使用 NodeWatchFileSystem 类关联
        watcher.once("aggregated",callback);
        watcher.watch(files, directories, this.startTime);
        return watcher;
    }
}

Watchpack 用于汇总文件信息、管理不同watcher

class Watchpack extends EventEmitter{
    constructor(){
        this.fileWatchers = new Map();
        this.directoryWatchers = new Map();
    }
    watch(files, directories, startTime){
        // 为每个文件添加一个或多个 DirectoryWatcher
        for(let file of files){
            let watcher = new DirectoryWatcher();
            this.fileWatchers.set(file, watcher);
            watcher.on("change", this._onTimeout);
            watcher.watch(file, startTime);
        }
        // for of directories
        // bala bala
        
        // 源码这里的方法调用,没太明白是个啥意思?
        // 后面还有一个watchEventSource.watch()
        watchEventSource.batch()
    }
    _onTimeout() {
        const changes = this.aggregatedChanges;
        const removals = this.aggregatedRemovals;
        this.emit("aggregated", changes, removals);
    }
}

DirectoryWatcher 主要处理单个文件、目录的信息

class DirectoryWatcher extends EventEmitter {
    constructor(){
       this.watchers = new Map();
       this.files = new Map();
       
       this.watcher = watchEventSource.watch(file);
       this.watcher.on("change", this.onWatchEvent);
    }
    watch(file, startTime){
        let watchers = new Set();
        let watcher = new Watcher(startTime);
        watchers.add(watcher);
        this.watchers.set(file, watchers);     
    }
    onWatchEvent(eventType, filename){
        fs.lstat(filePath, (err, stats)=>{
            this.setFileTime(filePath, stats.mtime);
            // this.setDirectory(filePath, eventType);
        })
    }
    setFileTime(filePath, mtime){
        // 记录文件信息
        let safeTime = Date.now();
        this.files.set(filePath, {
            safeTime,
            timestamp: mtime
        })
        let watchers = this.watchers.get(filePath)
        for (const w of watchers) {
           if (w.checkStartTime(safeTime)) {
                // 文件更新后触发的事件源
                w.emit("change", mtime);
            }
        }
       
    }
}
class Watcher extends EventEmitter {
    constructor(startTime){
        this.startTime = startTime
    }
    checkStartTime(mtime) {
        // startTime 编译器创建之后的时间
        // mtime 文件变化时的时间
        const startTime = this.startTime;
        return startTime <= mtime;
    }
}

最终的使用逻辑,大概是这样

const compiler = new Compiler(options.context, options);
compiler.watch = () => {
    return new Watching(this, watchOptions, handler)
}
compiler.watch()

上面就是整个Watch经过简化后的主要逻辑,其设计架构简化后大概是这样:

事件回调处理 <--> 文件汇总处理 <--> 单文件信息处理 <--> 文件修改事件

从左往右是订阅的过程,从右往左是发布的过程。

看源码的时候有个问题一直卡住我,我们知道发布订阅模式,有两个必要的条件那就是:

  1. 先订阅
  2. 再发布

首先,订阅是在Watchpack.watch中,这个没问题,问题是没有地方调用??

经过多次调试之后发现,原来是在new的时候自动调用内部方法实现的,流程大概是这样this._invalidate -> this._done() --> this.watch() --> watcher.once("aggregated",callback)

这里面还有个小小的问题,正常订阅不应该是用on吗?那once是如何实现循环监听的呢?答案其实隐藏callback中,callback就是重复上面的调用过程this._invalidate --> ... ,回调之后会重新注册。

留个小小的疑问:作者为什么不用on来订阅呢?

其次,事件发布在哪里触发的呢?

理论上应该是通过fs.watch来触发的,但也没看到其调用的地方,也就没有一个事件源来触发emit('change'),那上层的订阅是如何触发的呢?

还是经过断点调试才发现,由于fs.watch在某些平台上没有效果,为了兼容这种情况,作者采用了时间轮训的方式来实现,即setTimeout(this.setFileTime,timeout)(简化的代码,源码并不长这样)。最终通过对比缓存的文件信息checkStartTime,来判断是否需要触发事件,将事件源集中在下面这段代码里:

DirectoryWatcher中的w.emit("change", mtime);

那对于可以正常使用fs.watch的平台呢,其实现大概是这样

class DirectWatcher{
    constructor(filePath){
        this.watchers = new Set()
        const watcher = fs.watch(filePath);
        watcher.on("change", (type, filename) => {
            for (const w of this.watchers) {
                // DirectWatcher本身不是EventEmitter对象,没有emit
                // watchEventSource.watch调用后,this.watchers才存放EventEmitter
                w.emit("change", type, filename);
            }
        });

    }
    add(watcher){
        this.watchers.add(watcher);
    }
}

可以看到上面调用了fs.watch,这也正是我们要找的事件源

DirectWatcher中的this.watchers在实例化的时候是没有值的,要通过下面这两个方法watchEventSource.batchwatchEventSource.watch添加继承自EventEmitter类的实例, 这两个方法中的执行过程大家可以自行断点调试下,单看代码还真有点难以理解。

虽然说fs.watch有兼容性问题,但作者其实并没有写任何的兼容性代码,那是如何实现的呢?

具体的做法就是把球踢给用户。

用户:你这watch没效果啊?

作者:你加个配置项试试~~

就这一小小的设计,看完源码只能说两个字:佩服!因为我相信绝大部分人都会通过兼容检测来实现。

最后,订阅发布位于不同的实例对象中,那么事件是如何传递顶层的呢?

通常订阅/发布都是在同一个实例类上完成的,比如:

const eventEmitter = new EventEmitter();

eventEmitter.on(event,callback);
eventEmitter.emit(event,params);

假设有多个实例对象,例如:

const watcher1 = new EventEmitter();
watcher1.on('change',callback);

const watcher2 = new EventEmitter();
watcher2.emit('change',file);

上面这样watcher1显然无法接收到watcher2发出的事件。

webpack的源码可以看出,其事件的订阅发布也是分散在不同的实例中,尤其最后部署fs.watch时,每个文件都有一个或多个watcher

要实现不同事件对象之前的通信,那么必须在中间架设一座桥梁,这也是源码中比较绕的地方。

作者的做法主要是通过实例传参、实例映射2种方式来实现的,比如:

方式1

const watcher1 = new EventEmitter();
watcher1.on('change',callback);

const watcher2 = new EventEmitter(watcher1); 
watcher2.watcher = watcher1;
watcher2.on('change',(file)=>{
    watcher2.watcher.emit('change',file);
});

方式2

const parentWatcher = new EventEmitter();
parentWatcher.on('change',callback);

// parentWatcher 通过方式1传递到当前上下文
const eventMap  = new Map();
for(let file of files){
    let watcher = new EventEmitter();    
    eventMap.set(file, watcher);
    watcher.on('change',(file)=>{
        parentWatcher.emit('change', file);
    });
}

// 事件源change
const watcher = fs.watch('file');
watcher.on('change',(file)=>{
    const watcher = eventMap.get(file);
    watcher.emit('change', file);
})

以上就是整个watch系统的基本原理介绍,想了解更多信息还请看webpack官方源码


watch源码完整流程参考


以下是各个类的方法调用过程,以及类之间的依赖关系。看源码的时候可以参考下面的顺序由浅入深。

ps:不对的地方还请指出来~~

options.watch = true;
webpack(options);

1. Compiler --> new Compiler()  --> Compiler.watchFileSystem = new NodeWatchFileSystem()
    --> Compiler.watch() 
    --> this.watching = new Watching()

2. Watching --> new Watching() --> this._invalidate() --> this._go() --> this._done() 
    --> this.watch() 
    --> Compiler.watchFileSystem.watch(callback) 
   
3. NodeWatchFileSystem --> this.watcher = new Watchpack(watcherOptions)[EventEmitter] 
    --> this.watcher.once('aggregated',callback) 
    --> this.watcher.watch({files, directories})
   
4. Watchpack[EventEmitter] --> this.watcherManager = new WatcherManager()
    --> watcher = new DirectoryWatcher()[EventEmitter].watch()
    --> this.watcherManager.watchFile() --> new WatchpackFileWatcher(watcher)
    --> this.watcherManager.watchDirectory() --> new WatchpackDirectoryWatcher(watcher)  
    --> watcher.on('change',this._onTimeout()) 
    --> watcher.emit('aggregated')

5. DirectoryWatcher[EventEmitter] --> this.watch(path) 
    --> this.watchers = new Map();this.watchers[path] = new Set([new Watcher(), ...])
    --> watchEventSource.watch() --> new [DirectWatcher | RecursiveWatcher](filePath)   
    --> this.on('change',this.onWatchEvent)     
    --> this.doScan() --> this.setFileTime() | this.setDirectory() 
    --> Watcher.checkStartTime() === true --> this.emit("change)
    
6. DirectWatcher --> new DirectWatcher(filePath) --> watcher = fs.watch(filePath) 
      --> watcher.on('change',Watcher.emit('change'))
      
7. RecursiveWatcher --> new RecursiveWatcher(filePath) --> watcher = fs.watch(filePath) 
      --> watcher.on('change',Watcher.emit('change'))

最后,以上是暂时的一些心得,后面如果有新的收获再补上~

参考链接: