JS高阶函数与高阶组件

830 阅读5分钟

大家好,今天将向大家介绍JS中的高阶函数,什么是高阶函数、高阶函数的一些常见使用场景,以及如何在Vue中使用高阶组件。

首先,让我们来聊聊什么是高阶函数(higher-order function)。

大家知道在JS语言中,函数是一等公民(first class citizen)。而函数一等公民所指的是,在JS语言中,函数可以作为函数的参数和返回值,也可以赋值给变量。

这样的语言特性,衍生出了高阶函数这一概念。高阶函数值得是满足下列至少一条的函数:

  • 接受一个或多个函数作为输入
  • 输出一个函数

高阶函数在JS中使用非常广泛,其中非常常见的例子之一,便是数组操作:

[1, 2, 3].reduce((sum, v) => sum + v) // 6
[1, 2, 3].filter(v => v % 2) // [1, 3]
[{ v: 1 }, { v: 3 }, { v: 2 }].sort((a, b) => a.v - b.v) // [{ v: 1 }, { v: 2 }, { v: 3 }]

大家熟悉的柯里化(currying)也可以和高阶函数结合使用:

function add(x, y, z) {
  return x + y + z
}

function multiple(x, y, z) {
  return x * y * z
}

function currying(f, ...args1) {
  return function (...args2) {
    return f.apply(this, args1.concat(args2))
  }
}

const curryingAdd = currying(add, 1)
curryingAdd(2, 3) // 6

const curryingMultiple = currying(multiple, 2, 3)
curryingMultiple(4) // 24

这里我们借助高阶函数和rest参数实现了一个通用的柯里化函数。

(顺便提一下,bind也可以用来实现柯里化,这一点经常被忽略):

const bindAdd = add.bind(this, 1, 2)
bindAdd(3) // 6

我们可以通过柯里化,实现一个简单的汇率计算器:

function calculatePrice(exchangeRate, price) {
  return (price * exchangeRate).toFixed(2);
}

const calculatePriceByUSD = currying(calculatePrice, 6.91);
calculatePriceByUSD(10);
calculatePriceByUSD(25);

这样,我们不必直接调用calculatePrice,避免了反复传入汇率。

接下来在看一个例子:

function fibonacci(n) {
  if (n === 1 || n === 2) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(7)

我们用递归实现了斐波那契数列的计算。不过这里有个问题,我们进行了多次的重复计算,例如fibonacci(5)被计算了2次,fibonacci(4)被计算了3次,fibonacci(3)被计算了5次。

通过观察不难发现,fibonacci是一个纯函数。纯函数指的是,一个函数在被调用时,若参数相同,则永远返回相同的结果。即其调用结果只取决于参数,而不依赖与任何外部状态。例如无论我们何时调用fibonacci(7),都将得到相同的结果13。

由此,我们可以通过缓存调用结果的方式来优化以上代码:

function fibonacci(n, f) {
  if (n === 1 || n === 2) return 1;
  return f(n - 1, f) + f(n - 2, f);
}

// fibonacci(7, fibonacci)

function memorized(f) {
  const map = {};
  const memorizedFunction = function(n) {
    if (n in map) return map[n];
    return map[n] = f(n, memorizedFunction);
  }
  return memorizedFunction;
}

const memorizedFibonacci = memorized(fibonacci);

memorizedFibonacci(7)

在JS语言引入模板字符串(template literal)之前,涌现出了包括mustache、artTemplate等基于字符串的模板引擎。

这些模板引擎最核心的原理是将模板转换为一个负责拼接字符串的函数:

// <div>{{ a }}</div>
 
function tpl(obj) {
  with (obj) {
    return '<div>' + a + '</div>'
  }
}

tpl({ a: 'hello' })

我们来看一个极简版本的模板引擎:

function buildTpl(str) {
  const funcBody = 'with (obj) { return "' + str.replace(/{{/g, '"+').replace(/}}/g, '+"') + '" }'
  return new Function('obj', funcBody)
}


const tplStr = '{{ a }}<div>{{ b }}<span>{{ c }}</span></div>'
const tpl = buildTpl(tplStr)
tpl({ a: 1, b: 2, c: 3 })

这里,我们使用了new Function来创建模板函数并返回。无论是基于字符串的模板引擎,或是Vue等框架中的模板引擎,都是高阶函数的典型应用场景。

最后的一个例子,也是高阶函数的常用场景之一:函数防抖(debounce)。

首先我们想象有一个按钮,每次点击时会将count值加一,并通过AJAX请求将count值保存到服务器。

let count = 0;

function saveCount() {
  console.log('saved', count)
}

function add() {
  count++;
  saveCount();
}

add();
add();
add();

如果我们关心的只是将count的最终值保存到服务端,那么在用户快速连续点击按钮时,一次又一次的发起请求似乎就有些不必要了。

于是,我们使用debounce函数来创建一个防抖版本的saveCount函数,使得快速连续点击时只在最后发起一次请求并保存最终值:

let count = 0;
const debouncedSave = debounce(saveCount, 300)

function saveCount() {
  console.log('saved', count)
}

function add() {
  count++;
  debouncedSave();
}

function debounce(fn, delay) {
  let timer
  return function() {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn();
    }, delay);
  }
}

