vlambda博客
学习文章列表

【第2220期】前端函数式演进之函数式思维和前端特征

前言

今日前端早读课由《前端函数式演进》作者@邵丁丁授权分享。

正文从这开始~~

关于语言和语言范式的演进,早在 ES6 加入 Lambda 表达式和更多数组组合子方法的时候,主流高级语言如 Java8、Python 等就支持类似的语法特性。关注前端框架、库中的内容,参考 Java 虚拟机平台上的语言(Groovy、Scala、Kotlin)的发展,我们可以看到以类操作为主的主流业务语言都在逐渐吸收函数式编程的优点。

其中,无论是语言特性、框架和库的补充,还是设计模式的实现,都在寻求能更系统化地解决编程实现中遇到的问题的最佳方案。

JavaScript 在设计之初是比较简洁的,后面开发的内容以在这些简洁内容上进行扩展为主。凭借对函数良好的支持、原型继承特点和灵活的语言特征等优势,JavaScript 化繁为简,用精炼的元素堆叠出复杂的结构。在大多数业务场景中,我们更倾向于用 JavaScript 保持项目精简。基于函数式的设计可以提高系统的稳定性,这需要开发者思考函数式的编码思维和其他主流业务开发的区别,再结合前端的特征进行考量。

一 状态和副作用

对于大多数系统来说,我们根据系统状态决定系统采用的运作模式,这些状态体现在系统之外,比如高层建筑使用速度较快的电梯,而低层公寓可以选用成本较低、运行较慢的电梯。

在系统内部,我们用明确的局部属性、状态值和调用全局变量来记录当前系统的状态。电梯里有几个人、按了哪几层按钮,通信场景里网线开通到哪个路由节点,这些都直接影响系统接下来的行为。甚至系统的外观(贴图、广告页)也会影响用户的判断和行为,如图 3-1 所示。

图 3-1 电梯的广告页影响用户的判断和行为

在编码语言层面,我们使用条件控制语句(if/while)描述在某些状态下程序执行的次序和跳转方向。我们还会用 for 循环语句检查所有元素是否满足某些条件,进而选择做映射处理还是 break 操作。如果把代码块看作一段过程(一个模块),执行语句会产生输出结果以外的副作用(条件控制语句一般没有标准的输出结果,所以产生副作用就是运行这段代码的意义),进而影响模块以外的系统状态。否则运行这段代码只是一个占用资源(也是对资源产生的副作用)、无意义的过程。

状态和副作用如同流程树中每段流程伸出了枝丫,理想情况是流程树组成了一小片森林,而实际上很可能是一片荆棘。这些流程树需要有很好的聚合度,每个模块需要能灵活处理因外部状态变化导致的内容改变。当外部状态变化不可控、模块执行的时序对模块产生了影响时,整理起来会很麻烦,图 3-2 所示是凌乱的状态管理。

【第2220期】前端函数式演进之函数式思维和前端特征

图 3-2 凌乱的状态管理

良好的设计可以帮助开发者避开系统中各状态的高耦合。从全局角度来说,开发者可以借助一些额外的分层,如适配器、中间者、代理、外观包装等模式,限制因内部状态变化产生的交汇影响,减少对外耦合。从每个细粒度的流程上看,我们希望系统是一个高度封装的纯结构体,便于后期调试、替换,并且稳定运行,进而确保系统的可维护性和扩展性。

在函数式思维中也要处理状态和副作用,没有状态和副作用的程序是无意义的。函数式的一般处理方式是基于要发生的“过程”,偏向于把状态处理集中在过程的一端,尽量理想化地将其处理成过程的输入参数,将副作用集中在过程的另一端作为输出结果。

还有一种处理方式是把一些对外部的依赖写在模块中,作为一种可被封装的不确定因素,比如对父类的 Super 调用、对 this 中信息的调用等。面向对象语言常常封装这些不确定因素,而函数式则倾向于在外部暴露它们,从而减少内部的不确定性。

