Steps 元件的設計與實現

jdf2e發表於2020-12-02

NutUI 元件原始碼揭祕

前言

本文的主題是 Steps 元件的設計與實現。Steps 元件是 Steps 步驟和 Timeline 元件結合的元件,在此之前他們是兩個不同的元件,在 NutUI 最近一次版本升級的時候將他們合二為一了,來看看在元件的開發過程中是如何一步步實現元件功能的。

說到 NutUI , 可能有些人還不太瞭解,容我們先簡單介紹一下。 NutUI 是一套京東風格的移動端Vue元件庫,開發和服務於移動 Web 介面的企業級前中後臺產品。通過 NutUI ,可以快速搭建出風格統一的頁面,提升開發效率。目前已有 50+ 個元件,這些元件被廣泛使用於京東的各個移動端業務中。

在此之前他們要分開使用,但是又有很多功能是交叉的,而且並不能滿足步驟和時間同時出現的業務場景,因此將他們進行了合併。

先來看下 Steps 元件的最終呈現效果,資料展示,並帶有一些流程性的邏輯。

元件的功能:

  1. 根據不同場景採用不同的佈局方式
  2. 可以指定當前所在的節點
  3. 可以橫向或者縱向排列
  4. 能夠動態響應資料的變化

一般來說在物流資訊、流程資訊等內容的展示需要使用到這個元件,可以像下面這樣使用它。

<nut-steps type="mini">
      <nut-step title="已簽收" content="您的訂單已由本人簽收。如有疑問您可以聯絡配送員,感謝您在京東購物。" time="2020-03-03 11:09:96" />
      <nut-step title="運輸中" content="您的訂單已達到京東【北京舊宮營業部】" time="2020-03-03 11:09:06" />
      <nut-step content="您的訂單已達到京東【北京舊宮營業部】" time="2020-03-03 11:09:06" />
      <nut-step content="您的訂單由京東【北京順義分揀中心】送往【北京舊宮營業部】" time="2020-03-03 11:09:06" />
      <nut-step title="已下單" content="您提交了訂單,請等待系統確認" time="2020-03-03 11:09:06"/>
</nut-steps>

元件封裝的思路

大多數的元件是一個單獨的元件,使用起來很簡單,比如我們 NutUI 元件庫中的 <nut-button block>預設狀態</nut-button><nut-icon type="top"></nut-icon> 等等這樣簡單的使用方式就可以實現元件的功能。

這樣設計元件是相當優秀的,因為使用者用的時候真的非常方便簡單。

這樣簡單而優雅的元件設計方式適用於大多數功能簡單的元件,但是對於邏輯相對複雜、佈局也比較複雜的元件來說就不合適了。

功能相對複雜的元件,會讓元件變得很不靈活,模板固定,使用自由度很低,對於開發者來,元件編碼也會變得十分臃腫。

所以在 vue 元件開發過程中合理使用插槽 slot 特性,讓元件更加的靈活和開放。就像下面這樣:

<nut-tab @tab-switch="tabSwitch">
  <nut-tab-panel tab-title="頁籤一">這裡是頁籤1內容</nut-tab-panel>
  <nut-tab-panel tab-title="頁籤二">這裡是頁籤2內容</nut-tab-panel>
  <nut-tab-panel tab-title="頁籤三">這裡是頁籤3內容</nut-tab-panel>
  <nut-tab-panel tab-title="頁籤四">這裡是頁籤4內容</nut-tab-panel>
</nut-tab>

<nut-subsidenavbar title="人體識別1" ikey="9">
  <nut-sidenavbaritem ikey="10" title="人體檢測1"></nut-sidenavbaritem>
  <nut-sidenavbaritem ikey="11" title="細粒度人像分割1"></nut-sidenavbaritem>
</nut-subsidenavbar>

...

有很多相對複雜的元件採用這種方式,既能保證元件功能的完整性,也能自由配置子元素內容。

元件的實現

基於上面的設計思路,就可以著手實現元件了。

