鹅厂原创 | 前端中的函数式编程

鹅厂原创 | 前端中的函数式编程

文/baixinchen

腾讯SNG事业群——web前端开发工程师



0 写在前面

前端的技术革新从来没有停止过,但从最近的趋势来看,貌似有一个“新”名词出现,那就是函数式编程(FP,functional programming)。

vue、react这些热门的框架都多多少少有点涉及到函数式编程的领域,甚至已经开始有一些以函数式编程作为主范式的框架出现,比如说cyclejs。

那么,为什么函数式编程会如此重要呢?

或许我们可以先从函数式编程的认识聊起。


1 何为函数式编程?


1.1 什么是纯函数?

让我们回想一下初中数学

我们知道一个函数有定义域和值域,对于定义域里面的每一个值,都会对应值域中唯一确定的一个值。

这种函数的基本性质在编程里面却不一定成立,因为代码中的函数可能依赖于外部环境:

var count = 0;
function fnNotPure() {
    if (count++ % 2) {
        return 1;
    } else {
        return 2;
    }
}

fnNotPure(); // 0
fnNotPure(); // 1

正如上面的代码,函数依赖了外部变量count,导致相同的输入却得到不同的输出。

在函数式编程有个概念称之为副作用(side effect),指的是函数的执行依赖于外部环境,这里的依赖可能是读取了外部变量,也可能是修改了外部变量。

一个函数如果依赖外部环境,那么它的行为就会变得不可预测。因为你不知道外部环境什么时候会被改变,有句话貌似流传已久,很直接地指出了副作用给代码组织可能带来的问题:

"Shared mutable state/variable is the root of evil"(可共享可修改的变量是所有罪恶的根源)

纯函数(pure function)的概念就是指没有副作用的函数,在理论上它等价于我们数学世界里面的函数概念。

纯函数的优势在于,它向我们保证了其"纯粹性",同样的输入无论调用多少次,都会是一样的输出,并且不用担心调用过程会修改外部环境。每个函数都是足够独立足够抽象的个体,我们可以放心地将函数进行组合(compose),这让我们在做代码复用或者重构时,不用去担心函数是否会影响到其他地方。


1.2 是什么而非怎么做

函数式编程是声明式编程(declarative programming)的一种形式,你可能会联想到命令式编程(imperative programming)。

命令式编程通过编程语句声明了每一步的具体操作,如何修改变量以及按照什么样的顺序,这里强调的是how to

而函数式编程关注的点在于是需要哪些变量,需要什么样的操作,这里强调的是 what is

直接看例子更容易理解:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// imperative let result = [];
for (let i = 0; i < arr.length; ++i) {
   if (arr[i] % 2) {
       result.push(arr[i] * arr[i]);
   }
}

// functional let oddItems = arr.filter(i => i % 2);
let result = oddItem.map(i => i * i);


从上面的代码能看出一些问题来,对于命令式编程来讲,我们需要关注对数据的操作,如何创建数组,如何遍历元素,如何插入元素等等。

而函数式编程写出来的东西更像是一系列声明语句:什么奇数,什么是平方。

很难说这两种编程范式哪种更好

好纠结,纠结,纠结。。。


  • 命令式编程符合人类的线性思维,首先做什么,然后做什么,步骤详细具体但稍显繁杂。同时也因为涉及到变量(状态)的共享和修改,在非线性(并行)计算里面,就会存在数据同步的问题。
  • 函数式编程抽象层次更好,代码编写和组织要求的门槛相对更高,不过其代码往往更能直接体现问题核心,同时对于并行计算有天然的支持。


1.3 关注计算而非数据

我们都知道对于冯诺依曼架构的计算机来讲,核心是存储和计算。这两个概念体现到编程中,分别就是数据以及对于数据的操作。

编程范式也有相对应的,我们熟知的面向对象编程,关注点在于数据的抽象,如果你对大学的编程课还有印象的话,应该会知道,如果有一组固定的操作,为这些操作添加新的数据类型是很简单的事情。相对应的,函数式编程注重对数据的操作,在数据类型不变的情况下,想要添加新的计算方法很简单。相反如果要添加新的数据类型,那么你就不得不将大部分函数都进行修改。


2 前端中的函数式编程

函数式编程出现的时间很早,但在近几年才慢慢在前端中有所表现。

个人觉得主要原因在于,前端技术的快速发展虽然足以支持日益复杂的页面交互需求,但我们仍然需要一种能够更好表达交互的范式或者框架。

而函数式编程在这方面还是挺适合前端的,列举我觉得最重要的两个点

  • 前端麻烦的异步问题,可以由函数式编程中的异步计算来解决;
  • 声明式编程基本被业界证明是前端UI编程的一种最佳实践方式。

当然这些共性问题已经被发现了,前端领域有很多特性、库或者框架来支持和应用函数式编程。


2.1 函数式语言

有很多语言都是支持函数式编程的,当然我们的 JavaScript也支持。

一门高级语言是否支持函数式编程,只要看其函数是否是一等公民(first class):函数能够作为其他函数的参数或者返回值。

这个概念其实跟高阶函数(higher-order function)差不多,只是两者描述的主体不一样。

JavaScript中的语法

