欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

函数式编程

最编程 2024-08-04 13:40:10
...

文章目录

    • 函数式编程是什么
    • 为什么选择函数式编程
    • 前端视角看编程范式
    • 纯函数与副作用
      • 纯函数、副作用的定义
      • 函数为何非纯不可?
    • 函数是一等公民
      • “一等公民”的 JS 函数
      • “一等公民”的本质:JS 函数是可执行的对象
    • JS 世界的“不可变数据”
      • 不可变的值,可变的引用内容
      • 为什么函数式编程不喜欢可变数据
      • “不可变”不是要消灭变化,而是要控制变化
      • 不可变数据の实践原则:拷贝,而不是修改
    • 持久化数据结构
      • Immutable.js
      • Git commit
      • 数据共享
    • Immer.js
      • Immer.js 是如何工作的
      • Produce 关键逻辑抽象
      • Produce 原理:将拷贝操作精准化
      • Produce 逐层拷贝
      • 按需变化
    • 因为DRY,所以HOF
      • 什么是DRY?
      • DRY 原则的 JS 实践:HOF
      • WHY HOF?
    • Reduce:函数式语言的“万金油”
      • Reduce 工作流分析
      • 用 `reduce()` 推导 `map()`
      • `reduce()` 映射了函数组合思想
    • 声明式数据流
      • 链式调用
      • 链式调用的前提
      • 回调地狱
    • 深入函数组合思想
      • 借助 reduce 推导函数组合
      • 借助 reduce 推导 pipe
      • compose:倒序的 pipe
      • Why Compose?
    • “多元函数”解决方案
      • 函数组合链中的多元参数问题
      • 求解多元参数问题
      • 偏函数 VS 柯里化
      • 函数逻辑复用问题
    • 柯里化:构造一个通用函数
      • 柯里化解决 multiply 函数的参数问题
      • 柯里化的“套路”
      • 通用柯里化函数:自动化的“套娃”
      • 柯里化解决组合链的元数问题

函数式编程是什么

函数式编程,是“优美范式”,更是“进阶套路”

函数式编程是一种编程范式。

编程范式可以理解为编程的风格/方式,它决定了我们将以一种什么样的方法和规范去组织自己的代码,是一门研究“如何写代码”的学问。

对前端工程师来说,我们可能接触过的编程范式有以下几种:

  • 命令式编程
  • 面向对象编程
  • 函数式编程

我们之所以“有可能”接触过这 3 种范式,是因为 JS 是多范式的语言

为什么选择函数式编程

为什么 React 选择逐步告别 Class 组件,拥抱“函数组件”?

为什么 Redux 的 Reducer 必须是“纯函数”?

为什么各路前端框架、小程序框架总在强调“不可变值”?

为什么各厂的前端面试中,无论是问答题目还是 coding-test,都开始越来越频繁地考察高阶函数、柯里化、偏函数、compose/pipe 等函数式能力?

归根结底,是因为函数式思想正在以越来越快的速度渗透前端生态

React、Redux、Ramda.js、Lodash/fp、Immutable.js、Immer.js这些现代前端框架/库中,有的借助函数式思想实现了部分功能,有的则整个基于函数式来设计自身的软件架构。

函数式编程和设计模式一样,都致力于解决软件设计的复杂度的问题,着力点都在于“如何应对变化”,引导我们用健壮的代码解决具体的问题用抽象的思维应对复杂的系统。

前端视角看编程范式

对前端工程师来说,我们可能接触过的编程范式有以下几种:

  • 命令式编程
  • 面向对象编程
  • 函数式编程

命令式编程关注的是一系列具体的执行步骤,当你想要使用一段命令式的代码来达到某个目的,你需要一步一步地告诉计算机应该“怎样做”。

与命令式编程严格对立的其实是“声明式编程”:不关心“怎样做”,只关心“得到什么”。

函数式编程是声明式编程的一种。

具体到范式表达上,函数式编程总是需要我们去思考这样两个问题:

  • 我想要什么样的输出?
  • 我应该提供什么样的输入?

一个例子????:

有一个员工信息数据库。现在为了对年龄大于等于 24 岁的员工做生涯指导,需要拉出一张满足条件的员工信息清单,要求清单中每一条信息中间用逗号分隔,并按照年龄升序展示。

把这个需求简单梳理一下,分三步走:

  1. 对列表进行排序
  2. 筛选出 >= 24 岁这个区间内的员工列表
  3. 针对该列表中的每一条员工数据历史,保存到 logText

命令式编程的代码如下所示:

// 这里我mock了一组员工信息作为原始数据,实际处理的数据信息量应该比这个大很多
const peopleList = [
  {
    name: 'John Lee',
    age: 24,
    career: 'engineer'
  },
  {
    name: 'Bob Chen',
    age: 22,
    career: 'engineer'
  },
  {
    name: 'Lucy Liu',
    age: 28,
    career: 'PM'
  },
  {
    name: 'Jack Zhang',
    age: 26,
    career: 'PM'
  },
  {
    name: 'Yan Xiu',
    age: 30,
    career: 'engineer'
  }
]

const len = peopleList.length

// 对员工列表按照年龄【排序】
for(let i=0;i<len;i++) {
  // 内层循环用于完成每一轮遍历过程中的重复比较+交换
  for(let j=0;j<len-1;j++) {
    // 若相邻元素前面的数比后面的大
    if(peopleList[j].age > peopleList[j+1].age) {
      // 交换两者
      [peopleList[j], peopleList[j+1]] = [peopleList[j+1], peopleList[j]]
    }
  }
}

let logText = ''
for(let i=0; i<len; i++) {
  const person = peopleList[i]
  // 【筛选】出年龄符合条件的
  if( person.age >= 24 ) {
    // 从数组中【提取】目标信息到 logText
    const perLogText = `${person.name}'s age is ${person.age}`
    if(i!==len-1) {
      logText += `${perLogText},`
    } else {
      logText += perLogText
    }
  }
}

console.log(logText)

下面再来看看函数式的解法:

// 定义筛选逻辑
const ageBiggerThan24 = (person)=> person.age >= 24

// 定义排序逻辑
const smallAgeFirst = (a, b) => {
  return a.age - b.age
}

// 定义信息提取逻辑
const generateLogText = (person)=>{
  const perLogText = `${person.name}'s age is ${person.age}`
  return perLogText
}

const logText = peopleList.filter(ageBiggerThan24)
                      .sort(smallAgeFirst)
                      .map(generateLogText)
                      .join(',')

console.log(logText)

作为用户不需要了解每个函数内部都执行了哪些语句,仅仅通过函数名就可以推断出来这个调用链做了哪些事情。

此外,声明式代码定义的 ageBiggerThan24sortByAgegetLogText等方法,是可以被复用的。

而命令式代码中的比大小、排序、字符串处理等逻辑,更像是“一锤子买卖”,执行完也就过去了,日后想要实现相同的逻辑,只能靠复制粘贴。

在函数式编程的代码组织模式下,我们关注的不再是具体逻辑的实现,而是对“变换”的组合

那么就 JS 函数式编程而言,以下三个特征是其关键:

  • 拥抱纯函数,隔离副作用
  • 函数是“一等公民”
  • 避免对状态的改变(不可变值)

纯函数与副作用

纯函数是函数式编程的一个最大的前提,也是这坨知识体系的根基

接下来我们探讨一下以下问题:

  • 纯函数、副作用的内涵
  • 纯函数/非纯函数的辨析
  • 从数据流的角度理解“纯”与“不纯”的本质
  • 纯函数解决了什么问题

纯函数、副作用的定义

什么是纯函数?

同时满足以下两个特征的函数,我们就认为是纯函数:

  • 对于相同的输入,总是会得到相同的输出
  • 在执行过程中没有语义上可观察的副作用。

什么是副作用?

如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的”副作用”。

看几个例子????:

下面这个add()函数不是一个纯函数,因为它违背了对于相同的输入,总是会得到相同的输出。只要在全局作用域上改变a和b的值,那么这个函数执行后就没办法得到相同的输出。

