iview 在今年 7 月 28 號釋出了 3.0.0 版本,大版本升級往往意味著功能、介面的大變更。 雖然官網已經有長長的更新日誌,但看起來還是有些抽象了, 所以我決定做個新舊版本的比較,盤點新版本到底為我們帶來了什麼新特性。
本篇是系列文章的第三篇,重點並不在介紹 MenuItem 的功能特性,而在於對其程式碼的討論; 對其設計的思考。 班門弄斧,見諒。
唯一新增的特性 —— 支援連結模式
循例是該先聊聊新特性的。Menu 有四個關聯的元件,分別為:Menu、MenuItem、SubMenu、MenuGroup,
這些元件的新舊版本之間並沒有太大差異,向後相容的很好,理論上可以平滑升級。
新版本只有 MenuItem 增加了一個特性:支援連結模式,可以通過向元件傳入 to
屬性啟用,效果與 連結模式的 Button 完全一樣,這裡就不贅述了。
問題
MenuItem 是一個非常非常簡單的元件,一開始覺得並沒有太多好寫的,細細看了程式碼...個人感覺問題不少,還是有必要單獨寫一篇文章聊聊的。
問題一: 程式碼重複
首先,依然是程式碼重複的問題,在 Button
篇中 我們已經見識了一些無意義的重複,在 MenuItem
元件中也是不遑多讓啊:
<template>
<a
v-if="to"
:href="linkUrl"
:target="target"
:class="classes"
@click.exact="handleClickItem($event, false)"
@click.ctrl="handleClickItem($event, true)"
@click.meta="handleClickItem($event, true)"
:style="itemStyle"><slot></slot></a>
<li v-else :class="classes" @click.stop="handleClickItem" :style="itemStyle"><slot></slot></li>
</template>
複製程式碼
這段模板有兩處重複,一是標籤,二是事件繫結。
1. 重複的標籤定義
模板中,通過判斷 to
屬性,確定需要渲染的標籤型別,用於相容新增的連結模式,這種寫法很符合直覺,但有另一種更優雅的方案:is
特性,同樣的功能,用 is
實現:
/* 模擬MenuItem元件 */
Vue.component("MenuItem", {
name: "MenuItem",
// 簡化過的模板,乾淨無重複
template: `<component :is="tagName" v-bind="tagProps"><slot></slot></component>`,
props: {
to: { type: String, required: false }
},
computed: {
isLink() {
const { to } = this;
return !!to;
},
// 使用計算器屬性,按需計算標籤名
// 這種方式可以承載更復雜的計算邏輯
tagName() {
const { isLink } = this;
return isLink ? "a" : "li";
},
// 通過計算器+v-bind 語法,實現按標籤型別傳遞不同屬性
// 這裡把本來放在模板的運算,轉嫁到計算器上
tagProps() {
const { isLink, to } = this;
const baseProps = { class: "menu-item", style: { display: "block" } };
if (isLink) {
return Object.assign(baseProps, {
href: to,
target: "_blank"
});
}
return baseProps;
}
}
});
複製程式碼
示例中使用了 computed 屬性、v-bind、is 三種特性,把本應在模板做的計算轉移到計算屬性;通過 v-bind
繫結複雜物件;通過 is
渲染不同的標籤型別...達成與 iView 相同的功能,執行效果歡迎到 線上 demo 體驗。這種寫法,有兩個好處,一是減少模板上的重複;二是減少模板上的計算,轉而在計算屬性上實現,配合快取效果,有一定的效能提升。
2. 重複的事件繫結
另外一個問題,在 iView 的 MenuItem 中,a
標籤會重複繫結三次相關的 click 回撥,分別配以 exact
、ctrl
、meta
,這種寫法在 Button
元件也出現過,用以模擬 a
標籤的不同點選效果,之前在 Button
篇已做過深入討論,這裡不再贅述。
問題二:不符合 html 標準
現在,我們看看 Menu 與 MenuItem 的模板程式碼:
<!-- Menu 元件模板部分原始碼 -->
<template>
<ul :class="classes" :style="styles"><slot></slot></ul>
</template>
<!-- MenuItem 元件模板部分原始碼 -->
<template>
<a v-if="to"><slot></slot></a>
<li v-else :class="classes"><slot></slot></li>
</template>
複製程式碼
如果沒有 to
屬性,MenuItem 渲染為 li
,這沒問題,但如果是連結模式,渲染結果就會是:
<ul>
<a></a>
<a></a>
<a></a>
...
</ul>
複製程式碼
ul
中直接包含了 a
!遙想我最初學習 html 的時候,就已經被一再警告 ul
就應該老老實實包著 li
,確實也偶爾會看到其他一些框架漠視這條規則,沒成想在 iView 這裡也能遇到。
h5 包容性是很強,這段程式碼完全可以 work,沒毛病,但沒毛病不代表足夠好,我們本可以做的更好,為什麼不選擇做的更好呢?
解法很簡單,依然是 is
特性,只需多一層包裹,核心程式碼如下:
<template>
<li class="menu-item">
<component :is="tagName">
<slot></slot>
</component>
</li>
</template>
<script>
export default {
computed: {
tagName() {
const { isLink } = this;
return isLink ? "a" : "span";
}
}
};
</script>
複製程式碼
問題三: 父子元件通訊
這個問題有點複雜,要講述清楚並不容易,還望讀者朋友們能給多些耐心。
我注意到在 MenuItem 元件中有這樣 一行程式碼:this.$on('on-update-active-name', (name) => {...}
,MenuItem 會在回撥中給自身設定各種值。
事件繫結的程式碼用的多了,但這種元件自己偵聽自己的方式卻不多見,更奇怪的是 MenuItem 並沒有 $emit
過 on-update-active-name
事件。
出於好奇,我仔細翻查原始碼,發現真正發出 on-update-active-name
事件的是父級 Menu 元件!
一般情況下,Menu 與 MenuItem 是以父子關係成對出現的元件,比如:
<template>
<Menu active-name="1">
<MenuItem name="1">內容管理</MenuItem>
<MenuItem name="2">使用者管理</MenuItem>
</Menu>
</template>
複製程式碼
上例中,改變 Menu 的active-name
值後,Menu 會執行 this.broadcast('MenuItem', 'on-update-active-name', this.currentActiveName);
,即呼叫broadcast
函式,向下廣播 on-update-active-name
事件,注意我們的關鍵字:向下廣播!
1.x 版本的 Vue 確實提供過兩種傳播事件的方法:$dispatch
、$broadcast
,其中 $broadcast
用於父元件向子元件 傳播 事件,但到 2.x 時放棄了這種設計,官網 提供的說法是這樣的:
因為基於元件樹結構的事件流方式實在是讓人難以理解,並且在元件結構擴充套件的過程中會變得越來越脆弱。 這種事件方式確實不太好,我們也不希望在以後讓開發者們太痛苦。並且
$dispatch
和$broadcast
也沒有解決兄弟元件間的通訊問題。
我確信這是一個合理的設計優化 —— $broadcast
這種元件通訊方式會增加父子元件間的耦合性,無論是業務層面的開發,還是框架層面的開發,都應該摒棄這種設計模式。
但 iView 卻大方復辟,不惜自行實現了 一套 $broadcast
邏輯,為什麼?
我認為一種可信的說法是:這是不得已的妥協。
Menu 元件提供了 active-name
屬性,用於指明當前處於啟用態的選單項,但真正使用 active-name
屬性的則是 MenuItem 元件。那麼 Menu 從使用者拿到 active-name
後,如何傳遞到 MenuItem 元件呢?iView 選擇了通過向下廣播事件的方式,將值傳遞給 Menu 元件下的 MenuItem,合理有效,只是 broadcast
的復辟,讓我覺得非常不舒服。
問題梳理清楚了,那麼如何優化?
1. 通過 Vuex 管理狀態
最簡單的方式,是遵循 Vue 官網的建議,使用 Vuex 管理狀態。這在日常業務開發中是相當有效的,但作為一個框架卻萬萬使不得 —— 你總不能強行綁著另一個框架,要求使用者必須同時使用吧?
作為一個變通,也可以設計一個全域性狀態變數,但這必然又會引發更多問題。
2. 通過 JSX 實現
另一種方法是通過 JSX 方式,在渲染 MenuItem 前以 props 方式,將 active-name
給傳過去:
Vue.component("MenuItem", {
render() {
const {
$slot: { default: children },
activeName
} = this;
return (
<ul>
{children.map(node => {
node.props = { activeName };
return node;
})}
</ul>
);
}
});
複製程式碼
如果我們只有 Menu、MenuItem,那上面的方式已經足夠實現功能,也算是比較優雅,但如果把 SubMenu、MenuGroup 元件加入考慮範圍,那麼問題就會變得更復雜 —— active-name
需要從 Menu 跨過中間的 SubMenu、MenuGroup 傳遞到 MenuItem。這種跨元件的資訊傳遞,在 JSX 環境下,我只想到兩種解決方案:在 Menu 遞迴查詢 MenuItem 元件;在 SubMenu、MenuGroup 中重複定義 props 的賦值邏輯。
最近新冒出來一個 UI 庫 —— ant-design
,它的 Menu
正是基於 JSX 方式實現的,原諒我才疏學淺,看起來實現費勁吃力。
3. 通過 provide/inject 實現
Vue 2.2.0 版本後提供了 provide/inject 特性,官網是這麼介紹的:
這對選項需要一起使用,以允許一個祖先元件向其所有子孫後代注入一個依賴,不論元件層次有多深, 並在起上下游關係成立的時間裡始終生效。如果你熟悉 React,這與 React 的上下文特性很相似。
這真是一個大殺器 —— 祖先元件可以宣告需要向所有後代傳遞的值;而後代元件,無論多深層次的後代,都可以按需訂閱感興趣的內容。我用這個特性做了個簡單的 demo,核心程式碼:
Vue.component("MenuItem", {
template: `<li :class="classes" class="menu-item"><slot></slot></li>`,
// 在此宣告“注入”activeName值
inject: ["activeName"],
props: {
name: { type: String, required: true }
},
computed: {
classes() {
const { activeName, name } = this;
return activeName === name ? "active" : "";
}
}
});
Vue.component("Menu", {
template: `<ul class="menu"><slot></slot></ul>`,
// 向所有後代元件傳遞此項
provide() {
return {
activeName: this.activeName
};
},
props: {
activeName: { type: String, required: true }
}
});
複製程式碼
修改後的 Menu、MenuItem 依然可以保持父子關係,互相之間卻不強耦合 —— 任何通過 provide
提供 activeName
屬性的元件,都可以作為 MenuItem 的祖先。巢狀 Menu 也可以變得更簡單些,我寫了另外一個 demo,歡迎查閱,時間關係,不再贅述。