深刻理解webpack的chunkId對線上緩存的思考

前言

想必常用基於webpack打包工具的框架的同窗們,不管是使用React仍是Vue在性能優化上使用最多的應該是分包策略(按需加載)。按需加載的方式使咱們的每個bundle變的更小,在每個單頁中只須要引入當前頁所使用到的JavaScript代碼,從而提升了代碼的加載速度。可是,因爲webpack目前版本自身的緣由分包策略雖然能夠提升咱們的加載速度,然而在線上緩存方面卻給了咱們極大的破壞(webpack5中解決了這個問題)。node

本文主要經過如下四個方面,來深刻剖析chunkId:webpack

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

chunkId的生成策略是什麼?

webpack是一個基於模塊化的打包工具,其整體打包流程可分爲:web

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

初始化階段算法

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

  • webpack-cli啓動,獲取webpack.config.js配置及合併shell參數
  • 根據cli獲得的配置合併默認配置
  • 建立compiler實例
  • 遍歷配置中的plugins加載第三方插件
  • 初始化默認插件

編譯階段npm

初始化完成以後,cli獲得compiler實例,執行compiler.run()開始編譯,編譯的過程主要分爲如下步驟:數組

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

輸出階段緩存

  • 輸出文件

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

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

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目錄中的文件以下:

很完美,打包成功,結果也確定和咱們想的同樣。

假若有一天,需求變了,關於咱們頁不想要了,讓它暫時不存在項目裏面了(爲了方便文件的diff,咱們先把當前的代碼作一個備份),咱們能夠先把About的代碼在index.js中的代碼註釋。

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

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

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

註釋以後,從新打包。從新生成的文件和備份的以下

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

哇,你會發現,除了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()
})
複製代碼

打包結果以下

咱們把當前dist備份,再把about註釋,從新打包結果爲

咱們來選擇articleList來比較一下,打開Beyond Compare,選擇dist和備份中的articleList文件。

呵呵,主要內容確實沒有變化,可是咱們的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()
    ]
}
複製代碼

打包一下查看結果:

在打包日誌的咱們發現,Chunks那裏變成了一串hash值,這就是根據module內容計算出的hash值。咱們再把about功能模塊註釋,打包一下,並查看結果。

沒有報紅,減小一個模塊並不會影響其餘模塊,完美。

小結

  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中打包,結果以下:

由上圖結果可見,Chunks欄都變成了一個肯定的數字值,而且 可能(接下來論證)是不受其餘chunk影響的。

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

沒有影響,webpack5完美的解決了這個問題。

雖然webpack5能夠執行以上操做,可是因爲目前還未發佈,以cli的配合並不完善。目前的版本,只要寫webpack.config.js使用cli啓動就會報錯,若是要使用配置文件的話就只能使用node來啓動webpack。

而且若是要使用webpack5完美的chunkId,還須要在webpack配置文件中配置一下內容:

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

小結

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

總結

咱們在開發過程當中關注甚少的chunkId竟能引起這個大的問題,因此我的認爲不只是在學習仍是在深刻研究的過程當中都要抱有疑問或是懷疑態度,促使咱們去挖掘原理,只有明白真正內部實現的時候,才能徹底的相信它,這也是個人一種自我提高的方式。

相關文章
相關標籤/搜索