这些形式都不是绝对的。在实际编码时,前端框架如 Flux、React Hooks 的一些写法也可能把影响外部再次调用的内容重新拉入模块(组件)内部。这些做法最终还是会展开成较为纯粹的函数调用链路,但写法上更符合开发者的编写习惯。

函数式语言的设计出发点偏向于研究怎么把“过程”进行组合、拼装和复用,这个组合、拼装的过程最好没有外部状态参与。大型项目需要对这类“过程”(需要集中管理的命令)进行一些批量的业务操作和必要的抽象封装,下面我们继续讨论这些业务过程的抽象。

过程和高阶抽象

函数在某些语言环境下是可执行的过程,构建过程抽象和构建数据抽象是计算机语言系统的两种编程语言世界观,而抽象的核心在于对本质的解释和对屏蔽细节的处理。

我们可以简便地使用类型 / 原型来定义一个实例,以便高效地声明一类数据模型。随着时间的推移,这些数据模型的状态会发生变化、互相影响,进而演化出整套系统。

这种描述有些类似电影中的群戏,如图 3-3 所示。在电影《十二怒汉》中,12个主角代表了 12 种人物类型,剧中人物的行为(状态变化)来自每个人物(实例)的设定。这些行为相互推进,影响整个电影(数据模型)的走向。

【第2220期】前端函数式演进之函数式思维和前端特征

图 3-3 电影中的群戏示意图

群戏的核心在于多种事物的串联行为,需要先对事物进行描述,再对行为进行归类,进而按照剧本驱动光影(系统)流转。

与之类似,我们也可以高效地抽离受到较多关注的过程,通过命令、迭代器对进行类型描述过程 / 函数,这种编程叙事的世界观来自数据模型层面的抽象。

从过程推演的角度来说,如果不是很执着于过程的归类和定义,我们可以尝试多做一些事情。

便捷地对过程反复包装

JavaScript 的前端函数式思维中,对过程进行反复包装的优势有二:首先函数式可以使用匿名函数,即没有锚的过程抽象;其次在函数式之外,我们可以利用语言特性实现一些元编程能力,比如模板能力、原型能力,这方便我们打破类型束缚和编译期限制。

元编程的语言能力没有指定的内容(在 JavaScript 中修改原型使用的是 Ruby 语言的 Method_missing 方法),甚至不是必选的,而且使用元编程还意味着纯函数会受到隐性的影响。但如果我们不能快捷地在运行时更改“方法”的运行方式,对过程编码的精简处理就会变得很烦琐。

更灵活的编程语言能力往往意味着更大的编码风险,不过函数式集中处理状态和副作用会帮助我们处理风险,甚至提高代码的稳定性。函数式的类型处理也更关注语言能力,这部分内容我们将在 3.5 节展开介绍。

代码清单 3-1 展示了过程的便捷封装以及封装后的方法在 JSX 中的使用。

代码清单 3-1 过程的便捷封装及使用

 
   
   
 
  1. // 对数组 map 的容错操作

  2. const _map = (arr, func) => !!(arr && arr.length) && arr.map(func)


  3. // JSX 中的应用

  4. {

  5. _map(items, group => {

  6. return (

  7. <div key={group.id} className="grid-row">

  8. {

  9. _map(group, x => ( <div key={x.id}>{x.name}</div> ))

  10. }

  11. </div>

  12. )

  13. })

  14. }

另一种编程世界观:流过系统的信息流

我们将这种编程世界观的表达形式类比为电影中的长镜头。长镜头往往以一个主要事物和它的状态(主角或其他事物的视角)来推进情节,一个镜头反复切换到需要观众关注的内容上,而不切换大场景,一镜到底地表述整个过程和结果。很多电影,比如《鸟人》(见图 3-4)就使用了这一经典的拍摄手法。

【第2220期】前端函数式演进之函数式思维和前端特征

图 3-4 长镜头 / 一镜到底示意图

