webpack 中的 watch & cache (上)

咱們在平常使用 webpack 或者是在以它爲基礎開發的時候,可能更多的時候關注的是配置以及配置的插件開發。在平常的開發過程當中,會發現 watch 狀態下的編譯流程有一個規律是,第一次會較爲緩慢,後續的編譯會很快速,看起來像是有緩存的控制,那麼具體內部的緩存流程存在哪些節點呢?下面進行一些探索總結,但願能爲平常的插件 pluginloader 開發起到幫助。javascript

webpack --watch

對於 cache 使用的入口,其實在咱們平常構建中,大可能是藉助 webpack 啓動一個構建 watch 服務java

入口

最普通的相比於 webpack 不帶參數直接執行的方式, webpack --watch 的執行邏輯存在較爲明顯的區別。node

webpack/bin/webpack.js:

if(options.watch) {
  var primaryOptions = !Array.isArray(options) ? options : options[0];
  var watchOptions = primaryOptions.watchOptions || primaryOptions.watch || {};
  if(watchOptions.stdin) {
    process.stdin.on('end', function() {
      process.exit(0); // eslint-disable-line
    });
    process.stdin.resume();
  }
  compiler.watch(watchOptions, compilerCallback);
} else
  compiler.run(compilerCallback);

從執行文件中 webpack/bin/webpack.js 找到 --watch 邏輯,相比於直接 webpack 不帶參數執行對應的是 compiler.run 方法,--watch 則對應的是 compiler.watch 方法。webpack

除了 webpack --watch 調用,這裏還能夠關聯一下在平常使用中很日常的 webpack-dev-middleware 模塊。git

webpack-dev-middleware/middleware.js:

if(!options.lazy) {
  var watching = compiler.watch(options.watchOptions, function(err) {
    if(err) throw err;
  });
}

從代碼能夠看到,在非 lazylazy 模式指的是根據請求來源狀況來直接調用 compiler.run 進行構建)模式下,實際上也是一樣經過 compiler.watch 方法進行文件的監聽編譯。印證了前面的github

大可能是藉助 webpack 啓動一個構建 watch 服務web

更準確的說法是,經過 compiler.watch 來建立 watch 服務。npm

如圖對應上文不一樣調用方式之間的差別。數組

watch 編譯生命週期

上面小結的內容,在整個 webpack 的過程當中,是處在完成 compiler = webpack(config) 函數調用以後,獲得一個 Compiler 實例以後,進行正式編譯流程以前的節點,詳細的編譯流程文章推薦 [][]Webpack 源碼(二)—— 如何閱讀源碼細說 webpack 之流程篇 ,後續咱們也會不斷輸出一些細節實現的文章。緩存

