webpack 中的 watch & cache (下)

整個 watch 的過程經過事件的機制,完成幾個抽象對象的邏輯串聯,當觸發 Watching.prototype.watch 的調用回調函數時,流程便進入到了另一端,開始進行從新編譯,相較於第一次編譯,在 webpack 中在二次編譯階段利用了不少緩存機制,來加速代碼編譯。javascript

文章描述會涉及到整個 webpack 的編譯流程,有一些細節能夠在這篇文章中 Webpack 源碼(二)—— 如何閱讀源碼 詳細的流程描述圖中查看。這裏會針對 webpack 中涉及緩存的部分和狀況進行梳理。html

part1. 緩存配置初始化

compilation 初始化

在上一篇文章提到過:java

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

Watching.prototype.invalidate 調用後,會再次調用 Watching.prototype._go 方法從新進行編譯流程,而不管在 Watching.prototype._go 方法仍是 Compiler.prototype.run 方法,編譯核心邏輯在 Compiler.prototype.compile 完成。而編譯中第一個緩存設置則就在 Compiler.prototype.compile 中初始化 compilation 中觸發。webpack

webpack/lib/Compiler.js

Compiler.prototype.compile = function(callback) {
    var params = this.newCompilationParams();
    this.applyPlugins("compile", params);

    var compilation = this.newCompilation(params);
  
      // ... 省略具體編譯流程
}

關聯前面的 watch 流程,能夠發現,每次編譯開始,也就是每次由 invalidate -> _go -> compile 這條邏輯鏈觸發編譯的過程當中,都會生成一個 compilation 對象,而實際上 compilation 對象是每單獨一次編譯的「流程中心」「數據中心」,從編譯開始、文件輸出到最後的日誌輸出,都關聯在 compilation 上。git

而在 Compiler.prototype.newCompilation 中,則完成了大部分的 webpack 中緩存機制使用的大部分數據github

webpack/lib/Compiler.js

Compiler.prototype.createCompilation = function() {
    return new Compilation(this);
};
Compiler.prototype.newCompilation = function(params) {
    var compilation = this.createCompilation();
    compilation.fileTimestamps = this.fileTimestamps;
    compilation.contextTimestamps = this.contextTimestamps;
    // 省略其餘屬性賦值、事件觸發
    this.applyPlugins("compilation", compilation, params);
    return compilation;
};

在調用 new Compilation(this) 生成實例以後,開始進行屬性賦值,在 Compiler.prototype.newCompilation 中,主要涉及緩存數據的初始化有兩部分web

文件(夾)變動記錄初始化

webpack/lib/Compiler.js

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 回調函數中 this.compiler.fileTimestamps = fileTimestamps;this.compiler.contextTimestamps = contextTimestamps; 文件(夾)監聽底層的 fileTimestampscontextTimestamps 數據賦值到新生成的 compilation 上。數組

這兩個值,在編譯時觸發編譯模塊實例判斷是否須要從新編譯的 needRebuild 方法中起到做用。緩存

CachePlugin 加載

第三個部分的入口是觸發webpack 編譯流程中的 compilation 事件,事件觸發主要引發 CachePlugin 插件邏輯的加載。

watch 過程當中,會發現一個規律是,編譯時間在編譯第一次以後,後面的編譯會增長不少,緣由是 watch 模式正在流程中,會默認開啓 cache 配置。在 webpackcache 選項則是對應 CachePlugin 的加載:

webpack/lib/WebpackOptionsApply.js

if(options.cache === undefined ? options.watch : options.cache) {
  var CachePlugin = require("./CachePlugin");
  compiler.apply(new CachePlugin(typeof options.cache === "object" ? options.cache : null));
}

那麼在 CachePlugin 中對於 watch 流程中,最重要的一段邏輯則是將 CachePlugincache 屬性與當前編譯 compilation 對象進行關聯

webpack/lib/CachePlugin.js

compiler.plugin("compilation", function(compilation) {
    compilation.cache = this.cache;
}.bind(this));

這樣操做以後,編譯過程 compilation 中的緩存設置,因爲是引用的關係則會使 CachePlugincache 屬性也保持同步。

同時,在完成一次編譯後觸發變動開始下一次編譯的時候,上一次編譯完成後更新完成的 cache 結果經過 compilation 事件的觸發,就能無縫的銜接到下一次的 compilation 對象上,經過 CachePlugin 完成緩存在每次編譯流程中的同步

