一步一步教你 JavaScript 函数式本赛季(第一部分)

10年服务1亿Tian开发工程师

在阅读关于 Currying(柯里化)Partial Application(偏函数应用) 和其他函数式本赛季技术之后,一些开发人员不知道应该什么时候使用这些方法;为什么要这样使用?

在接下来的三篇系列文章中,我们将尝试解决这个问题,我们会尝试,并向你展示如何在一个短小而现实的例子中用函数式本赛季的方式,解决这个问题。

什么是函数式本赛季(Functional Programming)

在我们深入之前,让我们花一点时间来回顾一下一些实用的函数式本赛季概念。

函数式本赛季把“function”作为重复使用的主要表达式。通过构建专注于某个特定任务的小函数,函数式本赛季使用合成(compose)来构建更复杂的函数 —— 这就是 Currying(柯里化) 和 Partial Application(偏函数应用) 这样的技术发挥作用的地方了。

函数式本赛季使用函数作为重复使用的声明表达式,避免对状态进行修改,消除了副作用,并使用合成来构建函数。

功能本赛季本质上是用功能本赛季的!额外需要考虑的是:如避免状态改变,无副作用的纯函数,消除循环支持递归是纯函数式本赛季方法的一部分,用 Haskell 语言是这样构建的。

我们将重点介绍函数式本赛季的实用部分,以便我们可以在本系列博客文章中立即使用 Javascript 。

高阶函数(Higher Order Functions) – JavaScript 中函数是“一等公民(first-class)”,这意味着我们可以将函数作为参数传递给其他函数;也可以将函数作为其他函数的值返回。注:以函数为参数或返回值的函数称为“高阶函数”。

装饰器(Decorators) – 因为 JavaScript 中函数可以是高阶函数,所以我们可以创建函数来增加其他函数的行为 和/或 作为其他函数的参数。

合成(Composition) – 我们还可以创建由多个函数合成的函数,创建链式的输入处理。

我们将介绍我们要使用的技术,以便在需要时利用这些特性。这让我们可以在上下文环境中引入它们,并使概念易于消化和理解。

让我们开始吧

OK,那我们打算怎么办呢?

我们来看一个典型的例子,它需要处理从异步请求中获取的一些数据。 在这种情况下,异步获取数据采用了JSON格式,并包含了一个博客文章的摘要列表。

以下是我们将使用的异步获取数据:查看 和 示例数据。

// 异步获取 JSON 数据的一条示例数据
var records = [  
  {
    "id": 1,
    "title": "Currying Things",
    "author": "Dave",
    "selfurl": "/posts/1",
    "published": 1437847125528,
    "tags": [
      "functional programming"
    ],
    "displayDate": "2015-07-25"
  },
  // ...
];

我们的需求:现在,假设我们想要显示最近的文章(不超过一个月),按标签分组,按发布日期排序。让我们思考一下我们需要做些什么

  • 过滤掉一个月以前的文章(比如30天)。
  • 通过他们的 tags 对文章进行分组(这可能意味着如果他们有多个标签,则会显示在两个分组中。)
  • 按发布日期排序每个标签列表,降序。

我们将在本系列文章中涵盖上述每个需求,这篇文章从过滤开始。

过滤数据

我们的第一步是过滤掉发布日期超过 30 天的文章记录。由于函数式本赛季都是作为重用的主要表达式的函数,所以让我们构建一个函数来封装过滤列表的行为。

function filter(list, fn) {  
  return list.filter(fn);
}

有些朋友可能会问,“真的吗?就这样好了吗?”

嗯,是的,没有更多要写的了。

这个函数使用 predicate 断言函数(fn) 来过滤一个数组(list),或许你会说,这可以通过直接调用 list.filter(fn) 来轻松实现。那么为什么不这样做呢?

因为当我们将操作抽象成一个函数时,我们就可以使用 Currying(柯里化) 来构建一个更有用的函数。

Currying(柯里化) 是使用 N 个参数的函数,返回一个 N 个函数的嵌套系列,每个函数都采用 1 个参数。

有关 Currying(柯里化) 概念的更多信息,请阅读我以前的文章,并实现 left -> right 的 currying(柯里化) 。

currying,柯里化

在这种情况下,我们将使用一个名为 rightCurry() 的函数,该函数将函数的参数从右向左进行柯里化。通常,一个普通 curry() 函数会将参数从左到右进行柯里化。

这是我们的实现,以及它在内部使用的另一个实用函数 flip()

// 返回一个函数,
// 该函数在调用时将参数的顺序颠倒过来。
function flip(fn) {  
    return function() {
        var args = [].slice.call(arguments);
        return fn.apply(this, args.reverse());
    };
}