對於 watch 這種須要不斷進行觸發編譯的流程的狀況,會出現不斷重複地經歷幾個相同流程,能夠稱之爲 watch 的 生命週期,而 cache 的出現和使用一樣也融入了在這個生命週期中。

  1. 生成 Watching 實例 watching,將編譯流程控制交給 watching

    webpack/lib/Compiler.js
    
    Compiler.prototype.watch = function(watchOptions, handler) {
      this.fileTimestamps = {};
      this.contextTimestamps = {};
      var watching = new Watching(this, watchOptions, handler);
      return watching;
    };

    不管是 webpack --watch,仍是 webpack-dev-middleware 模塊,都是調用 compiler.watch 方法進行初始化 watch 流程,在 Compiler.prototype.watch 邏輯中,與 Compiler.prototype.run 在方法中完成具體編譯流程不一樣的是,會經過生成 watching 實例來接管具體編譯流程

    1. 構造實例,進行第一次編譯初始化
      watching 做爲 watch 監聽流程中的最上層對象,知足了 watch 流程在邏輯最上層的各個階段銜接。

      webpack/lib/Compiler.js
      
      function Watching(compiler, watchOptions, handler) {
        this.startTime = null;
        this.invalid = false;
        this.error = null;
        this.stats = null;
        this.handler = handler;
        if(typeof watchOptions === "number") {
          this.watchOptions = {
            aggregateTimeout: watchOptions
          };
        } else if(watchOptions && typeof watchOptions === "object") {
          this.watchOptions = Object.create(watchOptions);
        } else {
          this.watchOptions = {};
        }
        this.watchOptions.aggregateTimeout = this.watchOptions.aggregateTimeout || 200;
        this.compiler = compiler;
        this.running = true;
        this.compiler.readRecords(function(err) {
          if(err) return this._done(err);
      
          this._go();
        }.bind(this));
      }

      對於 Watching 構造函數,其實能夠分紅兩個部分

      1. 基礎屬性設置

        1. startTime:執行每次編譯時(Watching.prototype._go 方法調用) ,會賦值編譯啓動時間,在後續文件是否須要再次編譯時,做爲重要根據之一

        2. invalid:代表如今 watching 的調用狀態,例如在 this.runing 爲 true 時,代表運行正常,會賦值該屬性爲 true

        3. error:存放編譯過程的錯誤對象,完成每次編譯後會回傳給 handler 回調

        4. stats :存放編譯過程當中的各個數值,一樣也是會在每次編譯後會回傳給 handler 回調

        5. handler:指的是,每次編譯完執行的回調函數,一個常見的例子是每次編譯完在命令行中出現的資源列表就是經過這個函數實現

        6. watchOptionswatch 調用參數設置,其中 aggregateTimeout 參數表明的是每一次文件(夾)變化後在 aggregateTimeout 值內的變化都會進行合併發送

        7. compiler:生成 watching 對象的 Compiler 實例

        8. runningwatching 實例的運行狀態

      2. 執行初始化編譯
        this._go 調用開始,就會進入 編譯 -> watch監聽編譯 -> 文件變動觸發編譯 -> 編譯 的循環

    2. 執行編譯
      做爲執行編譯的入口 Watching.prototype._go 函數的結構與 Compiler.prototype.run 的結構相似,都是調用 Compiler 提供的諸如 this.compile 、this.emitAssets 等方法完成編譯過程。

      run 相似,_go 函數一樣會調用 compiler.compile 方法進行編譯,同時在完成 emitAssets (資源輸出)、emitRecords (記錄輸出) 後,也就是完成這一次編譯後,會調用 this.done 方法進行 watch 循環的最後一步

    3. 調用文件監聽
      在完成編譯後,爲了在不重複啓動編譯進程的狀況下,文件改動會自動從新編譯。會在 Watching.prototype._done 中實時監聽文件操做進行編譯。

      Watching.prototype._done = function(err, compilation) {
       // 省略部分流程(結束狀態值設置、結束事件觸發等)
       if(!this.error)
           this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
      };

      這裏在 _done 的最後一個步驟,會調用 Watching.prototype.watch 來進行文件監聽:

      Watching.prototype.watch = function(files, dirs, missing) {
       this.watcher = this.compiler.watchFileSystem.watch(files, dirs, missing, this.startTime, this.watchOptions, function(err, filesModified, contextModified, missingModified, fileTimestamps, contextTimestamps) {
           this.watcher = null;
           if(err) return this.handler(err);
      
           this.compiler.fileTimestamps = fileTimestamps;
           this.compiler.contextTimestamps = contextTimestamps;
           this.invalidate();
       }.bind(this), function() {
           this.compiler.applyPlugins("invalid");
       }.bind(this));
      };

    Watching.prototype.watch 經過 compiler.watchFileSystemwatch 方法實現,能夠大體看出在文件(夾)變化觸發編譯後,會執行傳遞的回調函數,最終會調用 Watching.prototype.invalidate 進行編譯觸發:

    Watching.prototype.invalidate = function() {
        if(this.watcher) {
            this.watcher.pause();
            this.watcher = null;
        }
        if(this.running) {
            this.invalid = true;
            return false;
        } else {
            this._go();
        }
    };

    到了 Watching.prototype.invalide 這個方法後,又去從 Watching.prototype._go 函數開始進行新一輪的編譯,到這裏整個 watch 的流程就串起來了。

在進入 watchFileSystem 以前,回顧上面的整個流程,webpack 中的 watch 流程大體就是 Watching.prototype._go -> Watching.prototype.watch -> Watching.prototype.invalidate 三個函數循環調用的過程。銜接初始化截圖,大體以下圖。

後續主要對 監聽觸發 兩個部分所涉及的一些細節進行深刻。

watchFileSystem

由上面內容看出對於 Watching.prototype.watch 實現文件監聽的核心是 compiler.watchFileSystem 對象的 watch 方法。 watchFileSystemwebpack 中經過 NodeEnvironmentPlugin 來進行加載

webpack/lib/node/NodeEnvironmentPlugin.js

var NodeWatchFileSystem = require("./NodeWatchFileSystem");

NodeEnvironmentPlugin.prototype.apply = function(compiler) {
    compiler.inputFileSystem = new NodeJsInputFileSystem();
    var inputFileSystem = compiler.inputFileSystem = new        CachedInputFileSystem(compiler.inputFileSystem, 60000);
    compiler.resolvers.normal.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.context.fileSystem = compiler.inputFileSystem;
    compiler.resolvers.loader.fileSystem = compiler.inputFileSystem;
    compiler.outputFileSystem = new NodeOutputFileSystem();
    compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
    compiler.plugin("run", function(compiler, callback) {
        if(compiler.inputFileSystem === inputFileSystem)
            inputFileSystem.purge();
        callback();
    });
};

這裏會設置不少的 fileSystem ,而這樣作的好處能夠關聯到前面的 webpack-dev-middleware 模塊,在本地調試等對編譯性能有較高要求的場景下,須要儘可能利用緩存的速度,而 webpack-dev-middleware 將物理 io 切換成緩存設置,經過修改 fileSystem 來實現。

webpack-dev-middleware/middleware.js

var fs = new MemoryFileSystem();

// the base output path for web and webworker bundles
var outputPath;

compiler.outputFileSystem = fs;
outputPath = compiler.outputPath;

compileroutputFileSystem 設置成內存 (MemoryFileSystem) 的方式,將資源編譯文件不落地輸出,大大提升編譯性能。在 webpack 中存在文件系統的抽象處理,方便一些優秀的文件系統處理模塊功能(例如讀取緩存、內存讀寫)接入利用。

