新书终于截稿,今天稍有空闲,为大家奉献一篇关于 JavaScript 语言风格的文章,主角是函数声明式。

灵活的 JavaScript 及其 multiparadigm

相信“函数式”这个概念对于很多前端开发者早已不再陌生:我们知道 JavaScript 是一门非常灵活,融合多模式(multiparadigm)的语言,这篇文章将会展示 JavaScript 里命令式语言风格和声明式风格的切换,目的在于使读者了解这两种不同语言模式的各自特点,进而在日常开发中做到合理选择,发挥 JavaScript 的最大威力。

为了方便说明,我们从典型的事件发布订阅系统入手,一步步完成函数式风格的改造。事件发布订阅系统,即所谓的观察者模式(Pub/Sub 模式),秉承事件驱动(event-driven)思想,实现了“高内聚、低耦合”的设计。如果读者对于此模式尚不了解,建议先阅读我的原创文章:探索 Node.js 事件机制源码 打造属于自己的事件发布订阅系统。这篇文章中从 Node.js 源码入手,剖析了事件发布订阅系统的实现,并基于 ES Next 语法,实现了一个命令式的事件发布模式。对于此基础内容,本文不再过多展开。

典型 EventEmitter 和改造挑战

了解事件发布订阅系统实现思想,我们来看一段简单且典型的基础实现:

  1. class EventManager {
  2. construct (eventMap = new Map()) {
  3. this.eventMap = eventMap;
  4. }
  5. addEventListener (event, handler) {
  6. if (this.eventMap.has(event)) {
  7. this.eventMap.set(event, this.eventMap.get(event).concat([handler]));
  8. } else {
  9. this.eventMap.set(event, [handler]);
  10. }
  11. }
  12. dispatchEvent (event) {
  13. if (this.eventMap.has(event)) {
  14. const handlers = this.eventMap.get(event);
  15. for (const i in handlers) {
  16. handlers[i]();
  17. }
  18. }
  19. }
  20. }

上面代码,实现了一个 EventManager 类:我们维护一个 Map 类型的 eventMap,对不同事件的所有回调函数(handler)进行维护。

  • addEventListener 方法对指定事件进行回调函数存储;
  • dispatchEvent 方法对指定的触发事件,逐个执行其回调函数。

在消费层面:

  1. const em = new EventManager();
  2. em.addEventListner('hello', function() {
  3. console.log('hi');
  4. });
  5. em.dispatchEvent('hello'); // hi

这些都比较好理解。下面我们的挑战是:

  • 将以上 20 多行命令式的代码,转换为 7 行 2 个表达式的声明式代码;
  • 不再使用 {...} 和 if 判断条件;
  • 采用纯函数实现,规避副作用;
  • 使用一元函数,即函数方程式中只需要一个参数;
  • 使函数实现可组合(composable);
  • 代码实现要干净、优雅、低耦合。

Step1: 使用函数取代 class

基于以上挑战内容,addEventListener 和 dispatchEvent,不再作为 EventManager 类的方法出现,而成为两个独立的函数,eventMap 作为变量:

  1. const eventMap = new Map();
  2. function addEventListener (event, handler) {
  3. if (eventMap.has(event)) {
  4. eventMap.set(event, eventMap.get(event).concat([handler]));
  5. } else {
  6. eventMap.set(event, [handler]);
  7. }
  8. }
  9. function dispatchEvent (event) {
  10. if (eventMap.has(event)) {
  11. const handlers = this.eventMap.get(event);
  12. for (const i in handlers) {
  13. handlers[i]();
  14. }
  15. }
  16. }

在模块化的需求下,我们可以 export 这两个函数:

  1. export default {addEventListener, dispatchEvent};

同时使用 import 引入依赖,注意 import 使用都是单例模式(singleton):

  1. import * as EM from './event-manager.js';
  2. EM.dispatchEvent('event');

因为模块是单例情况,所以在不同文件引入时,内部变量 eventMap 是共享的,完全符合预期。

Step2: 使用箭头函数

箭头函数区别于传统的函数表达式,更符合函数式“口味”:

  1. const eventMap = new Map();
  2. const addEventListener = (event, handler) => {
  3. if (eventMap.has(event)) {
  4. eventMap.set(event, eventMap.get(event).concat([handler]));
  5. } else {
  6. eventMap.set(event, [handler]);
  7. }
  8. }
  9. const dispatchEvent = event => {
  10. if (eventMap.has(event)) {
  11. const handlers = eventMap.get(event);
  12. for (const i in handlers) {
  13. handlers[i]();
  14. }
  15. }
  16. }

这里要格外注意箭头函数对 this 的绑定。

Step3: 去除副作用,增加返回值

为了保证纯函数特性,区别于上述处理,我们不能再去改动 eventMap,而是应该返回一个全新的 Map 类型变量,同时对 addEventListener 和 dispatchEvent 方法的参数进行改动,增加了“上一个状态”的 eventMap,以便推演出全新的 eventMap:

  1. const addEventListener = (event, handler, eventMap) => {
  2. if (eventMap.has(event)) {
  3. return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  4. } else {
  5. return new Map(eventMap).set(event, [handler]);
  6. }
  7. }
  8. const dispatchEvent = (event, eventMap) => {
  9. if (eventMap.has(event)) {
  10. const handlers = eventMap.get(event);
  11. for (const i in handlers) {
  12. handlers[i]();
  13. }
  14. }
  15. return eventMap;
  16. }