// 返回一个新函数,
// 从右到左柯里化原始函数的参数。
function rightCurry(fn, n) {  
  var arity = n || fn.length,
      fn = flip(fn);
  return function curried() {
      var args = [].slice.call(arguments), 
          context = this;

      return args.length >= arity ?
          fn.apply(context, args.slice(0, arity)) : 
          function () {
              var rest = [].slice.call(arguments);
              return curried.apply(context, args.concat(rest));
          };
  };
}

通过 currying(柯里化) ,我们可以创建一些函数,允许我们创建新的、偏应用的函数,我们可以重用这些函数。 在我们这个例子中,我们将使用它来创建一个函数,该函数部分应用 predicate 断言函数(fn)来进行过滤列表的操作。

filterWith

// 一个函数,使用给定 predicate 断言函数 过滤列表
var filterWith = rightCurry(filter);  

这基本上与手动调用二元的 filter(list, fn) 函数一样,进行相同的操作。

function filterWith(fn) {  
  return function(list) {
    return filter(list, fn);
  }
}

我们可以如下使用它吗?

var list = [1,2,3,4,5,6,7,8,9,10];

// 创建一个偏应用过滤器,获取列表中的偶数
var justEvens = filterWith(function(n) { return n%2 == 0; });

justEvens(list);  
// [2,4,6,8,10]

哇,可以!最初似乎是很多的工作; 但是我们从这个方法中得出的结论是:

  • 使用 currying(柯里化) 创建一个通用的,可重用的函数,filterWith() ,可以在许多情况下使用它来创建更具体的列表过滤器
  • 每当我们得到一些数据时,都可以懒惰地执行这个新的过滤器。我们不能做到调用 Array.prototype.filter 的同时,不使其立即对数据列表执行操作
  • 一个更具声明性的API,有助于可读性和理解

关于 predicate 断言函数

我们的 filterWith() 函数需要一个 predicate 断言函数,当给定列表中的某个元素时,它返回truefalse,以确定是否应该在新过滤的列表中返回该元素。

让我们从一个更通用的比较函数开始,它可以告诉我们一个给定的数是否大于或等于另一个数。

// 简单的使用 '>=' 比较
function greaterThanOrEqual(a, b) {  
  return a >= b;
}

我们文章的发布日期可以转换成数字,时间戳格式(自Epoch以来的毫秒数)这应该可以正常工作。但是,用于过滤数组的断言函数只能传递一个参数来检查,而不是两个。

那么,在需要一元函数的情况下,如何使我们的二元比较函数工作呢?

Currying(柯里化) 可以再次拯救我们!我们将使用它来创建一个函数,该函数可以创建一元比较函数。

var greaterThanOrEqualTo = rightCurry(greaterThanOrEqual);  

我们现在可以使用这个柯里化版本来创建一个 predicate 断言函数,可以用于列表过滤,例如:

var list = [5,3,6,2,8,1,9,4,7],  
    // a unary comparison function to see if a value is >= 5
    fiveOrMore = greaterThanOrEqualTo(5);

filterWith(fiveOrMore)(list);  
// [5,6,8,9,7]

棒极了! 现在我们回到我们的示例,创建一个 predicate 断言函数,具体解决我们原先的过滤掉发布在30天以前的文章了:

var thirtyDaysAgo = (new Date()).getTime() - (86400000 * 30),  
    within30Days = greaterThanOrEqualTo(thirtyDaysAgo);

var dates = [  
  (new Date('2015-07-29')).getTime(), 
  (new Date('2015-05-01')).getTime() 
];

filterWith(within30Days)(dates);  
// [1438128000000]  - July 29th, 2015

到现在为止还挺好!

我们创建了一个可以轻松重用的 过滤 断言函数。另外,因为我们使用的是函数式方法,所以我们的代码更具声明性,易于遵循 – 它的读取方式与工作原理完全相同。可读性和维护是编写任何代码时需要考虑的重要事情!

类型问题…

呃,我们还有另一个问题!我们的程序需要过滤的是一个对象列表,所以我们的 predicate 断言函数将需要访问传入的每一项的 published 属性。

我们目前的 predicate 断言函数,within30Days() 不能处理对象类型的参数,只能处理具体的数值!让我们用另一个函数来解决这个问题吧!(你在这里看到一个模式了吗?)

我们想重用我们现有的断言函数;但修改其参数,以便它可以与我们的特定对象类型一起使用。这是一个新的实用函数,让我们通过修改其参数来扩展现有的函数。

