前端开发函数式编程入门

简介: 函数式编程是一门古老的技术,从上个世纪60年代Lisp语言诞生开始,各种方言层出不穷。各种方言带来欣欣向荣的生态的同时,也给兼容性带来很大麻烦。于是更种标准化工作也在不断根据现有的实现去整理,比如Lisp就定义了Common Lisp规范,但是一大分支scheme是独立的分支。另一种函数式语言ML,后来也标准化成Standard ML,但也拦不住另一门方言ocaml。后来的实践干脆成立一个委员会,定义一个通用的函数式编程语言,这就是Haskell。后来Haskell被函数式原教旨主义者认为是纯函数式语言,而Lisp, ML系都有不符合纯函数式的地方。

作者|旭伦 Alibaba F2E  9月1日


image.png


函数式编程是一门古老的技术,从上个世纪60年代Lisp语言诞生开始,各种方言层出不穷。各种方言带来欣欣向荣的生态的同时,也给兼容性带来很大麻烦。于是更种标准化工作也在不断根据现有的实现去整理,比如Lisp就定义了Common Lisp规范,但是一大分支scheme是独立的分支。另一种函数式语言ML,后来也标准化成Standard ML,但也拦不住另一门方言ocaml。后来的实践干脆成立一个委员会,定义一个通用的函数式编程语言,这就是Haskell。后来Haskell被函数式原教旨主义者认为是纯函数式语言,而Lisp, ML系都有不符合纯函数式的地方。


不管纯不纯,函数式编程语言因为性能问题,一直影响其广泛使用。直到单核性能在Pentium 4时代达到顶峰,单纯靠提升单线程性能的免费午餐结束,函数式编程语言因为其多线程安全性再次火了起来,先有Erlang,后来还有Scala, Clojure等。


函数式编程的思想也不断影响着传统编程语言,比如Java 8开始支持lambda表达式,而函数式编程的大厦最初就是基于lambda计算构建起来的。


不过比起后端用Java的同学对于函数式编程思想是可选的,对于前端同学变成了必选项。



前端同学为什么要学习函数式编程思想?



React框架的组件从很早开始就是不仅支持类式组件,也支持函数式的组件。


比如下面的类继承的方式更符合大多数学过面向对象编程思想同学的心智:



class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}


但是,完全可以写成下面这样的函数式的组件:



function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}


image.png


从React 16.8开始,React Hooks的出现,使得函数式编程思想越来越变得不可或缺。


比如通过React Hooks,我们可以这样为函数组件增加一个状态:



import React, { useState } from 'react';
function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}


同样我们可以使用useEffect来处理生命周期相关的操作,相当于是处理ComponentDidMount:



import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}


那么,useState, useEffect之类的API跟函数式编程有什么关系呢?


我们可以看下useEffect的API文档:


Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.


Instead, use useEffect. The function passed to useEffect will run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.


所有的可变性、消息订阅、定时器、日志等副作用不能使用在函数组件的渲染过程中。useEffect就是React纯函数世界与命令式世界的通道。


当我们用React写完了前端,现在想写个BFF的功能,发现serverless也从原本框架套类的套娃模式变成了一个功能只需要一个函数了。下面是阿里云serverless HTTP函数的官方例子:



var getRawBody = require('raw-body')module.exports.handler = var getRawBody = require('raw-body')
module.exports.handler = function (request, response, context) {
    // get requset header
    var reqHeader = request.headers
    var headerStr = ' '
    for (var key in reqHeader) {
        headerStr += key + ':' + reqHeader[key] + '  '
    };
    // get request info
    var url = request.url
    var path = request.path
    var queries = request.queries
    var queryStr = ''
    for (var param in queries) {
        queryStr += param + "=" + queries[param] + '  '
    };
    var method = request.method
    var clientIP = request.clientIP
    // get request body
    getRawBody(request, function (err, data) {
        var body = data
        // you can deal with your own logic here
        // set response
        var respBody = new Buffer('requestHeader:' + headerStr + '\n' + 'url: ' + url + '\n' + 'path: ' + path + '\n' + 'queries: ' + queryStr + '\n' + 'method: ' + method + '\n' + 'clientIP: ' + clientIP + '\n' + 'body: ' + body + '\n')
        response.setStatusCode(200)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
    })
};


虽然没有需要关注副作用之类的要求,但是既然是用函数来写了,用函数式思想总比命令式的要好。



学习函数式编程的方法和误区



如果在网上搜“如何学习函数式编程”,十有八九会找到要学习函数式编程最好从学习Haskell开始的观点。


