我從 Vuejs 中學(xué)到了什么
框架設(shè)計(jì)遠(yuǎn)沒(méi)有大家想的那么簡(jiǎn)單,并不是說(shuō)只把功能開(kāi)發(fā)完成,能用就算完事兒了,這里面還是有很多學(xué)問(wèn)的。比如說(shuō),我們的框架應(yīng)該給用戶(hù)提供哪些構(gòu)建產(chǎn)物?產(chǎn)物的模塊格式如何?當(dāng)用戶(hù)沒(méi)有以預(yù)期的方式使用框架時(shí)是否應(yīng)該打印合適的警告信息從而提升更好的開(kāi)發(fā)體驗(yàn),讓用戶(hù)快速定位問(wèn)題?開(kāi)發(fā)版本的構(gòu)建和生產(chǎn)版本的構(gòu)建有何區(qū)別?熱跟新(HMR:Hot Module Replacement)需要框架層面的支持才行,我們是否也應(yīng)該考慮?再有就是當(dāng)你的框架提供了多個(gè)功能,如果用戶(hù)只需要其中幾個(gè)功能,那么用戶(hù)是否可以選擇關(guān)閉其他功能從而減少資源的打包體積?所有以上這些問(wèn)題我們都會(huì)在本節(jié)內(nèi)容進(jìn)行討論。
本節(jié)內(nèi)容需要大家對(duì)常用的模塊打包工具有一定的使用經(jīng)驗(yàn),尤其是 rollup.js 以及 webpack。如果你只用過(guò)或了解過(guò)其中一個(gè)也沒(méi)關(guān)系,因?yàn)樗鼈兒芏喔拍钇鋵?shí)是類(lèi)似的。如果你沒(méi)有使用任何模塊打包工具那么需要你自行去了解一下,至少有了初步認(rèn)識(shí)之后再來(lái)看本節(jié)內(nèi)容會(huì)更好一些。
提升用戶(hù)的開(kāi)發(fā)體驗(yàn)
衡量一個(gè)框架是否足夠優(yōu)秀的指標(biāo)之一就是看它的開(kāi)發(fā)體驗(yàn)如何,我們拿 Vue3 舉個(gè)例子:
createApp(App).mount('#not-exist')
當(dāng)我們創(chuàng)建一個(gè) Vue 應(yīng)用并試圖將其掛載到一個(gè)不存在的 DOM 節(jié)點(diǎn)時(shí)就會(huì)得到一個(gè)警告信息:
warn
從這條信息中我們得知掛載失敗了,并說(shuō)明了失敗的原因:Vue 根據(jù)我們提供的選擇器無(wú)法找到相應(yīng)的 DOM 元素(返回 null),正式因?yàn)檫@條信息的存在使得我們能夠清晰且快速的了解并定位問(wèn)題,可以試想一下如果 Vue 內(nèi)部不做任何處理,那么很可能得到的是一個(gè) JS 層面的錯(cuò)誤信息,例如:Uncaught TypeError: Cannot read property 'xxx' of null,但是根據(jù)此信息我們很難知道問(wèn)題出在哪里。
所以在框架設(shè)計(jì)和開(kāi)發(fā)的過(guò)程中,提供友好的警告信息是至關(guān)重要的,如果這一點(diǎn)做得不好那么很可能經(jīng)常收到用戶(hù)的抱怨。始終提供友好的警告信息不僅能夠快速幫助用戶(hù)定位問(wèn)題,節(jié)省用戶(hù)的時(shí)間,還能夠?yàn)榭蚣苁斋@良好的口碑,讓用戶(hù)認(rèn)為你是非常專(zhuān)業(yè)的。
在 Vue 的源碼中,你經(jīng)常能夠看到 warn() 函數(shù)的調(diào)用,例如上面圖片中的信息就是由這句 warn() 函數(shù)調(diào)用打印的:
warn(
??`Failed?to?mount?app:?mount?target?selector?"${container}"?returned?null.`
)
對(duì)于 warn() 函數(shù)來(lái)說(shuō),由于它需要盡可能的提供有用的信息,因此它需要收集當(dāng)前發(fā)生錯(cuò)誤的組件的組件棧信息,所以如果你去看源碼你會(huì)發(fā)現(xiàn)有些復(fù)雜,但其實(shí)最終就是調(diào)用了 console.warn() 函數(shù)。
對(duì)于開(kāi)發(fā)體驗(yàn)來(lái)說(shuō),除了提供必要的警告信息,還有很多其他方面可以作為切入口,可以進(jìn)一步提升用戶(hù)的開(kāi)發(fā)體驗(yàn)。例如在 Vue3 中當(dāng)我們?cè)诳刂婆_(tái)打印一個(gè) Ref 數(shù)據(jù)時(shí):
const?count?=?ref(0)
console.log(count)
打開(kāi)控制臺(tái)查看輸出,如下圖所示:
沒(méi)有任何處理的輸出
可以發(fā)現(xiàn)非常的不直觀(guān),當(dāng)然我們可以直接打印 count.value ,這樣就只會(huì)輸出 0,但是有沒(méi)有辦法在打印 count 的時(shí)候讓輸出的信息更有好呢?當(dāng)然可以,瀏覽允許我們編寫(xiě)自定義的 formatter,從而自定義輸出的形式。在 Vue 的源碼中你可以搜索到名為 initCustomFormatter 的函數(shù),這個(gè)函數(shù)就是用來(lái)在開(kāi)發(fā)環(huán)境下初始化自定義 formatter 的,以 chrome 為例我們可以打開(kāi) devtool 的設(shè)置,然后勾選 Console -> Enable custom formatters:

