折疊面板組件的設(shè)計與實現(xiàn)
?前言
NutUI,大家應(yīng)該不陌生吧,前端開發(fā)的同學(xué)肯定是有些了解的。NutUI 是一個京東風(fēng)格的移動端組件庫,使用 Vue 語言來編寫可以在 H5,小程序平臺上的應(yīng)用。
目前 NutUI 擁有 70+ 組件,支持按需引用,支持 TypeScript,支持定制主題等功能,當然也支持最新的 Vue3 語法,在開發(fā)上能有效幫助研發(fā)人員提升效率,改善開發(fā)體驗。
言歸正傳,今天我們一起了解 NutUI 中折疊面板 Collapse 的實現(xiàn)與設(shè)計,以及在開發(fā)過程中學(xué)習(xí)到的新知識點。
折疊面板設(shè)計
其實折疊面板組件無論是在 PC 還是 M ,都是比較常見的組件,顧名思義就是可以折疊/展開的內(nèi)容區(qū)域。使用場景也比較廣泛,例如導(dǎo)航、文字類詳情、篩選分類等;
在組件開發(fā)階段,我們通常都會進行對比分析,取長補短。所以我們簡單通過功能上的對比來入組件的開發(fā)。

組件的本質(zhì)就是提升開發(fā)效率的,我們通過對業(yè)務(wù)場景的解構(gòu)和組合配置方式實現(xiàn)業(yè)務(wù)需求。好比組件庫是一個工具箱,每個組件就是箱子里的扳手、鉗子等工具,為業(yè)務(wù)場景提供各種工具,如何去打造一個合適趁手的工具干活,就需要我們對平時的業(yè)務(wù)開發(fā)有所了解和思考。
讓我們一起來探索吧~
實現(xiàn)展開收起
組件的基本交互已經(jīng)明了,那我們的標題和內(nèi)容的布局方式就比較簡單了?,F(xiàn)在我們需要去完成交互的開發(fā),也就是展開折疊的功能。

實現(xiàn)展開折疊的功能其實很簡單,就是通過一個變量控制內(nèi)容的展示隱藏就可以了,不用考慮其他因素的情況下,這種方法的確是最高效的方式。
<template>
<div>
<div @click="handle">
標題
</div>
<div v-show="show">
測試內(nèi)容測試內(nèi)容測試內(nèi)容測試內(nèi)容測試內(nèi)容測試內(nèi)容
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const show = ref(false);
const handle = () => {
show.value = !show.value;
}
</script>
但是采用這種方式可能對我們后期的功能擴展和交互效果不太友好。所以我的方案是通過改變折疊內(nèi)容的 height 的方式實現(xiàn)的,當然實現(xiàn)這個方法也比較好理解。
我們主要處理 content 的內(nèi)容,對于這塊樣式我們對它的 height 默認是 0,也就是內(nèi)容是折起的狀態(tài)。因為每個折疊內(nèi)容是無法確定的,所以我們需要動態(tài)計算內(nèi)容填充后的高度,這種方式也算是一種適配方案。
我動態(tài)計算的目的是為了實現(xiàn)后面動畫效果,提升用戶體驗感。我利用的是 height + transform 的方式實現(xiàn)的,同時使用 css 的屬性 will-change 對動畫效果進行優(yōu)化。
will-change 為 web 開發(fā)者提供了一種告知瀏覽器該元素會有哪些變化的方法,這樣瀏覽器可以在元素屬性真正發(fā)生變化之前提前做好對應(yīng)的優(yōu)化準備工作。這種優(yōu)化可以將一部分復(fù)雜的計算工作提前準備好,使頁面的反應(yīng)更為快速靈敏。
// 組件部分核心代碼
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 來計算出內(nèi)容的高度 offsetHeight 并賦值,通過高度的變化結(jié)合 transform 實現(xiàn)收起展開的動畫效果。

