Lambda Academy

用作用域插槽和偏函数编写高复用 Vue 组件

July 22, 2018

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 组件。

需求

Table

如上图,我们需要展示一个水果列表,列表中有每种水果的价格和库存信息。价格当然是我瞎编的。点击价格和库存表头,可根据相应标签进行排序。点击排序表头文字,第一次点击向上排序,接着点击,按上一次相反的方向排序。排序表头右边上下两个箭头,分别可点击向上向下排序。每次排序完后,对应标签的上或下标签根据排序方向高亮。

业务逻辑

列表的数据可以在组件里处理,也可以在 Vuex 里面处理,看业务需求。这里我就在 Vuex 里处理了。我们先写简单的。把 UI 需要的数据放在 state 里,然后写个 mutation 函数,根据传进来的标签和顺序,对数据进行排序。

// App.vue
import Vuex from "vuex";
import Vue from "vue";
Vue.use(Vuex);

import R from "ramda";
const sortBy = options => R.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
          ? R.sort(R.ascend(sortBy(sortOptions)))
          : R.sort(R.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)。原子组件就是可复用的,不能再继续拆分的最底层组件。原子组件有这样一些特征:

  1. 无业务逻辑,只执行传进来的方法。
  2. 不关心和它的功能不相关的信息。举个例子,一个开关(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");
    }
  }
};

onClickSortPriceonClickSortStock 函数利用闭包记住了排序标签。通过返回一个立即执行函数,这两个函数给 SORT_FRUITS 塞进了一个变量 sortBy。然后等排序表头组件执行这两个方法的时候,排序标签已经被提前填充进来了。

你可能会问,为什么不把排序标签作为属性传给排序表头组件,然后让它执行 SORT_FRUITS 时把全部参数传进去?答案是:

  1. 这违反了 DRY 原则。既然在一个排序表头里每次执行 SORT_FRUITS 方法时传的 sortBy 参数都一样,为什么不在父级就把这个参数填充了?而且,想象一下,如果 SORT_FRUITS 方法执行很多次,一直复制粘贴同一个参数,看起来实在乱。
  2. 给外部哪个数据排序,不是表头组件该关心的。它只关心是升序还是降序。

源码

至此,主要内容就讲完了。完整线上 demo 在此,需科学上网。GitHub Repo 在此


Lambda Academy

Lei Huang

这个博客我主要分享函数式编程和前端开发。我还有一个英文网站