function fnAsArgument(fn) {
    return fn();
}

function fnAsResult() {
    return function() {
        // function body
    };
}


2.2 Promise

Promise从 es6 开始成为JavaScript原生支持的语法,大家也应该不会陌生。作为处理异步的一种方式,它的特点在于通过将异步操作封装起来,让你可以像操作同步代码一样去进行操作:

var asyncTwo = new Promise(function(resolve){
    setTimeout(function() {
        resolve(2)
    }, 0)
});

function addThree(value) {
    return Promise.resolve(value + 3);
}

function multipleTwo(value) {
    return Promise.resolve(value * 2);
}

asyncTwo.then(addThree).then(multipleTwo).then(console.log); // 10

如果你对函数式编程熟悉的话,可能会意识到 promise 其实就是一种 monad。函数式编程中对于monad有一整套完善的操作,可以将异步函数和同步函数统一起来,完美地支持函数的组合。目前已经有类似的库来完成封装,比如RxJS,xstream 等。


2.3 主流框架与函数式编程

目前主流的前端框架,比如vue,react,大部分都是支持函数式编程的,甚至已经开始有一些以函数式编程为主范式的框架开始出现,比如 cyclejs,turbine。

这里有必要讲一下主流框架为何支持函数式编程。

在为了满足更多样化的需求的同时,前端页面变得越来复杂。

页面视图从最开始的静态页面,到服务端动态渲染,再到前端渲染。而渲染过程实际上是数据到视图的一种映射,传统的基于 DOM API直接操作视图的方式,在前端渲染时开始显得很麻烦,一个主要的原因在于命令式编程的抽象层次不够高,它将dom操作的细节完全暴露给开发者,所以很难建立直观、有效的映射关系。

前端开发者更希望有一种所见即所得的编程方式,可以完全将如何操作dom、如何更新dom等工作隔离开来,只要关注最核心的部分,数据和视图的映射关系。

幸运的是,前端模板技术的 快速发展满足了这个需求,比如react中使用了 jsx 来作为抽象视图层:

// imperative
function render(data) {
    $("div > h1").html(data.title);
    $("div > p").html(data.content);
}

// declarative
function render(data) {
    // jsx syntax
    return (
      <div>
          <h1>{ data.title }</h1>
          <p>{ data.content }</p>
     </div>
   );
}

抽象视图层的引入打开了前端函数式编程的大门,正如上面的例子,第二个render就是一个纯函数,它帮我们隐藏操作dom的细节(渲染实际上是一种副作用),只保留了最纯粹的映射关系。从因果关系上来讲,很难说是因为函数式编程才引入了抽象视图层,而应该是抽象视图层的选择反而无意中促进了函数式编程在前端中的应用。

其实正如第2部分一开始讲的,函数式编程本身的异步处理、声明式等特性是很适合前端开发的,所以才导致前端技术发展过程中,多多少少有点向函数式编程靠近、借鉴的原因。


2.4 一种新的编程范式 – FRP

最后再讲一种以函数式编程为基础引申出更具体的编程范式:

响应式函数编程(FRP,Functional Reactive Programming)。

目前已经有基于这种范式而开发的库或者框架,比如RxJS、cyclejs、turbine等。

要理解 FRP 其实很简单,函数式编程的概念在第1部分大致讲了,响应式编程的介绍不细讲,有兴趣可以看这篇文章:The introduction to Reactive Programming you've been missing。

这里简单讲响应式编程跟函数式编程的关系。

首先重新讲一下函数式编程的基础,即纯函数。我们知道纯函数不会对外部环境造成影响,那么问题来了:




假设我们写了一堆纯函数,并完美地将它们组合起来,但无论如何你都不能输出到外部,那我们的代码还有什么意义?

我们又怎么知道这些代码运行了没有?最终我们不得不承认,一个完美的项目其实离不开副作用(比如前端的dom操作,ajax请求等都属于副作用)。

但既然前面我们已经讲述了这么多函数式编程的特性和优点,我们不想让副作用毁了这个美好抽象的函数世界,而是希望找到一种优雅的方式来隔离它们,一种有效的方式就是响应式编程

FRP中通过构建一种特殊的 monad,这种 monad 可以通过被观察/订阅的方式(即响应式编程的方式)来抽离副作用。可能通过 RxJS 的demo来说明更容易理解:

var plusEl = document.querySelector('#plus');
var minusEl = document.querySelector('#minus');
var counterEl = document.querySelector('#result');

var plusStream = Rx.Observable.fromEvent(plusEl, 'click');
var minusStream = Rx.Observable.fromEvent(minusEl, 'click');

// functional
var plus = plusStream.map(1);
var minus = minusStream.map(-1);
var source = plus.merge(minus).scan(function(acc, v) {
    return acc + v;
}, 0);

// reactive
source.subscribe(function(v) {
    counterEl.innerHTML = v;
});


3 总结

函数式编程在前端中的应用非常广泛。

在某些语法、框架中实际上都借鉴了函数式编程的思想,但我们可能并没有发觉。

从前端目前的发展趋势来看,个人觉得函数式编程的应用还会继续下去,希望大家可以一起关注。

发布于 2017-08-10 11:39