let a = 10
let b = 20

function add() {
  return a+b
}

改为纯函数:

let a = 10  
let b = 20

function add(a, b) {
  return a+b
}

简单的改造后,add 函数就能够充分满足纯函数的两个条件了:

  1. 对于相同的输入,总是会得到相同的输出:对于相同的 ab 来说,它们的和总是相等的✅
  2. 在执行过程中没有语义上可观察的副作用:add() 函数除了加法计算之外没有做任何事,不会对外部世界造成额外影响✅

下面这个函数也是一个不纯的函数,console.log() 会在控制台打印一行文字,这改变了浏览器的控制台,属于对外部世界的影响,也就是说 processName 函数在执行过程中产生了副作用

function processName(firstName, secondName) {
  const fullName = `${firstName}·${secondName}`
  console.log(`I am ${fullName}`)
  return fullName
}

processName('约瑟翰', '庞麦郎')

要想把它改回纯函数也非常简单,只需要像这样把副作用摘出去就可以了:

function processName(firstName, secondName) {
  const fullName = `${firstName}·${secondName}`
  return fullName
}

console.log(processName('约瑟翰', '庞麦郎'))

再看一个网络请求函数,它也不是一个纯函数,一个引入了网络请求的函数,从原则上来说是纯不起来的

function getData(url) {
  const response = await fetch(url)
  cosnt { data } = response   
  return data
}

为什么网络请求会使函数变得不纯呢?我们以示例代码中的 get 请求为例来分析一下:

  • 请求获取到的 response 是动态的:需要通过网络请求获取的数据往往是动态的,对于相同的输入,服务端未必能够给到相同的输出
  • 请求可能出错:既然是网络请求,那就一定要考虑失败率的问题。网络拥塞、机房起火、后端删库跑路等等问题都有可能导致请求过程中的 Error未经捕获的 Error 本身就是一种副作用

当请求方法为 postdelete 等具有“写”能力的类型时,网络请求将会执行对外部数据的写操作,这会使函数的“不纯”更进一步。

看了这些例子,让我们来对纯函数的定义做一次总结:

纯函数:输入只能够以参数形式传入,输出只能够以返回值形式传递,除了入参和返回值之外,不以任何其它形式和外界进行数据交换的函数

函数为何非纯不可?

纯函数解决了两个大问题,那就是“不确定性”和“副作用”。

纯函数,高度确定的函数

不纯的函数(Impure function)最直接的问题,就是不确定性

Impure function 的行为是难以预测的;对于同样的输入,Impure function 不能够保证同样的输出。

以测试过程为例:单元测试的主要判断的依据就是函数的输入和输出。

如果对于同样的输入,函数不能够给到确定的输出,测试的难度将会陡然上升。

不确定性也会导致我们的代码难以被调试、数据变化难以被追溯、计算结果难以被复用等等。

总而言之一句话:不确定性意味着风险,而风险是万恶之源。

纯函数,没有副作用的函数

消除副作用,足以解决函数中大多数的不确定性。

此外,副作用的消除还解决了并行计算带来的竞争问题。

不纯的函数有可能访问同一块资源,进而相互影响,引发意想不到的混乱结果。

而纯函数则不存在这种问题,纯函数的计算完全发生在函数的内部,它不会对外部资源产生任何影响,因此纯函数的并行计算总是安全的。

纯函数,更加灵活的函数

不纯的函数是不灵活的

它们只能在某一个特定的上下文里运行,一旦脱离了这个上下文,就会失去预期中的效用。

纯函数则完全不存在这个弊端,因为它太简单了,它除了入参谁也不认,除了计算啥也不干。

因此,纯函数是高度灵活的函数,它的计算逻辑在任何上下文里都是成立的。

纯函数,可以改善代码质量

从研发效率上来看,纯函数的实践,实际上是将程序的“外部影响”和“内部计算”解耦了。

这间接地促成了程序逻辑的分层,将会使得模块的功能更加内聚。

作为程序员在开发中,不再需要去关注函数可能会造成的外部影响,只需要关注函数本身的运算逻辑。

