摺疊面板元件的設計與實現

語言: CN / TW / HK

前言

NutUI,大家應該不陌生吧 [鬼臉],前端開發的同學肯定是有些瞭解的。NutUI 是一個京東風格的移動端元件庫,使用 Vue 語言來編寫可以在 H5,小程式平臺上的應用。

目前NutUI 擁有 70+ 元件,支援按需引用,支援 TypeScript,支援定製主題等功能,當然也支援最新的 Vue3 語法,在開發上能有效幫助研發人員提升效率,改善開發體驗。

言歸正傳,今天我們一起了解NutUI 中摺疊面板 Collapse 的實現與設計,以及在開發過程中學習到的新知識點。

摺疊面板設計

其實摺疊面板元件無論是在 PC 還是 M ,都是比較常見的元件,顧名思義就是可以摺疊/展開的內容區域。使用場景也比較廣泛,例如導航、文字類詳情、篩選分類等;

在元件開發階段,我們通常都會進行對比分析,取長補短。所以我們簡單通過功能上的對比來入元件的開發。

摺疊面板 vant antd tdesign elementui varlet vuetify naiveui iview balam nutui
內容展開收起
動畫效果
手風琴模式
摺疊圖示icon
摺疊圖示color
摺疊圖示size
標題圖示位置
旋轉角度
副標題
支援禁用模式
可設定固定內容

元件的本質就是提升開發效率的,我們通過對業務場景的解構和組合配置方式實現業務需求。好比元件庫是一個工具箱,每個元件就是箱子裡的扳手、鉗子等工具,為業務場景提供各種工具,如何去打造一個合適趁手的工具幹活,就需要我們對平時的業務開發有所瞭解和思考。

讓我們一起來探索吧~

實現展開收起

元件的基本互動已經明瞭,那我們的標題和內容的佈局方式就比較簡單了。現在我們需要去完成互動的開發,也就是展開摺疊的功能。

實現展開摺疊的功能其實很簡單,就是通過一個變數控制內容的展示隱藏就可以了,不用考慮其他因素的情況下,這種方法的確是最高效的方式。

<template>
  <div class="container">
    <div class="title" @click="handle">
      標題
    </div>
    <div class="content" v-show="show">
      測試內容測試內容測試內容測試內容測試內容測試內容
    </div>
  </div>
</template>
<script setup lang="ts">
    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 實現收起展開的動畫效果。

靈活的標題欄

其次就是標題欄功能的完善,增加圖示及自定義位置和相關動畫功能。我們先來看下基本用法的右側圖示,它和內容的收起展開是相呼應的,互動上展開時是上箭頭收起時是下箭頭。那麼我們根據是否展開的狀態為變數,使用一個箭頭圖示就可以輕鬆搞定。實現的方案就是利用 css3rotate 屬性,反轉 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: 上圖:point_up_2:可看到示例)。

商城類移動端中的搜尋分類功能,比如下圖的這種場景。它會有預設的內容展示在外面,在摺疊後其餘內容進行摺疊或展開,所以新增了 slot:extraRender API,讓這部分內容以插槽的形式存在,方便開發者定義不同的展示形式,便於樣式的調整等。

以上功能的實現也比較簡單,就是在程式碼的中增加一個 slot 標籤接收傳入的內容即可。

<view v-if="$slots.extraRender" class="collapse-extraWrapper">
    <div class="collapse-extraRender">
        <slot name="extraRender"></slot>
    </div>
</view>

在這裡既然提到了 slot ,我就多囉嗦一下[憨笑]。關於上述提到的標題及內容的展示,設計的時候考慮能讓開發者省時省力,有更多的可操作性,基本上都是以 slot 的形式來接收入參(僅限於本元件,內容展示相關),這樣的話即使後端或者前端處理資料攜帶 HTMl 標籤也可以輕鬆識別,無需多餘處理。

面板既然都可以展開收起操作,那麼反之也有禁止操作的。我提供了一個簡單的屬性設定 disabled 來確定是否可操作,實現方式就是通過設定 style 樣式實現的。

.nut-collapse-item-disabled {
    color: #c8c9cc;
    cursor: not-allowed;
    pointer-events: none;
}

開發設計番外

Scss 中使用變數

這個功能大家想必也不陌生,說白了就是可以通過 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 的概念。

元件開發適配

想成為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);
  });
});

通過以上方法可以獲取到節點的資訊,包括 widthheightxy 等,大家可以體驗試一下檢視獲取的資訊。 還有一點需要注意,就是在給元素設定 style 樣式時,最好是在元件中使用 style 變數接收,不要直接賦值。

// 類似這種方式改變 style
const style = reactive({
    color: 'red',
    height: '100px',
});

const change = () => {
    style.color = 'blue';
}

vue3 元件通訊

在元件開發時,因為 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 的元件都可以作為其所有下級元件的依賴提供者。

provideinject 的實現原理主要是利用了原型和原型鏈來實現。

在 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 先獲取當前元件的例項物件,然後判斷是否根元件,如果是根元件則返回到 appContextprovides ,否則就返回父元件的 provides 。如果當前的 keyprovides 上有值,就返回該值,反之則判斷是否存在預設內容,預設內容如果是個函式,就執行並且通過 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 ,非常歡迎大家參與共建。

最後,非常感謝一直以來支援NutUI 的團隊及同學,大家的需求與建議,讓我們的元件庫越來越好,我們也會不斷努力,力爭更上一層樓!

來點個 Star :heart: 支援我們一下吧 ~

文章參考連結:

  1. Vue3 的 Provide / Inject 的實現原理: https://juejin.cn/post/7064904368730374180
  2. 一文讀懂vuex4原始碼,原來provide/inject就是妙用了原型鏈?: https://juejin.cn/post/6963802316713492516