例如 webpack 默認採用的是 graceful-fs,自己基於 Node.js 中的 fs 模塊進行了許多優化,而 webpack-dev-middleware 則是採用內存讀取的 memory-fs

對照 NodeEnvironmentPlugin 的代碼,能夠看到 watchFileSystem 指向的是同目錄下的 NodeWatchFileSystem.js 導出的構造函數生成的實例。

webpack/lib/node/NodeWatchFileSystem.js

var Watchpack = require("watchpack");

function NodeWatchFileSystem(inputFileSystem) {
    this.inputFileSystem = inputFileSystem;
    this.watcherOptions = {
        aggregateTimeout: 0
    };
    this.watcher = new Watchpack(this.watcherOptions);
}

NodeWatchFileSystem.js 中的實現再一次的依賴 watchpack 完成。經過封裝 watchpack 的監聽邏輯,完成綁定相應的文件變動事件,進行上層 compiler.invalidate 方法調用,觸發再次編譯流程。

webpack/lib/node/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略異常處理
  
    if(callbackUndelayed)
        this.watcher.once("change", callbackUndelayed);

    this.watcher.once("aggregated", function(changes) {
        // 省略具體流程
        callback(...);
    }.bind(this));
      
      this.watcher.watch(files.concat(missing), dirs, startTime);
     // 省略返回
}

這裏的 callback 就是 Watching.prototype.watch 方法中調用 this.compiler.watchFileSystem.watch 傳遞的回調函數,當用戶觸發了 watchpack 提供的文件(夾)變化事件,那麼就會經過 callback 回調中 Watching.prototype.invalidate 進行再次編譯。在進入 watchpack 細節以前總結一下 watch 調用層級。

webpack 中的 watch 調用,每一層都叫作 watch 方法,在每個 watch 方法中,都經過逐步對下一層的依賴調用,完成從 watching 實例與 watcher 實例的銜接解耦。

  • watching 層,完成對從新編譯的回調綁定

  • watchfileSystem 層,完成對下層監聽文件(夾)觸發邏輯以後信息返回的過濾處理,以及對上層回調的調用

  • watcer 層,只負責對文件(夾)的變化的事件監聽

經過多個層級的劃分,解耦邏輯,方便函數進行調整和功能橫向擴展。

watchpack 監聽

由上面 NodeWatchFileSystem.js 的代碼截斷中能夠看到,對應的 watch 方法,核心邏輯是 watchpack 的實例 watcher 對應的 watch 方法。直接找到對應的 Watchpack.prototype.watch 方法

watchpack/lib/watchpack.js

var watcherManager = require("./watcherManager");
Watchpack.prototype.watch = function watch(files, directories, startTime) {
    this.paused = false;
    // 省略 old watchers 處理
  
    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);

};

銜接上一層在 NodeWatchFileSystem.jsthis.watcher.watch(files.concat(missing), dirs, startTime); 的調用,在 watchpack 實例的 watch 方法中能夠看到會針對 文件文件夾 類型分別調用 watcherManager.watchFilewatcherManager.watchDirectory進行監聽。

watchpack/lib/watcherManager.js

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

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都傳遞了同類型的參數調用了 this.getDirectoryWatcher ,並在隨後調用了返回實例的 watch 方法,並將 watch 方法的返回結果繼續往上層 watchpack.jsthis._fileWatcherthis._dirWatcher 方法進行傳遞。

watchpack/lib/watcherManager.js

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

getDirectoryWatcher 的具體實現,則是建立一個由 ./DirectoryWatcher 導出的構造函數所構造出來的實例。這裏能夠看到以文件夾路徑(directory) 和配置 (options)兩個屬性做爲實例的 key 而且在函數最後,將實例進行返回。

整個邏輯經過 watchManager 進行底層邏輯建立,經過 _dirWatcher_fileWatcher 完成對底層邏輯的處理封裝。

DirectoryWatcher 實例建立

緊接着 wacthManagerwatchFilewatchDirectorygetDirectoryWatcher 調用完成後,則調用實例的 watch 方法,邏輯就走到了 DirectoryWatcher.js 文件。關聯在 getDirectoryWatcher 的實例生成過程,對應 DirectoryWatcher 的構造函數

watchpack/lib/DirectoryWatcher.js

var chokidar = require("chokidar");

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.path = directoryPath;
    this.files = {};
    this.directories = {};
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        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 = {};
    this.refs = 0;
}

找到這裏,能夠看到,監聽文件(夾)採用的是 chokidar 的能力。關聯前面的邏輯,能夠大體看出,經過 chokidar 綁定對應 directoryPath 的目錄的 addaddDirchangeunlinkunlinkDir 的事件,經過對應的事件回調函數來向上層邏輯傳遞文件(夾)變動信息。

除了 watcher 對應 chokidar 對象,這裏還有一些輔助的屬性來完成監聽處理邏輯

  • files:保存文件改變狀態(mtime)

  • directories:保存文件夾監聽狀態,以及嵌套文件夾監聽實例

  • initialScan:初次文件掃描標識

  • nestedWatching:是否存在嵌套文件夾監聽

  • initialScanRemoved: 首次查看過程當中刪除的文件(夾),對在首次查看過程當中對已刪除文件(夾)的過濾

  • watchers:以監聽路徑(filePath) 爲 key 的 watcher 數組爲值的 map 對象

  • refswatchers 的數量

