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