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 编译流程的部分结点进行分析。

相关文章
相关标签/搜索