在屬性複製完成後,會相似 Compiler.jsWatching 實例在實例建立時會進行首次編譯同樣,會進行首次文件夾的查看(doInitalScan) ,這裏會進行初始數據(this.filesthis.directories)的生成。

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

這裏是一個 async.forEach 撐起的函數結構,主要對傳入 directoryPath 下的文件(夾)經過 setFileTimesetDirectory 進行 DirectoryWatcher 實例的 filesdirectories 屬性賦值。

  • 對於文件狀況 (stat.isFiletrue) :

    調用 `setFileTime` 函數傳入文件最後修改時間( `stat.mtime`),函數自己分爲兩個步驟,而這裏主要是**存儲文件的變動記錄**,而另外一部則是**變動事件的觸發**,在後面的內容也會提到。
    DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      var now = Date.now();
      var old = this.files[filePath];
      this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      // 省略變動觸發
    };

    這裏會以數組的形式,存儲 變動流程執行時間點文件最後修改時間點
    通常 setFileTime 的調用的時候,就認爲觸發了文件觸發了變動,進行文件變動記錄更新,而對於初始化狀況,主要目的是爲了初始化數據,並不爲變動而調用 setFileTime,因此對於初始化的返回是進行比較 Math.min(now, mtime) 而不是直接返回當前時間。

  • 對於文件夾狀況(stat.isDirectorytrue

    調用 setDirectory 來進行子文件夾標記,方便後續進行子文件夾監聽的建立:

    DirectoryWatcher.prototype.setDirectory = function setDirectory(directoryPath, exist, initial) {
    var old = this.directories[directoryPath];
    if(!old) {
      if(exist) {
    if(this.nestedWatching) {
      this.createNestedWatcher(directoryPath);
    } else {
      this.directories[directoryPath] = true;
    }
      }
    } 
    // 省略文件夾刪除事件觸發
    }

    doInitalScan 的場景下,會判斷 nestedWatching 的狀況,若是爲 false 則賦值 this.directories[directoryPath]true,表示文件夾沒有建立對應的監聽;或者是經過 this.createNestedWatcher 進行子文件夾監聽的建立,最終也會賦值到 this.directories[directoryPath] 上的則是對應的內嵌 Watcher 實例。而這裏的子文件夾的狀態在後續也是可能發生變化的。
    完成賦值過程後, 會將 this.initialScan 設置成 false 表示首次查看結束,設置 this.initialScanRemovednull ,表示在首次查看過程當中就刪除的文件(夾)的處理也結束。

在完成基礎 this.watcher 文件系統監聽邏輯(chokidar )建立,基礎屬性 this.filesthis.directories 初始化後,則完成了整個 DirectoryWatcher 實例的生成。

搭建監聽通道(建立內部 Watcher 實例)

getDirectoryWatcher 完成調用返回 DirectoryWatcher 的實例以後,調用實例的 watch 方法,傳入文件(夾)路徑。對最上層 Compiler 傳入的 filesmissings 文件,dirs 文件夾進行循環調用,進行監聽流程。watch 方法經過三個階段完成底層到上層的監聽信息通道的搭建。

  1. 生成 Watcher 實例
    第一個部分是針對傳入的路徑生成對應的 Watcher 實例,最終經過 WatcherManagerwatchFilewatchDirectory 返回到上層 watchpack 中的 watch 方法中 this._fileWatcherthis._dirname調用的返回結果,就是這個內部 Watcher 實例。

    watchpack/lib/DirectoryWatcher.js
    
    function Watcher(directoryWatcher, filePath, startTime) {
        EventEmitter.call(this);
        this.directoryWatcher = directoryWatcher;
        this.path = filePath;
        this.startTime = startTime && +startTime;
        this.data = 0;
    }
    
    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() {
        // 省略 closed 事件處理
      }.bind(this));
      
      this.watchers[withoutCase(filePath)].push(watcher);
      // 省略設置子文件內嵌監聽
      // 省略已有數據處理
      return watcher;  
    };

    這裏內部 Watcher 實例主要是經過繼承 EventEmitter 來實現實例的事件支持,那麼傳遞迴上層例如 watchpack 時,就能夠綁定該 Watcher 實例的事件,底層的文件改動觸發實例的事件,上層對事件處理,經過這個對象創建數據傳遞的通道,完成監聽數據的傳遞。在完成 watcher實例建立後,會將實例 pushthis.watchers 中以 filePath 爲 key 的 watcher 數組,並將實例返回。

  2. 設置子文件夾內嵌監聽
    watch 方法的另外一部分,則是進行設置內嵌監聽 setNestedWatching

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略內部 Watcher 實例生成
        var data;
        if(filePath === this.path) {
            this.setNestedWatching(true);
        }
          // 省略已有數據處理
    };
    
    DirectoryWatcher.prototype.setNestedWatching = function(flag) {
        if(this.nestedWatching !== !!flag) {
            this.nestedWatching = !!flag;
            if(this.nestedWatching) {
                Object.keys(this.directories).forEach(function(directory) {
                    this.createNestedWatcher(directory);
                }, this);
            } else {
                Object.keys(this.directories).forEach(function(directory) {
                    this.directories[directory].close();
                    this.directories[directory] = true;
                }, this);
            }
        }
    };

    在處理 filePath == this.path 的時候,也就是 DirectoryWatcher.prototype.watch 傳入的路徑與 Directory 生成實例的路徑相同的時候(watchManager.js 中的 watchDirectory 方法的調用 this.getDirectoryWatcher(directory, options).watch(directory, startTime) 知足此條件)會在 watch 中調用 DirectoryWatcher.prototype.setNestedWatching 進行子文件夾的監聽的建立。

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.createNestedWatcher = function(directoryPath) {
      this.directories[directoryPath] = watcherManager.watchDirectory(directoryPath, this.options, 1);
      this.directories[directoryPath].on("change", function(filePath, mtime) {
        if(this.watchers[withoutCase(this.path)]) {
          this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(w.checkStartTime(mtime, false)) {
              w.emit("change", filePath, mtime);
            }
          });
        }
      }.bind(this));
    };

    子文件夾的監聽一樣是經過上層watchManager.js 中的 watchManager.watchDirectory 的調用實現,同時這裏會多綁定一次 change 事件,實現當子文件夾變化的時候觸發父文件夾的 change 事件。

  3. 處理已有數據
    在完成 watcher 實例建立以後,會針對在 watch 實例建立過程中發生的文件(夾)變更進行處理,保證文件的變更能完備更新

    watchpack/lib/DirectoryWatcher.js
    
    DirectoryWatcher.prototype.watch = function watch(filePath, startTime) {
        // 省略內部 Watcher 實例生成
        var data;
          if(filePath === this.path) {
          // 省略設置子文件內嵌監聽
          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) {
            if(data[0] > startTime)
              watcher.emit("change", data[1]);
          } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
            watcher.emit("remove");
          }
        }.bind(this));
    };

    處理已有數據也是分紅兩個步驟

    1. 讀取數據
      這裏對於文件、文件夾的處理,獲取數據的方式也不一樣。
      對於監聽文件夾路徑的狀況:

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

      能夠從對 this.files 的循環看出,這裏其實是取到的是該文件夾下全部文件中的變動流程執行時間點文件最後修改時間點 的最大值。
      對於單個文件路徑的狀況:

      data = this.files[filePath];

      則是直接取到當前監聽文件路徑的數據。

    2. 觸發事件
      當數據完成獲取後,就進入到 觸發事件 的階段,這個階段會將前面取到的 變動流程執行時間點 與由 Watching.prototype._go 中設置的編譯開始時間 startTime 進行比較:

      process.nextTick(function() {
      if(data) {
        if(data[0] > startTime)
       watcher.emit("change", data[1]);
      } else if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }
      }.bind(this));

      變動流程執行時間點startTime 時間晚的時候說明,在編譯開始後,針對文件夾的狀況是文件夾其中的文件發生了變化,對於單個文件的狀況,則是該文件發生變化。則觸發 change 事件。
      這裏還會有一個判斷是:

      if(this.initialScan && this.initialScanRemoved.indexOf(filePath) >= 0) {
        watcher.emit("remove");
      }

      對於第一個條件 this.initialScan,上面提到在完成 doInitialScan 完成後會複製爲 false

      完成賦值過程後, 會將 this.initialScan 設置成 false 表示首次查看結束,設置 this.initialScanRemovednull ,表示在首次查看過程當中就刪除的文件(夾)的處理也結束

      則這條判斷是在 watch 進行的同時,doInitialScan 也還在進行的時候生效。
      對於第二個條件 this.initialScanRemoved.indexOf(filePath) ,這裏主要落腳點在於 initialScanRemoved 對這個數組的操做

      watchpack/lib/DirectoryWatcher.js
       
      this.watcher.on("unlink", this.onFileUnlinked.bind(this));
      this.watcher.on("unlinkDir", this.onDirectoryUnlinked.bind(this));
       
      DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
        // 省略判斷
        if(this.initialScan) {
         this.initialScanRemoved.push(filePath);
        }
      };
       
      DirectoryWatcher.prototype.onDirectoryUnlinked = function onDirectoryUnlinked(directoryPath) {
        // 省略判斷
        if(this.initialScan) {
         this.initialScanRemoved.push(directoryPath);
        }
      };

      從事件綁定中能夠看到,當在進行 doInitialScan 過程當中,發生了文件(夾)刪除的狀況,則會將刪除的路徑 pushinitialScanRemoved 數組中。
      那麼整合兩個條件,在初始掃描的場景下,監聽文件(夾)發生刪除的狀況時,則觸發 remove 事件,避免增長無效的監聽。