在後續環節中,對於文件更新判斷,每每基於 contextTimestampsfileTimestamps ,而對於緩存的存儲,則大可能是放在由 cachePlugin 初始化在 compilation 對象中的 cache 屬性上。

part2. 文件路徑查找(resolve)

webpack 編譯流程中,時刻都在處理着文件路徑問題,其中不管是編譯某一個文件,仍是調用某一個 loader ,都須要從配置的各類狀況(多是相對路徑、絕對路徑以及簡寫等狀況)的路徑中找到實際文件對應的絕對路徑。而這裏牽涉到一些耗時的操做,例如會對不一樣的文件夾類型文件類型,以及一些 resolve配置進行處理。

這裏經過在 compiler.resolvers 中的三個 Resolver 實例加載 UnsafeCachePlugin 來針對路徑查找進行結果緩存,在相同狀況(request)下,經過緩存直接返回。

webpack/lib/WebpackOptionsApply.js    

compiler.resolvers.normal.apply(
  new UnsafeCachePlugin(options.resolve.unsafeCache),
  // 省略其餘插件加載
);
compiler.resolvers.context.apply(
  new UnsafeCachePlugin(options.resolve.unsafeCache),
  // 省略其餘插件加載
);
compiler.resolvers.loader.apply(
  new UnsafeCachePlugin(options.resolve.unsafeCache),
  // 省略其餘插件加載
);

分別針對處理編譯文件路徑查找的 normal 、處理文件夾路徑查找的 context 以及 loader 文件路徑查找的 loader 都加載了 UnsafeCachePlugin 插件。

enhanced-resolve/lib/UnsafeCachePlugin.js

UnsafeCachePlugin.prototype.apply = function(resolver) {
    var oldResolve = resolver.resolve;
    var regExps = this.regExps;
    var cache = this.cache;
    resolver.resolve = function resolve(context, request, callback) {
        var id = context + "->" + request;
        if(cache[id]) {
            // From cache
            return callback(null, cache[id]);
        }
        oldResolve.call(resolver, context, request, function(err, result) {
            if(err) return callback(err);
            var doCache = regExps.some(function(regExp) {
                return regExp.test(result.path);
            });
            if(!doCache) return callback(null, result);
            callback(null, cache[id] = result);
        });
    };
};

UnsafeCachePlugin 在這裏會直接執行 UnsafeCachePlugin.prototype.apply 方法會重寫原有 Resolver 實例的 resolve 方法,會加載一層路徑結果 cache ,以及在完成原有方法後更新 cache

  • 當調用 resolver.resolve 時,會首先判斷是否在 UnsafeCachePlugin 實例的 cache 屬性中已經存在結果,存在則直接返回,不存在則執行原有 resolve 方法

  • 當原有 resolve 方法完成後,會根據加載 UnsafeCachePlugin 時傳入的 regExps 來判斷是否須要緩存,若是須要則經過 callback(null, cache[id] = result); 返回結果的同時,更新UnsafeCachePlugincache 緩存對象。

part3. 判斷是否須要編譯

在完成了編譯文件路徑查找以後,即將開始對文件進行編譯,由輸入輸出來看能夠粗略的當作字符串轉換流程,而這個流程是 webpack 中最耗時的流程,webpack 在開始實際的 loader 處理編譯以前,進行是否已有緩存的判斷。

webpack/lib/Compilation.js

Compilation.prototype.addModule = function(module, cacheGroup) {
    cacheGroup = cacheGroup || "m";
    var identifier = module.identifier();
  
    if(this.cache && this.cache[cacheGroup + identifier]) {
        var cacheModule = this.cache[cacheGroup + identifier];
        var rebuild = true;
        if(!cacheModule.error && cacheModule.cacheable && this.fileTimestamps && this.contextTimestamps) {
            rebuild = cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);
        }

        if(!rebuild) {
            cacheModule.disconnect();
            this._modules[identifier] = cacheModule;
            this.modules.push(cacheModule);
            cacheModule.errors.forEach(function(err) {
                this.errors.push(err);
            }, this);
            cacheModule.warnings.forEach(function(err) {
                this.warnings.push(err);
            }, this);
            return cacheModule;
        } else {
            module.lastId = cacheModule.id;
        }
    }
    //省略緩存不存在的處理
};

