按需加載是所有組件庫都會(huì)提供的一個(gè)基礎(chǔ)能力,本文會(huì)分析ElementUI
阻问、Vant
及varlet
幾個(gè)組件庫的實(shí)現(xiàn)并進(jìn)行相應(yīng)實(shí)踐吟逝,幫助你徹底搞懂其實(shí)現(xiàn)原理。
先搭個(gè)簡單的組件庫
筆者從ElementUI
里copy
了兩個(gè)組件:Alert
和Tag
琳袄,并將我們的組件庫命名為XUI
江场,當(dāng)前目錄結(jié)構(gòu)如下:
組件都放在packages
目錄下,每個(gè)組件都是一個(gè)單獨(dú)的文件夾窖逗,最基本的結(jié)構(gòu)是一個(gè)js
文件和一個(gè)vue
文件址否,組件支持使用Vue.component
方式注冊,也支持插件方式Vue.use
注冊碎紊,js
文件就是用來支持插件方式使用的佑附,比如Alert
的js
文件內(nèi)容如下:
import Alert from './src/main';
Alert.install = function(Vue) {
Vue.component(Alert.name, Alert);
};
export default Alert;
就是給組件添加了一個(gè)install
方法,這樣就可以使用Vue.use(Alert)
來注冊仗考。
組件的主題文件統(tǒng)一放在/theme-chalk
目錄下音同,也是每個(gè)組件一個(gè)樣式文件,index.css
包含了所有組件的樣式秃嗜,ElementUI
的源碼內(nèi)是scss
文件权均,本文為了簡單,直接復(fù)制了其npm
包內(nèi)已經(jīng)編譯后的css
文件锅锨。
最外層還有一個(gè)index.js
文件叽赊,這個(gè)文件很明顯是用來作為入口文件導(dǎo)出所有組件:
import Alert from './packages/alert/index.js';
import Tag from './packages/tag/index.js';
const components = [
Alert,
Tag
]
const install = function (Vue) {
components.forEach(component => {
Vue.component(component.name, component);
});
};
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue);
}
export default {
install,
Alert,
Tag
}
首先依次引入組件庫的所有組件,然后提供一個(gè)install
方法必搞,遍歷所有組件必指,依次使用Vue.component
方法注冊,接下來判斷是否存在全局的Vue
對象恕洲,是的話代表是CDN
方式使用塔橡,那么自動(dòng)進(jìn)行注冊,最后導(dǎo)出install
方法和所有組件霜第。
Vue
的插件就是一個(gè)帶有install
方法的對象葛家,所以我們可以直接引入所有組件:
import XUI from 'xui'
import 'xui/theme-chalk/index.css'
Vue.use(XUI)
也可以單獨(dú)注冊某個(gè)組件:
import XUI from 'xui'
import 'xui/theme-chalk/alert.css'
Vue.use(XUI.Alert)
為什么不直接通過import { Alert } form 'xui'
來引入呢,很明顯泌类,會(huì)報(bào)錯(cuò)癞谒。
因?yàn)槲覀兊慕M件庫并沒有發(fā)布到NPM
,所以通過npm link
將我們的組件庫鏈接到全局。
接下來筆者使用Vue CLI
搭建了一個(gè)測試項(xiàng)目扯俱,運(yùn)行npm link xui
來鏈接到組件庫。然后使用前面的方式注冊組件庫或某個(gè)組件喇澡,這里我們只使用Alert
組件迅栅。
通過測試可以發(fā)現(xiàn),無論是注冊所有組件晴玖,還是只注冊Alert
組件读存,最后打包后的js
里都存在Tag
組件的內(nèi)容:
接下來開啟本文的正文,看看如何把Tag
去掉呕屎。
最簡單的按需引入
因?yàn)槊總€(gè)組件都可以單獨(dú)作為一個(gè)插件让簿,所以我們完全可以只引入某個(gè)組件,比如:
import Alert from 'xui/packages/alert'
import 'xui/theme-chalk/alert.css'
Vue.use(Alert)
這樣我們只引入了alert
相關(guān)的文件秀睛,當(dāng)然最后只會(huì)包含alert
組件的內(nèi)容尔当。這樣的問題是比較麻煩,使用上成本比較高蹂安,最理想的方式還是下面這種:
import { Alert } from 'xui'
通過babel插件
使用babel
插件是目前大多數(shù)組件庫實(shí)現(xiàn)按需引入的方式椭迎,ElementUI
使用的是babel-plugin-component
:
可以看到能直接使用import { Alert } form 'xui'
方式來引入Alert
組件,也不需要手動(dòng)引入樣式田盈,那么這是怎么實(shí)現(xiàn)的呢畜号,接下來我們來擼一個(gè)極簡版的。
原理很簡單允瞧,我們想要的是下面這種方式:
import { Alert } from 'xui'
但是實(shí)際按需使用需要這樣:
import Alert from 'xui/packages/alert'
很明顯简软,我們只要幫用戶把第一種方式轉(zhuǎn)換成第二種就可以了,而通過babel
插件來轉(zhuǎn)換對用戶來說是無感的述暂。
首先在babel.config.js
同級(jí)新增一個(gè)babel-plugin-component.js
文件痹升,作為我們插件文件,然后修改一下babel.config.js
文件:
module.exports = {
// ...
plugins: ['./babel-plugin-component.js']
}
使用相對路徑引用我們的插件贸典,接下來就可以愉快的編碼了视卢。
先來看一下import { Alert } from 'xui'
對應(yīng)的AST
樹
整體是一個(gè)ImportDeclaration
,通過souce.value
可以判斷導(dǎo)入的來源廊驼,specifiers
數(shù)組里可以找到導(dǎo)入的變量据过,每個(gè)變量是一個(gè)ImportSpecifier
,可以看到里面有兩個(gè)對象:ImportSpecifier.imported
和ImportSpecifier.local
妒挎,這兩個(gè)有什么區(qū)別呢绳锅,在于是否使用了別名導(dǎo)入,比如:
import { Alert } from 'xui'
這種情況imported
和local
是一樣的酝掩,但是如果使用了別名:
import { Alert as a } from 'xui'
那么是這樣的:
我們這里簡單起見就不考慮別名情況鳞芙,只使用imported
。
接下來的任務(wù)就是進(jìn)行轉(zhuǎn)換,看一下import Alert from 'xui/packages/alert'
的AST
結(jié)構(gòu):
目標(biāo)AST
結(jié)構(gòu)也清楚了接下來的事情就簡單了原朝,遍歷specifiers
數(shù)組創(chuàng)建新的importDeclaration
節(jié)點(diǎn)驯嘱,然后替換掉原來的節(jié)點(diǎn)即可:
// babel-plugin-component.js
module.exports = ({
types
}) => {
return {
visitor: {
ImportDeclaration(path) {
const {
node
} = path
const {
value
} = node.source
if (value === 'xui') {
// 找出引入的組件名稱列表
let specifiersList = []
node.specifiers.forEach(spec => {
if (types.isImportSpecifier(spec)) {
specifiersList.push(spec.imported.name)
}
})
// 給每個(gè)組件創(chuàng)建一條導(dǎo)入語句
const importDeclarationList = specifiersList.map((name) => {
// 文件夾的名稱首字母為小寫
let lowerCaseName = name.toLowerCase()
// 構(gòu)造importDeclaration節(jié)點(diǎn)
return types.importDeclaration([
types.importDefaultSpecifier(types.identifier(name))
], types.stringLiteral('xui/packages/' + lowerCaseName))
})
// 用多節(jié)點(diǎn)替換單節(jié)點(diǎn)
path.replaceWithMultiple(importDeclarationList)
}
}
},
}
}
接下來打包測試結(jié)果如下:
可以看到Tag
組件的內(nèi)容已經(jīng)沒有了。
當(dāng)然喳坠,以上實(shí)現(xiàn)只是一個(gè)最簡單的demo
鞠评,實(shí)際上還需要考慮樣式的引入、別名壕鹉、去重剃幌、在組件中引入、引入了某個(gè)組件但是實(shí)際并沒有使用等各種問題晾浴,有興趣的可以直接閱讀babel-plugin-component源碼负乡。
Vant
和antd
也都是采用這種方式,只是使用的插件不一樣脊凰,這兩個(gè)使用的都是babel-plugin-import抖棘,babel-plugin-component
其實(shí)也是fork
自babel-plugin-import
。
Tree Shaking方式
Vant
組件庫除了支持使用前面的Babel
插件按需加載外還支持Tree Shaking
方式狸涌,實(shí)現(xiàn)也很簡單钉答,Vant
最終發(fā)布的代碼里提供了三種規(guī)范的源代碼,分別是commonjs
杈抢、umd
数尿、esmodule
,如下圖:
commonjs
規(guī)范是最常見的使用方式惶楼,umd
一般用于cdn
方式直接在頁面引入右蹦,而esmodule
就是用來實(shí)現(xiàn)Tree Shaking
的,為什么esmodule
能實(shí)現(xiàn)Tree Shaking
而commonjs
規(guī)范不行呢歼捐,原因是esmodule
是靜態(tài)編譯的何陆,也就是在編譯階段就能確定某個(gè)模塊導(dǎo)出了什么,引入了什么豹储,代碼執(zhí)行階段不會(huì)改變贷盲,所以打包工具在打包的時(shí)候就能分析出哪個(gè)方法被使用了,哪些沒有剥扣,沒有用到的就可以放心的刪掉了巩剖。
接下來修改一下我們的組件庫,讓它也支持Tree Shaking
钠怯,因?yàn)槲覀兊慕M件本身就是esmodule
模塊佳魔,所以不需要修改,但是要修改一下導(dǎo)出的文件index.js
晦炊,因?yàn)槟壳斑€不支持下面這種方式導(dǎo)出:
import { Alert } from 'xui'
增加如下代碼:
// index.js
// ...
export {
Alert,
Tag
}
// ...
接下來需要修改package.json
鞠鲜,我們都知道package.json
里的main
字段是用來指示包的入口文件宁脊,那么是不是只要把這個(gè)字段指向esmodule
的入口文件就行了呢,其實(shí)是不行的贤姆,因?yàn)橥ǔG闆r下它都是指向commonjs
模塊入口榆苞,而且一個(gè)包有可能支持nodejs
和web
兩種環(huán)境使用,nodejs
環(huán)境是有可能不支持esmodule
模塊的霞捡,既然不能修改舊的字段语稠,那么就只能引入新的字段,也就是pkg.module
弄砍,所以修改package.json
文件如下:
// package.json
{
// ...
"mains": "index.js",
"module": "index.js",// 增加該字段
// ...
}
因?yàn)槲覀兊慕M件庫只有esmodule
模塊,所以其實(shí)這兩個(gè)字段指向的是一樣的输涕,在實(shí)際開發(fā)中音婶,需要向Vant
一樣編譯成不同類型的模塊,而且發(fā)布到npm
的模塊一般也需要編譯成es5
語法的莱坎,因?yàn)檫@些不是本文的重點(diǎn)衣式,所以就省略了這個(gè)步驟。
添加了pkg.module
字段檐什,如果打包工具能識(shí)別這個(gè)字段碴卧,那么會(huì)優(yōu)先使用esmodule
規(guī)范的代碼,但是到這里并沒有結(jié)束乃正,此時(shí)打包后發(fā)現(xiàn)Tag
組件的內(nèi)容依然在住册,這是為什么呢,不妨看看下面幾種導(dǎo)入場景:
import 'core-js'
import 'style.css'
這兩個(gè)文件都只引入了瓮具,但是并沒有明顯的進(jìn)行使用荧飞,可以把它們刪了嗎,顯然是不行的名党,這被稱為存在“副作用”叹阔,所以我們需要告訴打包工具哪些文件是沒有副作用的,可以刪掉传睹,哪些是有的耳幢,給我留著,Vue CLI
使用的是webpack
欧啤,對應(yīng)的我們需要在package.json
文件里新增一個(gè)sideEffects
字段:
// package.json
{
// ...
"sideEffects": ["**/*.css"],
// ...
}
我們的組件庫里只有樣式文件是存在副作用的睛藻。
接下來再打包測試,發(fā)現(xiàn)沒有引入的Tag
組件的內(nèi)容已經(jīng)被去除了:
更多關(guān)于Tree Shaking
的內(nèi)容可以閱讀Tree Shaking邢隧。
使用unplugin-vue-components插件
varlet
組件庫官方文檔上按需引入一節(jié)里提到使用的是unplugin-vue-components插件:
這種方式的優(yōu)點(diǎn)是完全不需要自己來引入組件修档,直接在模板里使用,由插件來掃描引入并注冊府框,這個(gè)插件內(nèi)置支持了很多市面上流行的組件庫吱窝,對于已經(jīng)內(nèi)置支持的組件庫讥邻,直接參考上圖引入對應(yīng)的解析函數(shù)配置一下即可,但是我們的小破組件庫它并不支持院峡,所以需要自己來寫這個(gè)解析器兴使。
首先這個(gè)插件做的事情只是幫我們引入組件并注冊,實(shí)際上按需加載的功能還是得靠前面兩種方式照激。
Tree Shaking
我們先在上一節(jié)的基礎(chǔ)上進(jìn)行修改发魄,保留package.json
的module
和sideEffects
的配置,然后從main.js
里刪除組件引入和注冊的代碼俩垃,然后修改vue.config.js
文件励幼。因?yàn)檫@個(gè)插件的官方文檔比較簡潔,看不出個(gè)所以然口柳,所以筆者是參考內(nèi)置的vant
解析器來修改的:
返回的三個(gè)字段含義應(yīng)該是比較清晰的苹粟,importName
表示引入的組件名,比如Alert
跃闹,path
表示從哪里引入嵌削,對于我們的組件庫就是xui
,sideEffects
就是存在副作用的文件望艺,基本就是配置對應(yīng)的樣式文件路徑苛秕,所以我們修改如下:
// vue.config.js
const Components = require('unplugin-vue-components/webpack')
module.exports = {
configureWebpack: {
plugins: [
Components({
resolvers: [{
type: "component",
resolve: (name) => {
if (name.startsWith("X")) {
const partialName = name.slice(1);
return {
importName: partialName,
path: "xui",
sideEffects: 'xui/theme-chalk/' + partialName.toLowerCase() + '.css'
};
}
}
}]
})
]
}
}
筆者怕前綴和ElementUI
重合,所以組件名稱前綴都由El
改成了X
找默,比如ElAlert
改成了XAlert
艇劫,當(dāng)然模板里也需要改成x-alert
,接下來進(jìn)行測試:
可以看到運(yùn)行正常惩激,打包后也成功去除了未使用的Tag
組件的內(nèi)容港准。
單獨(dú)引入
最后讓我們再看一下單獨(dú)引入的方式,先把pkg.module
和pkg.sideEffects
字段都移除咧欣,然后修改每個(gè)組件的index.js
文件浅缸,讓其支持如下方式引入:
import { Alert } from 'xui/packages/alert'
Alert
組件修改如下:
// index.js
import Alert from './src/main';
Alert.install = function(Vue) {
Vue.component(Alert.name, Alert);
};
// 新增下面兩行
export {
Alert
}
export default Alert;
接下來再修改我們的解析器:
const Components = require('unplugin-vue-components/webpack')
module.exports = {
configureWebpack: {
mode: 'production',
plugins: [
Components({
resolvers: [{
type: "component",
resolve: (name) => {
if (name.startsWith("X")) {
const partialName = name.slice(1);
return {
importName: partialName,
// 修改path字段,指向每個(gè)組件的index.js
path: "xui/packages/" + partialName.toLowerCase(),
sideEffects: 'xui/theme-chalk/' + partialName.toLowerCase() + '.css'
};
}
}
}]
})
]
}
}
其實(shí)就是修改了path
字段魄咕,讓其指向每個(gè)組件的index.js
文件衩椒,運(yùn)行測試和打包測試后結(jié)果也是符合要求的。
小節(jié)
本文簡單分析了一下組件庫實(shí)現(xiàn)按需引入的幾種方式哮兰,有組件庫開發(fā)需求的朋友可以自行抉擇毛萌,示例代碼請移步:https://github.com/wanglin2/ComponentLibraryImport。