然后很可能你就了解到那句著名的话”A monad is just a monoid in the category of endofunctors, what's the problem?“。


翻译过来可能跟没翻译差不多:”一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已“。


别被这些术语吓到,就像React在纯函数式世界外给我们提供了useState, useEffect这些Hooks,就是帮我们解决产生副作用操作的工具。而函子Functor,单子Monad也是这样的工具,或者可以认为是设计模式。


Monad在Haskell中的重要性在于,对于IO这样虽然基础但是有副作用的操作,纯函数的Haskell是无法用函数式方法来处理掉的,所以需要借助IO Monad。大部分其它语言没有这么纯,可以用非函数式的方法来处理IO之类的副作用操作,所以上面那句话被笑称是Haskell用户群的接头暗号。


有范畴论和类型论等知识做为背景,当然会有助于从更高层次理解函数式编程。但是对于大部分前端开发同学来讲,这笔技术债可以先欠着,先学会怎么写代码去使用可能是更好的办法。前端开发的计划比较短,较难有大块时间学习,但是我们可以迭代式的进步,最终是会殊途同归的。


先把架式练好,用于代码中解决实际业务问题,比被困难吓住还停留在命令式的思想上还是要强的。



函数式编程的精髓:无副作用


image.png


前端同学学习函数式编程的优势是React Hooks已经将副作用摆在我们面前了,不用再解释为什么要写无副用的代码了。


无副作用的函数应该符合下面的特点:


  1. 要有输入参数。如果没有输入参数,这个函数拿不到任意外部信息,也就不用运行了。
  2. 要有返回值。如果有输入没有返回值,又没有副作用,那么这个函数白调了。
  3. 对于确定的输入,有确定的输出


做到这一点,说简单也简单,只要保持功能足够简单就可以做到;说困难也困难,需要改变写惯了命令行代码的思路。


比如数学函数一般就是这样的好例子,比如我们写一个算平方的函数:



let sqr2 = function(x){
    return x * x; 
}
console.log(sqr2(200));


无副作用函数拥有三个巨大的好处:


  1. 可以进行缓存。我们就可以采用动态规划的方法保存中间值,用来代替实际函数的执行结果,大大提升效率。
  2. 可以进行高并发。因为不依赖于环境,可以调度到另一个线程、worker甚至其它机器上,反正也没有环境依赖。
  3. 容易测试,容易证明正确性。不容易产生偶现问题,也跟环境无关,非常利于测试。


即使是跟有副作用的代码一起工作,我们也可以在副作用代码中缓存无副作用函数的值,可以将无副作用函数并发执行。测试时也可以更重点关注有副作用的代码以更有效地利用资源。



用函数的组合来代替命令的组合



image.png


会写无副作用的函数之后,我们要学习的新问题就是如何将这些函数组合起来。


比如上面的sqr2函数有个问题,如果不是number类型,计算就会出错。按照命令式的思路,我们可能就直接去修改sqr2的代码,比如改成这样:



let sqr2 = function(x){
    if (typeof x === 'number'){
        return x * x;
    }else{
        return 0;
    }
}


但是,sqr2的代码已经测好了,我们能不能不改它,只在它外面进行判断?


是的,我们可以这样写:



let isNum = function(x){
    if (typeof x === 'number'){
        return x;
    }else{
        return 0;
    }
}
console.log(sqr2(isNum("20")));


或者是我们在设计sqr2的时候就先预留出来一个预处理函数的位置,将来要升级就换这个预处理函数,主体逻辑不变:



let sqr2_v3 = function(fn, x){
    let y = fn(x);
    return y * y; 
}
console.log((sqr2_v3(isNum,1.1)));


嫌每次都写isNum烦,可以定义个新函数,把isNum给写死进去:



let sqr2_v4 = function(x){
    return sqr2_v3(isNum,x);
}
console.log((sqr2_v4(2.2)));



用容器封装函数能力



image.png


现在,我们想重用这个isNum的能力,不光是给sqr2用,我们想给其它数学函数也增加这个能力。


比如,如果给Math.sin计算undefined会得到一个NaN:



console.log(Math.sin(undefined));


这时候我们需要用面向对象的思维了,将isNum的能力封装到一个类中:



class MayBeNumber{
    constructor(x){
        this.x = x;
    }
    map(fn){
        return new MayBeNumber(fn(isNum(this.x)));
    }
    getValue(){
        return this.x;
    }
}


这样,我们不管拿到一个什么对象,用其构造一个MayBeNumber对象出来,再调用这个对象的map方法去调用数学函数,就自带了isNum的能力。


