TL;DR: 如果你觉得文字啰嗦,直接拉到底部看源码。
引言
作用域插槽是 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
,可参考我另一篇文章 优雅代码指北 -- 巧用 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
方法执行很多次,一直复制粘贴同一个参数,看起来实在乱。 - 给外部哪个数据排序,不是表头组件该关心的。它只关心是升序还是降序。