这和设计模式的“单一职责”原则有异曲同工之妙

设计模式中,我们强调将“变与不变”分离,而纯函数强调将计算与副作用分离。

计算是确定性的行为,而副作用则充满了不确定性。这一实践,本质上也是在贯彻“变与不变分离”的设计原则。

这样的逻辑分层将会使得我们的程序更加健壮和灵活,也会促成更加专注、高效的协作。

副作用不是毒药

对于纯函数来说,副作用无疑是地雷、是毒药。

但对于一个完整的程序来说,副作用却至关重要。

函数生产的是数据,这些数据要想作用于外部世界、创造一些真正的改变,就必须借助副作用。

试想,公司为什么要花钱雇程序员?

因为要做网页,这需要程序员操作DOM;因为要 CRUD,这需要程序员操作DB;因为要读写文件,这需要程序员执行 IO…… 如果我们试图把一个业务程序员的简历用一句话概括,那无外乎“精通实现各种副作用”。

老板和客户不会关心你的代码是否优雅,只会关心那些肉眼可见的副作用——页面渲染、网络请求、数据读写等等是否符合预期。

对于程序员来说,实践纯函数的目的并不是消灭副作用,而是将计算逻辑与副作用做合理的分层解耦,从而提升我们的编码质量和执行效率。

纯函数的这些规则并不是为了约束而约束,而是为了追求更高的确定性;

同时引导我们做更加合理的逻辑分层,写出更加清晰、更善于应对变化的代码。

函数是一等公民

如果一门编程语言将函数当做一等公民对待,那么这门语言被称作“拥有头等函数”

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。 ——MDN Web Docs

【划重点】:头等函数的核心特征是“可以被当做变量一样用”。

“可以被当做变量一样用”意味着什么?它意味着:

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

以上三条,就是“函数是一等公民”这句话的内涵。

“一等公民”的 JS 函数

看几个例子????:

JS 函数可以被赋值给一个变量

// 将一个匿名函数赋值给变量 callMe
let callMe = () => {
   console.log('Hello World!')
}

// 输出 callMe 的内容
console.log(callMe)

// 调用 callMe
callMe()

JS 函数可以作为参数传递

咱要是说“JS 函数作为参数传递”,你可能还不太能转过这个弯儿来。

但咱要是说“回调函数”,你肯定一下就来精神了——它可不就是在说回调函数么!

众所周知,回调函数是 JS 异步编程的基础。

在前端,我们常用的事件监听、发布订阅等操作都需要借助回调函数来实现。比如这样:

function consoleTrigger() {
    console.log('spEvent 被触发')
}   

jQuery.subscribe('spEvent', consoleTrigger)

在这个例子中,consoleTrigger 函数就作为 subscribe 函数的第 2 个入参被传递。

而在 Node 层,我们更是需要回调函数来帮我们完成与外部世界的一系列交互(也就是所谓的“副作用”)。

这里举一个异步读取文件的例子:

function showData(err, data){
    if(err) {
      throw err
    }
    // 输出文件内容
    console.log(data);
})

// -- 异步读取文件
fs.readFile(filePath, 'utf8', showData)

在这个例子中, showData 函数作为 readFile 函数的第 3 个入参被传递。

JS 函数可以作为另一个函数的返回值

函数作为返回值传递,基本上都是馋人家闭包的特性。比如下面这个例子:

function baseAdd(a) {
  return (b) => {
    return a + b
  };
};

const addWithOne = baseAdd(1)

// .... (也许在许多行业务逻辑执行完毕后)

const result = addWithOne(2)

显然,add 函数想要做一个加法,但是在只能够确认其中一个加数(a)的时候,它并不急于立刻做这个加法。

怎么办呢?先把这个已经确定的加数(a)以【闭包中的*变量】的形式存起来,然后返回一个待执行的加法函数。等什么时候第二个加数也确定了,就可以立刻执行这段逻辑。

“一等公民”的本质:JS 函数是可执行的对象

