做者:崔靜css
webpack 的特色之一是處理一切模塊,咱們能夠將邏輯拆分到不一樣的文件中,而後經過模塊化方案進行導出和引入。如今 ES6 的 Module 則是你們最經常使用的模塊化方案,因此你必定寫過 import './xxx'
或者 import 'something-in-nodemodules'
再或者 import '@/xxx'
(@ 符號經過 webpack 配置中 alias 設置)。webpack 處理這些模塊引入 import
的時候,有一個重要的步驟,就是如何正確的找到 './xxx'
、'something-in-nodemodules'
或者 '@/xxx'
等等對應的是哪一個文件。這個步驟就是 resolve 的部分須要處理的邏輯。vue
其實不只是針對源碼中的模塊須要 resolve,包括 loader 在內,webpack 的總體處理過程當中,涉及到文件路徑的,都離不開 resolve 的過程。node
同時 webpack 在配置文件中有一個 resolve 的配置,能夠對 resolve 的過程進行適當的配置,好比設置文件擴展名,查找搜索的目錄等(更多的參考官方介紹)。webpack
下面,將主要介紹針對普通文件的 resolve 流程 和 loader 的 resolve 主流程。git
首先先準備一個簡單的 demogithub
import { A } from './a.js'
複製代碼
而後針對這個 demo 來看主流程。在 webpack 系列之一總覽 文章中有一個 webpack 編譯總流程圖,圖中能夠看到在 webpack 處理每個文件開始以前都會有一個 resolve 的過程,找到完整的文件路徑信息。web
webpack 源碼中 resolve 流程開始的入口在 factory 階段, factory 事件會觸發 NormalModuleFactory 中的函數。先放一張粗略的整體流程圖,在深刻源碼前現有一個大概的框架圖json
接下來咱們就從 NormalModuleFactory.js 文件中開始看起緩存
this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
// 首先獲得 resolver
let resolver = this.hooks.resolver.call(null);
// Ignored
if (!resolver) return callback();
// 執行
resolver(result, (err, data) => {
if (err) return callback(err);
// Ignored
if (!data) return callback();
// direct module
if (typeof data.source === "function") return callback(null, data);
this.hooks.afterResolve.callAsync(data, (err, result) => {
//... resolve結束後流程,此處省略
});
});
});
複製代碼
第一步得到 resolver 邏輯比較簡單,觸發 resolver 事件(SyncWaterfallHook類型的Hook,關於Hook的類型,能夠參考上一篇文章),同時 NormalModuleFactory 中註冊了 resolver 事件。下面是 resolver 事件的代碼,能夠看到返回了一個函數。bash
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
//...先展現省略具體內容,後面會詳細解釋。
})
複製代碼
所以 this.hooks.resolver.call(null); 結束後,將獲得一個函數。而後接下來就是執行該函數得到 resolver 結果。 resolver 函數中,從總體看分爲兩大主要流程 loader 和 文件。
loader流程
import Styles from 'style-loader!css-loader?modules!./styles.css';
複製代碼
會從中解析出 style-loader
和 css-loader
。因爲此步驟只是爲了解析出路徑,因此對於 loader 的配置部分並不關心。
獲得 loader 類型的 resolver 處理實例,即 const loaderResolver = this.getResolver("loader");
對每個 loader 用 loaderResolver 依次處理,獲得執行文件的路徑。
文件流程
獲得普通文件的 resolver 處理實例,即代碼 const normalResolver = this.getResolver("normal", data.resolveOptions);
用 normalResolver 處理文件,獲得最終文件絕對路徑
下面是具體的 resolver 代碼:
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
const contextInfo = data.contextInfo;
const context = data.context;
const request = data.request;
// ... 省略部分和 loader 處理相關的代碼
// 處理 inline loaders,拿到 loader request 部分(loader 的名稱或者 loader 的路徑,因爲這裏不關係 loader 的配置等其餘細節,因此直接將開頭的 -!, 和 ! 直接替換掉,將多個 ! 替換成一個,方便後面處理)
let elements = request
.replace(/^-?!+/, "")
.replace(/!!+/g, "!")
.split("!");
let resource = elements.pop();
// 提取出具體的 loader
elements = elements.map(identToLoaderRequest);
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
asyncLib.parallel(
[
callback =>
this.resolveRequestArray(
contextInfo,
context,
elements,
loaderResolver,
callback
),
callback => {
if (resource === "" || resource[0] === "?") {
return callback(null, {
resource
});
}
normalResolver.resolve(
contextInfo,
context,
resource,
{},
(err, resource, resourceResolveData) => {
if (err) return callback(err);
callback(null, {
resourceResolveData,
resource
});
}
);
}
],
(err, results) => {
// ... reslover callback
})
)
})
複製代碼
結合上面的步驟和代碼看,其實 loader 類和普通文件類型(後面稱爲 normal 類),大體流程是類似的。咱們先看獲取不一樣類型的 resolver 實例部分。
getResolver 函數,會調用到 webpack/lib/ResolverFactory.js 中的 get 方法。該方法中獲取 resolver 實例的具體流程以下圖。
上圖中,首先根據不一樣 type 獲取 options 。那麼這些 options 配置都存在哪裏呢?
webpack中options配置
webpack 直接對外暴露的 resolve 的配置,在配置文件中 resolve 和 resolveLoader 部分,詳細的字段見官網。可是其內部會有一個默認的配置,在 webpack.js 入口處理函數中,初始化了全部的默認配置
// ...
if (Array.isArray(options)) {
compiler = new MultiCompiler(options.map(options => webpack(options)));
} else if (typeof options === "object") {
options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
// ...
複製代碼
在 WebpackOptionsDefaulter()
中,配置了不少關於 resolve 和 resolveLoader 的配置。process
方法將咱們寫的 webpack 的配置 和默認的配置合併。
// WebpackOptionsDefaulter.js 文件
//...
this.set("resolve", "call", value => Object.assign({}, value));
this.set("resolve.unsafeCache", true); // 默認開啓緩存
this.set("resolve.modules", ["node_modules"]); // 默認從 node_modules 中查找
// ...
複製代碼
webpack.js 中,接下來有一句
new WebpackOptionsApply().process(options, compiler);
複製代碼
其中 process 過程裏會注入關於 normal/context/loader 的默認配置的獲取函數。
compiler.resolverFactory.hooks.resolveOptions
.for("normal")
.tap("WebpackOptionsApply", resolveOptions => {
return Object.assign(
{
fileSystem: compiler.inputFileSystem
},
options.resolve,
resolveOptions
);
});
compiler.resolverFactory.hooks.resolveOptions
.for("context")
.tap("WebpackOptionsApply", resolveOptions => {
return Object.assign(
{
fileSystem: compiler.inputFileSystem,
resolveToContext: true
},
options.resolve,
resolveOptions
);
});
compiler.resolverFactory.hooks.resolveOptions
.for("loader")
.tap("WebpackOptionsApply", resolveOptions => {
return Object.assign(
{
fileSystem: compiler.inputFileSystem
},
options.resolveLoader,
resolveOptions
);
});
複製代碼
options 介紹到此先結束,咱們繼續沿着上面流程圖往下看。當獲取到 resolver 實例後,就開始 resolver 的過程:根據類型的不一樣,會有 normalResolver 和 loaderResolver,同時在 normalResolver 中會區分文件和 module。
webpack 中有不少針對路徑的配置,例如 alias, extensions, modules 等等,node.js 中的 require 已經沒法知足 webpack 對路徑的解析的要求。所以,webpack 封裝出一個單獨的庫 enhanced-resolve,專門用來處理各類路徑的解析,仍然採用了 webpack 的插件模式來組織代碼。 接下來會深刻到這個庫中,依次介紹普通文件、module 和 loader 的處理過程(webpack 中還有一個 context 的 resolve 過程,因爲其過程沒太多特別之處,放在 module 過程當中一塊兒介紹)。先看普通文件的處理過程。
普通文件 resolver 處理入口爲 webpack 中 normalResolver.resolve
方法,而整個 resolve 過程能夠當作事件的串聯,當全部串聯在一塊兒的事件執行完以後,resolve 就結束了。
將這些事件一個一個串聯起來的關鍵部分在 doResolve 和每一個事件的處理函數中。這裏以 doResolve 和調用的 UnsafePlugin 爲例,看一下銜接的過程。
// 第一個參數 hook,函數中用到的 hook 是經過參數傳進來的。
doResolve(hook, request, message, resolveContext, callback) {
// ...
// 生成 context 棧。
const stackLine = hook.name + ": (" + request.path + ") " +
(request.request || "") + (request.query || "") +
(request.directory ? " directory" : "") +
(request.module ? " module" : "");
let newStack;
if(resolveContext.stack) {
newStack = new Set(resolveContext.stack);
if(resolveContext.stack.has(stackLine)) {
// Prevent recursion
const recursionError = new Error("Recursion in resolving\nStack:\n " + Array.from(newStack).join("\n "));
recursionError.recursion = true;
if(resolveContext.log) resolveContext.log("abort resolving because of recursion");
return callback(recursionError);
}
newStack.add(stackLine);
} else {
newStack = new Set([stackLine]);
}
// 簡單的demo中這裏沒有事件註冊,先忽略
this.hooks.resolveStep.call(hook, request);
// 若是該hook有註冊過事件,則調觸發該 hook
if(hook.isUsed()) {
const innerContext = createInnerContext({
log: resolveContext.log,
missing: resolveContext.missing,
stack: newStack
}, message);
return hook.callAsync(request, innerContext, (err, result) => {
if(err) return callback(err);
if(result) return callback(null, result);
callback();
});
} else {
callback();
}
}
複製代碼
調用到 hook.callAsync 時,進入 UnsafeCachePlugin,而後看 UnsafeCachePlugin 中部分實現:
class UnsafeCachePlugin {
constructor(source, filterPredicate, cache, withContext, target) {
this.source = source;
// ... 省略部分
this.target = target;
}
apply(resolver) {
// ensureHook 主要邏輯:若是 resolver 已經有對應的 hook 則返回;若是沒有,則會給 resolver 增長一個 this.target 類型的 hook
const target = resolver.ensureHook(this.target);
// getHook 會根據 this.source 字符串獲取對應的 hook
resolver.getHook(this.source).tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {
//... 先省略 UnsafeCache 中其餘邏輯,只看銜接部分
// 繼續調用 doResolve,可是注意這裏的 target
resolver.doResolve(target, request, null, resolveContext, (err, result) => {
if(err) return callback(err);
if(result) return callback(null, this.cache[cacheId] = result);
callback();
});
});
}
}
複製代碼
UnsafeCachePlugin 分爲兩部分:事件註冊(new 和 執行apply) 和事件執行(resolver.getHook(this.source).tapAsync
的回調部分)。事件註冊階段發在 webpack 獲取不一樣類型 resolve 處理實例時(前面獲取不一樣類型 resolver 處理實例小節中,getResolver 的時候),這時會傳入一個 source 值(字符串類型)和一個 target 值(字符串類型),代碼以下
// source 值爲 resolve,target 值爲 new-resolve
new UnsafeCachePlugin("resolve", cachePredicate, unsafeCache, cacheWithContext, "new-resolve")` //...而後會調用 apply 方法 複製代碼
在 apply
中,將 UnsafeCachePlugin 的處理邏輯註冊爲 source 事件的回調,同時確保 target 事件的存在(若是沒有則註冊一個)。
事件執行階段,完成 UnsafeCachePlugin 自己的邏輯以後,遞歸調用 resolver.doResolve(target, ...)
,這時第一個參數爲 UnsafeCachePlugin 中的 target 事件。如此,再進入到 doResolve 以後,再觸發 target 的事件,這樣就造成了事件流。而總體的調用過程,簡化來看總體邏輯就是:
doResolve(target1)
-> target1 事件(srouce:target1, target: target2)
-> 遞歸調用doResolve(target2)
-> target2 事件(srouce:target2, target: target3)
-> 遞歸調用doResolve(target3)
-> target3 事件(srouce:target3, target: target4)
...
->遇到遞歸結束標識,結束遞歸
複製代碼
經過對 doResolve 的遞歸調用,事件之間就銜接了起來,造成完整的處事件流,最終獲得 resolve 結果。在 ResolverFactory.js 文件的 createResolver
方法中各個 plugin 的註冊方法,決定了整個 resolve 的事件流。
exports.createResolver = function(options) {
// ...
// 根據 options 中條件的不一樣,加入各類 plugin
if(unsafeCache) {
plugins.push(new UnsafeCachePlugin("resolve", cachePredicate, unsafeCache, cacheWithContext, "new-resolve"));
plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));
} else {
plugins.push(new ParsePlugin("resolve", "parsed-resolve"));
}
// ... plugin 加入的代碼
plugins.forEach(plugin => {
plugin.apply(resolver);
});
// ...
複製代碼
上面代碼整理一下,能夠獲得完整的事件流圖(下圖爲簡化版本,完成版本附圖)
結合上面的圖和 demo,咱們來一步一步看這個事件流中每一環都作了什麼。(ps:下面步驟中,會涉及到 request 參數,這個參數貫穿全部事件處理邏輯,保存了整個 resolve 的信息)
增長一層緩存,因爲 webpack 處理打包的過程當中,涉及到大量的 resolve 過程。因此須要增長一層緩存,提升效率。webpack 默認會啓用 UnsafeCache。
ParsePlugin
初步解析路徑,判斷是否爲 module/directory/file,結果保存到 request 參數中。
DescriptionFilePlugin 和 NextPlugin
DescriptionFilePlugin 中會尋找描述文件,默認會尋找 package.json。首先會在 request.path 這個目錄下尋找,若是沒有則按照路徑一層一層往上尋找。最後讀取到 package.json 的信息和其所在的目錄/路徑信息,存入 request 中。咱們在 demo 的根目錄有 package.json 文件,因此這裏會獲取到根目錄的文件。
NextPlugin 起一個銜接的做用,內部邏輯就是直接調用 doResolve,而後觸發下一個事件。當 DescriptionFilePlugin 中未找到 package.json 文件時,會進入 NextPlugin,而後讓事件流繼續。
AliasPlugin/AliasFieldPlugin
這一步開始處理別名,因爲 AliasFieldPlugin 中依賴於 package.json 的配置,因此這一步放在了 DescriptionFilePlugin 以後。 除了咱們在配置文件中寫一些別名外,webpack 還會有一些自帶的 alias;每個 alias 配置,都會註冊一個函數。這一步將執行全部的函數,一一對比。 若命中某一 alias 的配置或者 aliasField,那麼就會進入上圖紅色虛線的分支。用新的別名替換 request 參數內容,而後再次開始 resolve 過程。 沒有命中,則進入下一個處理函數 ModuleKindPlugin
根據 request.module
的值走不一樣的分支。若是是 module,則後續進入 rawModule 的邏輯。前面 ParsePlugin 中獲得的結果中 request.module
爲 false
,因此這裏返回 undefined,繼續進入下一個處理函數。
將 request 中 path 和 request 合併起來,將 request 中 relativePath 和 request 合併起來,獲得兩個完整的路徑。在這個 demo 中會獲得 /Users/didi/dist/webpackdemo/webpack-demos/demo01/a.js
和 ./demo01/a.js
這時會再次進入 DescriptionFilePlugin 。不過與第一次進入時不一樣之處在於,此時的 request.path 變成了 /dir/demo/a.js`。因爲 path 改變了,因此須要再次查找一下 package.json
隨後觸發 describedRelative 事件,進入下一個流程
判斷是否爲一個 directory,若是是則返回 undefined, 進入下一個 tryNextPlugin,這時會進入 directory 的分支。不然,則代表是一個文件,進入 rawFile 事件。咱們的 demo 中,這裏將走向 rawFile 分支。
因爲 webpack 中默認的 enforceExtension 值爲 true
,因此這裏會進入 TryNextPlugin,同時 enableConcord 爲 false
,不會有 ConcordExtensionsPlugin。
TryNextPlugin 和 NextPlugin 相似,起一個銜接的做用,內部邏輯就是直接調用 doResolve,而後觸發下一個事件。因此在這個階段會直接走到觸發 file
事件的分支。 當 TryNextPlugin 有返回,且返回爲 undefined 。這時意味着沒有找到 request.path 所對應的文件,那麼會繼續執行後續的 AppendPlugin。
AppendPlugin 主要邏輯:webpack 會設置 resolve.extensions 參數(配置中設置或者使用 webpack 默認的),AppendPlugin 會給 request.path 和 request.relativePath 逐一添加這些後綴,而後進入 file
分支,繼續事件流程。
這時會再次進入到 Alias 的處理邏輯,注意在此步中 webpack 內部自帶的不少 Alias 不會再有。 與前面相同,這裏依然沒有 ConcorModulesPlugin SymlinkPlugin 用來處理路徑中存在 link 的狀況。因爲 webpack 默認是按照真實的路徑來解析的,因此這裏會檢查路徑中每一段,若是遇到 link,則替換爲真實路徑。因爲 path 改變了,因此會再回到 relative
階段。 若路徑中沒有 link,則進入 FileExistsPlugin
讀取 request.path
所在的文件,看文件是否存在。文件存在則進入到 existingFile 事件。
經過 NextPlugin 銜接,再進入 Resolved 事件。而後執行 ResultPlugin,到此 resolve 整個流程就結束了,request 保存了 resolve 的結果。
在 webpack 中,咱們除了會 import 一個文件之外,還會 import 一個模塊,好比 import Vue from 'vue'
。那麼這時候,webpack 就須要正確找到 vue 所對應的入口文件在哪裏。針對 vue,ParsePlugin 結果中 request.module = true
,隨後在 ModuleKindPlugin 就會進入上面圖中 rawModule 的分支。咱們就以 import Vue from 'vue'
爲 demo,看一下 rawModule 分支流程。
ModuleAppendPlugin 和上面的 AppendPlugin 相似,添加後綴。 TryNextPlugin 進入 module 事件
ModulesInHierachicDirectoriesPlugin 中會依次在 request.path 的每一層目錄中尋找 node_modules。例如 request.path = 'dir/demo'
那麼尋找 node_modules 的過程爲:
dir/demo/node_modules
dir/node_modules
/node_modules
複製代碼
若是 dir/demo/node_modules
存在,則修改 request.path 和 request.request
const obj = Object.assign({}, request, {
path: addr, // node_module 所在的路徑
request: "./" + request.request
});
複製代碼
對於 ModulesInRootPlugin,則默認爲在根目錄下尋找,直接進行替換
const obj = Object.assign({}, request, {
path: this.path,
request: "./" + request.request
});
複製代碼
隨後,因爲改變了 request.path 和 request.request,因此從新回到 resolve 開始的階段。可是這時 request.request 從一個 module 變成了一個普通文件類型./vue
。
按照普通文件的方式查找 dir/demo/node_module/vue
的過程與前文中普通文件 resolve 過程相似,經歷上一節中 1-7 的步驟,而後觸發 describedRelative 事件(這個事件下注冊了兩個函數 FileKindPlugin 和 TryNextPlugin)。 首先進入 FileKindPlugin 的邏輯,因爲 dir/demo/node_module/vue
不是一個文件地址,因此在第 8 步 FileKindPlugin 中最終會返回 undefined。 這時候會進入下一個處理事件 TryNextPlugin,而後觸發 directory 事件,把 dir/demo/node_module/vue
按照文件夾的方式來解析。
確認 dir/demo/node_module/vue
是否存在。(ps: 針對 context 的 resolve 過程,到這裏若是文件夾存在,則就結束了。)
webpack 默認的 mainField 爲 ['browser', 'module', 'main']
。這裏會按照順序,在 dir/demo/node_module/vue/package.json
中找對應字段。 vue 的 package.json 中定義了
{
"module": "dist/vue.runtime.esm.js"
}
複製代碼
因此找到該字段後,會將 request.request 的值替換爲 ./dist/vue.runtime.esm.js
。以後又回到 resolve 節點,開始新一輪,尋找一個普通文件 ./dist/vue.runtime.esm.js
的過程。 當 MainFieldPlugin 執行完,都沒有結果時,會進入 UseFilePlugin
當咱們 package.json 中沒有寫 browser、module、main 時,webpack 會自動去找目錄下的 index 文件,request 變成以下
{
//...省略其餘部分
relativePath: "./index",
path: 'dir/demo/node_modules/vue/index'
}
複製代碼
而後觸發 undescribedRawFile 事件
針對新的 request.path ,從新尋找描述文件,即 package.json
依次爲 'dir/demo/node_modules/vue/index' 添加後綴名,而後尋找該文件是否存在。與前文中 file 以後的流程相同。直到最後找到存在的文件,整個針對 module 的 resolve 過程就結束了。
loader 的 resolve 過程和 module 的過程相似,咱們以 url-loader 爲例,入口在 NormalModuleFactory.js 中 resolveRequestArray 函數。這裏會執行 resolver.resolve
,這裏的 resolver 爲以前獲得的 loaderResolver,resolve 過程開始時 request 參數以下:
{
context: {
compiler: undefined,
issuer: "/dir/demos/main.js"
},
path: "/dir/demos"
request: "url-loader"
}
複製代碼
在 ParsePlugin 中,request: "url-loader"
會被解析爲 module。隨後過程當中整個和 module 執行流程相同。
到此 webpack 中關於 resolve 流程就結束了。除此以外 webpack 還有很多的細節處理,鑑於篇幅有限這裏就不展開細細討論了,你們能夠結合文章看 webpack 代碼時去細細品味。
webpack 中每涉及到一個文件,就會通過 resolve 的過程。而 resolve 過程當中其中針對一些不肯定的因素,好比後綴名,node_modules 路徑等,會存在探索的過程,從而使得整個 resolve 的鏈條很長。不少針對 webpack 的優化,都會提到利用 resolve 配置來減小文件搜索範圍:
咱們平常開發項目中,經常會存在相似 common 這樣的目錄,common 目錄下的文件,會被常常引用。好比 'common/index.js'。若是咱們針對 common 目錄創建一個 alias 的話,在全部用到 'common/index.js' 的文件中,能夠寫 import xx from 'common/index.js'
。 因爲 UnsafeCachePlugin 的存在,當 webpack 再次解析到 'common/index.js' 時,就能夠直接使用緩存。
不止如此,重點是解析鏈條變短,緩存只是一部分吧
resolve.modules 的默認值爲 ['node_modules']
,因此在對 module 的 resolve 過程當中,會依次查找 ./node_modules、../node_modules、../../node_modules 等,即沿着路徑一層一層往上找,直到找到 node_modules。能夠直接設置
resolve.modules:[path.resolve(__dirname, 'node_modules')]
複製代碼
如此會進入 ModulesInRootPlugin 而不是 ModulesInHierachicDirectoriesPlugin,避免了層層尋找 node_modules 的開銷。
對第三方的 module 進行 resolve 過程當中,除了上面提到的 node_modules 目錄查找過程,還會涉及到對 package.json 中配置的解析等。能夠直接爲其設置 alias 爲執行文件,來簡化整個 resolve 過程,以下:
resolve.alias: {
'vue': path.resolve(__dirname, './node_modules/vue/dist/vue.common.js')
}
複製代碼
當咱們的文件沒有後綴時,AppendPlugin 會根據 resolve.extensions 中的值,依次添加後綴而後查找文件。爲了減小文件查找,咱們能夠直接將文件後綴寫上,或者設置 resolve.extensions 中的值,列表值儘可能少,頻率高的文件類型的後綴寫在前面。
明白了 resolve 的細節以後,再來看這些優化策略,即可以更好的瞭解其緣由,作到「知其然知其因此然」。
附圖(resolve事件流完整版):