图 3-5 中,整个链路使顾客、商家、订单、配送等关键字段串联出清晰的软件模型。对于信息流来说,我们可以有更多精力关注过程本身的工作:并发 / 并行、同步 / 异步、有限工作 / 无限待命,以及工作流的合并 / 拆分。

【第2220期】前端函数式演进之函数式思维和前端特征

图3-5 信息流式系统中的交易和履约场景示意图

如果能系统地区分过程的种类并进行优化,就可以节省更多处理业务流程的精力。本节提到的两种编程时对过程高度抽象的方式:反复对过程进行抽象和对信息流过程处理方法的归纳,使得我们可以更高效地描述系统的运行过程。现在我们已经了解了函数和过程的抽象,在设计系统时可以站在全局进行思考。

下节我们将回到程序的设计中,看一看函数式思维影响代码的两个重要体现:程序本身拥有更多控制权和函数式思维对数据结构的影响。

运行环境承担更多的职责

编译期或者代码部署期间(Webpack 作用时),我们可以借助工具处理部分工作,比如发现代码类型错误和提高代码覆盖率、减少代码体积等。函数式语言并不排斥这些工具,多数函数式语言也并不是 JavaScript 这样类型松散的解释型语言。

在上节介绍过程和高阶抽象时,我们提到了系统更高效的表达方式,本节我们展开介绍前端代码运行时环境承担的一些常见功能。

循环、映射和递归

对一类事物进行批量处理是机械计算的高效表现之一。在前端我们通常使用 for循环对 List 对象(数组表达)执行循环操作,for 循环需要声明数组长度和底标的步进。

如果使用 map 操作将数组当作集合来处理,那么集合的边界本身作为集合的一个特性,在运行时使约束了循环的界限,这时我们做的是按照 map 方法进行简单映射。

递归是一种解决过程堆叠的方法,在运行时承担了更多的工作。递归的终止时机取决于上一层返回的结果,也就是在编码时,我们只能给出终止条件,但代码段的执行次数不像拥有步进或枚举条件的循环那样一目了然。通过第 2 章介绍的尾递归和CPS 我们可以看出,完整的递归操作会将函数的返回结果持续包装进新的递归方法中,进行反复调用。

递归在完成操作之前,副作用最收敛,不依赖外部状态变化和内部消化条件。也就是说,递归将状态的管理和转移交付给运行时,是更接近底层级别的操作。不过递归的缺点也比较明显,更底层的操作更难控制资源消耗,控制不好会造成调用栈超限,此时我们要根据环境适当增加递归次数或对时间、空间进行限制。

综合考虑编码效率和稳定性,函数组合子优于递归,递归优于循环。在我们使用函数组合子和递归时,系统都基于运行时承担了一些代码限制功能,高阶函数 / 过程的调用取代了迭代。当然,如果从可手动操作和可以提前退出这两点带来的运行效率来看,循环和递归更优。表 3-1 是循环 / 迭代、递归和映射 / 化约的优缺点对比。

【第2220期】前端函数式演进之函数式思维和前端特征

循环 / 迭代、递归和映射 / 化约的优缺点对比

感兴趣的读者可以思考一下没有锚点时递归的等价描述:不动点和 Y 组合子,以便更细致地了解函数式内涵。

运行时环境承担了更多编码能力还有另一种优势,那就是当开发者想提高编码的处理层次时,在运行时承载的工具方法并不需要多次编译(除非是语法层面的更改)。从语言层面来看,在运行时处理更高一级的过程抽象是非常有必要的。

函数式过程抽象忽略的细节操作

为了更高效地处理核心逻辑,高级语言已经逐步包裹了烦琐的细节处理。相对于早期语言,Java 和 JavaScript 的垃圾处理机制(GC)更为方便。

代码运行场景中最核心的内容是输入 / 输出和调用 / 消耗。如果参照函数式的理想封装模型,固定的输入内容会得到固定的输出结果。我们可以用输出结果对函数调用做等价转换,当我们不再关心封装在黑盒内的细节时,黑盒的结果可以作为缓存记忆。这一操作就是对于过程抽象细节的封装。