为什么 JS 中的函数这么牛x,可以为所欲为呢?本质上是因为它不仅仅是个函数,它还是个对象

对象能干啥?咱对照“一等公民”的特征来一个一个看一下:

  1. 能不能赋值给变量?能!
  2. 能不能作为函数参数传递?能!
  3. 能不能作为返回值返回?能!

到这里我们不难看出,“First-Class Function(头等函数)” 的本质,其实是"First-Class Object(头等对象)”。

JS 函数的本质,就是可执行的对象

JS 世界的“不可变数据”

JS中的数据类型,整体上来说只有两类:值类型(也称基本类型/原始值)和引用类型(也称复杂类型/引用值)。

其中值类型包括:String、Number、Boolean、null、undefined、Symbol

这类型的数据最明显的特征是大小固定、体积轻量、相对简单。

而排除掉值类型,剩下的 Object 类型就是引用类型(复杂类型)

这类数据相对复杂、占用空间较大、且大小不定。

保存值类型的变量是按值访问的, 保存引用类型的变量是按引用访问的。

这两类数据之间最大的区别,在于变量保存了数据之后,我们还能对这个数据做什么

不可变的值,可变的引用内容

值类型的数据无法被修改,当我们修改值类型变量的时候,本质上会创建一个新的值。

let a = 1
let b = a

// true
a === b

b = 2

// false
a === b

当我把 a 赋值给 b 的时候,相当于在内存里开辟了一个新的坑位,然后将此时此刻的 a 值拷贝了一份、塞了进去。从这一刻开始,ab 各据一坑,界限分明,谁也不会再影响谁。

当修改 b 值的时候,相当于解除了 b 变量和旧的 b 值(也就是 1)之间的关联关系,然后建立了 b 变量和新的 b 值(也就是 2)之间的关联关系。此时 b 的值已经发生了变化,但 a 坑里的 1 纹丝不动。

在这整个过程中,出现的值有三个:a 值 = 1、b 值(初始值) = 1、b 值(修改后) = 2

1、1、2 这三个数字从创建开始就不会再发生任何改变

我们修改 b 值的时候,其实是在修改数字 1、2 与“b 变量”之间的关系,而并不是在修改数字本身。

像数字类型这样,自创建起就无法再被修改的数据,我们称其为“不可变数据”。

对应到 JS 的数据分类上,“值类型”数据均为不可变数据。

但引用类型就没有那么好对付了。在引用本身不变的情况下,引用所指向的内容是可以发生改变的。

const a = {
  name: 'ruimengmeng',
  age: 30
}

const b = a


// true 
a === b 

b.name = 'mengmengda'   
 
// true
a === b 

对于引用类型来说,当把 a 对象赋值给 b 时,并不会发生“开辟一个新的 b 对象坑位、放入一份 a 对象的副本”这种事——JS 会直接把 a 的引用赋值给 b。

引用类型的赋值过程本质上是给同一块数据内容起一个新的名字。赋值结束后,a 和 b 都会指向内存中的同一块数据。

对于引用类型来说,我们总是可以在数据被创建后,随时修改数据的内容。

像这种创建后仍然可以被修改的数据,我们称其为“可变数据”。

为什么函数式编程不喜欢可变数据

可变数据使函数行为变得难以预测

可变数据带来的最根本的问题——不确定性

可变数据会使数据的变化变得隐蔽,进而使函数的行为变得难以预测。

在函数式编程这种范式下,我们校验一个函数有效性的关键依据,永远是“针对已知的输入,能否给出符合预期的输出”,这样的校验非常清晰、且容易实现。

而可变数据的出现则将会使函数的作用边界变得模糊,进而导致使用者、甚至开发者自身都难以预测它的行为最终会指向什么样的结果。同时,这也会大大增加测试的难度。

可变数据使函数复用成本变高

可变数据的存在,要求我们不得不在调用一个函数之前,先去了解它的逻辑细节、定位它对外部数据的依赖情况和影响情况,由此来确保调用动作的安全性。

而当我们使用某一个函数的时候,我们会默认它是一个黑盒

