NutUI 組件源碼揭秘
前言
本文的主題是 Steps 組件的設計與實現。Steps 組件是 Steps 步驟和 Timeline 組件結合的組件,在此之前他們是兩個不同的組件,在 NutUI 最近一次版本升級的時候將他們合二為一了,來看看在組件的開發過程中是如何一步步實現組件功能的。
說到 NutUI , 可能有些人還不太了解,容我們先簡單介紹一下。 NutUI 是一套京東風格的移動端Vue組件庫,開發和服務於移動 Web 界面的企業級前中后台產品。通過 NutUI ,可以快速搭建出風格統一的頁面,提升開發效率。目前已有 50+ 個組件,這些組件被廣泛使用於京東的各個移動端業務中。
在此之前他們要分開使用,但是又有很多功能是交叉的,而且並不能滿足步驟和時間同時出現的業務場景,因此將他們進行了合並。
先來看下 Steps 組件的最終呈現效果,數據展示,並帶有一些流程性的邏輯。
組件的功能:
- 根據不同場景采用不同的布局方式
- 可以指定當前所在的節點
- 可以橫向或者縱向排列
- 能夠動態響應數據的變化
一般來說在物流信息、流程信息等內容的展示需要使用到這個組件,可以像下面這樣使用它。
<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 動態更新
但是,在實際項目應用中,發現在動態刷新這塊存在很大問題。
例如:
- 當前所處狀態發生改變需要遍歷所用子組件,性能低下
- 子組件內容或某個屬性變化,想要更新組件會變得異常麻煩
- 父組件中要維護管理很多子組件的屬性
在剛開始甚至用了比較笨拙的方法,將渲染子組件用到的 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 實例有兩個配置選項:
- provide: 指定我們想要提供給后代組件的數據/方法。
- 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
中的屬性的。
解決了跨層級嵌套的問題,還有另一個問題,監聽父組件屬性的變化。因為:
provide
和inject
綁定並不是可響應的。
比如 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.$watch
和 watch
方法中監聽是相同的效果,可以主動觸發監聽,this.$watch()
回返回一個取消觀察函數,用來停止觸發回調。 這里在組件掛載完成后監聽 index
的變化,index
變化再立即觸發 current
屬性變化的監聽。
這樣就能實時獲得父組件的屬性變化了,實現數據監聽刷新組件。
至此這個組件的主要難點就攻克了。
當然這種方式只適用於父子層級比較深的場景,同層級兄弟組件之間是無法通過這種方式實現通信的。
另外 provide
和 inject
主要適用於開發高階組件或組件庫的時候使用,在普通的應用程序代碼中最好不要使用。因為這可能會造成數據混亂,業務於邏輯混雜,項目變得難以維護。
總結
在組件開發過程中,為了保證組件的靈活性、整體性,很多組件都會出現這種嵌套問題,甚至深層嵌套導致的屬性共享問題、數據監聽問題,那么本文主要根據 Steps 組件的開發經驗提供一種解決方案,希望對大家有那么一丟丟的幫助或啟發。