add();
add();
add();

同样的,我们可以通过新的语法特性:装饰器(decorator)来实现debounce:

function debounce(t) {
  return function({ key, descriptor }) {
    const origin = descriptor.value
    let timer;
    descriptor.value = function(...params) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        origin.call(this, ...params)
      }, t);
    }
  };
}

class Counter {
  constructor() {
    this.count = 0;
  }

  add(time) {
    this.count++
    this.save()
  }

  @debounce(500)
  save() {
    console.log('saved', this.count)
  }
}

const counter = new Counter()
counter.add()
counter.add()
counter.add()

高阶组件(high-order component)可以说是高阶函数的组件版,其接收一个组件并返回装饰过的组件。高阶组件聚焦于业务层而非UI层,提供了组件业务逻辑上的抽象性与复用性。

让我们来看看如何实现一个防止重复提交的高阶组件。

首先我们添加一个简单的组件:

<template>
  <div>
    <el-button @click="onClick">save {{ count }}</el-button>
  </div>
</template>

<script>
const postToServer = function() {
  return new Promise((r) => {
    setTimeout(r, 1000);
  });
};

export default {
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    async onClick() {
      await postToServer();
      console.log('saved on server');
      this.count += 1;
    },
  },
};
</script>

这里我们放置了一个按钮,每次点击时,按钮会向服务端发起请求,并在请求结束后更新count。

如果我们快速点击按钮两次,1秒之后,count将被陆续更新为1、2。

借助Vue提供的JSX支持,我们可以方便的实现高阶组件,来提供函数节流:

<script>
function debounce(fn, delay) {
  let timer
  return function() {
    clearTimeout(timer)
    timer = setTimeout(() => {
      fn();
    }, delay);
  }
}

export default {
  props: ['time', 'cls'],
  data() {
    return {
      loading: false,
    }
  },
  render(h) {
    const { cls } = this;
    const events = Object.keys(this.$listeners).reduce((map, k) => {
      map[k] = debounce(this.$listeners[k], this.time)
      return map
    }, {})
    return (<cls on={ events } loading={this.loading} { ...{ attrs: this.$attrs, props: this.attrs }}>{ this.$slots.default[0] }</cls>)
  }
}
</script>

这里我使用了element的Button组件,我们需要将其通过通过名为cls的prop传递进来。修改父组件:

<template>
  <div>
    <el-button @click="onClick">save {{ count }}</el-button>
    <DebouncedBtn :cls="btnCls" :time="500" @click="onClick" type="primary">save {{ count }}</DebouncedBtn>
  </div>
</template>

<script>
import DebouncedBtn from './DebouncedBtn';
import { Button } from 'element-ui';

...

export default {
  data() {
    return {
      count: 0,
      btnCls: Button,
    };
  },
  ...
  components: {
    DebouncedBtn,
  },
};
</script>

我们还可以更进一步。我们可以约定点击事件调用的函数均返回Promise(通过async语法),那么我们可以等待Promise被resolve或reject,再次之前,阻止按钮点击函数被调用,同时由高阶组件来维护el-button的loading状态:

<script>
const promisedFunc = function(func, $vm) {
  return async function() {
    if ($vm.loading) return
    $vm.loading = true
    try {
      await func()
    } catch(err) {}
    $vm.loading = false
  }
};

export default {
  props: ['cls'],
  data() {
    return {
      loading: false,
    }
  },
  render(h) {
    const { cls } = this;
    const events = Object.keys(this.$listeners).reduce((map, k) => {
      map[k] = promisedFunc(this.$listeners[k], this)
      return map
    }, {})
    return (<cls on={ events } loading={this.loading} { ...{ attrs: this.$attrs, props: this.attrs }}>{ this.$slots.default[0] }</cls>)
  }
}
</script>