Js函数式编程,给你的代码增加一点点函数式编程的特性

时间: 2017-11-07阅读: 392标签: 函数作者: Peeke Kuepers

-- 给你的代码增加一点点函数式编程的特性

最近我对函数式编程非常感兴趣。这个概念让我着迷:应用数学来增强抽象性和强制纯粹性,以避免副作用,并实现代码的良好可复用性。同时,函数式编程非常复杂。

函数式编程有一个非常陡峭的学习曲线,因为它来源于数学中的范畴论。接触不久之后,就将遇到诸如组合(composition)、恒等(identity),函子(functor)、单子(monad),以及逆变(contravariant)等术语。我根本不太了解这些概念,可能这也是我从来没有在实践中运用函数式编程的原因。

我开始思考:在常规的命令式编程和完全的函数式编程之间是否可能会有一些中间形式?既允许在代码库引入函数式编程的一些很好的特性,同时暂时保留已有的旧代码。

对我而言,函数式编程最大的作用就是强制你编写声明性代码:代码描述你做什么,而不是在描述如何做。这样就可以轻松了解特定代码块的功能,而无需了解其真正的运行原理。事实证明,编写声明式代码是函数式编程中最简单的部分之一。

循环

...一个循环就是一个命令式控制结构,难以重用,并且难以插入到其他操作中。此外,它还得不断变化代码来响应新的迭代需求。
-- Luis Atencio

所以,让我们先看一下循环,循环是命令式编程的一个很好的例子。循环涉及很多语法,都是描述它们的行为是如何工作,而不是它们在做什么。例如,看看这段代码:

function helloworld(arr) {
    for (let i = 1; i < arr.length; i++) {
        arr[i] *= 2
        if (arr[i] % 2 === 0) {
            doSomething(arr[i])
        }
    }
}

这段代码在做什么呢?它将数组内除第一个数字 (let i = 1)的其他所有数字乘以 2,如果是偶数的话(if (arr % 2 === 0)),就进行某些操作。在此过程中,原始数组的值会被改变。但这通常是不必要的,因为数组可能还会在代码库中的其他地方用到,所以刚才所做的改变可能会导致意外的结果。

但最主要的原因是,这段代码看起来很难一目了然。它是命令式的,for 循环告诉我们如何遍历数组,在里面,使用一个 if 语句有条件地调用一个函数。

我们可以通过使用数组方法以声明式的方式重写这段代码。数组方法直接表达所做的事,比较常见的方法包括:forEach,map,filter,reduce 和 slice。

结果就像下面这样:

function helloworld(arr) {
    const evenNumbers = n => n % 2 === 0

    arr
        .slice(1)
        .map(v => v * 2)
        .filter(evenNumbers)
        .forEach(v => doSomething(v))    
}

在这个例子中,我们使用一种很好的,扁平的链式结构去描述我们在做什么,明确表明意图。此外,我们避免了改变原始数组,从而避免不必要的副作用,因为大多数数组方法会返回一个新数组。当箭头函数开始变得越来越复杂时,可以地将其提取到一个特定的函数中,比如 evenNumbers, 从而尽量保持结构简单易读。

在上面的例子,链式调用并没有返回值,而是以 forEach 结束。然而,我们可以轻松地剥离最后一部分,并返回结果,以便我们可以在其他地方处理它。如果还需要返回除数组以外的任何东西,可以使用 reduce 函数。

对于接下来的一个例子,假设我们有一组 JSON 数据,其中包含在一个虚构歌唱比赛中不同国家获得的积分:

[
    {
        "country": "NL",
        "points": 12
    },
    {
        "country": "BE",
        "points": 3
    },
    {
        "country": "NL",
        "points": 0
    },
    ...
]

我们想计算荷兰(NL)获得的总积分,根据印象中其强大的音乐能力,我们可以认为这是一个非常高的分数,但我们想要更精确地确认这一点。

使用循环可能会是这样:

function countVotes(votes) {
    let score = 0;

    for (let i = 0; i < votes.length; i++) {
        if (votes[i].country === 'NL') {
            score += votes[i].points;
        }
    }

    return score;
}

使用数组方法重构,我们得到一个更干净的代码片段:

function countVotes(votes) {
    const sum = (a, b) => a + b;

    return votes
        .filter(vote => vote.country === 'NL')
        .map(vote => vote.points)
        .reduce(sum);
}

有时候 reduce 可能有点难以阅读,将 reduce 函数提取出来会在理解上有帮助。在上面的代码片段中,我们定义了一个 sum 函数来描述函数的作用,因此方法链仍然保持很好的可读性。

if else 语句

接下来,我们来聊聊大家都很喜欢的 if else 语句,if else 语句也是命令式代码里一个很好的例子。为了使我们的代码更具声明式,我们将使用三元表达式。

一个三元表达式是 if else 语句的替代语法。以下两个代码块具有相同的效果:

// Block 1
if (condition) {
    doThis();
} else {
    doThat();
}

// Block 2
const value = condition ? doThis() : doThat();

当在定义(或返回)一个常量时,三元表达式非常有用。使用 if else 语句会将该变量的使用范围限制在语句内,通过使用三元语句,我们可以避免这个问题:

if (condition) {
    const a = 'foo';
} else {
    const a = 'bar';
}

const b = condition ? 'foo' : 'bar';

console.log(a); // Uncaught ReferenceError: a is not defined
console.log(b); // 'bar'

现在,我们来看看如何应用这一点来重构一些更重要的代码:

const box = element.getBoundingClientRect();

if (box.top - document.body.scrollTop > 0 && box.bottom - document.body.scrollTop < window.innerHeight) {
    reveal();
} else {
    hide();
}

那么,上面的代码发生了什么呢?if 语句检查元素当前是否在页面的可见部分内,这个信息在代码的任何地方都没有表达出来。基于此布尔值,再调用 reveal() 或者 hide() 函数。

将这个 if 语句转换成三元表达式迫使我们将条件移动到它自己的变量中。这样我们可以将三元表达式组合在一行上,现在通过变量的名称来传达布尔值表示的内容,这样还不错。

const box = element.getBoundingClientRect();
const isInViewport = 
    box.top - document.body.scrollTop > 0 && 
    box.bottom - document.body.scrollTop < window.innerHeight;

isInViewport ? reveal() : hide();

通过这个例子,重构带来的好处可能看起来不大。接下来会有一个相比更复杂的例子:

elements
    .forEach(element => {
        const box = element.getBoundingClientRect();

        if (box.top - document.body.scrollTop > 0 && box.bottom - document.body.scrollTop < window.innerHeight) {
            reveal();
        } else {
            hide();
        }

    });

这很不好,打破了我们优雅的扁平的调用链,从而使代码更难读。我们再次使用三元操作符,而在使用它的时候,使用 isInViewport 检查,并跟它自己的动态函数分开。

const isInViewport = element => {
    const box = element.getBoundingClientRect();
    const topInViewport = box.top - document.body.scrollTop > 0;
    const bottomInViewport = box.bottom - document.body.scrollTop < window.innerHeight;
    return topInViewport && bottomInViewport;
};

elements
    .forEach(elem => isInViewport(elem) ? reveal() : hide());

此外,现在我们将 isInViewport 移动到一个独立函数,可以很容易地把它放在它自己的 helper 类/对象之内:

import { isInViewport } from 'helpers';

elements
    .forEach(elem => isInViewport(elem) ? reveal() : hide());

虽然上面的例子依赖于所处理的是数组,但是在不明确是在数组的情况下,也可以采用这种编码风格。

例如,看看下面的函数,它通过三条规则来验证密码的有效性。

import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value => {
  if (value.length < 6) return false
  if (!requiredChars.test(value)) return false

  const forbidden = await getJson('/forbidden-passwords')
  if (forbidden.includes(value)) return false

  return value
}

validatePassword(someValue).then(persist)

如果我们使用数组包装初始值,就可以使用在上面的例子中里面所用到的所有数组方法。此外,我们已经将验证函数打包成 validationRules 使其可重用。

import { minLength, matchesRegex, notBlacklisted } from 'validationRules'
import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value => {
  const result = Array.from(value)
    .filter(minLength(6))
    .filter(matchesRegex(requiredChars))
    .filter(await notBlacklisted('/forbidden-passwords'))
    .shift()

  if (result) return result
  throw new Error('something went wrong...')
}

validatePassword(someValue).then(persist)

目前在 JavaScript 中有一个 管道操作符 的提案。使用这个操作符,就不用再把原始值换成数组了。可以直接在前面的值调用管道操作符之后的函数,有点像 Array 的 map 功能。修改之后的代码大概就像这样:

import { minLength, matchesRegex, notBlacklisted } from 'validationRules'
import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value =>
  value
    |> minLength(6)
    |> matchesRegex(requiredChars)
    |> await notBlacklisted('/forbidden-passwords')

try { someValue |> await validatePassword |> persist }
catch(e) {
  // handle specific error, thrown in validation rule
}

但需要注意的是,这仍然是一个非常早期的提案,不过可以稍微期待一下。

事件

最后,我们来看看事件处理。一直以来,事件处理很难以扁平化的方式编写代码。可以 Promise 化来保持一种链式的,扁平化的编程风格,但 Promise 只能 resolve 一次,而事件绝对会多次触发。

在下面的示例中,我们创建一个类,它对用户的每个输入值进行检索,结果是一个自动补全的数组。首先检查字符串是否长于给定的阈值长度。如果满足条件,将从服务器检索自动补全的结果,并将其渲染成一系列标签。

注意代码的不“纯”,频繁地使用 this 关键字。几乎每个函数都在访问 this 这个关键字:

译注:作者在这里使用 "this keyword",有一种双关的意味
import { apiCall } from 'helpers'

class AutoComplete {

  constructor (options) {

    this._endpoint = options.endpoint
    this._threshold = options.threshold
    this._inputElement = options.inputElement
    this._containerElement = options.list

    this._inputElement.addEventListener('input', () =>
      this._onInput())

  }

  _onInput () {

    const value = this._inputElement.value

    if (value > this._options.threshold) {
      this._updateList(value)
    }

  }

  _updateList (value) {

    apiCall(this._endpoint, { value })
      .then(items => this._render(items))
      .then(html => this._containerElement = html)

  }

  _render (items) {

    let html = ''

    items.forEach(item => {
      html += `<a href="${ item.href }">${ item.label }</a>`
    })

    return html

  }

}

通过使用 Observable,我们将用一种更好的方式对这段代码进行重写。可以简单将 Observable 理解成一个能够多次 resolve 的 Promise。

Observable 类型可用于基于推送模型的数据源,如 DOM 事件,定时器和套接字

Observable 提案目前处于 Stage-1。在下面 listen 函数的实现是从 GitHub 上的提案中直接复制的,主要是将事件监听器转换成 Observable。可以看到,我们可以将整个 AutoComplete 类重写为单个方法的函数链。

import { apiCall, listen } from 'helpers';
import { renderItems } from 'templates'; 

function AutoComplete ({ endpoint, threshold, input, container }) {

  listen(input, 'input')
    .map(e => e.target.value)
    .filter(value => value.length >= threshold)
    .forEach(value => apiCall(endpoint, { value }))
    .then(items => renderItems(items))
    .then(html => container.innerHTML = html)

}

由于大多数 Observable 库的实现过于庞大,我很期待 ES 原生的实现。map,filter和 forEach方法还不是规范的一部分,但是在 zen-observable 已经在扩展 API 实现,而 zen-observable 本身是 ES Observables 的一种实现 。

--

我希望你会对这些“扁平化”模式感兴趣。就个人而言,我很喜欢以这种方式重写我的程序。你接触到的每一段代码都可以更易读。使用这种技术获得的经验越多,就越来越能认识到这一点。记住这个简单的法则:

The flatter the better!


原文:Writing flat & declarative code
作者:Peeke Kuepers

js构造函数

JS中的函数即可以是构造函数又可以当作普通函数来调用,当使用new来创建对象时,对应的函数就是构造函数,通过对象来调用时就是普通函数。在我们平时工作中,经常会需要我们创建一个对象,而我们更多的是使用对像直接量,直接创建

什么时候不能使用箭头函数?

JS中对象方法的定义方式是在对象上定义一个指向函数的属性,当方法被调用的时候,方法内的this就会指向方法所属的对象:定义字面量方法、定义原型方法、定义事件回调函数、定义构造函数、追求过短的代码

什么是纯函数_以及为什么要用纯函数?

当我第一次听到 “纯函数 (Pure Function)” 这个术语的时候我很疑惑。常规的函数做错了什么?为什么要变纯? 为什么我需要纯的函数?除非你已经知道什么是纯函数,否则你可能会问同样的疑惑

JavaScript push() 方法详解

push() 方法主要用于向数组的末尾添加一个或多个元素,其返回值为添加后新的长度,即push后的数组长度,该值为number类型。介绍:一个数组中添加新元素、把一个数组的值赋值到另一个数组上、在对象使用push

什么是函数的副作用——理解js编程中函数的副作用

函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情

js中sort函数用法总结_sort排序算法原理

js中sort方法用于对数组的元素进行排序,并返回数组。默认排序顺序是根据字符串Unicode码点。如果要得到自己想要的结果,不管是升序还是降序,就需要提供比较函数了。该函数比较两个值的大小,然后返回一个用于说明这两个值的相对顺序的数字

“回调函数”超难面试题!!

进来的小伙伴可以先自己思考一下 。对于还属于小白的我来说扫了一眼这些代码的反应是:这都是什么鬼?但是我也比较喜欢钻研~ 仔细看了第二眼的反应是:这回调函数也太回调了吧

js函数内部两个特殊的对象之arguments和this

arguments是一个类数组对象。包含着传入函数中的所有参数。但这个对象还有一个名叫callee的属性,该属性是一个指针,指向拥有这个arguments对象的函数。this引用的是函数执行的环境对象(当在网页的全局作用域中调用函数时,this对象引用的就是window)

js中函数声明、函数表达式、匿名函数的理解和区别?

js和其他编程语言相对比较随意,首先来理解下函数基本概念:函数声明、函数表达式、匿名函数。函数声明和函数表达式区别有哪些?

js中的立即执行函数的写法,立即执行函数作用是什么?

这篇文章主要讲解:js立即执行函数是什么?js使用立即执行函数有什么作用呢?js立即执行函数的写法有哪些?