函数式编程是一种编程范式。相对命令式编程的过程思维,函数式编程更侧重于结果思维。对于每个函数调用,不必关注内部的执行细节,而只需要关注函数的输入与输出。

纯函数与副作用

实践纯函数的目的不是消灭副作用,而是将逻辑与副作用做合理的分层解耦,从而提升编码质量与执行效率。

纯函数

纯函数是输入输出数据流全是显式的函数。

数据以入参的形式传入,即为显式输入数据流;数据以返回值的形式输出,即为显式输出数据流。

显式数据流意味着函数除了入参和返回值之外,不以任何其他形式与外界进行数据交换

因此,当函数的入参相同时,得到的结果总是一致的。

纯函数的实践,实际上是将程序的外部影响内部计算进行了解耦,间接促成了程序逻辑的分层,使得模块功能更加内聚。

副作用

副作用是指,如果函数除了计算之外,还对其执行上下文、执行宿主等外部环境造成了一些其他影响,则这些影响就是副作用。

纯函数具有显式的输入输出流,而导致函数具有副作用的则是隐式数据流。以下是两个隐式数据流的 case:

  1. 隐式数据输入

ab两个变量并没有以入参的形式传入add(),而是被定义在全局作用域下。因此,当ab发生变化时,即使add()的入参没有变化(为空),输出结果也会被改变。

const a = (b = 1);
const add = () => a + b;
  1. 隐式数据输出

add函数中,除了完成了计算的工作外,还进行了 console 输出,这种和函数本身执行目的无关的行为属于隐式数据输出。

const add = (a, b) => {
  console.log("add");
  return a + b;
};

函数是一等公民

当一门编程语言的函数可以被当作变量一样使用时,则称该门语言拥有头等函数(被当作一等公民对待的函数)。

能被当作变量使用,意味着:

  1. 可以被当作参数传递给其他函数(回调函数)
  2. 可以作为另一个函数的返回值(闭包)
  3. 可以被赋值给一个变量

JS 中,函数可以被当作变量使用,本质上是因为它是一个可执行的对象。

数据可变性