在整個數據監聽通道的流程中,都是圍繞 Watcher 實例進行開展,經過 Watcher 承上啓下銜接上下邏輯的做用。

觸發流程

在完成了從 Watchpack.prototype.watch -> WatcherManager.prototype.watchFileWatcherManager.prototype.watchDirectory -> Directory.prototype.watch 這條調用鏈以後,webpack --watch 就會等待文件的改動,進行編譯的再次觸發。

chokidar

目前 watchpack 中對文件(夾)的監聽經過 chokidar 來實現,首先關聯的邏輯就是 chokidar 的具體調用,關注到 DirectoryWatcher 中調用 chokidar 的部分

watchpack/lib/DirectoryWatcher.js

function DirectoryWatcher(directoryPath, options) {
    EventEmitter.call(this);
    this.watcher = chokidar.watch(directoryPath, {
        ignoreInitial: true,
        persistent: true,
        followSymlinks: false,
        depth: 0,
        atomic: false,
        alwaysStat: true,
        ignorePermissionErrors: true,
        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));
}

首先是 chokidar 的初始化,

  • ignoreInitial:默認爲false, 設置爲 true ,避免在 chokidar 自身初始化的過程當中觸發 addaddDir 事件

  • persistent:默認爲 true,設置爲 true,保持文件監聽,爲 false 的狀況下,會在 ready 事件後再也不觸發事件

  • followSymlinks:默認爲 true,設置爲 false,對 link 文件不監聽真實文件內容的變化

  • depth: 設置爲 0 ,代表對子文件夾不進行遞歸監聽

  • atomic:默認爲 false,設置爲 false,關閉對同一文件刪除後 100ms 內從新增長的行爲觸發 change 事件,而不是 unlinkadd 事件的默認行爲

  • alwaysStat:默認爲false,設置爲 true,保持傳遞 fs.Stats,即便可能存在不存在的狀況

  • ignorePermissionErrors:默認爲 false,設置爲 true,忽略權限錯誤的提示

  • usePolling:默認爲 false,根據實際配置來設置,是否開啓 polling 輪詢模式

  • interval:輪詢模式的週期時間,根據實際配置來設置,輪詢模式的具體時間