我们先看调用sqr2的例子:



let num1 = new MayBeNumber(3.3).map(sqr2).getValue();
console.log(num1);
let notnum1 = new MayBeNumber(undefined).map(sqr2).getValue();
console.log(notnum1);


我们可以将sqr2换成Math.sin:



let notnum2 = new MayBeNumber(undefined).map(Math.sin).getValue();
console.log(notnum2);


可以发现,输出值从NaN变成了0.


封装到对象中的另一个好处是我们可以用"."多次调用了,比如我们想调两次算4次方,只要在.map(sqr2)之后再来一个.map(sqr2)


let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();
console.log(num3);


使用对象封装之后的另一个好处是,函数嵌套调用跟命令式是相反的顺序,而用map则与命令式一致。


如果不理解的话我们来举个例子,比如我们想求sin(1)的平方,用函数调用应该先写后执行的sqr2,后写先执行的Math.sin:


console.log(sqr2(Math.sin(1)));


而调用map就跟命令式一样了:



let num4 = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();
console.log(num4);



用 of 来封装 new



image.png


封装到对象中,看起来还不错,但是函数式编程还搞出来new对象再map,为什么不能构造对象时也用个函数呢?


这好办,我们给它定义个of方法吧:



MayBeNumber.of = function(x){
    return new MayBeNumber(x);
}


下面我们就可以用of来构造MayBeNumber对象啦:



let num5 = MayBeNumber.of(1).map(Math.cos).getValue();
console.log(num5);
let num6 = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();
console.log(num6);


有了of之后,我们也可以给map函数升升级。


之前的isNum有个问题,如果是非数字的话,其实没必要赋给个0再去调用函数,直接返回个0就好了。


之前我们一直没写过箭头函数,顺手写一写:



isNum2 = x => typeof x === 'number';


map用isNum2和of改写下:



map(fn){
        if (isNum2(this.x)){
            return MayBeNumber.of(fn(this.x));
        }else{
            return MayBeNumber.of(0);
        }
    }


我们再来看下另一种情况,我们处理返回值的时候,如果有Error,就不处理Ok的返回值,可以这么写:



class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }
    isOk(){
        return this.Err === null || this.Err === undefined;
    }
    map(fn){
        return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err));
    }
}
Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}
console.log(Result.of(1.2,undefined).map(sqr2));


输出结果为:



Result { Ok: 1.44, Err: undefined }


我们来总结下前面这种容器的设计模式:


  1. 有一个用于存储值的容器
  2. 这个容器提供一个map函数,作用是map函数使其调用的函数可以跟容器中的值进行计算,最终返回的还是容器的对象


我们可以把这个设计模式叫做Functor函子。


如果这个容器还提供一个of函数将值转换成容器,那么它叫做Pointed Functor.


比如我们看下js中的Array类型:



let aa1 = Array.of(1);
console.log(aa1);
console.log(aa1.map(Math.sin));


它支持of函数,它还支持map函数调用Math.sin对Array中的值进行计算,map的结果仍然是一个Array。


那么我们可以说,Array是一个Pointed Functor.



简化对象层级



image.png


有了上面的Result结构了之后,我们的函数也跟着一起升级。如果是数值的话,Ok是数值,Err是undefined。如果非数值的话,Ok是undefined,Err是0:



let sqr2_Result = function(x){
    if (isNum2(x)){
        return Result.of(x*x, undefined);
    }else{
        return Result.of(undefined,0);
    }
}


我们调用这个新的sqr2_Result函数:



console.log(Result.of(4.3,undefined).map(sqr2_Result));


返回的是一个嵌套的结果:


Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }

我们需要给Result对象新加一个join函数,用来获取子Result的值给父Result:



    join(){
        if (this.isOk()) {
            return this.Ok;
        }else{
            return this.Err;
        }
    }


我们调用的时候最后加上调用这个join:



console.log(Result.of(4.5,undefined).map(sqr2_Result).join());


嵌套的结果变成了一层的:



Result { Ok: 20.25, Err: undefined }


每次调用map(fn).join()两个写起来麻烦,我们定义一个flatMap函数一次性处理掉:



    flatMap(fn){
        return this.map(fn).join();
    }


调用方法如下:



console.log(Result.of(4.7,undefined).flatMap(sqr2_Result));


结果如下:



Result { Ok: 22.090000000000003, Err: undefined }


我们最后完整回顾下这个Result:



class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }
    isOk(){
        return this.Err === null || this.Err === undefined;
    }
    map(fn){
        return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err));
    }
    join(){
        if (this.isOk()) {
            return this.Ok;
        }else{
            return this.Err;
        }
    }
    flatMap(fn){
        return this.map(fn).join();
    }
}
Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}


不严格地讲,像Result这种实现了flatMap功能的Pointed Functor,就是传说中的Monad.



偏函数和高阶函数



image.png


在前面各种函数式编程模式中对函数的用法熟悉了之后,回来我们总结下函数式编程与命令行编程体感上的最大区别:


  1. 函数是一等公式,我们应该熟悉变量中保存函数再对其进行调用
  2. 函数可以出现在返回值里,最重要的用法就是把输入是n(n>2)个参数的函数转换成n个1个参数的串联调用,这就是传说中的柯里化。这种减少了参数的新函数,我们称之为偏函数
  3. 函数可以用做函数的参数,这样的函数称为高阶函数


偏函数可以当作是更灵活的参数默认值。


比如我们有个结构叫spm,由spm_a和spm_b组成。但是一个模块中spm_a是固定的,大部分时候只需要指定spm_b就可以了,我们就可以写一个偏函数:



const getSpm = function(spm_a, spm_b){
    return [spm_a, spm_b];
}
const getSpmb = function(spm_b){
    return getSpm(1000, spm_b);
}
console.log(getSpmb(1007));


高阶函数我们在前面的map和flatMap里面已经用得很熟了。但是,其实高阶函数值得学习的设计模式还不少。


比如给大家出一个思考题,如何用函数式方法实现一个只执行一次有效的函数?



不要用全局变量啊,那不是函数式思维,我们要用闭包。


once是一个高阶函数,返回值是一个函数,如果done是false,则将done设为true,然后执行fn。done是在返回函数的同一层,所以会被闭包记忆获取到:



const once = (fn) => {
    let done = false;
    return function() {
        return done ? undefined : ((done=true), fn.apply(this,arguments));
    }
}
let init_data = once(
    () => {
        console.log("Initialize data");
    }
);
init_data();
init_data();


我们可以看到,第二次调用init_data()没有发生任何事情。



递归与记忆



前面介绍了这么多,但是函数编程其实还蛮复杂的,比如说涉及到递归。


递归中最简单的就是阶乘了吧:



let factorial = (n) => {
    if (n===0){
        return 1;
    }
    return n*factorial(n-1);
}
console.log(factorial(10));


但是我们都知道,这样做效率很低,会重复计算好多次。应该采用动态规划的办法。


那么如何在函数式编程中使用动态规划,换句话说我们如何保存已经计算过的值?


想必经过上一节学习,大家肯定想到要用闭包,没错,我们可以封装一个叫memo的高阶函数来实现这个功能:



const memo = (fn) => {
    const cache = {};
    return (arg) => cache[arg] || (cache[arg] = fn(arg));
}


逻辑很简单,返回值是lamdba表达式,它仍然支持闭包,所以我们在其同层定义一个cache,然后如果cache中的某项为空则计算并保存之,如果已经有了就直接使用。


这个高阶函数很好用,阶乘的逻辑不用改,只要放到memo中就好了:



let fastFact = memo(
    (n) => {
        if (n<=0){
            return 1;
        }else{
            return n * fastFact(n-1);
        }
    }
);


在本文即将结尾的时候,我们再回归到前端,React Hooks里面提供的useMemo,就是这样的记忆机制:


const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);



小结



综上,我们希望大家能记住几点:


  1. 函数式编程的核心概念很简单,就是将函数存到变量里,用在参数里,用在返回值里
  2. 在编程时要时刻记住将无副作用与有副作用代码分开
  3. 函数式编程的原理虽然很简单,但是因为大家习惯了命令式编程,刚开始学习时会有诸多不习惯,用多了就好了
  4. 函数式编程背后有其数学基础,在学习时可以先不要管它,当成设计模式学习。等将来熟悉之后,还是建议去了解下背后的真正原理