当我们使用类型约束代码时,在类为主要结构的语言中,每次演进类型的声明和推导都很重要。我们可能会依赖泛型来填补方法处理中参数类型的不确定性,而从JavaScript 的角度或参看其他函数式语言,类型标识更多是对于函数方法的描述。我们最终关注的是类型表述为“ f:: a -> b -> c -> a”的过程描述和参数 a 的类型描述,那么过程中的类型细节也和过程抽象本身一样可以进行封装。这一过程类似自动洗车机器,如图 3-6 所示,我们只关注结果,也就是洗完的汽车。

【第2220期】前端函数式演进之函数式思维和前端特征

图3-6 自动洗车机器——汽车变为洗完的汽车

除细节封装和类型简略之外,闭包和函数部分施用等函数式特征,将无用环境变量的回收时机、函数的调用时机(惰性调用)等工作交付给运行时。运行时和函数式特征相结合,可以帮我们屏蔽很多操作细节。

类型和数据结构

本节我们会探讨更多类型和数据结构的内容,它们是从结构上描述对象的核心概念。

面向能力的数据结构

传统命令式 / 面向对象语言鼓励开发者建立专门针对某个类的方法,而函数式鼓励的是在数据结构上使用共通的变换。某些语言在对集合做映射处理时,可能在一类子类中实现 map 方法的接口。而在 JavaScript 中,我们可以使用“ Array.prototype.map”方法直接处理所有具备 length 属性和自然数下标的类数组鸭子类型(可以参考代码清单 3-1 中 range 的实现)。

数据结构和类内部方法的绑定操作很重要,确保了方法调用时的合理性和精准度。在构建大规模系统时,良好的数据结构可以给代码带来更强的约束。函数式的关注点在于数据的操作过程和变化,它可以不使用类结构,而使用具备某些能力的结构体进行数据操作。

Haskell 中有明确的类型类(TypeClass)的概念,int 类是明确的 Eq(可判断相等)、Ord(可比较大小和排序)和 Show(可转为字符串表述)的实例类型,这有些像Java 中实现 isEqual 和具备其他能力的接口,更类似 JavaScript 中的能力检测,根据判断给定的数据是否具备 toString 方法,来判断是否进行某些操作。

类型类和能力检测方便我们跨越类型进行某些操作,比如基于 Show 类型类和toString 方法做全局的序列化。在 JavaScript 中,以是否具备某个属性、某种能力来判断是否进行某些操作,比另一种过程变量必须是某种类型,且需要使用类型结构的变化才能完成特定操作要方便一些。

我们刚提到的数据结构主要是基于业务模型的类结构,它们不会增加流程的复杂度,过程操作应尽可能少出现数据结构,以方便编码者对关键分层处(如前后端分离时的接口约定)的结构进行监测和模拟,也方便程序在数据不满足细节条件时,抛出相应的错误并处理。在函数式影响的语言中,我们更希望基于业务的数据结构是一个灵活的结构体。

一个普通的 JavaScript 对象可以通过表达式添加 length 方法和自然数 keys 形成数组对象,而基于类型结构的语言需要使用转换器才能做到这一点。在第 4 章我们会介绍一个 Just 对象,通过增加 flatMap 方法可以很轻松地形成 Maybe 对象。如果我们希望能这样灵活地操纵数据,就需要对数据结构和类型进行重新分类,做出必要的取舍。

言到此处,可能很多读者已经发现,在语言层面进行类型的划分并非那么绝对。在一些类语言中,某些能力可以通过某些接口的类来实现,而函数式语言如果做底层和工具开发,仍然需要使用类型配合约束工具定义出各种树、链表等适合运行算法的结构。