這裏有一個上下文是,每個完成路徑查找以後的編譯文件,會生成對應的一個邏輯編譯模塊 module,而編譯過程當中的每個編譯模塊,都會關聯到 compilation 上的 modules 數組中。

執行 addModule 的時機正式完成路徑查找生成模塊以後,完成 compilation 添加 module 的過程。

  1. 首先調用 module.identifier(); 得到編譯文件的絕對路徑,賦值爲 identifier,而且以 cacheGroup + identifier 爲 存儲的 key,在 cacheGroup 值以及自定義 loader 參數不變的狀況下,cache 對象中的模塊緩存就由文件的絕對路徑保證惟一性。

  2. 而後判斷是否已經生成過該路徑的 modulethis.cache && this.cache[cacheGroup + identifier]

  3. 判斷是否須要從新編譯

    var rebuild = true;        
    if(!cacheModule.error && cacheModule.cacheable && this.fileTimestamps && this.contextTimestamps) {
      rebuild = cacheModule.needRebuild(this.fileTimestamps, this.contextTimestamps);
    }

在進入 cacaheModule.needRebuild 以前,有四個前置條件

  • cacheModule.error:模塊編譯過程出現錯誤,則會將錯誤對象複製到 moduleerror 屬性上

  • cacheModule.cacheable:模塊是否能緩存,在一些不能緩存的狀況,例如在編譯過程增長對其餘未添加到 modulefileDependencies 的文件依賴,依賴文件變動,可是引用原文件沒有變動。在 loader 的函數中調用 this.cacheable() 實際上就是申明設置編譯能夠緩存。後續還會詳細提到。

  • this.fileTimestampsthis.contextTimestamps:首次活或前一次編譯存儲的文件最後變動記錄

在前置條件知足的狀況下,進入 moduleneedRebuild 方法,根據前置條件參數進行邏輯判斷

webpack/lib/NormalModule.js

NormalModule.prototype.needRebuild = function needRebuild(fileTimestamps, contextTimestamps) {
    var timestamp = 0;
    this.fileDependencies.forEach(function(file) {
        var ts = fileTimestamps[file];
        if(!ts) timestamp = Infinity;
        if(ts > timestamp) timestamp = ts;
    });
    this.contextDependencies.forEach(function(context) {
        var ts = contextTimestamps[context];
        if(!ts) timestamp = Infinity;
        if(ts > timestamp) timestamp = ts;
    });
    return timestamp >= this.buildTimestamp;
};

這裏以 NormalModule 爲例,會針對 this.fileDependenciesthis.contextDependencies 進行相同邏輯的判斷。

fileDependencies 指的是編譯 module 所關聯的文件依賴,通常會包含模塊初始化傳入的本來編譯文件,也可能包含經過在 loader 中調用 this.addDependency 增長的其餘的文件依賴,例如在樣式文件中的 import 語法引入的文件,在模塊邏輯上,模塊以入口樣式文件爲入口做爲標識,以 import 進入的樣式文件爲 fileDependency

contextDependencies 相似,是 module 關聯的文件夾依賴,例如在 WatchMissingNodeModulesPlugin 實現中就是對 contextDependencies 操做,完成對目標目錄的監聽。

var ts = contextTimestamps[context];
if(!ts) timestamp = Infinity;
if(ts > timestamp) timestamp = ts;

經過這段通用邏輯獲取兩類依賴的最後變動時間的最大值,與上次構建時間(buildTimestamp)比較 return timestamp >= this.buildTimestamp; 判斷是否須要從新編譯。那麼若是最後變動時間大於模塊自己上次的編譯時間,則代表須要從新編譯。

part4. 編譯過程

若是判斷緩存過時失效,則須要進行編譯。在編譯流程中,會看到不少 loader 會有 this.cacheable(); 調用,一樣也會看到 this.addDependencythis.dependency 以及不多見的 this.addContextDependency ;同時也會在 modulecompilation 裏面看到兩個常見的變量 fileDependenciescontextDependencies 。下面會進行一些深刻。

cacheable 屬性

承接上面提到在判斷是否須要從新編譯時的條件 cacheModule.cacheable,上面提到

每個完成路徑查找以後的編譯文件,會生成對應的一個邏輯編譯模塊 module

