Steps 組件的設計與實現

NutUI 組件源碼揭祕

前言

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

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

在此以前他們要分開使用,可是又有不少功能是交叉的,並且並不能知足步驟和時間同時出現的業務場景,所以將他們進行了合併。vue

先來看下 Steps 組件的最終呈現效果,數據展現,並帶有一些流程性的邏輯。java

組件的功能:node

  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> 等等這樣簡單的使用方式就能夠實現組件的功能。ide

這樣設計組件是至關優秀的,由於使用者用的時候真的很是方便簡單。函數

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

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

因此在 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 組件的開發經驗提供一種解決方案,但願對你們有那麼一丟丟的幫助或啓發。

相關文章
相關標籤/搜索