我们使用类型和类似能力的根本原因在于业务层次需要做强类型约束和校验,这实际上是一个关于数据结构粒度的问题。在 Web 业务层面,我们通常只需要下探到数据对一些粗粒度的接口实现上(如 thenable 函数、sortable 函数),更深层的结构约束很难调动 JavaScript 引擎做更多优化,反而会增加 JavaScript 运作时的圈复杂度。想要提高更底层的开发效率,需要一些语言层面和运行环境层面的能力加以配合,我们就不展开讨论了。

对场景下类型的作用进行替换

在 JavaScript 中我们可以凭借能力 / 接口层面的业务类型进行更细致的操作。我们从类型系统中得到的最大好处是获得统一模块实例行为的能力。在其他常见业务开发语言中,类型系统还会帮助我们完成代码的封装和复用,以及在编码时的代码检验。

在前端,函数通过自身行为特性,比如闭包,帮助开发者实现便捷封装。我们还可以借用一些工厂方法和元编程能力,进一步实现函数的封装和复用,这也是JavaScript 实现 Class 语法糖的封装方式。前端经常切换使用执行函数生成对象和使用类生成对象这两种方式产生目标实例,它们的区别在于前者要额外考虑成员的封装,而后者最好能确定生成专门的构造函数。

考虑到出入参的约束问题,如果忽略编译期间的类型检查,我们可以在函数组合的传入部分进行手动校验,并根据类型重载,在代码执行时把异常态向后传导,对运行结果进行检查。

除去编译器对代码的检查,编码时我们完全可以按照需求,对类型的约束作用进行补强。补强会增加编码复杂度,但我们更关注编码的目的和方式。补强过程需要设计好调试姿势,以补充约束能力。代码清单 3-2 演示了手动类型约束操作。

代码清单 3-2 深克隆和组合模式根据反射类型判断实现多态 / 根据闭包变量记录引用

 
   
   
 
  1. // 深克隆和组合模式

  2. function cloneDeep(target){

  3. let copyed_objs = [];

  4. const _cloneDeep = target => {

  5. if (!target || (typeof target !== 'object')) {

  6. return target;

  7. }

  8. for(let i= 0; i < copyed_objs.length; i += 1) {

  9. if(copyed_objs[i].target === target) {

  10. return copyed_objs[i].copyTarget;

  11. }

  12. }

  13. let obj = Array.isArray(target) ? [] : {};

  14. copyed_objs.push({ target:target, copyTarget:obj});

  15. Object.keys(target).forEach(_key => {

  16. if(obj[_key]) { return; }

  17. obj[_key] = _cloneDeep(target[_key]);

  18. });

  19. return obj;

  20. }

  21. return _cloneDeep(target);

  22. }

回到前端场景,JavaScript 拥有快捷实现对象的能力,并不一定要向类的实例靠拢。快速对对象进行校验,有时可以减少对模板的额外开发,此时类型只是作为一种模板,应用在对象通用场景并不多的情况下,不应该进行过多的描述。

关于类型还有更多内容需要考量,比如类型可以提高编辑器对代码的理解能力;可以帮助我们在编码时快速查看对象的结构(如枚举值),等等。我们可以根据项目的深度和复杂度选择类型方案,不要在非必要时引入新的实体。

进行函数式编程时,我们还要依赖函数式语言的特性完成过程的切面操作。函数式编程偏向于在代码的执行阶段操作对象,使用函数式语言去迎合问题,通过重塑语言来解决问题,这样也更容易产生描述式风格的代码(声明式代码)。

编码中的问题其实可以通过一些解决方案进行处理,如经典的设计模式、各语言的特性和这些解决方案的快速上手包——框架。在 3.5 节我们将对函数式和前端的设计方案进行更多的讨论。

设计模式和语言特征

设计模式可以看作赋予了名字、编目记录的常见问题的解决方案。设计模式诞生之初主要基于主流的命令式语言加上以类为基础的封装模式。

在繁杂的前端场景中,设计模式的很多常用模型已经包含在语言特征和框架的实现中(如单例:有初始值的闭包元素)。结合 JavaScript 灵活的数据结构,一些设计模式在前端的实现是非常简洁的,我们通过下面两个例子进行说明。

