深入理解webpack的chunkId對線上快取的思考

c_Kim發表於2019-08-26

前言

想必經常使用基於webpack打包工具的框架的同學們,無論是使用React還是Vue在效能優化上使用最多的應該是分包策略(按需載入)。按需載入的方式使我們的每一個bundle變的更小,在每一個單頁中只需要引入當前頁所使用到的JavaScript程式碼,從而提高了程式碼的載入速度。但是,由於webpack目前版本自身的原因分包策略雖然可以提高我們的載入速度,然而線上上快取方面卻給了我們極大的破壞(webpack5中解決了這個問題)。

本文主要通過以下四個方面,來深入剖析chunkId:

  • chunkId是怎麼生成的?
  • chunkId是怎麼破壞線上快取的?
  • 解決chunkId對破壞快取的方法
  • 遠觀未來,webpack5完美解決

chunkId的生成策略是什麼?

webpack是一個基於模組化的打包工具,其總體打包流程可分為:

  1. 初始化階段
  2. 編譯階段
  3. 輸出階段

初始化階段

webpack初始化階段主要在webpack.js中完成,有以下方面:

  • webpack-cli啟動,獲取webpack.config.js配置及合併shell引數
  • 根據cli得到的配置合併預設配置
  • 建立compiler例項
  • 遍歷配置中的plugins載入第三方外掛
  • 初始化預設外掛

編譯階段

初始化完成之後,cli得到compiler例項,執行compiler.run()開始編譯,編譯的過程主要分為以下步驟:

  • 分析entry,逐一遍歷
  • 確定依賴模組,遞迴解析
  • 分包策略確定每一個chunk所包含的module,合併module生成chunk。
  • 確定模組資源
  • 根據chunk的entry的不同,確定輸出template模板。

輸出階段

  • 輸出檔案

上面簡單瞭解一下打包流程,當然最主要的目的不是為了瞭解打包流程,而是其中的一個點:chunk是怎麼生成的

從編譯階段中可以看出,chunk是由多個module合併生成的,每一個chunk生成的時候都會有一個對應的chunkId,chunkId的生成策略是本節討論的重點。

chunkId的生成策略可以在官網中找到,主要有五種規則:

  1. false:不適用任何演算法,通過外掛提供自定義演算法。
  2. natural:自然數ID
  3. named:使用name值作為Id,可讀性高。
  4. size:數字ID,依據最小的初始下載大小。
  5. total-size:數字ID,依據最小的總下載大小。

不同的生成規則所打包出來的chunkId是不同的。但是,其實內部生成方式是一樣的,不同的規則只是對chunks中的chunk排序規則不同(說的什麼玩意,什麼一會相同,一會不同的)。不要著急,接下來就來看一下這東西到底是怎麼生成的。

我們都知道webpack的optimization中有個chunkIds的配置,上面五種值,就是它的可選值。在開發環境下預設值為named,在生產環境下預設值為size。

在webpack初始化階段會掛載內部外掛,我們直接定位到WebpackOptionsApply.js這個檔案的第437行。

if (chunkIds) {
	const NaturalChunkOrderPlugin = require("./optimize/NaturalChunkOrderPlugin");
	const NamedChunksPlugin = require("./NamedChunksPlugin");
	const OccurrenceChunkOrderPlugin = require("./optimize/OccurrenceChunkOrderPlugin");
	switch (chunkIds) {
		case "natural":
			new NaturalChunkOrderPlugin().apply(compiler);
			break;
		case "named":
			new OccurrenceChunkOrderPlugin({
				prioritiseInitial: false
			}).apply(compiler);
			new NamedChunksPlugin().apply(compiler);
			break;
		case "size":
			new OccurrenceChunkOrderPlugin({
				prioritiseInitial: true
			}).apply(compiler);
			break;
		case "total-size":
			new OccurrenceChunkOrderPlugin({
				prioritiseInitial: false
			}).apply(compiler);
			break;
		default:
			throw new Error(`webpack bug: chunkIds: ${chunkIds} is not implemented`);
	}
}
複製程式碼