相关文章
|
1月前
|
前端开发 机器人 API
前端大模型入门(一):用 js+langchain 构建基于 LLM 的应用
本文介绍了大语言模型(LLM)的HTTP API流式调用机制及其在前端的实现方法。通过流式调用,服务器可以逐步发送生成的文本内容,前端则实时处理并展示这些数据块,从而提升用户体验和实时性。文章详细讲解了如何使用`fetch`发起流式请求、处理响应流数据、逐步更新界面、处理中断和错误,以及优化用户交互。流式调用特别适用于聊天机器人、搜索建议等应用场景,能够显著减少用户的等待时间,增强交互性。
248 2
|
2月前
|
前端开发
【前端web入门第四天】02 CSS三大特性+背景图
本文详细介绍了CSS的三大特性:继承性、层叠性和优先级,并深入讲解了背景图的相关属性,包括背景属性、背景图的平铺方式、位置设定、缩放、固定以及复合属性。其中,继承性指子元素自动继承父元素的文字控制属性;层叠性指相同属性后定义覆盖前定义,不同属性可叠加;优先级涉及选择器权重,包括行内样式、ID选择器等。背景图部分则通过具体示例展示了如何设置背景图像的位置、大小及固定方式等。
256 91
|
7天前
|
编解码 前端开发 JavaScript
从入门到精通:揭秘前端开发中那些不为人知的优化秘籍!
前端开发是充满无限可能的领域,从初学者到资深专家,每个人都追求更快、更稳定、更用户体验友好的网页。本文介绍了四大优化秘籍:1. HTML的精简与语义化;2. CSS的优雅与高效;3. JavaScript的精简与异步加载;4. 图片与资源的优化。通过这些方法,可以显著提升网页性能和用户体验。
13 3
|
12天前
|
机器学习/深度学习 自然语言处理 前端开发
前端神经网络入门:Brain.js - 详细介绍和对比不同的实现 - CNN、RNN、DNN、FFNN -无需准备环境打开浏览器即可测试运行-支持WebGPU加速
本文介绍了如何使用 JavaScript 神经网络库 **Brain.js** 实现不同类型的神经网络,包括前馈神经网络(FFNN)、深度神经网络(DNN)和循环神经网络(RNN)。通过简单的示例和代码,帮助前端开发者快速入门并理解神经网络的基本概念。文章还对比了各类神经网络的特点和适用场景,并简要介绍了卷积神经网络(CNN)的替代方案。
|
12天前
|
移动开发 前端开发 JavaScript
前端实训,刚入门,我用原生技术(H5、C3、JS、JQ)手写【网易游戏】页面特效
于辰在大学期间带领团队参考网易游戏官网的部分游戏页面,开发了一系列前端实训作品。项目包括首页、2021校园招聘页面和明日之后游戏页面,涉及多种特效实现,如动态图片切换和人物聚合效果。作品源码已上传至CSDN,视频效果可在CSDN预览。
18 0
前端实训,刚入门,我用原生技术(H5、C3、JS、JQ)手写【网易游戏】页面特效
|
2月前
|
JavaScript 前端开发 小程序
一小时入门Vue.js前端开发
本文是作者关于Vue.js前端开发的快速入门教程,包括结果展示、参考链接、注意事项以及常见问题的解决方法。文章提供了Vue.js的基础使用介绍,如何安装和使用cnpm,以及如何解决命令行中遇到的一些常见问题。
一小时入门Vue.js前端开发
|
1月前
|
自然语言处理 资源调度 前端开发
前端大模型入门(四):不同文本分割器对比和效果展示-教你如何根据场景选择合适的长文本分割方式
本文详细介绍了五种Langchain文本分割器:`CharacterTextSplitter`、`RecursiveCharacterTextSplitter`、`TokenTextSplitter`、`MarkdownTextSplitter` 和 `LatexTextSplitter`,从原理、优缺点及适用场景等方面进行了对比分析,旨在帮助开发者选择最适合当前需求的文本分割工具,提高大模型应用的处理效率和效果。
|
1月前
|
机器学习/深度学习 人工智能 自然语言处理
前端大模型入门(三):编码(Tokenizer)和嵌入(Embedding)解析 - llm的输入
本文介绍了大规模语言模型(LLM)中的两个核心概念:Tokenizer和Embedding。Tokenizer将文本转换为模型可处理的数字ID,而Embedding则将这些ID转化为能捕捉语义关系的稠密向量。文章通过具体示例和代码展示了两者的实现方法,帮助读者理解其基本原理和应用场景。
190 1
|
1月前
|
人工智能 前端开发 JavaScript
前端大模型入门(二):掌握langchain的核心Runnable接口
Langchain.js 是 Langchain 框架的 JavaScript 版本,专为前端和后端 JavaScript 环境设计。最新 v0.3 版本引入了强大的 Runnable 接口,支持灵活的执行方式和异步操作,方便与不同模型和逻辑集成。本文将详细介绍 Runnable 接口,并通过实现自定义 Runnable 来帮助前端人员快速上手。
|
1月前
|
存储 JavaScript 前端开发
前端开发:Vue.js入门与实战
【10月更文挑战第9天】前端开发:Vue.js入门与实战