访问者模式

访问者(visitor)模式可理解为基于调用者的方法重载(编译时的多态)。在函数式语言中,我们有时会遇到和命令式一样的问题,编程语言自身可能不支持参数重载。这时我们可以在方法中根据访问者的类型结构,做不同的 mapping 处理。

组合模式

这里的组合(composite)模式指的不是函数式的函数组合,而是对存在包含关系或指向关系的节点(node)和枝叶(leaf)的组合操作。组合模式广泛应用于前端级联选择器或其他树结构的业务组件中。

如图 3-7 所示是级联选择器的访问者和组合模式。

【第2220期】前端函数式演进之函数式思维和前端特征

图3-7 级联选择器的访问者模式(左)和组合模式

如果我们使用对象直接操作,只要根据当前目标是否含有子节点等后续节点,灵活做出有分支的操作即可。

两种模式的代码都可以参考代码清单 3-2 中深克隆代码的内容进行理解。

这些模式的前端处理看起来有些随意,却都能方便地对对象结构进行操作。编码设计的一个出发点是合成复用原则,它同时体现了开闭原则。这些原则的实践之一是使用组合和聚合代替继承复用,以便保持良好的封装性和减少新旧类的耦合度,进而保证代码良好的扩展能力和改进代码时的稳定性。

函数式的思想也偏向于对对象结构的扩展。结合之前提到的不可变数据结构,扩展行为可以保持原型的数据结构,方便在编码中实现缓存(备忘录 Memento 模式)和其他特征。如果编码涉及更多的时序和并发操作,不可变数据结构原本就符合线程安全的要求。

函数以各种形态的第一公民类型存在于很多语言里,尤其是在 JavaScript 中,函数更是以对象的形态存在的。我们对函数的抽象操作可以具象到对一类数据结构的操作,此时,我们可以在高级函数,甚至语言层面(自举、计时器模拟调度线程)调控系统调用函数的行为。这里我们需要解决一系列的问题:占用的空间和运算资源、运行时错误、任务的调度等。

3.6 节会以异常态为例,介绍函数式语言如何在代码中再次对运行时进行包装。

异常态

一个良好的系统,除了会产生常态的错误信息外,还可能产生运行时的异常。我们经常看到的“Cannot read property 'xx' of undefined”这种语法错误,多源于缺失类型约束。其他常见问题还有堆栈溢出、一些框架如 React 在做代码转换时的错误等。

对于运行时才出现的错误(除了大范围的数据资源缺失外),我们使用函数式时更倾向于把错误当作正常态进行处理。这样处理之所以可行,是因为基于过程的高阶抽象可以包裹运行环境,接住错误并处理成错误对象,这样做方便开发者对错误进行传导、收集和改进。

异常态的处理有两种归宿,一种是让它消失在被捕获后,另一种是让它带来系统的可控崩溃。我们日常写的业务代码崩溃的代价不总是可控的,有时代码运行错误仅影响一个代码块,但在框架层层包裹的情况下,这个错误会影响外层的更多功能,比如造成渲染白屏。我认为,对于异常态的处理,JavaScript 和多数编程语言一样,更适合捕获后接住错误并收集,或者在生产环境客户端尽量达到要求。能把运行时可能出现的错误全部提前识别并分类处理,是高阶语言处理异常问题的理想方式。除了使用装饰器代理等方法加入“ try/catch”模块外,我们也可以使用类似Maybe、Promise 的 Reject 方式,如图 3-8 所示。这些处理方式是把代码可能的运行状态都进行封装,用事件流的分支状态来控制事件流中成功、错误、迭代等状态。

【第2220期】前端函数式演进之函数式思维和前端特征

图3-8 Promise-error/reject 方式示意图

前端的其他特征

本节我们讨论前端的一些特征。这些特征不一定包含在函数式的概念中,但在设计工具和编码时常常和函数式一起考虑,也常被混淆。