其次綁定對應的文件(夾)事件 addaddDirchangeunlinkunlinkDir

完成初始化和事件綁定後,經過各個事件的回調函數來進行監聽邏輯的觸發和向上層傳遞。

文件時間精確度數值(FS_ACCURENCY)肯定

根據上面提到的 this.watcher.on("change", this.onChange.bind(this)); 當文件內容發生變化時,進入綁定的 onChange 回調函數

watchpack/lib/DirectoryWatcher.js

var FS_ACCURENCY = 10000;

DirectoryWatcher.prototype.onChange = function onChange(filePath, stat) {
    if(filePath.indexOf(this.path) !== 0) return;
    if(/[\\\/]/.test(filePath.substr(this.path.length + 1))) return;
    var mtime = +stat.mtime;
    if(FS_ACCURENCY > 1 && mtime % 1 !== 0)
        FS_ACCURENCY = 1;
    else if(FS_ACCURENCY > 10 && mtime % 10 !== 0)
        FS_ACCURENCY = 10;
    else if(FS_ACCURENCY > 100 && mtime % 100 !== 0)
        FS_ACCURENCY = 100;
    else if(FS_ACCURENCY > 1000 && mtime % 1000 !== 0)
        FS_ACCURENCY = 1000;
    else if(FS_ACCURENCY > 2000 && mtime % 2000 !== 0)
        FS_ACCURENCY = 2000;
    this.setFileTime(filePath, mtime, false, "change");
};

onChange 中,除了調用 this.setFileTime 進行文件變動數據更新、對應 watcher 實例事件觸發以外,還會進行 FS_ACCURENCY 的校準邏輯。能夠看到校準的規則是根據文件的修改時間取模的精度來肯定值。關於這個變量值,這裏從 issue 中找到 webpack 做者 sokra 的描述:

FS_ACCURENCY should automatically adjust to your file system accuracy

With low fs accuracy files could have changed even if mime is equal

其中說到,在文件系統數據低精確度的狀況,可能出現 mime 相同,但也發生了改變的狀況。經過在後面的變動判斷中經過加入精確值的度量值計算,起到平衡數值的做用(例如var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;)。

watcher 實例事件觸發

以前提到,watcher 實例是文件變動信息的通道,經過在 watcher 上的事件綁定,將 chokidar 監聽到的文件(夾)變動信息,傳遞到 watchpack 層的邏輯。進入 this.setFileTime 後,則進行對應事件的觸發

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
      
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
               // 文件事件觸發具體邏輯
            }
        }
    } else if(!initial && mtime && type !== "add") {
      // 文件事件觸發具體邏輯
    } else if(!initial && !mtime) {
      // 文件事件觸發具體邏輯
    }
    if(this.watchers[withoutCase(this.path)]) {
      // 文件目錄事件觸發
    }
};

事件觸發分爲兩個大的階段,第一個階段爲對於 filePath 文件的事件觸發,第二個階段爲對於當前 DirectoryWatcher 對應 path 屬性文件夾的事件觸發。

1.filepath 文件的事件觸發