然后刷新瀏覽器后查看控制臺(tái),會(huì)發(fā)現(xiàn)輸出的內(nèi)容變得非常直觀(guān):

控制框架代碼的體積
框架的大小也是衡量框架的標(biāo)準(zhǔn)之一,在實(shí)現(xiàn)同樣功能的情況下當(dāng)然是用越少的代碼越好,這樣體積就會(huì)越小,最后瀏覽器加載資源的時(shí)間也就越少。這時(shí)我們不禁會(huì)想,提供越完善的警告信息就意味著我們要編寫(xiě)更多的代碼,這不是與控制代碼體積相駁嗎?沒(méi)錯(cuò),所以我們要想辦法解決這個(gè)問(wèn)題。
如果我們?nèi)タ?Vue 的源碼會(huì)發(fā)現(xiàn),每一個(gè) warn() 函數(shù)的調(diào)用都會(huì)配合 __DEV__ 常量的檢查,例如:
if?(__DEV__?&&?!res)?{
??warn(
????`Failed?to?mount?app:?mount?target?selector?"${container}"?returned?null.`
??)
}
可以看到,打印警告信息的前提是:__DEV__ 這個(gè)常量一定要為真,這里的 __DEV__ 常量就是達(dá)到目的的關(guān)鍵。
Vue 使用的是 rollup.js 對(duì)項(xiàng)目進(jìn)行構(gòu)建的,這里的 __DEV__ 常量實(shí)際上是通過(guò) rollup 的配置來(lái)預(yù)定義的,其功能類(lèi)似于 webpack 中的 DefinePlugin 插件。
Vue 在輸出資源的時(shí)候,會(huì)輸出兩個(gè)版本的資源,其中一個(gè)資源用于開(kāi)發(fā)環(huán)境,如 vue.global.js ;另一個(gè)與其對(duì)應(yīng)的用于生產(chǎn)環(huán)境,如:vue.global.prod.js ,通過(guò)文件名稱(chēng)我們也能夠區(qū)分。
當(dāng) Vue 構(gòu)建用于開(kāi)發(fā)環(huán)境的資源時(shí),會(huì)把 __DEV__ 常量設(shè)置為 true,這時(shí)上面那段輸出警告信息的代碼就等價(jià)于:
if?(true?&&?!res)?{
??warn(
????`Failed?to?mount?app:?mount?target?selector?"${container}"?returned?null.`
??)
}
可以看到這里的 __DEV__ 被替換成了字面量 true ,所以這段代碼在開(kāi)發(fā)環(huán)境是肯定存在的。
當(dāng) Vue 構(gòu)建用于生產(chǎn)環(huán)境的資源時(shí),會(huì)把 __DEV__ 常量設(shè)置為 false,這時(shí)上面那段輸出警告信息的代碼就等價(jià)于:
if?(false?&&?!res)?{
??warn(
????`Failed?to?mount?app:?mount?target?selector?"${container}"?returned?null.`
??)
}
可以看到 __DEV__ 常量被替換為字面量 false ,這時(shí)我們發(fā)現(xiàn)這段分支代碼永遠(yuǎn)都不會(huì)執(zhí)行,因?yàn)榕袛鄺l件始終為假,這段永遠(yuǎn)不會(huì)執(zhí)行的代碼被稱(chēng)為 Dead Code,它不會(huì)出現(xiàn)在最終的產(chǎn)物中,在構(gòu)建資源的時(shí)候就會(huì)被移除,因此在 vue.global.prod.js 中是不會(huì)存在這段代碼的。
這樣我們就做到了在開(kāi)發(fā)環(huán)境為用戶(hù)提供友好的警告信息的同時(shí),還不會(huì)增加生產(chǎn)環(huán)境代碼的體積。
框架要做到良好的 Tree-Shaking
上文中我們提到通過(guò)構(gòu)建工具設(shè)置預(yù)定義的常量 __DEV__ ,就能夠做到在生產(chǎn)環(huán)境使得框架不包含打印警告信息的代碼,從而使得框架自身的代碼量變少。但是從用戶(hù)的角度來(lái)看,這么做仍然不夠,還是拿 Vue 來(lái)舉個(gè)例子,我們知道 Vue 提供了內(nèi)置的組件例如 ,如果我們的項(xiàng)目中根本就沒(méi)有使用到該組件,那么 組件的代碼需要包含在我們項(xiàng)目最終的構(gòu)建資源中嗎?答案是當(dāng)然不需要,那如何做到這一點(diǎn)呢?這就不得不提到本節(jié)的主角 Tree-Shaking。
那什么是 Tree-Shaking 呢?在前端領(lǐng)域這個(gè)概念因 rollup 而普及,簡(jiǎn)單的說(shuō)所謂 **Tree-Shaking **指的就是消除哪些永遠(yuǎn)不會(huì)執(zhí)行的代碼,也就是排除 dead-code,現(xiàn)在無(wú)論是 rollup 還是 webpack 都支持 Tree-Shaking。
想要實(shí)現(xiàn) Tree-Shaking 必須滿(mǎn)足一個(gè)條件,即模塊必須是 ES Module,因?yàn)?Tree-Shaking 依賴(lài) ESM 的靜態(tài)結(jié)構(gòu)。我們使用 rollup 通過(guò)一個(gè)簡(jiǎn)單的例子看看 Tree-Shaking 如何工作,我們 demo 的目錄結(jié)構(gòu)如下:
├──?demo
│???└──?package.json
│???└──?input.js
│???└──?utils.js
首先安裝 rollup:
yarn?add?rollup?-D?#?或者?npm?install?rollup?-D
下面是 input.js 和 utils.js 文件的內(nèi)容:
//?input.js
import?{?foo?}?from?'./utils.js'
foo()
//?utils.js
export?function?foo(obj)?{
??obj?&&?obj.foo
}
export?function?bar(obj)?{
??obj?&&?obj.bar
}
代碼很簡(jiǎn)單,我們?cè)?utils.js 文件中定義并導(dǎo)出了兩個(gè)函數(shù),分別是 foo 和 bar,然后在 input.js 中導(dǎo)入了 foo 函數(shù)并執(zhí)行,注意我們并沒(méi)有導(dǎo)入 bar 函數(shù)。
接著我們執(zhí)行如下命令使用 rollup 構(gòu)建:
npx?rollup?input.js?-f?esm?-o?bundle.js
這句命令的意思是以 input.js 文件問(wèn)入口,輸出 ESM 模塊,輸出的文件名叫做 bundle.js 。命令執(zhí)行成功后,我們打開(kāi) bundle.js 來(lái)查看一下它的內(nèi)容:
//?bundle.js
function?foo(obj)?{
??obj?&&?obj.foo
}
foo();
可以看到,其中并不包含 bar 函數(shù),這說(shuō)明 Tree-Shaking 起了作用,由于我們并沒(méi)有使用 bar 函數(shù),因此它作為 dead-code 被刪除了。但是如果我們仔細(xì)觀(guān)察會(huì)發(fā)現(xiàn),foo 函數(shù)的執(zhí)行也沒(méi)啥意義呀,就是讀取了對(duì)象的值,所以它執(zhí)行還是不執(zhí)行也沒(méi)有本質(zhì)的區(qū)別呀,所以即使把這段代碼刪了,也對(duì)我們的應(yīng)用沒(méi)啥影響,那為什么 rollup 不把這段代碼也作為 dead-code 移除呢?
這就涉及到 Tree-Shaking 中的第二個(gè)關(guān)鍵點(diǎn),即副作用。如果一個(gè)函數(shù)調(diào)用會(huì)產(chǎn)生副作用,那么就不能將其移除。什么是副作用?簡(jiǎn)單地說(shuō)副作用的意思是當(dāng)調(diào)用函數(shù)的時(shí)候,會(huì)對(duì)外部產(chǎn)生影響,例如修改了全局變量。這時(shí)你可能會(huì)說(shuō),上面的代碼明顯是讀取對(duì)象的值怎么會(huì)產(chǎn)生副作用呢?其實(shí)是有可能的,想想一下如果 obj 對(duì)象是一個(gè)通過(guò) Proxy 創(chuàng)建的代理對(duì)象那么當(dāng)我們讀取對(duì)象屬性時(shí)就會(huì)觸發(fā) Getter ,在 Getter 中是可能產(chǎn)生副作用的,例如我們?cè)?Getter 中修改了某個(gè)全局變量。而到底會(huì)不會(huì)產(chǎn)生副作用,這個(gè)只有代碼真正運(yùn)行的時(shí)候才能知道, JS 本身是動(dòng)態(tài)語(yǔ)言,想要靜態(tài)的分析哪些代碼是 dead-code 是一件很有難度的事兒,上面只是舉了一個(gè)簡(jiǎn)單的例子。
正因?yàn)殪o態(tài)分析 JS 代碼很困難,所以諸如 rollup 等這類(lèi)工具都會(huì)給我提供一個(gè)機(jī)制,讓我們有能力明確的告訴 rollup :”放心吧,這段代碼不會(huì)產(chǎn)生副作用,你可以放心移除它“,那具體怎么做呢?如下代碼所示,我們修改 input.js 文件:
import?{foo}?from?'./utils'
/*#__PURE__*/?foo()
注意這段注釋代碼 /*#__PURE_*_/,該注釋的作用就是用來(lái)告訴 rollup 對(duì)于 foo() 函數(shù)的調(diào)用不會(huì)產(chǎn)生副作用,你可以放心的對(duì)其進(jìn)行 Tree-Shaking,此時(shí)再次執(zhí)行構(gòu)建命令并查看 bundle.js 文件你會(huì)發(fā)現(xiàn)它的內(nèi)容是空的,這說(shuō)明 Tree-Shaking 生效了。
基于這個(gè)案例大家應(yīng)該明白的是,在編寫(xiě)框架的時(shí)候我們需要合理的使用 /*#__PURE_*_/ 注釋?zhuān)绻闳ニ阉?Vue 的源碼會(huì)發(fā)現(xiàn)它大量的使用了該注釋?zhuān)缦旅孢@句:
export?const?isHTMLTag?=?/*#__PURE__*/?makeMap(HTML_TAGS)
也許你會(huì)覺(jué)得這會(huì)不會(huì)對(duì)編寫(xiě)代碼帶來(lái)很大的心智負(fù)擔(dān)?其實(shí)不會(huì),這是因?yàn)橥ǔ.a(chǎn)生副作用的代碼都是模塊內(nèi)函數(shù)的頂級(jí)調(diào)用,什么是頂級(jí)調(diào)用呢?如下代碼所示:
foo()?//?頂級(jí)調(diào)用
function?bar()?{
??foo()?//?函數(shù)內(nèi)調(diào)用
}
可以看到對(duì)于頂級(jí)調(diào)用來(lái)說(shuō)是可能產(chǎn)生副作用的,但對(duì)于函數(shù)內(nèi)調(diào)用來(lái)說(shuō)只要函數(shù) bar 沒(méi)有被調(diào)用,那么 foo 函數(shù)的調(diào)用當(dāng)然不會(huì)產(chǎn)生副作用。因此你會(huì)發(fā)現(xiàn)在 Vue 的源碼中,基本都是在一些頂級(jí)調(diào)用的函數(shù)上使用 /*#__PURE__*/ 注釋的。當(dāng)然該注釋不僅僅作用與函數(shù),它可以使用在任何語(yǔ)句上,這個(gè)注釋也不是只有 rollup 才能識(shí)別,webpack 以及壓縮工具如 terser 都能識(shí)別它。
框架應(yīng)該輸出怎樣的構(gòu)建產(chǎn)物
上文中我們提到 Vue 會(huì)為開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境輸出不同的包,例如 vue.global.js 用于開(kāi)發(fā)環(huán)境,它包含了必要的警告信息,而 vue.global.prod.js 用于生產(chǎn)環(huán)境,不包含警告信息。實(shí)際上 Vue 的構(gòu)建產(chǎn)物除了有環(huán)境上的區(qū)分之外,還會(huì)根據(jù)使用場(chǎng)景的不同而輸出其他形式的產(chǎn)物,這一節(jié)我們將討論這些產(chǎn)物的用途以及在構(gòu)建階段如何輸出這些產(chǎn)物。
不同類(lèi)型的產(chǎn)物一定是有對(duì)應(yīng)的需求背景的,因此我們從需求講起。首先我們希望用戶(hù)可以直接在 html 頁(yè)面中使用