值类型数据均为不可变数据。包括 6 个基本数据类型(StringNumberBooleannullundefinedSymbol

而对于引用类型,创建后仍可以修改数据,为可变数据。

可变数据对于函数式编程而言是危险的,因为函数可能会改变入参的可变数据,数据的变化变得隐蔽,从而使函数行为变得难以预测。

此外,可变数据的存在,使函数复用成本变高。它要求我们需要先了解函数的逻辑细节,定位它对外部数据的依赖和影响,确保调用动作的安全性。但实际上我们在使用函数时是默认该黑盒是可靠的,并不会关注实现细节,所以我们有必要确保该黑盒是可靠的、受控的。

所以,如果想写出好的函数式代码,需要确保数据的不可变性。这要求我们将变化控制在函数的内部,确保所有变化都在可预期的范围内发生,而不去改变函数外的任何东西。

拷贝,而非修改

值类型数据天然存在不可变性,此处讨论 JS 的数据不可变性,也就是讨论如何保证引用类型数据的不可变性。

JS 的关键字const用于定义一个不能被 reassign 的变量,但由于修改引用类型变量时,并没有将新的对象 reassign 给引用类型变量,而是用.运算符来访问并修改既有对象的其中一个属性,所以不能保证引用数据类型的不变性。

const people = {
  name: "xxx",
  age: 18,
};
people.age = 19; // 并不会报错

虽然const无法保证引用类型的不可变性,但是我们可以通过拷贝该引用类型、修改其拷贝的副本,达到不影响入参的引用类型变量的目的。

而在 JS 中实现引用类型数据的拷贝,一般有如下手段:

  1. 拓展运算符
  2. Object.assign
  3. slice / concat / map (针对数组)
  4. lodash 的 deepClone

持久化数据结构

若数据规模庞大、数据变化频繁,拷贝行为将对性能造成较大影响。而持久化数据结构避开了拷贝的弊端。

git在创建commit时,会对整个项目的所有文件做一个快照。此处快照并非为当前文件创建一个完整的拷贝,而是记录当前文件的索引。

commit发生时,git会保存当前版本所有文件的索引。对于未发生变化的文件,git将沿用其原有的索引;对于已经发生变化的文件,git会记录变化后的文件索引。

而持久化数据结构的核心思想和快照是一致的:

  1. 快照保存文件索引,而不保存文件本身
  2. 变化的文件将拥有新的存储空间和新的索引,不变的文件将永远呆在原地。

持久化数据结构的精髓在于数据共享,意味着将变与不变分离,确保只有变化的部分被处理,而不变的部分被复用。

Immutable.js被开发团队 FB 定位为实现持久化数据结构的库。在Immutable.js的世界里,这种变与不变可以细化到数组的某一元素、对象的某一字段。

假如现在借助Immutable.js,在data对象的基础上修改了age创建出了cloneData,则Immutable.js仅会创建变化的那部分,并为cloneData对象生成一套指回data的指针,从而复用data对象中不变的剩余字段。

为了达到这种数据共享的效果,持久化数据结构在底层依赖了字典树。

当创建cloneData时,可以只针对发生变化的字段创建一条新的数据,再将cloneData剩余字段的指针指回data。如此,cloneData已经区分于data成为一个新的对象、具备一个新的索引。但是相对于直接拷贝源数据,cloneData通过和data共享不变的那部分数据,成功提升了管理数据的效率。

Proxy 代理模式

Immutability的解决方案并非只有持久化数据结构,譬如Immer.jsImmutability的实践便是使用了 Proxy,对目标对象的行为进行元编程,实现了数据的不可变性。

Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义。如属性查找、复制、枚举、函数调用等。其使用范例如下:

// 定义一个 programmer 对象
const programmer = {
  name: "xiuyan",
  age: 30,
};

// 定义这个对象的拦截逻辑
const proxyHandler = {
  // obj 是目标对象, key 是被访问的键名
  get(obj, key) {
    if (key === "age") return 100;
    return obj[key];
  },
};

// 借助 Proxy,将这个对象使用拦截逻辑包起来
const wrappedProgrammer = new Proxy(programmer, proxyHandler);

// 'xiuyan'
console.log(wrappedProgrammer.name);
// 100
console.log(wrappedProgrammer.age);

而使用Immer.js,只需要 import 一个名为produce的 api。它的工作原理是将拷贝操作精准化,严格控制了拷贝的时机:produce借助Proxy,将拷贝动作发生的时机和setter函数的触发时机牢牢绑定。仅当写操作发生时,拷贝动作才会进行,setter方法就会被逐层触发,呈现逐层浅拷贝的效果,间接地实现了数据在新老对象间的共享。

produce不仅会拦截setter,也会拦截getter。通过对getter的拦截,produce可以按需地对被访问到的属性进行懒代理:访问得有多深,代理逻辑就能走多深;而所有被代理的属性,都会具备新的setter方法。

对于 Immutable.js 来说,它通过构建一套原生 JS 无法支持的 Trie 数据结构,最终实现了树节点的按需创建。

对于 Immer.js 来说,它借助 Proxygetter 函数实现了按需代理,借助 Proxysetter 函数实现了对象属性的按需拷贝。

Immer.js 的简易实现

Immer.js的核心 api——produce的一个简易版的复现 demo 如下(完整版Immer.js的浅拷贝是可递归的):

function produce(base, recipe) {
  // 预定义一个 copy 副本
  let copy;
  // 定义 base 对象的 proxy handler
  const baseHandler = {
    set(obj, key, value) {
      // 先检查 copy 是否存在,如果不存在,创建 copy
      if (!copy) {
        copy = { ...base };
      }
      // 如果 copy 存在,修改 copy,而不是 base
      copy[key] = value;
      return true;
    },
  };

  // 被 proxy 包装后的 base 记为 draft
  const draft = new Proxy(base, baseHandler);
  // 将 draft 作为入参传入 recipe
  recipe(draft);
  // 返回一个被“冻结”的 copy,如果 copy 不存在,表示没有执行写操作,返回 base 即可
  // “冻结”是为了避免意外的修改发生,进一步保证数据的纯度
  return Object.freeze(copy || base);
}

// 调用
// 这是我的源对象
const baseObj = {
  a: 1,
  b: {
    name: "修言",
  },
};

// 这是一个执行写操作的 recipe
const changeA = (draft) => {
  draft.a = 2;
};

// 这是一个不执行写操作、只执行读操作的 recipe
const doNothing = (draft) => {
  console.log("doNothing function is called, and draft is", draft);
};

// 借助 produce,对源对象应用写操作,修改源对象里的 a 属性
const changedObjA = produce(baseObj, changeA);

// 借助 produce,对源对象应用读操作
const doNothingObj = produce(baseObj, doNothing);

// 顺序输出3个对象,确认写操作确实生效了
console.log(baseObj);
console.log(changedObjA);
console.log(doNothingObj);

// 【源对象】 和 【借助 produce 对源对象执行过读操作后的对象】 还是同一个对象吗?
// 答案为 true
console.log(baseObj === doNothingObj);
// 【源对象】 和 【借助 produce 对源对象执行过写操作后的对象】 还是同一个对象吗?
// 答案为 false
console.log(baseObj === changedObjA);
// 源对象里没有被执行写操作的 b 属性,在 produce 执行前后是否会发生变化?
// 输出为 true,说明不会发生变化
console.log(baseObj.b === changedObjA.b);

参考

JavaScript 函数式编程实践指南