折疊面板組件的設計與實現
?前言
NutUI,大家應該不陌生吧,前端開發的同學肯定是有些了解的。NutUI 是一個京東風格的移動端組件庫,使用 Vue 語言來編寫可以在 H5,小程序平臺上的應用。
目前 NutUI 擁有 70+ 組件,支持按需引用,支持 TypeScript,支持定制主題等功能,當然也支持最新的 Vue3 語法,在開發上能有效幫助研發人員提升效率,改善開發體驗。
言歸正傳,今天我們一起了解 NutUI 中折疊面板 Collapse 的實現與設計,以及在開發過程中學習到的新知識點。
折疊面板設計
其實折疊面板組件無論是在 PC 還是 M ,都是比較常見的組件,顧名思義就是可以折疊/展開的內容區域。使用場景也比較廣泛,例如導航、文字類詳情、篩選分類等;
在組件開發階段,我們通常都會進行對比分析,取長補短。所以我們簡單通過功能上的對比來入組件的開發。
組件的本質就是提升開發效率的,我們通過對業務場景的解構和組合配置方式實現業務需求。好比組件庫是一個工具箱,每個組件就是箱子里的扳手、鉗子等工具,為業務場景提供各種工具,如何去打造一個合適趁手的工具干活,就需要我們對平時的業務開發有所了解和思考。
讓我們一起來探索吧~
實現展開收起
組件的基本交互已經明了,那我們的標題和內容的布局方式就比較簡單了。現在我們需要去完成交互的開發,也就是展開折疊的功能。
實現展開折疊的功能其實很簡單,就是通過一個變量控制內容的展示隱藏就可以了,不用考慮其他因素的情況下,這種方法的確是最高效的方式。
<template>
<div>
<div @click="handle">
標題
</div>
<div v-show="show">
測試內容測試內容測試內容測試內容測試內容測試內容
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const show = ref(false);
const handle = () => {
show.value = !show.value;
}
</script>
但是采用這種方式可能對我們后期的功能擴展和交互效果不太友好。所以我的方案是通過改變折疊內容的 height 的方式實現的,當然實現這個方法也比較好理解。
我們主要處理 content 的內容,對于這塊樣式我們對它的 height 默認是 0,也就是內容是折起的狀態。因為每個折疊內容是無法確定的,所以我們需要動態計算內容填充后的高度,這種方式也算是一種適配方案。
我動態計算的目的是為了實現后面動畫效果,提升用戶體驗感。我利用的是 height + transform 的方式實現的,同時使用 css 的屬性 will-change 對動畫效果進行優化。
will-change 為 web 開發者提供了一種告知瀏覽器該元素會有哪些變化的方法,這樣瀏覽器可以在元素屬性真正發生變化之前提前做好對應的優化準備工作。這種優化可以將一部分復雜的計算工作提前準備好,使頁面的反應更為快速靈敏。
// 組件部分核心代碼
const wrapperRefEle: any = wrapperRef.value;
const contentRefEle: any = contentRef.value;
if (!wrapperRefEle || !contentRefEle) {
return;
}
const offsetHeight = contentRefEle.offsetHeight || 'auto';
if (offsetHeight) {
const contentHeight = `${offsetHeight}px`;
wrapperRefEle.style.willChange = 'height';
wrapperRefEle.style.height = !proxyData.openExpanded ? 0 : contentHeight;
}
以上代碼就是通過獲取元素的 DOM 來計算出內容的高度 offsetHeight 并賦值,通過高度的變化結合 transform 實現收起展開的動畫效果。
靈活的標題欄
其次就是標題欄功能的完善,增加圖標及自定義位置和相關動畫功能。我們先來看下基本用法的右側圖標,它和內容的收起展開是相呼應的,交互上展開時是上箭頭收起時是下箭頭。那么我們根據是否展開的狀態為變量,使用一個箭頭圖標就可以輕松搞定。實現的方案就是利用 css3 的 rotate 屬性,反轉 180° 就可以了。
if (parent.props.icon && !proxyData.openExpanded) {
proxyData.iconStyle['transform'] = 'rotate(0deg)';
} else {
proxyData.iconStyle['transform'] = 'rotate(' + parent.props.rotate + 'deg)';
}
為了用戶的自定義性更高,更好的擴展組件能力,對外暴露了關于圖標配置的 API,比如自定義圖標、圖標的旋轉角度等。這些配置參考不同場景,比如某些新聞報道的內容折疊旋轉 90° 。
當然,標題欄文字也可以配置相關圖標,包括圖標的位置、顏色、大小等。這種功能增加了用戶的個性化配置,他可以用來展示某些重要消息、新消息提醒,未查看信息等場景使用。
某些組件庫的開發者可能沒有此配置,首先個人感覺和組件是無關的。組件的設計是需要與業務之間進行銜接,抽象出一些功能,這樣能更好的完善組件的功能,包括后期組件的擴展等,都是在業務發展中成長的。
配置項升級
在后期的使用過程中,我們根據某些場景對組件功能進行了優化升級。
首先增加了副標題的配置,通過 sub-title 就可以輕松設置(PS: 上圖??可看到示例)。
商城類移動端中的搜索分類功能,比如下圖的這種場景。它會有默認的內容展示在外面,在折疊后其余內容進行折疊或展開,所以新增了 slot:extraRender API,讓這部分內容以插槽的形式存在,方便開發者定義不同的展示形式,便于樣式的調整等。
以上功能的實現也比較簡單,就是在代碼的中增加一個 slot 標簽接收傳入的內容即可。
<view v-if="$slots.extraRender">
<div>
<slot name="extraRender"></slot>
</div>
</view>
在這里既然提到了 slot,我就多?嗦一下[憨笑]。關于上述提到的標題及內容的展示,設計的時候考慮能讓開發者省時省力,有更多的可操作性,基本上都是以 slot 的形式來接收入參(僅限于本組件,內容展示相關),這樣的話即使后端或者前端處理數據攜帶 HTMl 標簽也可以輕松識別,無需多余處理。
面板既然都可以展開收起操作,那么反之也有禁止操作的。我提供了一個簡單的屬性設置 disabled 來確定是否可操作,實現方式就是通過設置 style 樣式實現的。
.nut-collapse-item-disabled {
color: #c8c9cc;
cursor: not-allowed;
pointer-events: none;
}
開發設計番外
01Scss 中使用變量
這個功能大家想必也不陌生,說白了就是可以通過 JS 控制 CSS 的樣式,目前 Vue3 支持我們使用在 CSS 中使用變量,直接上代碼。
<template>
<span>NutUI</sapn>
</template>
<script>
export default {
data () {
return {
color: 'red'
}
}
}
</script>
<style vars="{ color }" scoped>
span {
color: var(--color);
}
</style>
是不是很簡單,其實類似的寫法,在之前就有類似的插件支持的。
- emotion
- jss
- styled-components
- aphrodite
- radium
- glamor
這些插件大家感興趣的可以嘗試一下,小編用過 styled-components,還是很容易上手的,在上手前建議大家了解下 CSS-in-JS 的概念。
02組件開發適配
想成為 NutUI 的 contributor 嗎?如果也想為 NutUI 貢獻自己的組件,下面可是適配小程序的一些要點喲~
在 H5 開發時獲取 DOM 元素是比較容易的,通過 document 或者 ref 都可以。但是我們在適配小程序的時候這種方式是獲取不到的,需要根據 Taro 提供的方法去獲取。
import Taro, { eventCenter, getCurrentInstance as getCurrentInstanceTaro } from '@tarojs/taro';
eventCenter.once((getCurrentInstanceTaro() as any).router.onReady, () => {
const query = Taro.createSelectorQuery();
query.selectAll('.collapse-content').boundingClientRect();
query.exec((res) => {
console.log(res);
});
});
通過以上方法可以獲取到節點的信息,包括 width、height、x、y 等,大家可以體驗試一下查看獲取的信息。還有一點需要注意,就是在給元素設置 style 樣式時,最好是在組件中使用 style 變量接收,不要直接賦值。
// 類似這種方式改變 style
const style = reactive({
color: 'red',
height: '100px',
});
const change = () => {
style.color = 'blue';
}
03vue3 組件通信
在組件開發時,因為 nut-collapse nut-collapse-item 父子組件需要進行通信,我使用的是 provide/inject 的方式,所以對此通信方式進行了簡單的的學習了解。
關于組件通信的方式,props、emit、attrs 等等方式,大家必然已了然于胸,我就不獻丑了。現在我簡單和大家分享一下 provide/inject 的傳參形式,這個 API 在 vue2 的時候已經存在。
//a.vue 組件
//創建一個 provide
import { defineComponent, provide } from 'vue';
export default defineComponent({
setup () {
const msg: string = 'Hello NutUI';
// provide 出去
provide('msg', msg);
}
})
//b.vue 組件
//接收數據
import { defineComponent, inject } from 'vue'
export default defineComponent({
setup () {
const msg: string = inject('msg') || '';
}
})
通過以上 2 個示例,操作是不是非常簡單,但需要注意一點,provide 不是響應式的,如果你要使其具備響應性,你需要傳入也應該是響應式數據。
provide 提供的數據不考慮組件層次結構,也就是發起 provide 的組件都可以作為其所有下級組件的依賴提供者。
provide 和 inject 的實現原理主要是利用了原型和原型鏈來實現。
在 Vue3 中 provide 函數就是給當前組件實例上的 provides 對象屬性,添加鍵值對 key/value。還有一個地方就是如果當前組件和父級組件的 provides 相同時,在當前組件實例中的 provides 對象和父級,則建立鏈接,即原型 prototype。
function provide(key, value) {
if (!currentInstance) {
if ((process.env.NODE_ENV !== 'production')) {
warn(`provide() can only be used inside setup().`);
}
}
else {
// 獲取當前組件實例的 provides 屬性
let provides = currentInstance.provides;
// 獲取當前父級組件的 provides 屬性
const parentProvides = currentInstance.parent && currentInstance.parent.provides;
if (parentProvides === provides) {
// Object.create() es6創建對象的一種方式,可以理解為繼承一個對象,添加的屬性是在原型下。
provides = currentInstance.provides = Object.create(parentProvides);
}
provides[key] = value;
}
}
關于 inject 的實現我就不多贅述了,大家有興趣的可以去根據源碼做更深入的了解。
從下面代碼可以大致了解,inject 先獲取當前組件的實例對象,然后判斷是否根組件,如果是根組件則返回到 appContext 的 provides,否則就返回父組件的 provides。如果當前的 key 在 provides 上有值,就返回該值,反之則判斷是否存在默認內容,默認內容如果是個函數,就執行并且通過 call 方法把組件實例的代理對象綁定到該函數的 this 上,否則就直接返回默認內容。
function inject(key, defaultValue, treatDefaultAsFactory = false) {
// 如果是被一個函數式組件調用則取 currentRenderingInstance
const instance = currentInstance || currentRenderingInstance;
if (instance) {
// 如果intance位于根目錄下,則返回到appContext的provides,否則就返回父組件的provides
const provides = instance.parent == null
? instance.vnode.appContext && instance.vnode.appContext.provides
: instance.parent.provides;
if (provides && key in provides) {
return provides[key];
}
// 如果參數大于1個 第二個則是默認值 ,第三個參數是 true,并且第二個值是函數則執行函數。
else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance.proxy)
: defaultValue;
}
}
}
大致可以這么理解 provide API 調用的時候,設置父級的 provides 為當前 provides 對象原型對象上的屬性,在 inject 獲取 provides 對象中的屬性值時,優先獲取 provides 對象自身的屬性,如果自身查找不到,則沿著原型鏈向上一個對象中去查找。
總結
本文主要介紹了 NutUI 中折疊面板組件的設計思路與實現原理,并分享了一些開發中遇到的問題,希望能在開發中幫到大家。
如果在開發中遇到問題,可隨時提 issue,NutUI 團隊的同學都會認真對待并解決問題。如您有好的組件,業務類、通用類的都可,都可向 NutUI 組件庫提交 PR,非常歡迎大家參與共建。