靈活的標題欄
其次就是標題欄功能的完善,增加圖標及自定義位置和相關(guān)動畫功能。我們先來看下基本用法的右側(cè)圖標,它和內(nèi)容的收起展開是相呼應(yīng)的,交互上展開時是上箭頭收起時是下箭頭。那么我們根據(jù)是否展開的狀態(tài)為變量,使用一個箭頭圖標就可以輕松搞定。實現(xiàn)的方案就是利用 css3 的 rotate 屬性,反轉(zhuǎn) 180° 就可以了。
if (parent.props.icon && !proxyData.openExpanded) {
  proxyData.iconStyle['transform'] = 'rotate(0deg)';
} else {
  proxyData.iconStyle['transform'] = 'rotate(' + parent.props.rotate + 'deg)';
}為了用戶的自定義性更高,更好的擴展組件能力,對外暴露了關(guān)于圖標配置的 API,比如自定義圖標、圖標的旋轉(zhuǎn)角度等。這些配置參考不同場景,比如某些新聞報道的內(nèi)容折疊旋轉(zhuǎn) 90° 。

當然,標題欄文字也可以配置相關(guān)圖標,包括圖標的位置、顏色、大小等。這種功能增加了用戶的個性化配置,他可以用來展示某些重要消息、新消息提醒,未查看信息等場景使用。

某些組件庫的開發(fā)者可能沒有此配置,首先個人感覺和組件是無關(guān)的。組件的設(shè)計是需要與業(yè)務(wù)之間進行銜接,抽象出一些功能,這樣能更好的完善組件的功能,包括后期組件的擴展等,都是在業(yè)務(wù)發(fā)展中成長的。
配置項升級
在后期的使用過程中,我們根據(jù)某些場景對組件功能進行了優(yōu)化升級。
首先增加了副標題的配置,通過 sub-title 就可以輕松設(shè)置(PS: 上圖??可看到示例)。
商城類移動端中的搜索分類功能,比如下圖的這種場景。它會有默認的內(nèi)容展示在外面,在折疊后其余內(nèi)容進行折疊或展開,所以新增了 slot:extraRender API,讓這部分內(nèi)容以插槽的形式存在,方便開發(fā)者定義不同的展示形式,便于樣式的調(diào)整等。

以上功能的實現(xiàn)也比較簡單,就是在代碼的中增加一個 slot 標簽接收傳入的內(nèi)容即可。
<view v-if="$slots.extraRender">
<div>
<slot name="extraRender"></slot>
</div>
</view>
在這里既然提到了 slot,我就多?嗦一下[憨笑]。關(guān)于上述提到的標題及內(nèi)容的展示,設(shè)計的時候考慮能讓開發(fā)者省時省力,有更多的可操作性,基本上都是以 slot 的形式來接收入?yún)ⅲ▋H限于本組件,內(nèi)容展示相關(guān)),這樣的話即使后端或者前端處理數(shù)據(jù)攜帶 HTMl 標簽也可以輕松識別,無需多余處理。