事件驱动、手动处理并行操作、浏览器 JavaScript 环境的线程特性、优秀的 DSL(如 CSS、JSON),这些都使得前端在函数式上可以衍生出特有的工具和语法特征,本节我们会从实用的角度进行分析。

有时我们会把前端的特征当作函数式的特征与面向对象进行对比,可能正是因为这些特征和函数式的一些思想比较契合,而和函数式思想契合的特征,并不一定和面向对象思想冲突。

弱类型和动态类型

如果在语言设计之初有机会细致地设计类型处理方式,设计者们一般都会对类型要求得更加严格,不管语言本身是偏函数式还是多范式,JavaScript 的弱类型和动态类型的出发点都是提高脚本语言的开发和学习速度。在前端复杂度还不够高的时候,原生编码盛行,交互数据层常常处理不当,此时 JavaScript 的类型时常会带来一些隐患。

虽然我们讨论类型和数据结构时,提到了一些设计上的思维变更和类型可能带来的便利,但实际上不管是什么语言,当涉及跨层数据交互导致较多参数类型要被处理时,都希望有更多明确的约束条件。在使用 JavaScript 编码时,我们首先要明确不应该支持不显著的隐式转换(使用变量加空字符串等形式预处理类型)和跨类型的值比较。其次,我们也不要排斥通过手动类型转换和动态类型来执行一些操作,比如假值判断 ( “!(a); a || b” )。

在开发工具时,我们使用“ ===”进行带类型判等。在需要断言类型的时候,我们进行手动检验。而回到业务上,我们用对象表述复杂的数据类型,其对应的基本类型都是 Object 这个笼统的代名词,我们对类型的要求完全可以做到按需操作。

类型对编码的影响还体现在编码效率和系统健壮性的取舍上。在高度抽象过程的函数式场景下,我们更看重编码效率。同时,我们应该在编码设计中通过更好的设计、更多的手动介入和运行时限制来弥补系统健壮性的缺失。

Array 的组合运算

在 Array 的原生运算方法中,有一些具备显著的函数式风格,比如 map、filter、reduce,可以将其理解为函数式的典型用法。数组支持操作后返回新的数组,支持链式操作,甚至还支持在数组中添加额外的字段(比如 Vue 添加观察属性)以加强结构稳定,或保存值以外的信息(比如错误值或者 Map 结构的 Key)。方法被链式调用时,this 默认指向点操作符最左侧的主体,也使得传入的方法更便于支持箭头函数。

但是和函数式特征相违背的是,Array 中还有部分运算是对传入值做处理,并不支持同级的链式操作,比如常用的 push、reverse 等方法(可以使用 concat 等代替)。另外,Array 作为 JavaScript 的常用类型,使用时可能会受到便捷覆写原型方法的影响,这需要开发者通过 Lint 等工具进行约束。

Array 可以模拟很多常用的基础数据结构,比如 map、LinkedList、多维数组 / 矩阵、简便的树结构等,如图 3-9 所示,这些结构常被用在基础开发中。Array 有比较好的扩展能力,这来自 JavaScript 设计时对集合结构的简单抽象。

图 3-9 JavaScript 中 Array 承担了集合的概念

小结

本章我们从系统中的状态和过程,到运行时承担的更多内容,如类型和数据结构;再到具体的编码设计、异常态的改变和前端的其他特征,对前端和函数式思维进行了简单的介绍。有时与其说函数式思维是设计思维,不如说它是从语言设计层面给开发者在设计取舍上提供了便利。

语言范式经历了从理论完善到相关语言的诞生,再到持续满足具体领域的工程化需求,已经融合了很多内容,这些内容没有很彻底地实现语言范式背后的理论,但是结果却很实用。本章只是从前端的视角片面地做了一些论述,读者可以就自己感兴趣的内容展开学习和思考。

关于本书更详细内容,欢迎通过本文左下角“阅读原文”了解。

为你推荐






欢迎自荐投稿,前端早读课等你来