我们关注的都是这个函数的效用、函数的输入与输出,而不会去关注它的实现细节

因此,我们有必要确保,这个黑盒是可靠的、受控的。

一个可靠、受控的黑盒,应该总是将变化控制在盒子的内部,而不去改变盒子外面的任何东西

要想做到这一点,就必须把可变数据从我们的函数代码里铲除干净。

“不可变”不是要消灭变化,而是要控制变化

大家知道,我们现代前端应用的复杂度整体是比较高的,其中最引入注目的莫过于“状态的复杂度”。

“状态”其实就是数据。

一个看似简单的页面,背后可能就有几十上百个状态需要维护

如果没有状态之间的相互作用、相互转化,又怎能将精彩纷呈的前端交互呈现给用户呢?

程序失去变化,宛如人类失去灵魂。

所以说,消灭变化是不可能的事情,也是万万不可的事情。

我们真正要做的,是控制变化,确保所有的变化都在可预期的范围内发生,从而防止我们的程序被变化“偷袭”。

不可变数据の实践原则:拷贝,而不是修改

先看一个修改的例子:

function dynamicCreateJob(baseJob) {
  let newJob = baseJob
  if(isHighPosition()) {
      newJob.level = 10 
  }
  return newJob
}

这个粗糙版本显然并没有遵循“不可变数据”的原则——它直接在 baseJob 的对象本体上进行了篡改。

再看一个最常用的拷贝方式:

function dynamicCreateJob(baseJob) {
  // 创建一个 baseJob 的副本
  let newJob = {...baseJob}
  if(isHighPosition()) {
      newJob.level = 10 
  }
  return newJob
}

通过拷贝,我们顺利地将变化控制在了 dynamicCreateJob() 函数内部,避免了对全局其它逻辑模块的影响。

用拷贝代替修改后,baseJob 对于 dynamicCreateJob() 函数来说,成为了一个彻头彻尾的只读数据

对于函数式编程来说,函数的外部数据是只读的,函数的内部数据则是可写的。