没错,这个过程就和 Redux 中的 reducer 函数极其类似。保持函数的纯净,是函数式理念中极其重要的一点。

Step4: 去除声明风格的 for 循环

接下来,我们使用 forEach 代替 for 循环:

  1. const addEventListener = (event, handler, eventMap) => {
  2. if (eventMap.has(event)) {
  3. return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  4. } else {
  5. return new Map(eventMap).set(event, [handler]);
  6. }
  7. }
  8. const dispatchEvent = (event, eventMap) => {
  9. if (eventMap.has(event)) {
  10. eventMap.get(event).forEach(a => a());
  11. }
  12. return eventMap;
  13. }

Step5: 应用二元运算符

我们使用 || 和 && 来使代码更加具有函数式风格:

  1. const addEventListener = (event, handler, eventMap) => {
  2. if (eventMap.has(event)) {
  3. return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  4. } else {
  5. return new Map(eventMap).set(event, [handler]);
  6. }
  7. }
  8. const dispatchEvent = (event, eventMap) => {
  9. return (
  10. eventMap.has(event) &&
  11. eventMap.get(event).forEach(a => a())
  12. ) || event;
  13. }

需要格外注意 return 语句的表达式:

  1. return (
  2. eventMap.has(event) &&
  3. eventMap.get(event).forEach(a => a())
  4. ) || event;

Step6: 使用三目运算符代替 if

三目运算符更加直观简洁:

  1. const addEventListener = (event, handler, eventMap) => {
  2. return eventMap.has(event) ?
  3. new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
  4. new Map(eventMap).set(event, [handler]);
  5. }
  6. const dispatchEvent = (event, eventMap) => {
  7. return (
  8. eventMap.has(event) &&
  9. eventMap.get(event).forEach(a => a())
  10. ) || event;
  11. }

Step7: 去除花括号 {...}

因为箭头函数总会返回表达式的值,我们不在需要任何 {...} :

  1. const addEventListener = (event, handler, eventMap) =>
  2. eventMap.has(event) ?
  3. new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
  4. new Map(eventMap).set(event, [handler]);
  5. const dispatchEvent = (event, eventMap) =>
  6. (eventMap.has(event) && eventMap.get(event).forEach(a => a())) || event;

Step8: 完成 currying 化

最后一步就是实现 currying 化操作,具体思路将我们的函数变为一元(只接受一个参数),实现方法即使用高阶函数(higher-order function)。为了简化理解,读者可以认为即是将参数 (a, b, c) 简单的变成 a => b => c 方式:

  1. const addEventListener = handler => event => eventMap =>
  2. eventMap.has(event) ?
  3. new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
  4. new Map(eventMap).set(event, [handler]);
  5. const dispatchEvent = event => eventMap =>
  6. (eventMap.has(event) && eventMap.get(event).forEach (a => a())) || event;

如果读者对于此理解有一定困难,建议先补充一下 currying 化知识,这里不再展开。

当然这样的处理,需要考虑一下参数的顺序。我们通过实例,来进行消化。

currying 化使用:

  1. const log = x => console.log (x) || x;
  2. const myEventMap1 = addEventListener(() => log('hi'))('hello')(new Map());
  3. dispatchEvent('hello')(myEventMap1); // hi

partial 使用:


  1. const log = x => console.log (x) || x;
  2. let myEventMap2 = new Map();
  3. const onHello = handler => myEventMap2 = addEventListener(handler)('hello')(myEventMap2);
  4. const hello = () => dispatchEvent('hello')(myEventMap2);
  5. onHello(() => log('hi'));
  6. hello(); // hi

熟悉 python 的读者可能会更好理解 partial 的概念。简单来说,函数的 partial 应用可以理解为:

函数在执行时,要带上所有必要的参数进行调用。但是,有时参数可以在函数被调用之前提前获知。这种情况下,一个函数有一个或多个参数预先就能用上,以便函数能用更少的参数进行调用。

对于 onHello 函数,其参数即表示 hello 事件触发时的回调。这里 myEventMap2 以及 hello 事件等都是预先设定好的。对于 hello 函数同理,它只需要出发 hello 事件即可。

组合使用:

  1. const log = x => console.log (x) || x;
  2. const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)));
  3. const addEventListeners = compose(
  4. log,
  5. addEventListener(() => log('hey'))('hello'),
  6. addEventListener(() => log('hi'))('hello')
  7. );
  8. const myEventMap3 = addEventListeners(new Map()); // myEventMap3
  9. dispatchEvent('hello')(myEventMap3); // hi hey

这里需要格外注意 compose 方法。熟悉 Redux 的读者,如果阅读过 Redux 源码,对于 compose 一定并不陌生。我们通过 compose,实现了对于 hello 事件的两个回调函数组合,以及 log 函数组合。

