用作用域插槽和偏函式編寫高複用 Vue 元件
作用域插槽是 Vue 2.1 之後引入的一種元件複用工具。其原理類似 React 裡面的 Render Props 元件設計模式。如果你使用過 Render Props,那麼你不僅可以很快理解作用域插槽,也能明白其實現原理。沒有使用過也沒關係,Vue 簡明的語法足以讓你短時間內掌握作用域插槽的用法。
偏函式(Partial Application)是一種函式複用和函式組合的技巧。舉個簡單的例子。
const add = x => y => x + y; 複製程式碼
你可以將 add 看成柯里化函式,也可以把它看成偏函式,這裡就不展開講了。重點是,基於 add
可以擴展出很多新函式。比如:
const add5 = add(5); add5(5); // => 10 const add10 = add(10); add10(5); // => 15 複製程式碼
基於上面簡單的例子再擴充套件下,把普通函式轉化成偏函式:
function partial(func, argArr) { return function(...args) { const allArguments = argArr.concat(args); return func.apply(this, allArguments); }; } const add = (x, y, z) => x + y + z; const addTwoAndThree = partial(add, [2, 3]); addTwoAndThree(5); // => 10 複製程式碼
就是這樣一個簡單到有點無聊的函式概念,在函式複用和組合上卻有著很強大的作用。
在接下來的例子中,我會把這兩個概念結合起來,寫一個高複用和符合 DRY (Don't repeat yourself) 原則的 Vue 元件。
需求

如上圖,我們需要展示一個水果列表,列表中有每種水果的價格和庫存資訊。價格當然是我瞎編的。點選價格和庫存表頭,可根據相應標籤進行排序。點選排序表頭文字,第一次點擊向上排序,接著點選,按上一次相反的方向排序。排序表頭右邊上下兩個箭頭,分別可點擊向上向下排序。每次排序完後,對應標籤的上或下標籤根據排序方向高亮。
業務邏輯
列表的資料可以在元件裡處理,也可以在 Vuex 裡面處理,看業務需求。這裡我就在 Vuex 裡處理了。我們先寫簡單的。把 UI 需要的資料放在 state
裡,然後寫個 mutation
函式,根據傳進來的標籤和順序,對資料進行排序。
// App.vue import Vuex from "vuex"; import Vue from "vue"; Vue.use(Vuex); import { descend, ascend, sortWith, prop } from "ramda"; const sortBy = options => prop(options.sortBy); const store = () => new Vuex.Store({ state: { fruits: [ { name: "bananas", price: 12, stock: 30 }, { name: "apples", price: 16, stock: 25 }, { name: "pineapples", price: 15, stock: 32 }, { name: "oranges", price: 10, stock: 34 }, { name: "pears", price: 13, stock: 60 }, { name: "avocado", price: 20, stock: 50 } ] }, mutations: { SORT_FRUITS(state, sortOptions) { const sortData = sortOptions.sortAscend ? sortWith([ascend(sortBy(sortOptions))]) : sortWith([descend(sortBy(sortOptions))]); const sortedFruits = sortData(state.fruits); state.fruits = [...sortedFruits]; } } }); 複製程式碼
SORT_FRUITS
函式接受一個物件 sortOptions
為引數(注:對 Vuex 不熟的讀者可能會對這部分困惑,我這裡是說 mutation
在被呼叫的時候,只接受一個引數),這個物件包含了排序依賴的資訊: sortAscend: Boolean
是否升序,和 sortBy: String
排序標籤。
這裡排序的邏輯我借用了 Ramda
庫,這只是我的個人偏好,你也可以用原生函式寫。如果你是新人,建議還是先熟悉原生 API 的寫法。如果想了解更多 Ramda
,可參考我另一篇文章 ofollow,noindex">優雅程式碼指北 -- 巧用 Ramda
主要的業務邏輯寫完了,接下來的任務就是讓 UI 事件來呼叫 SORT_FRUITS
,並傳入相應的引數來操作資料,最後利用 Vue 的雙向資料繫結來更新 UI。
原子元件
在對元件劃分的認識上,我自己發明了一個概念,叫原子元件(Atomic Components)。原子元件就是可複用的,不能再繼續拆分的最底層元件。原子元件有這樣一些特徵:
- 無業務邏輯,只執行傳進來的方法。
- 不關心和它的功能不相關的資訊。舉個例子,一個開關(toggle)元件,它只關心它處於開啟還是關閉的狀態,並執行對應的回撥函式,它不關心它開啟和關閉的是外部的哪個元素。這是元件複用的核心部分。
在我們在寫的 demo 中,排序表頭就是這樣一個原子元件。它的功能就是執行外面傳進來的排序函式,並記住排序順序,方便下一次排序和高亮箭頭。它不關心它到底是給價格排序還是給庫存排序,也不關心它該顯示什麼文字,這是外層元件該關心的事。
排序表頭元件
先來看錶頭元件的 Template:
<!-- TitleWithSortingArrows.vue --> <template> <div class="title"> <div class="title--text"> <slot :handleClick="onClickTitle"></slot> </div> <div class="title--arrows"> <div :class="upArrowHighlighted ? 'up-arrow__highlight' : 'up-arrow'" @click="onClickUpArrow"></div> <div :class="downArrowHighlighted ? 'down-arrow__highlight' : 'down-arrow'" @click="onClickDownArrow"></div> </div> </div> </template> 複製程式碼
排序表頭的文字因為是由外部定義的,所以放了個插槽。另外,由於在外部點選表頭文字時,執行的方法是由排序表頭狀態決定的,所以通過作用域插槽把排序表頭內部的方法傳到外部,這個函式是 onClickTitle
。模板下面的兩個上下箭頭用純 CSS 寫的,根據排序的狀態決定是否用高亮背景色。
再看 JS 部分:
export default { name: "titleWithSortingArrows", props: ["sortMethod"], data() { return { sortTriggered: false, sortAscend: true }; }, computed: { upArrowHighlighted: function() { return this.sortTriggered && this.sortAscend; }, downArrowHighlighted: function() { return this.sortTriggered && !this.sortAscend; } }, methods: { checkIfSortTriggered() { if (!this.sortTriggered) { this.sortTriggered = true; } }, onClickUpArrow() { this.sortMethod(true); this.sortAscend = true; this.checkIfSortTriggered(); }, onClickDownArrow() { this.sortMethod(false); this.sortAscend = false; this.checkIfSortTriggered(); }, onClickTitle() { this.sortMethod(!this.sortAscend); this.sortAscend = !this.sortAscend; this.checkIfSortTriggered(); } } }; 複製程式碼
可以看到元件接受一個排序方法 sortMethod
為屬性,並根據自身狀態,在不同部分執行排序方法時傳入升序(true)還是降序(false)。 computed
部分兩個變數是計算兩個箭頭是否應該高亮。 sortTriggered
狀態預設是 false,意味著元件首次載入時箭頭都是灰色。這個元件最值得注意的地方是 onClickTitle
方法,元件把父元件傳進來的方法根據自身特有的屬性(此時的排序順序)進行定製化,再通過作用於插槽把定製化後的方法提供給父元件呼叫。
通過作用域插槽取到子元件的資料(方法)
排序表頭元件通過作用域插槽向外傳資料( onClickTitle
方法)後,呼叫它的父級元件就能通過 slot-scope
這個標籤在模板裡取到相關資料了。來看父級元件是怎麼取作用域插槽的資料的:
<!-- TableHeader.vue --> <template> <div class="header"> <div class="header--item"> <span>Fruits</span> </div> <div class="header--item"> <title-with-sorting-arrows :sort-method="onClickSortPrice"> <span slot-scope="{handleClick}" @click="handleClick">Price</span> </title-with-sorting-arrows> </div> <div class="header--item"> <title-with-sorting-arrows :sort-method="onClickSortStock"> <span slot-scope="{handleClick}" @click="handleClick">Stock</span> </title-with-sorting-arrows> </div> </div> </template> 複製程式碼
handleClick
就是從作用域插槽傳來的方法。
難題:怎麼將 Vuex mutation 轉成偏函式
在上面的排序表頭元件裡,元件只關心是升序排序和降序排序,它並不關心是給哪個標籤排序。那問題來了。再看下我們在 mutation 裡寫的排序函式 SORT_FRUITS
,它需要兩個排序資訊才能工作:排序順序和排序標籤。如果 SORT_FRUITS
接受兩個引數,那我們可以利用偏函式,先把它應用一部分引數,再傳給表頭。類似這樣:
const sortByPrice = partial(this.SORT_FRUITS, ["price"]); 複製程式碼
然後我們就能在父級元件給表頭元件傳 sortByPrice
這個函數了。
問題是, SORT_FRUITS
接受的是一個物件,不是兩個引數!
考驗我們 JS 基礎知識的時間到了。其實只要理解了閉包和文章開頭寫的 partial
函式工作原理,是能很容易把接受物件為引數的函式也轉成偏函式的。這樣子:
// TableHeader.vue export default { name: "TableHeader", components: { TitleWithSortingArrows }, methods: { ...mapMutations({ SORT_FRUITS: "SORT_FRUITS" }), onClickSortPrice(sortAscend) { const self = this; return (function applySortBy(sortBy) { self.SORT_FRUITS({ sortAscend, sortBy }); })("price"); }, onClickSortStock(sortAscend) { const self = this; return (function applySortBy(sortBy) { self.SORT_FRUITS({ sortAscend, sortBy }); })("stock"); } } }; 複製程式碼
onClickSortPrice
和 onClickSortStock
函式利用閉包記住了排序標籤。通過返回一個立即執行函式,這兩個函式給 SORT_FRUITS
塞進了一個變數 sortBy
。然後等排序表頭元件執行這兩個方法的時候,排序標籤已經被提前填充進來了。
你可能會問,為什麼不把排序標籤作為屬性傳給排序表頭元件,然後讓它執行 SORT_FRUITS
時把全部引數傳進去?答案是:
- 這違反了 DRY 原則。既然在一個排序表頭裡每次執行
SORT_FRUITS
方法時傳的sortBy
引數都一樣,為什麼不在父級就把這個引數填充了?而且,想象一下,如果SORT_FRUITS
方法執行很多次,一直複製貼上同一個引數,看起來實在亂。 - 給外部哪個資料排序,不是表頭元件該關心的。它只關心是升序還是降序。