本文的 Steps 元件,包含外層的 <nut-steps> 和內層的 <nut-step> 兩個部分。

我們一般會這樣設計

<-- nut-steps -->
<template>
  <div class="nut-steps" :class="{ horizontal: direction === 'horizontal' }">
    <slot></slot>
  </div>
</template>
<-- nut-step -->
<template>
  <div class="nut-step clearfix" :class="`${currentStatus ? currentStatus : ''}`">
    ...
  </div>
</template>

外層元件控制整體元件的佈局,啟用狀態等,子元件主要渲染內容,但是他們之間的關聯成了難題。

子元件中的一些狀態邏輯需要由父元件來控制,這就存在父子元件之間屬性或狀態的通訊。

解決這個問題有兩種思路,一是在父元件中獲取子元件資訊,再將子元件需要的父元件資訊給子元件設定上,二是在子元件中獲取父元件的屬性資訊來渲染子元件。

第一種方案:

this.steps = this.$slots.default.filter((vnode) => !!vnode.componentInstance).map((node) => node.componentInstance);
this.updateChildProps(true);

首先通過 this.$slots.default 獲取到所有的子元件,然後在 updateChildProps 中遍歷 this.steps ,並根據父元件的屬性資訊更新子元件。

跑起來驗證下,似乎實現想要的效果!!!

Prop 動態更新

但是,在實際專案應用中,發現在動態重新整理這塊存在很大問題。

例如:

  1. 當前所處狀態發生改變需要遍歷所用子元件,效能低下
  2. 子元件內容或某個屬性變化,想要更新元件會變得異常麻煩
  3. 父元件中要維護管理很多子元件的屬性

在剛開始甚至用了比較笨拙的方法,將渲染子元件用到的 list 傳遞給父元件,並監聽該屬性的變化情況來重新渲染子元件。但是為了實現這種更新卻新增了一個毫無意義的資料監聽,還需要深度監聽,而部分場景下也並不是必須,重新遍歷渲染子元件也會造成效能消耗,效率低下。

所以這種方式並不合適,改用第二種方式。

在子元件中訪問父元件的屬性,利用 this.$parent 來訪問父元件的屬性。

// step 元件建立之前將元件例項新增到父元件的 steps 陣列中
beforeCreate() {
  this.$parent.steps.push(this);
},
  
data() {
  return {
    index: -1,
  };
},
  
methods: {
  getCurrentStatus() {
    // 訪問父元件的邏輯更新屬性
    const { current, type, steps, timeForward } = this.$parent;
    // 邏輯處理
  }
},
mounted() {
  // 監聽 index 的變化重新計算相關邏輯
  const unwatch = this.$watch('index', val => {
    this.$watch('$parent.current', this.getCurrentStatus, { immediate: true });
    unwatch();
  });
}

在父元件中,接收子元件例項並設定 index 屬性

data() {
  return {
    steps: [],
  };
},
watch: {
  steps(steps) {
    steps.forEach((child, index) => {
      child.index = index; // 設定子元件的 index 屬性,將會用於子元件的展示邏輯
    });
  }
},

通過下面這張圖來看下它的資料變化。

子元件中的屬性變化只依賴子元件的屬性,子元件內部的屬性變化並不需要觸發父元件的更新,而子元件數量的變化會觸達父元件,並按照建立順序給子元件重新排序設定 index 值,子元件再根據 index 值的變化重新渲染。

將更多的邏輯交給了子元件處理,而父元件更多的是做整體元件的功能邏輯。也不必要監聽子元件的資料來源也能更新元件。

但是,實現過程中有個關鍵屬性可能是造成 bug 的重要隱患,它就是 this.$parent .

只有子元件 <step> 的父級是 <steps> 時訪問到的 this.$parent 才是準確的。

如果不是直接的父子級就一定會出現 bug 。

實際使用中,不僅是這個元件,其他這類元件也會出現子元件的直接父級並不是它對應父級的情況,這就會產生 bug 。比如:

<nut-steps :current="active">
  <nut-row>
    <nut-step v-for="(step, index) in steps" :key="index" :title="step.title" :content="step.content" :time="step.time">
    </nut-step>
  </nut-row>
</nut-steps>

<nut-row> 元件作為 <nut-step> 元件的父級元件的時候, this.$parent 指向的就不是 <nut-steps> 了。

那麼在 <nut-step> 中可以加一些 hack:

let parent = this.$parent || this.$parent.$parent;

但這很快就會失控,治標不治本,再加幾層巢狀,立刻玩完。

多層傳遞的神器 - 依賴注入

現在主要要解決的問題是讓後代子元件訪問到父級元件例項上的屬性或方法,中間不管跨幾級。

vue 依賴注入可以派上用場了。

vue 例項有兩個配置選項:

  1. provide: 指定我們想要提供給後代元件的資料/方法。
  2. inject:接收指定的我們想要新增在這個例項上的 property 。

這兩個屬性是 vue v2.2.0 版本新增

這兩選項需要一起使用,以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深,並在其上下游關係成立的時間裡始終生效。如果熟悉 React,這與 React 的上下文特性很相似。

父元件使用 provide 提供可注入子孫元件的 property 。

// 父級元件 steps
provide() {
  return {
    timeForward: this.timeForward,
    type: this.type,
    pushStep: this.pushStep,
    delStep: this.delStep,
    current: this.current,
  }
}, 
  
methods: {
    pushStep(step) {
      this.steps.push(step);
    },
    delStep(step) {
      const steps = this.steps;
      const index = steps.indexOf(step);
      if (index >= 0) {
        steps.splice(index, 1);
      }
    }
},

子元件使用 inject 讀取父級元件提供的 property 。

// 子孫元件 step
inject: ['timeForward', 'type', 'current', 'pushStep', 'delStep']
// beforeCreate() {
//   this.$parent.steps.push(this);
//   // this.pushStep(this);
// },
created() {
  this.pushStep(this);
},

子元件不再使用 this.$parent 來獲取父級元件的資料了。

這裡有個細節,子元件更新父元件的 steps 值的時機從 beforeCreate 變成了 created ,這是因為 inject 的初始化是在 beforeCreate 之後執行的,因此在此之前是訪問不到 inject 中的屬性的。

解決了跨層級巢狀的問題,還有另一個問題,監聽父元件屬性的變化。因為:

provideinject 繫結並不是可響應的。

比如 current 屬性是可以動態改變的,像上面這個注入,子孫元件拿到的永遠是初始化注入的值,並不是最新的。

這個也很容易解決,在父元件注入依賴時使用函式來獲取實時的 current 值即可。

  provide() {
    return {
      getCurrentIndex: () => this.current,
    }
  },  

在子元件中:

  computed: {
    current() {
      return this.getCurrentIndex();
    }
  },
    
  mounted() {
    const unwatch = this.$watch('index', val => {
      this.$watch('current', this.getCurrentStatus, { immediate: true });
      unwatch();
    });
  },

this.$watchwatch 方法中監聽是相同的效果,可以主動觸發監聽,this.$watch() 回返回一個取消觀察函式,用來停止觸發回撥。 這裡在元件掛載完成後監聽 index 的變化,index 變化再立即觸發 current 屬性變化的監聽。

這樣就能實時獲得父元件的屬性變化了,實現資料監聽重新整理元件。

至此這個元件的主要難點就攻克了。

當然這種方式只適用於父子層級比較深的場景,同層級兄弟元件之間是無法通過這種方式實現通訊的。

另外 provideinject 主要適用於開發高階元件或元件庫的時候使用,在普通的應用程式程式碼中最好不要使用。因為這可能會造成資料混亂,業務於邏輯混雜,專案變得難以維護。

總結

在元件開發過程中,為了保證元件的靈活性、整體性,很多元件都會出現這種巢狀問題,甚至深層巢狀導致的屬性共享問題、資料監聽問題,那麼本文主要根據 Steps 元件的開發經驗提供一種解決方案,希望對大家有那麼一丟丟的幫助或啟發。

相關文章