关于 compose 方法的奥秘,以及不同实现方式,请关注作者:Lucas HC,我将会专门写一篇文章介绍,并分析为什么 Redux 对 compose 的实现稍显晦涩,同时剖析一种更加直观的实现方式。

总结

函数式理念也许对于初学者并不是十分友好。读者可以根据自身熟悉程度以及偏好,在上述 8 个 steps 中,随时停止阅读。同时欢迎讨论。

本文意译了 Martin Novák 的 新文章,欢迎大神斧正。

广告时间:
如果你对前端发展,尤其 React 技术栈感兴趣:我的新书中,也许有你想看到的内容。关注作者 Lucas HC,新书出版将会有送书活动。

Happy Coding!

PS: 作者 Github仓库 和 知乎问答链接 欢迎各种形式交流。

EventEmitter:从命令式 JavaScript class 到声明函数式的华丽转身的更多相关文章

  1. JavaScript:了解一下函数式编程

    一.简介 在JavaScript中,函数就是第一类公民,它可以像字符串.数字等变量一样,使用var修饰并作为数据使用.它可以作为数值.可以作为参数.还可以作为返回结果.可以说JavaScript就是函 ...

  2. JavaScript中变量声明有var和没var的区别

    JavaScript中变量声明有var和没var的区别 JavaScript中有var和没var的区别 Js中的变量声明的作用域是以函数为单位,所以我们经常见到避免全局变量污染的方法是 (functi ...

  3. javascript的变量声明、数据类型

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...

  4. javascript语法之声明变量

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...

  5. JavaScript的函数声明与函数表达式的区别

    1)函数声明(Function Declaration); // 函数声明 function funDeclaration(type){ return type==="Declaration ...

  6. 转载 JavaScript的函数声明与函数表达式的区别

    1)函数声明(Function Declaration); // 函数声明 function funDeclaration(type){ return type==="Declaration ...

  7. javascript:function 函数声明和函数表达式 详解

    函数声明(缩写为FD)是这样一种函数: 有一个特定的名称 在源码中的位置:要么处于程序级(Program level),要么处于其它函数的主体(FunctionBody)中 在进入上下文阶段创建 影响 ...

  8. JavaScript 基础 (变量声明, 数据类型, 控制语句)

    创建: 2017/09/16 更新: 2017/09/24 更改标题 [JavaScript 概要]-> [JavaScript 基础] 完成: 2017/09/25 更新: 2017/10/0 ...

  9. javascript中函数声明、变量声明以及变量赋值之间的关系与影响

    javascript中函数声明.变量声明以及变量赋值之间的关系与影响 函数声明.变量声明以及变量赋值之间有以下几点共识: 1.所有的全局变量都是window的属性 2.函数声明被提升到范围作用域的顶端 ...

随机推荐

  1. 监控 Linux 服务器活动的几个命令(watch top ac)

    watch.top 和 ac 命令为我们监视 Linux 服务器上的活动提供了一些十分高效的途径. 为了在获取系统活动时更加轻松,Linux 系统提供了一系列相关的命令.在这篇文章中,我们就一起来看看 ...

  2. GSON转换成Long型变为科学计数法及时间格式转换异常的解决方案

    直接上工具类了,简单实用 public class GsonUtils { private static Gson gson = null; static { if (gson == null) { ...

  3. BBuBBBlesort!

    题目描述 Snuke got an integer sequence of length N from his mother, as a birthday present. The i-th (1≦i ...

  4. aclocal-1.13: command not found

    原因: 将编译好的工程拷贝到系统版本不一样的系统中,再进行编译会出现此类问题. 解决方法: yum install automake autoconf yum install libtool auto ...

  5. VS2010 常用的快捷键

    1.强迫智能感知:Ctrl+J:2.强迫智能感知显示参数信息:Ctrl-Shift-空格:3.格式化整个块:Ctrl+K+F4.检查括号匹配(在左右括号间切换): Ctrl +]5.选中从光标起到行首 ...

  6. ambulance|severely|halt

    N-COUNT 救护车An ambulance is a vehicle for taking people to and from hospital. very seriously 严重地 Thei ...

  7. AndroidP推出多项AI功能,会不会引发新的隐私担忧?

    让谷歌很"伤心"的是,相比苹果iOS系统的统一,Android系统的碎片化态势实在太严重了.就像已经发布一年多的Android O,其占有率仅有4.6%.主要是因为很多手机厂商都会 ...

  8. 深入探讨Java中的异常与错误处理

    Java中的异常处理机制已经比较成熟,我们的Java程序到处充满了异常的可能,如果对这些异常不做预先的处理,那么将来程序崩溃就无从调试,很难找到异常所在的位置.本文将探讨一下Java中异常与错误的处理 ...

  9. vue-cli3初始化项目

    1 npm install -g @vue/cli 创建配置 创建 1 vue create vue-app 选择配置 1234 ? Please pick a preset: (Use arrow ...

  10. IOC初始化销毁的2种实现方式

    IOC初始化销毁的2种实现方式 1.bean内调用init-method 和destroy-method 2.通过注解实现@PostConstruct 和@PreDestroy ----------- ...