function useWith(fn /*, txfn, ... */) {  
  var transforms = [].slice.call(arguments, 1),
      _transform = function(args) {
        return args.map(function(arg, i) {
          return transforms[i](arg);
        });
      };
  return function() {
    var args = [].slice.call(arguments),
        targs = args.slice(0, transforms.length),
        remaining = args.slice(transforms.length);

    return fn.apply(this, _transform(targs).concat(remaining));
  }
}

这是迄今为止最有趣的函数式实用工具函数,并且几乎与 中相同名称的函数相同。

useWith() 返回一个修改原来函数(fn)的函数,所以当被调用时,它将通过相应的变换(txnfn)函数传递每个参数。如果在调用时比转换函数有更多的参数,那么剩下的参数将会以 “as is” 的形式传递。

让我们用一个小例子来帮助解释这个定义。简单地说,useWith() 让我们执行以下操作:

function sum(a,b) { return a + b; }  
function add1(v) { return v+1; }  
var additiveSum = useWith(sum, add1, add1);

// 在总和接收 4 & 5 之前,
// 它们都首先通过 'add1()' 函数进行转换
additiveSum(4,5);  // 11  

当我们调用 additiveSum(4,5) 时,我们基本上可以得到以下调用栈:

  • additiveSum(4,5)
    • add1(4) => 5
    • add1(5) => 6
    • sum(5, 6) => 11

我们可以使用 useWith() 来修改现有的 predicate 断言函数来在对象类型上操作,而不是数值。首先,让我们再次使用 currying(柯里化) 来创建一个函数,该函数允许我们创建 偏应用的函数,这些函数可以通过属性名访问对象。

// 用于访问对象属性的函数
function get(obj, prop) { return obj[prop]; }  
// `get()` 的柯里化版本
var getWith = rightCurry(get); 

现在我们可以使用 getWith() 作为变换函数,从每个对象获取 .published 日期,传递给用于过滤器(filter)的一元断言函数。

// 我们修改后的断言函数可以在 
// record 对象的 `.published` 属性上工作
var within30Days = useWith(greaterThanOrEqualTo(thirtyDaysAgo), getWith('published'));  

我们来试试看一下测试数据:

// 简单的对象数组
var dates = [  
      { id: 1, published: (new Date('2015-07-29')).getTime() }, 
      { id: 2, published: (new Date('2015-05-01')).getTime() }
    ],
    within30Days = useWith(greaterThanOrEqualTo(thirtyDaysAgo), getWith('published'));

// 获取数组中 published(发布日期)在30天内的任何对象
filterWith(within30Days)(dates);  
// { id: 1, published: 1438128000000 }

准备过滤!

好的,鉴于我们的第一个需求是保留最近30天内的文章记录,那么用我们的响应数据来提供一个完整的实现。

filterWith(within30Days)(records);  
// [
//    { id: 1, title: "Currying Things", displayDate: "2015-07-25", ... },
//    { id: 2, title: "ES6 Promises", displayDate: "2015-07-26", ... },
//    { id: 7, title: "Common Promise Idioms", displayDate: "2015-08-06", ... },
//    { id: 9, title: "Default Function Parameters in ES6", displayDate: "2015-07-06", ... },
//    { id: 10, title: "Use More Parenthesis!", displayDate: "2015-08-26", ... },
// ]

在过去的30天里,我们现在有了一个新的文章列表。看来我们已经满足了第一个需求,并且有了一个良好的开端。随着我们的继续,我们将把函数式实用工具函数放在一个可以重用的库中。

获取源代码:您可以看到这篇文章中 ,在单独的 functional.js 文件中包含了我们所有的函数式实用工具函数,在 app.js 文件中包含了我们的主应用程序的逻辑。我们将后续的本系列的翁中添加这些代码。

小结

我们已经发现了一些函数式本赛季中的关键技术,如 Currying(柯里化) 和 Partial Application(偏函数应用) 以及可以使用它们的上下文。我们还发现,专注于构建小而有用的行数,与函数式技术相结合,可以合成高阶函数,并实现更好的重用。有了这些基础,接下来的两篇文章看起来就不那么令人生畏了。

在本系列的下一篇文章中,我们将结合到目前为止所进行的过滤,使用标签名称对文章数据进行分组,在这里我们将介绍更多与列表相关的函数和更灵活的函数合成。

JavaScript 函数式本赛季系列文章

英文原文:

赞(0) 打赏
未经允许不得转载:WEBTian开发 » 一步一步教你 JavaScript 函数式本赛季(第一部分)

评论 1

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
  1. #-49

    666,花了10w块钱培训,我相信一年就能挣回来

    独裁者1年前 (2017-08-09)回复

Tian开发相关广告投放 更专业 更精准

联系我们

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