您好,登錄后才能下訂單哦!
開篇
組件庫能幫我們節省開發精力,無需所有東西都從頭開始去做,通過一個個小組件拼接起來,就得到了我們想要的最終頁面。在日常開發中如果沒有特定的一些業務需求,使用組件庫進行開發無疑是更便捷高效,而且質量也相對更高的方案。
目前的開源組件庫有很多,不管是react還是vue的體系里都有很多非常優秀的組件庫,比如我經常使用的就有elementui和iview。當然也還有其他的一些組件庫,他們的本質其實都是為了節省重復造基礎組件這一輪子的過程。也有的公司可能會對自己公司的產品有特別的需求,不太愿意使用開源的組件庫的樣式,或者自己有一些公司內部的業務項目需要用到,但開源項目無法滿足的組件需要沉淀下來的時候,自建一套組件庫就成為了一個作為業務驅動所需要的項目。
本文會從 ”準備“ 和 ”實踐“ 兩個階段來闡述,一步步完成一個組件庫的打造。大致內容如下:
準備:主要講了搭建組件庫之前我們需要先提及一下一些基礎知識,為實踐階段做鋪墊。
實踐:有了一些基本概念,咱們就直接通過一個實踐案例來動手搭建一套基礎的組件庫。從做的過程中去感受組件庫的設計。
希望通過本文的分享以及包含的一個簡單的 實際操作案例,能讓你從組件庫使用者的角色向組件庫創造者的角色邁進那么一小步,在日常使用組件庫的時候心里有個底,那我的目的也就達到了。
我們的案例地址是:arronkler.github.io/lime-ui/
對應的 repo也就是:github.com/arronKler/l…
準備 :打造組件庫之前你應該知道些什么?
這一個章節主要是想先解析清楚一些在組件庫的建立中會用到的一些平時在業務概念中很少去關注的概念。我會分為工程和組件兩個方面來闡述,把我所知道的一些其中的技巧和坑點都交付出來,以幫助我們在實際去做的過程中可以有所準備。
項目:做一個組件庫項目有哪些額外需要考慮的事?
做組件庫項目和常規業務項目肯定還是有一些事情是我們業務項目不怎么需要,但是類庫項目一般都會考慮的事,這一小節就是介紹說明一下,那些我們在做組件庫的過程中需要額外考慮的事。
組件測試
很多開發者平時業務項目都比較趕,然后就是一般業務項目中都不怎么寫測試腳本。但在做一個組件庫項目的過程中,最好還是有對應的組件測試的腳本。至少有兩點好處:
自動化測試你寫的組件的功能特性
改動代碼不用擔心會影響之前的使用者。(測試腳本會告訴你有沒有出現未預料到的影響)
對于類庫型項目,我覺得第二點好處還是很重要的,這才能保證你在不斷推進項目升級迭代的過程中,確保不會出現影響已經在用你所創造的類庫的那些人,畢竟你要是升級一次讓他的項目出現大問題,那可真保不準別人飯碗都能丟。(就像之前的antd的圣誕節雪花事件一樣)
由于我們是要寫vue的組件庫,這里推薦的測試工具集是 vue-test-utils 這套工具,vue-test-utils.vuejs.org/zh/ 。其中提供的各種測試函數和方法都能很好的滿足我們的測試需要。具體的安裝使用可以參見它的文檔。
我們這里主要想提的是 組件測試到底要測什么?
我們這里給到一張很直觀的圖,看到這張圖其實你應該也清楚了這個問題的答案
button
這張圖來自視頻 www.youtube.com/watch?v=OIp… ,也是vue-test-util推薦的一個非常棒的演講,想要具體了解可以進去看一下。
所以回過頭來,組件測試,實際需要我們不僅僅作為創造者的角度對組件的功能特性進行測試。更要從使用者的角度來看,把組件當做一個“黑盒子”,我們能給到它的是用戶的交互行為、props數據等,這個“黑盒子”也會對應的反饋出一定的事件和渲染的視圖可以被使用者所捕獲和觀察。通過對這些位置的檢查,我們就能獲知一個組件的行為是否如我們所愿的去進行著,確保它的行為一定是一致不出幺蛾子的。
另外還想提的一點偏的話題就是 契約精神。作為組件的使用者,我使用你的組件,等于咱們簽訂一個契約,這個組件的所有行為應該是和你描述的是一致的,不會出現第三種意料之外的可能。畢竟對于企業項目來說,我們不喜歡surprise。antd的彩蛋事件也是給各位都提個醒,咱們搞技術可以這么玩也挺有創意,但是這種公用類庫,特別是企業使用的也比較多的,還是把創意收一收,講究契約,不講surprise。就算是自家企業內部使用的組件庫,除非是業務上的人都是認可的,否則也不要做這種危險試探。
好的組件測試也是能夠幫助我們識別出那些我們有意或無意創造的surprise,有意的咱就不說了,就怕是那種無意中出現的surprise那就比較要命了,所以寫好組件測試還是挺有必要的。
文檔生成
一般來說,我們做一個類庫項目都會有對應的說明文檔的,有的項目一個README.md 的文檔就夠了,有的可能需要在來幾個 Markdown的文檔。對于組件庫這一類的項目來說,我們可以用文檔工具來輔助直接生成文檔。這里推薦 vuepress ,可以快速幫我們完成組件庫文檔的建設。(vuepress.vuejs.org/zh/guide/)
vuepress是一個文檔生成工具,默認的樣式和vue官方文檔幾乎是一致的,因為創造它的初衷就是想為vue和相關的子項目提供文檔支持。它內置了 Markdown的擴展,寫文檔的時候就是用 markdown來寫,最讓人省心的是你可以直接在 Markdown 文件中使用Vue組件,意味著我們的組件庫中寫的一個個組件,可以直接放到文檔里去用,展示組件的實際運行效果。 我們的案例網站也就是通過vuepress來寫的,生成靜態網站后,用 gh-pages 直接部署到github上。
vuepress更好的一點在于你可以自定義其webpack配置和主題,意味著你可以讓你自己的文檔站點在開發階段有更多的功能特性的支持,同時可以把站點風格改成自己的一套主題風格。這就無需我們重頭開始去做一套了,對于咱們想要快速完成組件庫文檔建設這一需求來說,還是挺有效的。
不過這只是咱們要做的事情的一個輔助性的東西,所以具體的使用咱們在實踐階段再說明,這里就不贅述了。
自定義主題
自定義主題的功能對于一個開源類庫來說肯定還是挺有好處的,這樣使用者就可以自己使用組件庫的功能而在界面設計上使用自己的設計風格。其實大部分組件庫的功能設計都是挺好挺完善的,所以一般來說中小型公司即使想要實現自己的一套組件風格的東西,直接使用開源類庫如 element、iview或者基于react的Antd 所提供的功能和交互邏輯,然后在其上進行主題定制基本就滿足需求了(除非你家設計師很有想法。。。)。
自定義主題的功能一般的使用方式是這樣的
通過主題生成工具。(制作者需要單獨做一個工具)
引入關鍵主題文件,覆蓋主題變量。(這種方式一般都需要適配制作者所使用的css預處理器)
對于第一種方式往往都是組件庫的制作者通過把生成組件樣式的那一套東西做成一個工具,然后提供給使用者去根據自己的需要來調整,最后生成一套特定的樣式文件,引入使用。
第二種方式,作為使用者來說,你主要做的其實是覆蓋了組件庫中的一些主題變量,因為具體的組件的樣式文件不是寫死的固定樣式值,而是使用了定義好的變量,所以你的自定義主題就生效了。但是這也會引入一個小問題就是你必須適配組件庫的創造者所使用的樣式預處理器,比如你用iview,那你的項目就要能解析Less文件,你用ElementUI,你的項目就必須可以解析SCSS。
其實對于第一種方式也主要是以調整主題變量為主。所以當咱們自己要做一套組件庫的時候,不難看出,一個核心點就是需要把主題變量文件和樣式文件拆開來,后面的就簡單了。
webpack打包
類庫項目的構建這里提兩點:
暴露入口
外部化依賴
先談第一點 “暴露接口”。業務項目中,我們的整個項目通過webpack或其他打包工具打包成一個或多個bundle文件,這些文件被瀏覽器載入后就會直接運行。但是一個類庫項目往往都不是單獨運行的,而是通過暴露一個 “入口”,然我在業務項目中去調用它。 在webpack配置文件里,可以通過定義 output 中的 library 和 libraryTarget 來控制我們要暴露的一個 “入口變量” ,以及我們要構建的目標代碼。
這一點可以詳細參考webpack官方文檔: webpack.js.org/configurati…
module.exports = {
// other config
output: {
library: "MyLibName",
libraryTarget: "umd",
umdNamedDefine: true
}
}
復制代碼
再說一下 “外部化依賴”,我們做一個vue組件庫項目的時候,我們的組件都是依賴于vue的,當我們組件庫項目中的某個地方引入了vue,那么打包的時候vue的運行時也是會被一塊兒打包進入最終的組件庫bundle文件的。這樣的問題在于,我們的vue組件庫是被vue項目使用的,那么項目中已經有運行時了,我們就沒必要在組件庫中加入運行時,這樣會多增加組件庫bundle的體積。使用webpack的 externals可以將vue依賴 "外部化"。
module.exports = {
// other config
externals: {
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
}
}
復制代碼
按需加載
組件庫的按需加載功能還是很實用的, 這樣可以避免我們在使用組件庫的過程中把所有的用到和沒用到的內容都打包到業務代碼中去,導致最后的bundle文件過大影響用戶體驗。
在業務項目中我們的按需加載都是把需要按需加載的地方單獨生成為一個chunk,然后瀏覽器運行我們的打包代碼的時候發現我們需要這一塊兒資源了,再發起請求獲取到對應的所需代碼。
在組件庫里邊,我們就需要改變一下引入的方式,比如一開始我們引入一個組件庫的時候是直接將組件庫和樣式全部引入的。如下面這樣
import LimeUI from 'lime-ui' // 引入組件庫
import 'lime-ui/styles/index.css' // 引入整個組件庫的樣式文件
Vue.use(LimeUI)
復制代碼
那么,換成手動的按需加載的方式就是
import { Button } from 'lime-ui' // 引入button組件
import 'lime-ui/styles/button.css' // 引入button的樣式
Vue.component('l-button', Button) // 注冊組件
復制代碼
這種方式的確是按需引入的,但也一個不舒服的地方就是每次我們引入的時候都需要手動的引入組件和樣式。一般來說一個項目里面用到的組件少說也有十多個,這就比較麻煩了。組件庫是怎么解決這個問題的呢?
通過babel插件的方式,將引入組件庫和組件樣式的模式自動化,比如antd、antd-mobile、material-ui都在使用的babel-plugin-import、還有ElementUI使用的 babel-plugin-component。在業務項目中配置好babel插件之后,它內部就可以給你做一個這樣的轉換(這里以 babel-plugin-component)
// 原始代碼
import { Button } from 'components'
// 轉換代碼
var button = require('components/lib/button')
require('components/lib/button/style.css')
復制代碼
OK,那既然代碼可以做這樣的轉換的話,其實我們所要做的一點就是在我們打造組件庫的時候,把我們的組件庫的打包代碼放到對應的文件目錄結構之下就可以了。使用者可以選擇手動載入組件,也可以使用babel插件的方式優化這一步驟。
babel-plugin-component 文檔: www.npmjs.com/package/bab…
babel-pluigin-import 文檔: www.npmjs.com/package/bab…
組件:比起日常的組件設計,做組件庫你還需要知道些什么?
做組件庫中的組件的技巧和在項目中用到的還是有一些區別的,這一小節就是告訴大家,組件庫中的組件設計,我們還應該知道哪些必要的知識內容。
組件通信:除了上下級之間進行數據通信,還有什么?
我們常規用到的組件通信的方法就是通過 props 和 $emit 來進行父組件和子組件之間的數據傳遞,如下面的示意圖中展示的那樣:父組件通過 props 將數據給子組件、子組件通過 $emit 將數據傳遞給父組件,頂多通過eventBus或Vuex來達到任意組件之間數據的相互通信。這些方法在常規的業務開發過程中是比較有效的,但是在組件庫的開發過程中就顯得有點力不從心了,主要的問題在于: 如何處理跨級組件之間的數據通信呢?
????
3??????
如果在日常項目中,我們當然可以使用像 vuex 這樣的將組件數據直接 ”外包“ 出去的方式來實現數據的跨級訪問,但是vuex 始終是一個外部依賴項,組件庫的設計肯定是不能讓這種強依賴存在的。下面我們就來說說兩個在組件庫項目中我們會用到的數據通信方式。
內置的provide/inject
provide/inject 是vue自帶的可以跨級從子組件中獲取父級組件數據的一套方案。 這一對東西類似于react里面的 Context ,都是為了處理跨級組件數據傳遞的問題。
使用的時候,在子組件中的 inject 處聲明需要注入的數據,然后在父級組件中的某個含有對應數據的地方,提供子級組件所需要的數據。不管他們之間跨越了多少個組件,子級組件都能獲取到對應的數據。(參考下面的偽代碼例子)
// 引用關系 CompA --> CompB --> CompC --> ... --> ChildComp
// CompA.vue
export default {
provide: {
theme: 'dark'
}
}
// CompB.vue
// CompC.vue
// ...
// ChildComp.vue
export default {
inject: ['theme'],
mounted() {
console.log(this.theme) // 打印結果: dark
}
}
復制代碼
不過provide/inject的方式主要是子組件從父級組件中跨級獲取到它的狀態,卻不能完美的解決以下問題:
子級組件跨級傳遞數據到父級組件
父級組件跨級傳遞數據到子級組件
派發和廣播: 自制dispatch和broadcast功能
dispatch和broadcast可以用來做父子級組件之間跨級通信。在vue1.x里面是有dispatch和broadcast功能的,不過在vue2.x中被取消掉了。這里可以參考一下下面鏈接給出的v1.x中的內容。
dispatch的文檔(v1.x):v1.vuejs.org/api/#vm-dis…
broadcast文檔(v1.x):v1.vuejs.org/api/#vm-bro…
根據文檔,我們得知
dispatch會派發一個事件,這個事件首先在自己這個組件實例上去觸發,然后會沿著父級鏈一級一級的往上冒泡,直到觸發了某個父級中聲明的對這個事件的監聽器后就停止,除非是這個監聽器返回了true。當然監聽器也是可以通過回調函數獲取到事件派發的時候傳遞的所有參數的。這一點很像我們在DOM中的事件冒泡機制,應該不難理解。
而broadcast就是會將事件廣播到自己的所有子組件實例上,一層一層的往下走,因為組件樹的原因,往下走的過程會遇到 “分叉”,也就可以看成是一條條的多個路徑。事件沿著每一個子路徑向下冒泡,每個路徑上觸發了監聽器就停止,如果監聽器返回的是true那就繼續向下再傳播。
簡單總結一下。dispatch派發事件往上冒泡,broadcast廣播事件往下散播,遇到處理對應事件的監聽器就處理,監聽器沒有返回true就停止
需要注意的是,這里的派發和廣播事件都是 跨層級的 , 而且可以攜帶參數,那也就意味著可以跨層級進行數據通信。
dispatch
由于dispatch和broadcast在vue2.x中取消了,所以我們這里可以自己寫一個,然后通過mixin的方式混入到需要使用到跨級組件通信的組件中。
方法內容其實很簡單,這里就直接列代碼
// 參考自iview的實現
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
parent.$emit.apply(parent, [eventName].concat(params));
}
},
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params);
}
}
};
復制代碼
其實這里的實現和vue1.x中的實現還是有一定的區別的:
dispatch沒有事件冒泡。找到哪個就直接執行
設定了一個name參數,只針對特定name的組件觸發事件
其實看懂了這里的代碼,你就應該可以舉一反三想出 找尋任何一個組件的方法了,不管是向上還是向下找,無非就是循環遍歷和迭代處理,直到目標組件出現,然后調用它。 派發和廣播無非就是找到之后利用vue自帶的事件機制來發布事件,然后在具體組件中監聽該事件并處理。
渲染函數:它可以釋放javascript的能力
首先我們回顧一下一個組件是如何從寫代碼到被轉換成界面的。我們寫vue單文件組件的時候一般會有template、script和style三部分,在打包的時候,vue-loader會將其中的template模板部分先編譯成Vue實例中render選項所需要的構建視圖的代碼。在具體運行的時候,vue運行時會使用$mount 進行渲染,渲染好之后將其掛載到你提供的DOM節點下。
整個過程里面我們只日常關注最多的當然就是template的部分,但是template其實只是vue提供的一個語法糖,只是讓我們寫代碼寫起來跟寫html一樣輕松,降低剛入手vue的小伙伴的學習成本。React就沒有提供template的語法糖,而是使用的JSX來降低寫組件的復雜度。(vue能在react和angular兩大框架的壓力下異軍突起,簡潔易懂的模板語法是有一定促進作用的,畢竟看起來更簡單)
通過上面我們回顧的內容,其實我們也發現了,我們寫的template,最終都是javascript。這里template被編譯之后,給到了 render這個渲染函數,在執行渲染的時候vue就會執行render中的操作來渲染我們的組件。
所以template是好,但如果你想要使用全部的javascript的能力,那就可以使用渲染函數。
渲染函數&JSX (官方文檔):cn.vuejs.org/v2/guide/re…
日常寫業務組件,我們用template就挺OK的,不過當遇到一些復雜情況,用 寫組件 --> 引入使用 --> 注冊組件 --> 使用組件 的方式就不好處理了,比如下面兩種情況:
通過代碼動態渲染組件
將組件渲染到其他位置
第一種情況是通過代碼動態渲染組件,比如運營常常使用的活動h6頁面,每個活動都不一樣,每次要么都重新做一份,要么在原有的基礎上修改。但是這種修改的頁面結構調整是很大的,每次都會是破壞性的,和重做其實沒區別。這樣的話,每次活動無論內容如何,前端都要上手去寫代碼。但其實只需要在管理后臺做一個活動編輯器,編輯器的內容直接轉化為render函數的代碼,然后通過配置下發到某個頁面上,承載頁拿到數據給到render函數執行渲染。這樣就可以動態的根據管理后臺配置的方式來渲染組件內容,每次的活動頁,運營也可以通過編輯器自行生成。
第二種情況是要將組件渲染到不同位置。我們日常寫業務組件基本就是寫一個組件,在需要的拿來使用。如果你只是在template中把組件寫進去,那你的組件的內容就都會作為當前組件的子組件進行渲染,所生成的DOM結構也是在當前的DOM結構之下的。知道render之后,其實我們可以新建vue實例,動態渲染之后,手動掛載到任意的DOM位置上去。
import CompA from './CompA.vue'
let Instance = new Vue({
render(h) {
return h(CompA)
}
})
let component = Instance.$mount() // 執行渲染
document.body.appendChild(component.$el) // 掛載到body元素下
復制代碼
我們使用的element里面的 this.$message 就用到了動態渲染,然后手動掛載到指定位置。
實踐:做一遍你就會了
這里先貼上我們的github地址,各位可以在做的過程中對照著看。github.com/arronKler/l…
建立一個工程化的項目
第一步,建立工程化結構
這里就不廢話了,直接貼目錄結構和解釋
|- assets/ # 存放一些額外的資源文件,圖片之類的
|- build/ # webpack打包配置
|- docs/ # 存放文檔
|- .vuepress # vuepress配置目錄
|- component # 組件相關的文檔放這里
|- README.md # 靜態首頁
|- lib/ # 打包生成的文件放這里
|- styles/ # 打包后的樣式文件
|- src/ # 在這里寫代碼
|- mixins/ # mixin文件
|- packages/ # 各個組件,每個組件是一個子目錄
|- styles/ # 樣式文件
|- common/ # 公用的樣式內容
|- mixins/ # 復用的mixin
|- utils # 工具目錄
|- index.js # 打包入口,組件的導出
|- test/ # 測試文件夾
|- specs/ # 存放所有的測試用例
|- .npmignore
|- .gitignore
|- .babelrc
|- README.md
|- package.json
復制代碼
這里比較重要的目錄就是我們的src目錄,下面存放了我們的各個單一的組件和一套樣式庫,另外還有一些輔助的東西。我們寫文檔就是在 docs目錄下去寫。項目目錄最外層都是些常規的配置內容,比如 .npmignore 和 .gitignore 這樣的文件我們都是很常見的,所以我就不具體細說這一部分了,要是有一定疑惑可以直接參見github上的源碼對照著看。
這里我們把需要使用到的類庫文件也先建立好
在 src/mixins 下創建一個 emitter.js,寫入如下內容,也就是我們的dispatch和broadcast的方法,之后的組件設計中會用到
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
parent.$emit.apply(parent, [eventName].concat(params));
}
},
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params);
}
}
};
復制代碼
然后在 src/utils 下新建一個 assist.js 文件,寫下輔助性的函數
export function oneOf(value, validList) {
for (let i = 0; i < validList.length; i++) {
if (value === validList[i]) {
return true;
}
}
return false;
}
復制代碼
這兩個地方都是之后會使用到的,如果你需要其他的輔助內容,也可以在這兩個文件所在的目錄下去建立。
第二步, 完善打包流程
目錄建好了,那就該填充血肉了,要打包一個組件庫項目,肯定是要先配置好我們的webpack,不然寫了源碼也沒法跑起來。所以我們先定位到 build目錄下,在build目錄下先建立三個文件
webpack.base.js 。存放基本的一些rules配置
webpack.prod.js 。整個組件庫的打包配置
gen-style.js 。單獨對樣式進行打包
以下是具體的配置內容
/ webpack.base.js /
const path = require('path');
const webpack = require('webpack');
const pkg = require('../package.json');
const VueLoaderPlugin = require('vue-loader/lib/plugin')
function resolve(dir) {
return path.join(__dirname, '..', dir);
}
module.exports = {
module: {
rules: [
{
test: /.vue$/,
loader: 'vue-loader',
options: {
loaders: {
css: [
'vue-style-loader',
{
loader: 'css-loader',
options: {
sourceMap: true,
},
},
],
less: [
'vue-style-loader',
{
loader: 'css-loader',
options: {
sourceMap: true,
},
},
{
loader: 'less-loader',
options: {
sourceMap: true,
},
},
],
},
postLoaders: {
html: 'babel-loader?sourceMap'
},
sourceMap: true,
}
},
{
test: /.js$/,
loader: 'babel-loader',
options: {
sourceMap: true,
},
exclude: /node_modules/,
},
{
test: /.css$/,
loaders: [
{
loader: 'style-loader',
options: {
sourceMap: true,
},
},
{
loader: 'css-loader',
options: {
sourceMap: true,
},
}
]
},
{
test: /.less$/,
loaders: [
{
loader: 'style-loader',
options: {
sourceMap: true,
},
},
{
loader: 'css-loader',
options: {
sourceMap: true,
},
},
{
loader: 'less-loader',
options: {
sourceMap: true,
},
},
]
},
{
test: /.scss$/,
loaders: [
{
loader: 'style-loader',
options: {
sourceMap: true,
},
},
{
loader: 'css-loader',
options: {
sourceMap: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
]
},
{
test: /.(gif|jpg|png|woff|svg|eot|ttf)\??.$/,
loader: 'url-loader?limit=8192'
}
]
},
resolve: {
extensions: ['.js', '.vue'],
alias: {
'vue': 'vue/dist/vue.esm.js',
'@': resolve('src')
}
},
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
new webpack.DefinePlugin({
'process.env.VERSION': '${pkg.version}'
}),
new VueLoaderPlugin()
]
};
復制代碼
/ webpack.prod.js */
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const webpackBaseConfig = require('./webpack.base.js');
process.env.NODE_ENV = 'production';
module.exports = merge(webpackBaseConfig, {
devtool: 'source-map',
mode: "production",
entry: {
main: path.resolve(dirname, '../src/index.js') // 將src下的index.js 作為入口點
},
output: {
path: path.resolve(dirname, '../lib'),
publicPath: '/lib/',
filename: 'lime-ui.min.js', // 改成自己的類庫名
library: 'lime-ui', // 類庫導出
libraryTarget: 'umd',
umdNamedDefine: true
},
externals: { // 外部化對vue的依賴
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
})
]
});
復制代碼
/ gen-style.js /
const gulp = require('gulp');
const cleanCSS = require('gulp-clean-css');
const sass = require('gulp-sass');
const rename = require('gulp-rename');
const autoprefixer = require('gulp-autoprefixer');
const components = require('./components.json')
function buildCss(cb) {
gulp.src('../src/styles/index.scss')
.pipe(sass())
.pipe(autoprefixer())
.pipe(cleanCSS())
.pipe(rename('lime-ui.css'))
.pipe(gulp.dest('../lib/styles'));
cb()
}
exports.default = gulp.series(buildCss)
復制代碼
OK,這里我們的webpack配置基本設置好了,webpack.base.js 中的配置就主要是一些loader和插件的配置,具體的出入口都是在 webpack.prod.js 中配置的。這里webpack.prod.js 合并了 webpack.base.js 中的配置項。關于 output.libary 和 externals ,閱讀了之前 “準備” 階段的內容的應該不會陌生了。
另外還有 gen-style.js 這個文件是單獨使用了 gulp 來對樣式文件進行打包操作的,我們這里選用的是 scss的語法,如果你想用less或其他的預處理器,也可以自行修改這里的文件和相關依賴。
不過這個配置肯定還沒有結束,首先我們需要安裝好這里的配置里使用到的各種loader和plugin。為了不漏掉安裝項和保持一致性,可以直接復制下面的配置內容放到 package.json 下,通過 npm install 來進行安裝。需要注意的是,這里的安裝完成之后,其實后面的一些內容的依賴也都一并安裝好了。
"dependencies": {
"async-validator": "^3.0.4",
"core-js": "2.6.9",
"webpack": "^4.39.2",
"webpack-cli": "^3.3.7"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/plugin-transform-runtime": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@vue/test-utils": "^1.0.0-beta.29",
"babel-loader": "^8.0.6",
"chai": "^4.2.0",
"cross-env": "^5.2.0",
"css-loader": "2.1.1",
"file-loader": "^4.2.0",
"gh-pages": "^2.1.1",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^7.0.0",
"gulp-clean-css": "^4.2.0",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.0.2",
"karma": "^4.2.0",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^3.1.0",
"karma-coverage": "^2.0.1",
"karma-mocha": "^1.3.0",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "^0.0.32",
"karma-webpack": "^4.0.2",
"less": "^3.10.2",
"less-loader": "^5.0.0",
"mocha": "^6.2.0",
"node-sass": "^4.12.0",
"rimraf": "^3.0.0",
"sass-loader": "^7.3.1",
"sinon": "^7.4.1",
"sinon-chai": "^3.3.0",
"style-loader": "^1.0.0",
"url-loader": "^2.1.0",
"vue-loader": "^15.7.1",
"vue-style-loader": "^4.1.2",
"vuepress": "^1.0.3"
},
復制代碼
另外,由于我們使用了babel,所以需要在項目的根目錄下設置一下 .babelrc 文件,內容如下:
{
"presets": [[
"@babel/preset-env",
br/>[
"@babel/preset-env",
"loose": false,
"modules": "commonjs",
"spec": true,
"useBuiltIns": "usage",
"corejs": "2.6.9"
}
]
],
"plugins": ["@babel/plugin-transform-runtime",
br/>"@babel/plugin-transform-runtime",
}
復制代碼
當然也不要忘記在package.json文件中寫上scripts簡化手動輸入命令的過程
{
"scripts": {
"build:style": "gulp --gulpfile build/gen-style.js",
"build:prod": "webpack --config build/webpack.prod.js",
}
}
復制代碼
第三步,建立文檔化工具
如果在上一步中未安裝了 vuepress ,可以通過 npm install vuepress --save-dev 來安裝,
然后在 package.json 中加入腳本,快速啟動
{
"scripts": {
// ...
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs"
}
}
復制代碼
這個時候你可以在你的 docs/README.md 文件里寫點內容,然后運行 npm run docs:dev 就可以看到本地的文檔內容了。需要打包的時候使用 npm run docs:build 就可以了。
如果我們的項目是要放到github上的,那么其實也可以一并將我們的文檔生成之后也放到github上去,利用github的pages功能讓這個本地的文檔在線運行。(github pages托管我們的靜態頁面和資源)
可以運行 npm install gh-pages --save-dev 安裝 gh-pages 這個可以幫我們一鍵部署github pages文檔的工具。它的工作原理就是將對應的某個文件夾下的資源遷移到我們的當前項目的gh-pages分支上,然后這個分支在push給了github之后,github就會將該分支內的內容服務起來。為了更好的使用它,我們可以在package.json中添加scripts
{
"scripts": {
// ...
"deploy": "gh-pages -d docs/.vuepress/dist",
"deploy:build": "npm run docs:build && npm run deploy",
}
}
復制代碼
這樣你就可以使用 npm run deploy 直接部署你的vuepress生成的靜態站點,不過務必在部署之前運行一下文檔的構建程序。因此我們也添加了一條 npm run deploy:build 命令,使用這條命令就可以直接把文檔的構建和部署直接一起解決。是不是很簡單呢?
不過為了我們能夠直接使用自己寫的組件,還需要對vuepress做一點點配置。在 docs/.vuepress目錄下新建一個 enhanceApp.js 文件,寫入如下內容,將我們的組件庫的入口和樣式注入進去
import LimeUI from '../../src/index.js'
import "../../src/styles/index.scss"
export default ({
Vue,
options,
router
}) => {
Vue.use(LimeUI)
}
復制代碼
這個時候我們之后寫的組件就可以直接在文檔中使用了。
第四步,樣式構建
先需要說明的是這里我們所使用的樣式預處理器的語法是scss。那么在“完善打包流程”這一小節中已經將用gulp進行打包的代碼給出了,不過有必要說明一下,我們又是如何去整合樣式內容的。
首先,為了之后便于做按需加載,對于每個組件的樣式都是一個單獨的scss文件,寫樣式的時候,為了避免太多的層級嵌套,使用了BEM風格的方式去書寫。
我們需要先在 src/styles目錄執行如下命令生成一個基本的樣式文件
cd src/styles
mkdir common
mkdir mixins
touch common/var.scss # 樣式變量文件
touch common/mixins.scss
touch index.scss # 引入所有樣式
復制代碼
然后將對應的 var.scss 和 mixins.scss 文件填充上一些基礎內容
/ common/var.scss /
$--color-primary: #ff6b00 !default;
$--color-white: #FFFFFF !default;
$--color-info: #409EFF !default;
$--color-success: #67C23A !default;
$--color-warning: #E6A23C !default;
$--color-danger: #F56C6C !default;
復制代碼
/ mixins/mixins.scss /
$namespace: 'lime'; / 組件庫的樣式前綴 /
/ BEM
-------------------------- /
@mixin b($block) {
$B: $namespace+'-'+$block !global;
.#{$B} {@content;
br/>@content;
}
復制代碼
在mixins文件中我們聲明了一個mixin,用于幫助我們更好的去構建樣式文件。
組件打造案例
上面的內容設置好了, 咱們就可以開始具體去做一個組件試試了
簡單的button組件
這是做好之后的大致效果
button
OK,那我們建立基本的button組件相關的文件
cd src/packages
mkdir button && cd button
touch index.js
touch button.vue
復制代碼
寫入button.vue的內容
<template>
<button class="lime-button" :class="{[lime-button-${type}
]: true}" type="button">
<slot></slot>
</button>
</template>
<script>
import { oneOf } from '../../utils/assist';
export default {
name: 'Button',
props: {
type: {
validator (value) {
return oneOf(value, ['default', 'primary', 'info', 'success', 'warning', 'error']);
},
type: String,
default: 'default'
}
}
}
</script>
復制代碼
這里我們需要在 index.js 中導出這個組件
import Button from './button.vue'
export default Button
復制代碼
這樣單個的一個組件就完成了,之后你可以再多做幾個組件試試,不過有一點就是這些組件需要一個統一的打包入口,我們再webpack中已經配置過了,那就是 src/index.js 這個文件,我們需要在這個文件里面將我們剛才寫的button組件以及你自己寫的其他組件都引入進來,然后統一導出給webpack打包使用,具體代碼見下
import Button from './packages/button'
const components = {
lButton: Button,
}
const install = function (Vue, options = {}) {
Object.keys(components).forEach(key => {
Vue.component(key, components[key]);
});
}
export default install
復制代碼
可以看到的是index.js中我們最終導出的是一個叫install的函數,這個函數其實就是Vue插件的一種寫法,便于我們在實際項目中引入的時候可以使用 Vue.use 的方式來自動安裝我們的整個組件庫。install接受兩個參數,一個是Vue,我們把它用來注冊一個個的組件。還有一個是options,便于我們可以在注冊組件的時候傳入一些初始化參數,比如默認的按鈕大小、主題等信息,都可以通過參數的方式來設定。
然后我們可以在 src/styles目錄下新建一個button.scss 文件,寫入我們button對應的樣式
/ button.scss /
@charset "UTF-8";
@import "common/var";
@import "mixins/mixins";
@include b(button) {
min-width: 60px;
height: 36px;
font-size: 14px;
color: #333;
background-color: #fff;
border-width: 1px;
border-radius: 4px;
outline: none;
border: 1px solid transparent;
padding: 0 10px;
&:active,
&:focus {
outline: none;
}
&-default {
color: #333;
border-color: #555;
&:active,
&:focus,
&:hover {
background-color: rgba($--color-primary, 0.3);
}
}
&-primary {
color: #fff;
background-color: $--color-primary;
&:active,
&:focus,
&:hover {
background-color: mix($--color-primary, #ccc);
}
}
&-info {
color: #fff;
background-color: $--color-info;
&:active,
&:focus,
&:hover {
background-color: mix($--color-info, #ccc);
}
}
&-success {
color: #fff;
background-color: $--color-success;
&:active,
&:focus,
&:hover {
background-color: mix($--color-success, #ccc);
}
}
}
復制代碼
最后我們還需要在 src/styles/index.scss 文件中將button的樣式引入進去
@import "button";
復制代碼
為了簡單的實驗,你可以直接在 docs/README.md 文件下寫兩個button組件試試看
<template>
<l-button type="primary">Click me</l-button>
</template>
復制代碼
如果你想要得到和我在 arronkler.github.io/lime-ui/ 上一樣的效果,可以參考 github.com/arronKler/l… 項目中的 docs 目錄下的配置。如果想要更個性化的配置,可以查閱vuepress的官方文檔。
Notice提示組件
這個組件就要用到我們的動態渲染的相關的東西了。具體最后的使用方式是這樣的
this.$notice({
title: '提示',
content: this.content || '內容',
duration: 3
})
復制代碼
效果類似于這樣
button
OK,我們先來寫一下這個組件的一個基本源碼
在 src/packages 目錄下新建notice文件夾,然后新建一個 notice.vue 文件
<template>
<div class="lime-notice">
<div class="lime-noticemain" v-for="item in notices" :key="item.id">
<div class="lime-noticetitle">{{item.title}}</div>
<div class="lime-notice__content">{{item.content}}</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
notices: []
}
},
methods: {
add(notice) {
let id = +new Date()
notice.id = id
this.notices.push(notice)
const duration = notice.duration
setTimeout(() => {
this.remove(id)
}, duration * 1000)
},
remove(id) {
for(let i = 0; i < this.notices.length; i++) {
if (this.notices[i].id === id) {
this.notices.splice(i, 1)
break;
}
}
}
}
}
</script>
復制代碼
代碼很簡單,其實就是聲明了一個容器,然后在其中通過控制 notices 的數據來展示和隱藏,接著我們在同一個目錄下新建一個notice.js 文件來做動態渲染
import Vue from 'vue'
import Notice from './notice.vue'
Notice.newInstance = (properties) => {
let props = properties || {}
const Instance = new Vue({
render(h) {
return h(Notice, {
props
})
}
})
const component = Instance.$mount()
document.body.appendChild(component.$el)
const notice = component.$children[0]
return {
add(_notice) {
notice.add(_notice)
},
remove(id) {
}
}
}
let noticeInstance
export default (_notice) => {
noticeInstance = noticeInstance || Notice.newInstance()
noticeInstance.add(_notice)
}
復制代碼
這里我們我們通過動態渲染的方式讓我們的組件可以直接掛在到body下面,而非歸屬于根掛載點之下。
然后在 src/styles 目錄下新建 notice.scss 文件,寫上我們的樣式文件
/ notice.scss /
@charset "UTF-8";
@import "common/var";
@import "mixins/mixins";
@include b(notice) {
position: fixed;
right: 20px;
top: 60px;
z-index: 1000;
&__main {
min-width: 100px;
padding: 10px 20px;
box-shadow: 0 0 4px #aaa;
margin-bottom: 10px;
border-radius: 4px;
}
&title {
font-size: 16px;
}
&content {
font-size: 14px;
color: #777;
}
}
復制代碼
最后同樣的,也需要在 src/index.js 這個入口文件中對 notice做處理。完整代碼是這樣的。
import Button from './packages/button'
import Notice from './packages/notice/notice.js'
const components = {
lButton: Button
}
const install = function (Vue, options = {}) {
Object.keys(components).forEach(key => {
Vue.component(key, components[key]);
});
Vue.prototype.$notice = Notice;
}
export default install
復制代碼
我們可以看到我們再Vue的原型上掛上了我們的 $notice 方法,這個方法調用的時候就會觸發我們在 notice.js 文件中動態渲染組件的一套流程。這個時候我們就可以在 docs/README.md 文檔中測試著用了。
<script>
export default() {
mounted() {
this.$notice({
title: '提示',
content: this.content,
duration: 3
})
}
}
<script>
復制代碼
單獨打包樣式和組件
為了能支持按需加載的功能,我們除了將整個組件庫打包之外,還需要對樣式和組件單獨打包成單個的文件。這里我們需要做兩件事兒
打包單獨的css文件
打包單獨的組件內容
對于第一點,我們需要對 build/gen-style.js 文件做一下改造,加上buildSeperateCss任務,完整代碼如下
// 其他之前的代碼...
function buildSeperateCss(cb) {
Object.keys(components).forEach(compName => {
gulp.src(../src/styles/${compName}.scss
)
.pipe(sass())
.pipe(autoprefixer())
.pipe(cleanCSS())
.pipe(rename(${compName}.css
))
.pipe(gulp.dest('../lib/styles'));
})
cb()
}
exports.default = gulp.series(buildCss, buildSeperateCss) // 加上 buildSeperateCss
復制代碼
對于第二點,我們可以用一個新的webpack配置來處理,新建一個 build/webpack.component.js 文件,寫入
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const webpackBaseConfig = require('./webpack.base.js');
const components = require('./components.json')
process.env.NODE_ENV = 'production';
const basePath = path.resolve(__dirname, '../')
let entries = {}
Object.keys(components).forEach(key => {
entries[key] = path.join(basePath, 'src', components[key])
})
module.exports = merge(webpackBaseConfig, {
devtool: 'source-map',
mode: "production",
entry: entries,
output: {
path: path.resolve(__dirname, '../lib'),
publicPath: '/lib/',
filename: '[name].js',
chunkFilename: '[id].js',
// library: 'lime-ui',
libraryTarget: 'umd',
umdNamedDefine: true
},
externals: {
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
})
]
});
復制代碼
這里我們引用了build文件夾下的一個叫做 component.json 的文件,該文件是我自定義用來標識我們的組件和組件路徑的,實際上你也可以通過腳本直接遍歷 src/packages目錄自動獲得這樣一些信息。這里只是簡單演示, build/component.json 的代碼如下
{
"button": "packages/button/index.js",
"notice": "packages/notice/notice.js"
}
復制代碼
所有的單獨打包流程配置好以后,我們就可以在 package.json 文件中再加上 scripts 命令
{
"scripts": {
// ...
"build:components": "webpack --config build/webpack.component.js",
"dist": "npm run build:style && npm run build:prod && npm run build:components",
}
}
復制代碼
OK,現在只需要運行 npm run dist 命令,它就會自動去構建完整的樣式內容和各個組件單獨的樣式內容,然后會打包一個完整的組件包和各個組件的單獨的包。
這里需要注意的一點就是你的package.json 文件中的這幾個字段需要做一下調整
{
"name": "lime-ui",
"version": "1.0.0",
"main": "lib/lime-ui.min.js",
//...
}
復制代碼
其中name表示別人使用了你的包的時候的包名,main字段很重要,表示別人直接引入你包的時候,入口文件是哪一個。這里因為我們webpack打包后的文件是 lib/lime-ui.min.js 所以我們這樣去設置。
一切就緒后,你就可以運行 npm run dist 打包你的組件庫,然后 npm publish 去發布你的組件庫了(發布前需要 npm login 登陸)
使用自己的組件庫
直接使用
我們可以用vue-cli 或其他工具另外生成一個demo項目,用這個項目去引入我們的組件庫。如果你的包還沒有發布出去,可以在你的組件庫項目目錄下 用 npm link 或者 yarn link的命令創建一個link(推薦使用yarn)
然后在你的demo目錄下使用 npm link package_name 或者 yarn link package_name 這里的package_name就是你的組件庫的包名,然后在你的demo項目的入口文件里
import Vue from vue
import LimeUI from 'lime-ui'
import 'lime-ui/lib/styles/lime-ui.css'
// 其他代碼 ...
Vue.use(LimeUI)
復制代碼
這樣設置好之后,我們創建的組件就可以在這個項目里使用了
按需加載
上面我們談的是全局載入的一種使用方法,那如何按需加載呢?其實我們之前也說過那么一點
先通過npm安裝好 babel-plugin-component 包,然后在你的demo項目的 .babelrc 文件中寫上這部分內容
{
"plugins": [
["component", {
"libraryName": "lime-ui",
"libDir": "lib",
"styleLibrary": {
"name": "styles",
"base": false, // no base.css file
"path": "[module].css"
}
}]
]
}
復制代碼
這里的配置是要符合我們的lime-ui 的一個目錄結構的,有了這個配置我們就可以進行按需加載了,你可以像這樣做加載一個Button
import Vue from 'vue'
import { Button } from 'lime-ui'
Vue.component('a-button', Button)
復制代碼
可以看到的是,我們并沒有在這個位置加載任何樣式,因為 babel-plugin-component 已經幫我們做了,不過因為我們只在組件庫的入口點里面設置了 install 方法用來注冊組件,所以這里我們按需引入的時候,就需要自己手動注冊了。
主題定制
前面的內容做好之后,主題定制就比較簡單了,我們先在DEMO項目的入口文件同級目錄下創建一個 global.scss 文件,然后在其中寫入類似下面這樣的代碼。
$--color-primary: red;
@import "~lime-ui/src/styles/index.scss";
復制代碼
然后在入口文件中把引入組件庫的方式改變一下
import Vue from vue
import LimeUI from 'lime-ui'
import './global.scss'
// 其他代碼 ...
Vue.use(LimeUI)
復制代碼
我們在入口文件中把對組件庫的樣式引入,改成引入我們自定義的global.scss文件。
其實這里就是覆蓋了我們在組件庫項目里 var.scss 里的變量的值,然后其余的組件基礎樣式還是使用了各自的樣式內容,這樣就可以達到主題定制了。
結語
本文通過對組件庫的一些特性的介紹和一個實際的操作案例,闡述了打造一套組件庫的一些基礎的東西。希望能通過這樣的一次分享,讓我們不只是去使用組件庫,而是能知道組件庫的誕生過程和了解組件庫的一些內部特性,幫助我們在日常使用的過程中能“心中有數”,當出現問題或組件庫需求可能不滿足的時候有一個新的思考入手點,那就足夠了。
引用參考
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。