对于一个纯函数来说,它需要把自己的入参当做只读数据,它也需要把自己可访问的所有全局变量/*变量当做只读数据。 有且仅有这些外部数据,存在【只读】的必要。

不可变数据的两种最直接的实践思路:

对于值类型数据,可以使用 const 来确保其不变性;

对于引用类型数据,可以使用拷贝来确保源数据的不变性。

这其中,引用类型数据的不可变性值得我们再三思考——有没有比拷贝更加高效的解法呢?

答案当然是有啦,接下来我们就学习一下不可变数据的进阶解法中最有名的一个——持久化数据结构

持久化数据结构

通过上面的学习我们已经知道,用拷贝代替修改,是确保引用类型数据不可变性的一剂良药。

然而,拷贝并非一个万能的解法。拷贝意味着重复,而重复往往伴随着着冗余。

当数据规模大、数据拷贝行为频繁时,拷贝将会给我们的应用性能带来巨大的挑战。

拷贝出来的冗余数据将盘踞大量的内存,挤占其它任务的生存空间

此外,拷贝行为本身也是需要吃 CPU 的,持续而频繁的拷贝动作,无疑将拖慢应用程序的反应速度

因此,对于状态简单、逻辑轻量的应用来说,拷贝确实是一剂维持数据不可变性的良药。

但是对于数据规模巨大、数据变化频繁的应用来说,拷贝意味着一场性能灾难。

接下来就来学习一下什么是持久化数据结构。

先看两个持久化数据结构实际使用的例子:

Immutable.js

React 开发者对它应该不会感到陌生。 Immutable.js 是持久化数据结构在前端领域影响最深远的一次实践。

// 引入 immutable 库里的 Map 对象,它用于创建对象
import { Map } from 'immutable'

// 初始化一个对象 baseMap
const originData = Map({
  name: 'ruimengmeng',
  hobby: 'coding',
  age: 777
})

// 使用 immutable 暴露的 Api 来修改 baseMap 的内容
const mutatedData = originData.set({
  age: 77.7
})

// 我们会发现修改 baseMap 后将会返回一个新的对象,这个对象的引用和 baseMap 是不同的
console.log('originData === mutatedData', originData === mutatedData)

Immutable.js 提供了一系列的 Api,这些 Api 将帮助我们确保数据的不可变性。

从代码上来看,它省掉了我们手动拷贝的麻烦。

从效率上来说,它在底层应用了持久化数据结构,解决了暴力拷贝带来的各种问题。

Git commit

在创建 commit 时,git 会对整个项目的所有文件做一个“快照”。

“快照”并不是对当前所有文件的一次拷贝而已,“快照”记录的不是文件的内容,而是文件的索引

当 commit 发生时, git 会保存当前版本所有文件的索引。

对于那些没有发生变化的文件,git 保存他们原有的索引;

对于那些已经发生变化的文件,git 会保存变化后的文件的索引。

也就是说,git 记录“变更”的粒度是文件级别的。

它会同时保有新老两份文件,不同的 version,索引指向不同的文件。

数据共享

和 git “快照”一样,持久化数据结构的精髓在于“数据共享”。

数据共享意味着将“变与不变”分离,确保只有变化的部分被处理,而不变的部分则将继续留在原地、被新的数据结构所复用。

不同的是,在 git 世界里,这个“变与不变”的区分是文件级别的;

而在 Immutable.js 的世界里,这个“变与不变”可以细化到数组的某一个元素、对象的某一个字段。

举个例子:

假如借助 Immutable.js 基于 A 对象创建出了 B 对象。

A 对象有 4 个字段:

const dataA = Map({
  do: 'coding',
  age: 666,
  from: 'a',
  to: 'b'
})

B 对象在 A 对象的基础上修改了其中的某一个字段(age):

// 使用 immutable 暴露的 Api 来修改 baseMap 的内容
const dataB = dataA.set({
  age: 66.6
})

那么 Immutable.js 仅仅会创建变化的那部分(也就是创建一个新的 age 给 B),并且为 B 对象生成一套指回 A 对象的指针,从而复用 A 对象中不变的那 3 个字段。

为了达到“数据共享”,持久化数据结构在底层依赖了一种经典的基础数据结构,那就是 Trie(字典树)。

当我们创建对象 B 的时候,我们可以只针对发生变化的 age 字段创建一条新的数据,并将对象 B 剩余的指针指回 A 去

img

Immer.js

“数据不可变性如何在前端业务中落地?”

这道题目的答案多年来几乎是“固定”的——Immutable.js/持久化数据结构

Immutable.js/持久化数据结构真的是唯一的答案吗?

Immutable.js 对于前端函数式编程来说,有划时代的意义,但它终究也只是实现 Immutability 的一种途径。

在活跃的函数式社区中,优秀的 Immutability 实践还有很多——比如,Immer.js

用 Immer.js 写代码,不需要操心深拷贝浅拷贝的事儿,更不需要背诵记忆 Immutable.js 定义的一大堆 API

所需要做的仅仅是在项目里轻轻地 Import 一个 produce:

import produce from "immer"

// 这是数据
const baseState = [
    {
        name: "aaa",
        age: 99
    },
    {
        name: "bbb",
        age: 100
    }
]

// 定义数据的写逻辑
const recipe = draft => {
    draft.push({name: "ccc", age: 101})
    draft[1].age = 102
}

// 借助 produce,执行数据的写逻辑
const nextState = produce(baseState, recipe)

这个 API 里有几个要素:

  • (base)state:源数据,是我们想要修改的目标数据
  • recipe:一个函数,我们可以在其中描述数据的写逻辑
  • draft:recipe 函数的默认入参,它是对源数据的代理,我们可以把想要应用在源数据的变更应用在 draft 上
  • produce:入口函数,它负责把上述要素串起来。

Immer.js 是如何工作的

Immer.js 实现 Immutability 的姿势非常有趣——它使用 Proxy,对目标对象的行为进行“元编程”。

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。 ——MDN

Proxy 是 JS 语言中少有的“元编程”工具。所谓“元编程”,指的是对编程语