watchpack/lib/DirectoryWatcher.js

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
    var now = Date.now();
    var old = this.files[filePath];
    this.files[filePath] = [initial ? Math.min(now, mtime) : now, mtime];
    if(!old) {
        if(mtime) {
            if(this.watchers[withoutCase(filePath)]) {
                this.watchers[withoutCase(filePath)].forEach(function(w) {
                    if(!initial || w.checkStartTime(mtime, initial)) {
                        w.emit("change", mtime);
                    }
                });
            }
        }
    } else if(!initial && mtime && type !== "add") {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("change", mtime);
            });
        }
    } else if(!initial && !mtime) {
        if(this.watchers[withoutCase(filePath)]) {
            this.watchers[withoutCase(filePath)].forEach(function(w) {
                w.emit("remove");
            });
        }
    }
  
      // 省略文件夾觸發
};

文件事件觸發,實際會涉及到三個邏輯,單純已有文件改變的觸發,對應第二個邏輯

  • 對於 filePath 以前沒有數據設置的狀況 if(!old)

    這裏穿插到前面初始化的邏輯,在前面 `doIntialScan` 中 `initial` 的參數爲 `true`, 則進入 `checkStartTime` 函數判斷
    watchpack/lib/DirectoryWatcher.js
    
    Watcher.prototype.checkStartTime = function checkStartTime(mtime, initial) {
    if(typeof this.startTime !== "number") return !initial;
    var startTime = this.startTime && Math.floor(this.startTime / FS_ACCURENCY) * FS_ACCURENCY;
    return startTime <= mtime;
    };
    會去比較編譯開始時間 `statrTime` 與文件最後修改時間 `mtime` 來判斷是否須要觸發事件,`doInitialScan` 場景下,默認 `FS_ACCURENCY` 的值是 `10000` ,意思是在編譯前的 10s 範圍內的改動都會觸發 `change` 事件,那麼這樣是否會存在初始化時多觸發一次編譯呢?在上面提到  [issue](https://github.com/webpack/watchpack/issues/25) 中,做者一樣給出瞭解釋
       > This may not happen fast enough if you have few files and the files are created unlucky on a timestamp modulo 10s
    
       > 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.
    
       及時觸發這樣的 `unlucky case`,也只會在 `doInitailScan` 過程當中文件內容真正發生變化致使 `hash` 變化的時候再次觸發編譯更新。
    
       這條判斷一樣適用當有新增文件,觸發 `add` 事件的狀況。
  • 對於已有文件變化(非 doInitial 過程當中、add 新增文件事件觸發,if(!initial && mtime && type !== "add")

    對應這種狀況,則直接會觸發 `change` 事件
    if(this.watchers[withoutCase(filePath)]) {
      this.watchers[withoutCase(filePath)].forEach(function(w) {
    w.emit("change", mtime);
      });
    }
    找到對應文件的監聽 `watcher` 觸發 `change` 事件,對應上層邏輯邏輯進行響應。
  • mtime 不存在的狀況(文件刪除)

    watchpack/lib/DirectoryWatcher.js
    
    this.watcher.on("unlink", this.onFileUnlinked.bind(this));
    DirectoryWatcher.prototype.onFileUnlinked = function onFileUnlinked(filePath) {
      // 省略其餘操做
      this.setFileTime(filePath, null, false, "unlink");
    };
當文件刪除觸發 `unlink` 事件時,調用 `setFileTime` 時,則會傳遞 `mtime` 爲 `null`。則事件觸發邏輯與第二種狀況方式相同,只是從 `change` 事件變成了 `remove` 事件。

2.DirectoryWatcher 對應 path 屬性文件夾的事件觸發

DirectoryWatcher.prototype.setFileTime = function setFileTime(filePath, mtime, initial, type) {
      // 省略文件觸發
  
    if(this.watchers[withoutCase(this.path)]) {
        this.watchers[withoutCase(this.path)].forEach(function(w) {
            if(!initial || w.checkStartTime(mtime, initial)) {
                w.emit("change", filePath, mtime);
            }
        });
    }
};

由於是監聽的是文件夾下的文件發生的變化,因此在完成了對應文件事件的觸發以後,會進行監聽文件夾(路徑爲實例化 DirectoryWatcher 時傳入的 this.path)的觸發,這裏除了會將文件的最後修改時間 mtine 傳遞,還會將對應的文件路徑 this.filePath 也當作參數一塊兒傳遞到綁定的事件回調參數中。

在經過 watcher 這個繼承了 EventEmitter 對象的實例觸發事件後,就完成了底層文件(夾)監聽觸發的功能,緊接着就是上層對象對於 watcher 實例的事件觸發的對應處理,最終關聯上 webpack 的編譯啓動流程。

上層響應

watchpack.js

在上面有提到

watcherManager.js 文件中的 watchFile 以及 watchDirectory 都傳遞了同類型的參數調用了 this.getDirectoryWatcher ,並在隨後調用了返回實例的 watch 方法,並將 watch 方法的返回繼續往上層 watchpack.jsthis._fileWatcherthis._dirWatcher 方法。

watch 實例的上層響應的第一層在 watchpack.js 中的 Watchpack.prototype._fileWatcherWatchpack.prototype._dirWatcher 中完成,分別針對文件和文件夾的變動處理

watchpack/lib/watchpack.js

Watchpack.prototype._fileWatcher = function _fileWatcher(file, watcher) {
    watcher.on("change", this._onChange.bind(this, file));
    return watcher;
};

Watchpack.prototype._dirWatcher = function _dirWatcher(item, watcher) {
    watcher.on("change", function(file, mtime) {
        this._onChange(item, mtime, file);
    }.bind(this));
    return watcher;
};

這裏 _fileWatcher_dirWatcherchange 的事件都是將邏輯導向了 Watchpack.prototype._onChange

watchpack/lib/watchpack.js

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 事件,傳入觸發的文件(夾)的路徑,以及最後修改時間,供上層邏輯操做。

而後開始進行 aggregate 邏輯的觸發,能夠看到這裏的大體含義是在文件(夾)發生變動 this.aggregateTimeout 後,進行 Watchpack.prototype._onTimeout 邏輯,在此以前,會將修改的文件(夾)路徑暫存到 aggregatedChanges 數組中

watchpack/lib/watchpack.js

Watchpack.prototype._onTimeout = function _onTimeout() {
    this.aggregateTimeout = 0;
    var changes = this.aggregatedChanges;
    this.aggregatedChanges = [];
    this.emit("aggregated", changes);
};

Watchpack.prototype._onTimeout 則是當最後一次文件(夾)觸發以後沒有變動的 200ms 後,經過 this.aggregatedChanges 將接連不斷的變動聚合經過 aggregated 事件傳遞給上層。

那麼對應每個變動,實際會牽涉觸發一次 change 事件,以及關聯一次 aggregated 事件,傳給給上層,關聯實際的編譯從新觸發邏輯。

NodeWatchFileSystem.js

前面提到

NodeWatchFileSystem.js 中的實現再一次的依賴 watchpack 完成。經過封裝 watchpack 的監聽邏輯,完成綁定相應的文件變動事件,進行上層 compiler.invalidate 方法調用,觸發再次編譯流程。

那麼綁定 watchpack 實例的事件,來完成這一層的邏輯

webpack/lib/NodeWatchFileSystem.js

NodeWatchFileSystem.prototype.watch = function watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {
    // 省略參數合法性檢測
  
    this.watcher = new Watchpack(options);

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

    this.watcher.once("aggregated", function(changes) {
          //1.
        if(this.inputFileSystem && this.inputFileSystem.purge) {
            this.inputFileSystem.purge(changes);
        }
          //2.
        var times = this.watcher.getTimes();
          //3.
        callback(null, changes.filter(function(file) {
            return files.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return dirs.indexOf(file) >= 0;
        }).sort(), changes.filter(function(file) {
            return missing.indexOf(file) >= 0;
        }).sort(), times, times);
    }.bind(this));

    this.watcher.watch(files.concat(missing), dirs, startTime);
  
    // 省略返回
};

與上面 watchpack 觸發事件一致,在 NodeWatchFileSystem 這一層邏輯中,其實對下一層 Watchpack 的就是經過綁定主要的 changeaggregated 事件完成的。

對於 change 事件,會直接傳遞到上層的 callbackUndelayed

對於 aggregated 事件,

  1. 首先會調用 this.inputFileSystem.purge(changes) ,將文件系統中涉及到變動的文件的記錄清空。

  2. 其次調用 Watchpack 實例的 getTimes() 方法獲取監聽文件(夾)的 變動流程執行時間點文件最後修改時間點 的最大值,便於在後續判斷是否須要進行從新編譯,例如 cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);

  3. 最後在調用上層回調以前,會將變化的文件(夾)根據監聽時傳入參數經過挨個過濾的方式進行分發到每一個參數中,完成以後,流程就會走到最後一層也是最初調用監聽的一層 Compiler.js

Compiler.js

在上文中提過

Watching.prototype.watch 經過 compiler.watchFileSystemwatch 方法實現,能夠大體看出在變化觸發編譯後,會執行傳遞的回調函數,最終會調用 Watching.prototype.invalidate 進行編譯觸發

從調用開始,經過最底層的 chokidar 完成文件(夾)監聽事件的觸發,經過事件傳遞的方式,又回到調用處,進行從新編譯。

回顧整個觸發流程,縱向 4 個邏輯層級之間進行傳遞,

  • DirectoryWatcher:完成對文件(夾)的監聽實現,以及初步監聽數據加工

  • watchpack:完成觸發底層邏輯的封裝,實現上層邏輯跟觸發邏輯解耦

  • NodeWatchFileSystem:完成對監聽數據業務邏輯處理,進行最後回調處理

  • Compiler:完成最終業務響應

總結 & 銜接

watch 流程利用事件模型,採用多個邏輯層的設計,對複雜的觸發流程進行解耦拆分,實現了比較清晰可維護的代碼結構。

在完成 watch 流程,觸發從新編譯後,與 run 流程相不一樣的是,webpack 爲了提升編譯速度,下降編譯的時間消耗與提升編譯性能,在從新編譯的不少環節中都設置了緩存機制,讓二次編譯的速度獲得大大提升。下一篇文章主要對 cache 的狀況進行描述。

相關文章
相關標籤/搜索