換一種較爲好理解的方式,在通常狀況下,每個 require(dep) 依賴,在 webpack 中都會生成與之對應的 module,其中以 module.request 爲惟一標識,而 module.request 就是爲 dep 在文件系統中的 路徑編譯參數 的拼接字符串。

這裏的 cacheModule.cacheable 就是模塊的 cacheable 屬性,代表 module 當前對應的文件以及編譯參數(request)上下文的狀況下能夠進行緩存。

this.cacheable() 、loaderContext、loaderContextCacheable

拿常見的 less-loader 舉例子

less-loader/index.js

module.exports = function(source) {
    var loaderContext = this;
    var query = loaderUtils.parseQuery(this.query);
    var cb = this.async();
    // 省略其餘配置操做
    
    this.cacheable && this.cacheable();
}

首先肯定 this 指向,less-loader 代碼中,其實有一句進行了說明 var loaderContext = this;,在 loader 文件邏輯中,this 綁定的是上層 module 建立的 loaderContext 對象

webpack-core/lib/NormalModuleMixin.js

var loaderContextCacheable;
var loaderContext = {
  cacheable: function(flag) {
    loaderContextCacheable = flag !== false;
  },
  dependency: function(file) {
    this.fileDependencies.push(file);
  }.bind(this),
  addDependency: function(file) {
    this.fileDependencies.push(file);
  }.bind(this),
  addContextDependency: function(context) {
    this.contextDependencies.push(context);
  }.bind(this),
  // 省略其餘屬性
}

這裏列了 loaderContext 其中的一些與目前討論話題相關的屬性,能夠看到 cacheable 其實是經過閉包來修改 loaderContextCacheable 這個變量的值,而 loaderContextCacheable 是最終影響 module.cacheable 的決定因素。

loader 執行與 module.cacheable

webpack 提供給 loader 模塊兩個接口,一個是默認 module.exports 的導出方法,一個是 module.exports.pitch導出方法,對應兩套不一樣的邏輯。按照在 webpack 中執行順序

  1. module.exports.pitch 導出方法邏輯

    webpack-core/lib/NormalModuleMixin.js
    
    // Load and pitch loaders
    (function loadPitch() {
      // 省略其餘判斷、處理邏輯
      loaderContextCacheable = false;
      runSyncOrAsync(l.module.pitch, privateLoaderContext, [remaining.join("!"), pitchedLoaders.join("!"), l.data = {}], function(err) {
        if(!loaderContextCacheable) this.cacheable = false;            
        if(args.length > 0) {
          nextLoader.apply(this, [null].concat(args));
        } else {
          loadPitch.call(this);
        }
      }.bind(this));
    }.call(this));

runSyncOrAsync 是執行 loader 具體實現的函數,在開始 pitch 流程以前,會首先設置 loaderContextCacheablefalse,而後經過 runSyncOrAsync 進入 loader 的具體 pitch 實現,這樣只有在 loader 方法中手動調用 this.cacheable() 纔會將保證loaderContextCacheable 的值設置成 true 從而不會進入 if(!loaderContextCacheable) this.cacheable = false;,標明 modulecacheablefalse

  1. module.exports 導出方法邏輯

    webpack-core/lib/NormalModuleMixin.js
    
    function nextLoader(err/*, paramBuffer1, param2, ...*/) {
     if(!loaderContextCacheable) module.cacheable = false;
     // 省略 privateLoaderContext 環境建立
     loaderContextCacheable = false;
     runSyncOrAsync(l.module, privateLoaderContext, args, function() {
      loaderContext.inputValue = privateLoaderContext.value;
      nextLoader.apply(null, arguments);
     });
    }

在完成 pitch 流程以後,會進入默認邏輯的流程,也相似 pitch 的流程,在調用 runSyncOrAsync 進入 loader 邏輯前,先設置 loaderContextCacheablefalse,在遞歸循環中判斷 loader 是否在執行中調用 this.cacheable()loaderContextCacheable 設置成 true,從而保證module.cacheable 的值爲 true

綜合上面的環節,就是若是要保證 module 可被緩存,則必定須要 loader 中調用 this.cacheable() 觸發如圖的邏輯鏈路。

addDependencydependencyaddContextDependency