面板既然都可以展開收起操作,那么反之也有禁止操作的。我提供了一個簡單的屬性設(shè)置 disabled 來確定是否可操作,實現(xiàn)方式就是通過設(shè)置 style 樣式實現(xiàn)的。
.nut-collapse-item-disabled {
    color: #c8c9cc;
    cursor: not-allowed;
    pointer-events: none;
}開發(fā)設(shè)計番外
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組件開發(fā)適配
想成為 NutUI 的 contributor 嗎?如果也想為 NutUI 貢獻自己的組件,下面可是適配小程序的一些要點喲~
在 H5 開發(fā)時獲取 DOM 元素是比較容易的,通過 document 或者 ref 都可以。但是我們在適配小程序的時候這種方式是獲取不到的,需要根據(jù) 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);
  });
});通過以上方法可以獲取到節(jié)點的信息,包括 width、height、x、y 等,大家可以體驗試一下查看獲取的信息。還有一點需要注意,就是在給元素設(shè)置 style 樣式時,最好是在組件中使用 style 變量接收,不要直接賦值。
// 類似這種方式改變 style
const style = reactive({
color: 'red',
height: '100px',
});
const change = () => {
style.color = 'blue';
}
03vue3 組件通信
在組件開發(fā)時,因為 nut-collapse nut-collapse-item 父子組件需要進行通信,我使用的是 provide/inject 的方式,所以對此通信方式進行了簡單的的學(xué)習(xí)了解。
關(guān)于組件通信的方式,props、emit、attrs 等等方式,大家必然已了然于胸,我就不獻丑了?,F(xiàn)在我簡單和大家分享一下 provide/inject 的傳參形式,這個 API 在 vue2 的時候已經(jīng)存在。
//a.vue 組件
//創(chuàng)建一個 provide
import { defineComponent, provide } from 'vue';
export default defineComponent({
setup () {
const msg: string = 'Hello NutUI';
// provide 出去
provide('msg', msg);
}
})
//b.vue 組件
//接收數(shù)據(jù)
import { defineComponent, inject } from 'vue'
export default defineComponent({
setup () {
const msg: string = inject('msg') || '';
}
})
通過以上 2 個示例,操作是不是非常簡單,但需要注意一點,provide 不是響應(yīng)式的,如果你要使其具備響應(yīng)性,你需要傳入也應(yīng)該是響應(yīng)式數(shù)據(jù)。
provide 提供的數(shù)據(jù)不考慮組件層次結(jié)構(gòu),也就是發(fā)起 provide 的組件都可以作為其所有下級組件的依賴提供者。
provide 和 inject 的實現(xiàn)原理主要是利用了原型和原型鏈來實現(xiàn)。
在 Vue3 中 provide 函數(shù)就是給當前組件實例上的 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創(chuàng)建對象的一種方式,可以理解為繼承一個對象,添加的屬性是在原型下。
            provides = currentInstance.provides = Object.create(parentProvides);
        }
        provides[key] = value;
    }
}關(guān)于 inject 的實現(xiàn)我就不多贅述了,大家有興趣的可以去根據(jù)源碼做更深入的了解。
從下面代碼可以大致了解,inject 先獲取當前組件的實例對象,然后判斷是否根組件,如果是根組件則返回到 appContext 的 provides,否則就返回父組件的 provides。如果當前的 key 在 provides 上有值,就返回該值,反之則判斷是否存在默認內(nèi)容,默認內(nèi)容如果是個函數(shù),就執(zhí)行并且通過 call 方法把組件實例的代理對象綁定到該函數(shù)的 this 上,否則就直接返回默認內(nèi)容。
function inject(key, defaultValue, treatDefaultAsFactory = false) {
    // 如果是被一個函數(shù)式組件調(diào)用則取 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];
        }
        // 如果參數(shù)大于1個 第二個則是默認值 ,第三個參數(shù)是 true,并且第二個值是函數(shù)則執(zhí)行函數(shù)。
        else if (arguments.length > 1) {
            return treatDefaultAsFactory && isFunction(defaultValue)
                ? defaultValue.call(instance.proxy) 
                : defaultValue;
        }
    }
}大致可以這么理解 provide API 調(diào)用的時候,設(shè)置父級的 provides 為當前 provides 對象原型對象上的屬性,在 inject 獲取 provides 對象中的屬性值時,優(yōu)先獲取 provides 對象自身的屬性,如果自身查找不到,則沿著原型鏈向上一個對象中去查找。
總結(jié)
本文主要介紹了 NutUI 中折疊面板組件的設(shè)計思路與實現(xiàn)原理,并分享了一些開發(fā)中遇到的問題,希望能在開發(fā)中幫到大家。
如果在開發(fā)中遇到問題,可隨時提 issue,NutUI 團隊的同學(xué)都會認真對待并解決問題。如您有好的組件,業(yè)務(wù)類、通用類的都可,都可向 NutUI 組件庫提交 PR,非常歡迎大家參與共建。















 
 
 










 
 
 
 