上面程式碼中可以看到,在初始化階段不同的chunkIds的值會載入不同的外掛,並且進入這個外掛內部你會發現他們都是掛載到compilation.hooks.optimizeChunkOrder這個鉤子上。那麼疑問來了,這個鉤子是在什麼時機執行的呢?定位到``compilation.js`的第1334行會得到答案。

//這個鉤子主要做的是確定以什麼規則生成chunkId
this.hooks.optimizeChunkOrder.call(this.chunks);
//生成前所要做的事,注:我們可以在這裡做手腳
this.hooks.beforeChunkIds.call(this.chunks);
//生成chunkId
this.applyChunkIds();
this.hooks.optimizeChunkIds.call(this.chunks);
this.hooks.afterOptimizeChunkIds.call(this.chunks);
複製程式碼

在執行流程中可以看出,chunkId在生成前確定生成規則。可能你的疑問又來了,它是怎麼根據chunkId的值的不同生成規則呢?其實所有的chunk都存放在一個陣列裡面(也就是chunks),在optimizeChunkOrder中根據規則的不同對chunk進行相應的排序,然後再applyChunkIds統一的對chunk.id進行賦值。眼見為實,我們先來看一下applyChunkIds中是怎麼賦值的,定位到compilation.js中的1754行。

let nextFreeChunkId = 0;
for (let indexChunk = 0; indexChunk < chunks.length; indexChunk++) {
	const chunk = chunks[indexChunk];
	if (chunk.id === null) {
		if (unusedIds.length > 0) {
			chunk.id = unusedIds.pop();
		} else {
			chunk.id = nextFreeChunkId++;
		}
	}
	if (!chunk.ids) {
		chunk.ids = [chunk.id];
	}
}
複製程式碼

這生成過程中判斷chunk.id是否為null,如果為null,對id賦值nextFreeChunkId。沒錯,無論是什麼生成規則,都是這樣賦值的。明白了所有的生成規則都是使用相同的賦值規則之後,我們現在的疑問應該就是每個規則中是怎麼對chunks進行排序的?接下來就來看一下每個規則是怎麼做的。

natural

WebpackOptionsApply.js中我們可以知道,chunkIds值為natural的時候,掛載的是NaturalChunkOrderPlugin這個外掛。

compilation.hooks.optimizeChunkOrder.tap(
	"NaturalChunkOrderPlugin",
	chunks => {
	    //排序
		chunks.sort((chunkA, chunkB) => {
		    //得到modulesIterable的iterator遍歷器
			const a = chunkA.modulesIterable[Symbol.iterator]();
			const b = chunkB.modulesIterable[Symbol.iterator]();
			while (true) {
				const aItem = a.next();
				const bItem = b.next();
				if (aItem.done && bItem.done) return 0;
				if (aItem.done) return -1;
				if (bItem.done) return 1;
				//獲取到module的id
				const aModuleId = aItem.value.id;
				const bModuleId = bItem.value.id;
				if (aModuleId < bModuleId) return -1;
				if (aModuleId > bModuleId) return 1;
			}
		});
	}
);
複製程式碼

首先,在每一個chunk中都有一個modulesIterable這個屬性,它是一個Set,裡面存放的是所有合併當前的module,每個module的id屬性表示當前module的相對路徑NaturalChunkOrderPlugin主要做的事就是根據moduleId來最為排序規則進行排序。

named

named的生成規則比較簡單,根據chunk的name取值

class NamedChunksPlugin {
	static defaultNameResolver(chunk) {
		return chunk.name || null;
	}
	constructor(nameResolver) {

		this.nameResolver = nameResolver || NamedChunksPlugin.defaultNameResolver;
	}
	apply(compiler) {
		compiler.hooks.compilation.tap("NamedChunksPlugin", compilation => {
			compilation.hooks.beforeChunkIds.tap("NamedChunksPlugin", chunks => {
				for (const chunk of chunks) {
					if (chunk.id === null) {
						chunk.id = this.nameResolver(chunk);
					}
				}
			});
		});
	}
}
複製程式碼

named與其他方式的區別在於,named不是在optimizeChunkOrder中對chunkId操作,而是在beforeChunkIds階段。NamedChunksPlugin所做的事是遍歷所有的chunk,判斷chunk的id值是否為null,如果為null,取到chunk的name值賦予id。

當執行applyChunkIds的時候,由於當前的id值已經不是null了,所以跳過賦值規則,直接使用已存在的值。

size和total-size

size和total-size規則由於呼叫的是相同的外掛,只是引數的不同,所以我們就一起看一下它是怎麼做的。開啟OccurrenceChunkOrderPlugin.js檔案。

size和total-size呼叫外掛的區別:

  1. size規則:prioritiseInitial為true。
  2. total-size規則:prioritiseInitial為false。
apply(compiler) {
	const prioritiseInitial = this.options.prioritiseInitial;
	compiler.hooks.compilation.tap("OccurrenceOrderChunkIdsPlugin",compilation => {
		compilation.hooks.optimizeChunkOrder.tap("OccurrenceOrderChunkIdsPlugin",chunks => {
			const occursInInitialChunksMap = new Map();
			const originalOrder = new Map();
			let i = 0;
			for (const c of chunks) {
				let occurs = 0;
				//得到chunk的chunkGroup
				for (const chunkGroup of c.groupsIterable) {
				    //檢視當前模組有沒有被其它模組引用
					for (const parent of chunkGroup.parentsIterable) {
					    //isInitial方法始終返回true
						if (parent.isInitial()) occurs++;
					}
				}
				occursInInitialChunksMap.set(c, occurs);
				originalOrder.set(c, i++);
			}
			//排序
			chunks.sort((a, b) => {
			    //如果規則是size,prioritiseInitial為true,通過父模組的數量來排序。如果父模組相同,則按照和total-size相同的規則排序。
				if (prioritiseInitial) {
					const aEntryOccurs = occursInInitialChunksMap.get(a);
					const bEntryOccurs = occursInInitialChunksMap.get(b);
					if (aEntryOccurs > bEntryOccurs) return -1;
					if (aEntryOccurs < bEntryOccurs) return 1;
				}
				//得到groups的大小,內部呼叫this._group.size
				const aOccurs = a.getNumberOfGroups();
				const bOccurs = b.getNumberOfGroups();
				if (aOccurs > bOccurs) return -1;
				if (aOccurs < bOccurs) return 1;
				//依據chunk在chunks中的索引位置排序
				const orgA = originalOrder.get(a);
				const orgB = originalOrder.get(b);
				return orgA - orgB;
	    	});
		});
	});
}
複製程式碼

OccurrenceChunkOrderPlugin通過prioritiseInitial區分是size還是total-size:

  • prioritiseInitial為true:根據父模組的數量排序,如果數量相同走total-size的邏輯。
  • prioritiseInitial為false:首先根據chunk的groups的數量排序,如果數量相同,根據chunk所在的索引排序。

小結

  1. 首先我們會發現除了named之外的規則都是生成的number值,並且只是在生成chunkId前,對chunks以不同的規則進行排序。
  2. 通過named規則,我們可以發現,如果在beforeChunkIds中給chunkId賦值,那麼就會阻截預設的規則。

chunkId是怎麼破壞線上快取的?

說到破壞,我們心中可能又會有疑問,這東西怎麼會破壞線上快取呢?我們來模擬一個場景。

想必業務思想很好的你,有時候也會讓業務的快速變更搞的非常煩惱,假設一個blog專案三個功能模組:文章列表頁、文章標籤頁、關於頁,並且三個功能模組都是非同步的。我們來簡寫一下程式碼。

首先入口檔案為index.js,三個功能模組程式碼為articleList.js、articleTag.js、about.js。

//三個功能模組的程式碼如下:
//articleList.js
const ArticleList = () => {
  console.log('ArticleList')
}

export default ArticleList
//articleTag.js
const ArticleTag = () => {
  console.log('ArticleTag')
}

export default ArticleTag
//about.js
const About = () => {
  console.log('About')
}

export default About;
複製程式碼

在index.js中非同步引入這三個功能模組。

// 引入articleList
import('./articleList').then(_ => {
  _.default()
})

// 引入articleTag
import('./articleTag').then(_ => {
  _.default()
})

// 引入about
import('./about').then(_ => {
  _.default()
})
複製程式碼

我們使用生產環境打包一下,得到dist目錄中的檔案如下:

深入理解webpack的chunkId對線上快取的思考

很完美,打包成功,結果也肯定和我們想的一樣。

假如有一天,需求變了,關於我們頁不想要了,讓它暫時不存在專案裡面了(為了方便檔案的diff,我們先把當前的程式碼做一個備份),我們可以先把About的程式碼在index.js中的程式碼註釋。

// 引入articleList
import('./articleList').then(_ => {
  _.default()
})

// 引入articleTag
import('./articleTag').then(_ => {
  _.default()
})

// 引入about
//import('./about').then(_ => {
//  _.default()
//})
複製程式碼

註釋之後,重新打包。重新生成的檔案和備份的如下

深入理解webpack的chunkId對線上快取的思考

打包結果如我們所想,一切都很平靜,但是殊不知平靜的背後正在掀起大浪。我們來使用一個比較工具檔案內容比較工具---Beyond Compare,選取dist中和備份中的1.js檔案來做一下比較。

深入理解webpack的chunkId對線上快取的思考
哇,你會發現,除了webpack的執行程式碼之外,其它的都不一樣了,如果這樣把程式碼拋到線上,這也就意味著在About的chunkId後非同步chunk線上快取都將失效。

可以你會說,這不小意思嗎?我有webpack的魔法註釋,不讓檔名變不就得了。(此時作者只能呵呵一笑)我們來驗證一下。

我們來把index中引入的三個模組都加上魔法註釋:

// 引入articleList
import( /* webpackChunkName: "articleList" */'./articleList').then(_ => {
  _.default()
})

// 引入articleTag
import( /* webpackChunkName: "articleTag" */ './articleTag').then(_ => {
  _.default()
})

// 引入about
import( /* webpackChunkName: "about" */ './about').then(_ => {
  _.default()
})
複製程式碼

打包結果如下

深入理解webpack的chunkId對線上快取的思考
我們把當前dist備份,再把about註釋,重新打包結果為

深入理解webpack的chunkId對線上快取的思考
我們來選擇articleList來比較一下,開啟Beyond Compare,選擇dist和備份中的articleList檔案。

深入理解webpack的chunkId對線上快取的思考
呵呵,主要內容確實沒有變化,但是我們的chunkId變了,那麼檔案內容也變了,快取失效。

小結

  1. 按需載入可以使單個js檔案的程式碼量更小、載入更快,但是帶來優化的同時也對快取產生了極大的傷害。
  2. 快取是效能優化中極為重要的部分,罪魁禍首在chunkId,所以必須盤它。

解決chunkId對破壞快取的方法

相信上述問題,早已被社群的同學們發現,筆者在也曾找了一會外掛,但都沒有如願,心裡不服,乾脆自己寫一個。

webpack-fixed-chunk-id-plugin 這個外掛已經被筆者釋出到npm,程式碼極簡,可能會存在不足,還望社群大佬多多提建議,共同成長。

說說我的想法

根據上文我們可以得出,萬物的罪魁禍首是chunkId,所以必須要固定它,才能讓檔案內容不會變。那如何固定呢?

第一點:根據上文第一部分分析chunkId生成原理的時候,我們從named這個規則中得出只要在beforeChunkIds,這個地方給chunkId一個值,在applyChunKId階段就不會對chunkId執行定義的規則。

第二點:上一點得出在webpack什麼階段來控制chunkId,那麼這點就應該討論控制chunkId要基於什麼來控制? 第一個想到的肯定是內容,基於內容來控制chunkId,當內容變chunkId變、內容不變chunkId不變。

基於上面兩點,外掛程式碼如下:

const crypto = require('crypto');
const pluginName = "WebpackFixedChunkIdPlugin";

class WebpackFixedChunkIdPlugin {
      constructor({hashLength = 8} = {}) {
            //todo 
            this.hashStart = 0;
            this.hashLength = hashLength;
      }
      apply(compiler) {
            compiler.hooks.compilation.tap(pluginName, (compilation) => {
                  compilation.hooks.beforeChunkIds.tap(pluginName, (chunks) => {
                        chunks.forEach((chunk,idx) => {
                              let  modulesVal,
                                    chunkId;
                              if(![...chunk._modules].length) {
                                    modulesVal = chunk.name;
                              } else {
                                    const modules = chunk._modules;
                                    for(let module of modules) {
                                          modulesVal += module._source._value;
                                    }
                              }
                              const chunkIdHash = crypto.createHash('md5').update(modulesVal).digest('hex');
                              chunkId = chunkIdHash.substr(this.hashStart, this.hashLength);
                              chunk.id = chunkId;
                        })
                  })
            })
      }
      
}

module.exports = WebpackFixedChunkIdPlugin;
複製程式碼

通過掛載到beforeChunkIds鉤子上,拿到所有的chunk,遍歷每一個chunk得到所有合併當前chunk的module的內容,使用node的crypto加密模組,對內容計算hash值,設定chunk.id。下面我們來測試一下,這個外掛好不好用。

//下載外掛:npm install webpack-fixed-chunk-id-plugin
const WebpackFixedChunkIdPlugin = require('webpack-fixed-chunk-id-plugin');
module.exports = {
    plugins: [
    new WebpackFixedChunkIdPlugin()
    ]
}
複製程式碼

打包一下檢視結果:

深入理解webpack的chunkId對線上快取的思考
在打包日誌的我們發現,Chunks那裡變成了一串hash值,這就是根據module內容計算出的hash值。我們再把about功能模組註釋,打包一下,並檢視結果。

深入理解webpack的chunkId對線上快取的思考
沒有報紅,減少一個模組並不會影響其他模組,完美。

小結

  1. 根據打包問題,確定事故發生地點---chunkId。
  2. 根據事故發生時機,找出阻截事故發生方案---beforeChunkIds。
  3. 定製可行方案---基於module內容來生成唯一hash。

遠觀未來,webpack5完美解決

chunkId事故問題可謂webpack自身留下的坑,chunkId方便了開發者,同樣chunkId也對我們造成了極大的破壞,正所謂:成也chunkId、敗也chunkId。

webpack4中遺留的問題,在還未現世的webpack5中得到了完美的解決。

接下來開始嚐鮮webpack5。由於webpack5還未發版,我們可以通過一些方法來使用它。

//下載webpack5
npm init -y
npm install webpack@next --save-dev
npm install webpack-cli --save-dev
複製程式碼

把webpack4中的src下的程式碼拷貝到webpack5中打包,結果如下:

深入理解webpack的chunkId對線上快取的思考
由上圖結果可見,Chunks欄都變成了一個確定的數字值,並且可能(接下來論證)是不受其他chunk影響的。

我們來按照之前的方式驗證一下,把about模組註釋,並使用Beyond Compare比較一下。

深入理解webpack的chunkId對線上快取的思考
沒有影響,webpack5完美的解決了這個問題。

雖然webpack5可以執行以上操作,但是由於目前還未釋出,以cli的配合並不完善。目前的版本,只要寫webpack.config.js使用cli啟動就會報錯,如果要使用配置檔案的話就只能使用node來啟動webpack。

並且如果要使用webpack5完美的chunkId,還需要在webpack配置檔案中配置一下內容:

module.exports = {
    optimization: {
        chunkIds: 'deterministic',
    }
}
複製程式碼

小結

目前的webpack5已經有了很多優秀的特性,包括程式碼也變的更加簡介,總之,擁抱webpack5吧。

總結

我們在開發過程中關注甚少的chunkId竟能引發這個大的問題,所以個人認為不僅是在學習還是在深入研究的過程中都要抱有疑問或是懷疑態度,促使我們去挖掘原理,只有明白真正內部實現的時候,才能完全的相信它,這也是我的一種自我提升的方式。

相關文章