loaderContext 還會提供兩類方法

  • 增長文件依賴,addDependencydependency:目的是在編譯過程當中,增長對沒有生成對應 module 的文件的依賴關係,例如 import common.less 這樣的引用文件

  • 增長文件夾依賴,addContextDependency :類比文件依賴,增長對文件夾的依賴

而從上面的實現中,能夠看到,兩類方法調用以後,會將文件(夾)路徑放在 fileDependenciescontextDependencies

fileDependenciescontextDependenciescompilation

在完成全部模塊的編譯以後,在 Compilation.js 中會調用 Compilation.prototype.summerizeDependencies ,其中會將 fileDependenciescontextDependencies 聚集到 compilation 實例上

webpack/lib/Compilation.js

Compilation.prototype.summarizeDependencies = function summarizeDependencies() {
    this.modules.forEach(function(module) {
        if(module.fileDependencies) {
            module.fileDependencies.forEach(function(item) {
                this.fileDependencies.push(item);
            }, this);
        }
        if(module.contextDependencies) {
            module.contextDependencies.forEach(function(item) {
                this.contextDependencies.push(item);
            }, this);
        }
    }, this);

    this.fileDependencies.sort();
    this.fileDependencies = filterDups(this.fileDependencies);
    this.contextDependencies.sort();
    this.contextDependencies = filterDups(this.contextDependencies);
    
     // 省略其餘操做
};

從實現中能夠看到,首先把全部編譯 modulefileDependenciescontextDependencies 都聚集到 compilation 對象,而且進行排序、去重。

可是可能看到這裏關於這兩個 dependency 的內容有個疑問,跟緩存更新有啥關係呢?

銜接 watch 流程

webpack/lib/Compiler.js

Watching.prototype._done = function(err, compilation) {
 // 省略其餘流程
 if(!this.error)
  this.watch(compilation.fileDependencies, compilation.contextDependencies, compilation.missingDependencies);
};

銜接上篇文章,在 watch 模式下,在完成編譯以後,傳入 watch 方法正是上面 Compilation.prototype.summarizeDependencies 聚集到 compilation 中的 fileDependenciescontextDependencies 屬性,代表上一次編譯結果中得出的做爲 編譯流程中的文件(夾)依賴做爲須要進行變動監聽的依據

整個流程下來,就能將編譯中涉及的文件進行管控,在下一次編譯觸發監控中,保證對涉及文件的監控,快速響應文件改動變動。

part5. 編譯完成

在完成了以前的編譯邏輯以後,webpack 便開始要渲染(render) 代碼,而這個拼接過程,是字符串不斷分割拼接的過程,對應一樣的輸入得到一樣的輸出。webpack 在這裏也一樣設置了一個緩存機制

webpack/lib/Compilation.js

Compilation.prototype.createChunkAssets = function createChunkAssets() {
  // 省略其餘邏輯
  for(i = 0; i < this.chunks.length; i++) {
    var useChunkHash = !chunk.entry || (this.mainTemplate.useChunkHash && this.mainTemplate.useChunkHash(chunk));
    var usedHash = useChunkHash ? chunkHash : this.fullHash;
    if(this.cache && this.cache["c" + chunk.id] && this.cache["c" + chunk.id].hash === usedHash) {
      source = this.cache["c" + chunk.id].source;
    } else {
      if(chunk.entry) {
        source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);
      } else {
        source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);
      }
      if(this.cache) {
        this.cache["c" + chunk.id] = {
          hash: usedHash,
          source: source = (source instanceof CachedSource ? source : new CachedSource(source))
        };
      }
    }
  }
};

Compilation.prototype.createChunkAssets 中,會判斷每一個 chunk 是否有代碼生成以後保留的緩存

這裏的 chunk 簡化來說,能夠看作對應的是配置在 webpack 中的 entry

this.cache && this.cache["c" + chunk.id] && this.cache["c" + chunk.id].hash === usedHash 看出,以 chunk.id 爲標識,若是整個 chunkwebpack 生成 hash 沒有變化,說明在 chunk 中的各個 module 等參數都沒有發生變化。則可使用上一次的代碼渲染緩存。

同時若是緩存失效,則會將生成以後的代碼儲存在 this.cache["c" + chunk.id] 對象中。

回顧

webpack 中的緩存機制保證了在屢次編譯的場景下,以增量變動編譯的方式保證編譯速度。文章內容大體截取了 webpack 編譯流程的部分結點進行分析。

相關文章
相關標籤/搜索