发布于 2026-01-06 0 阅读
0

使用 Ramda 在 Javascript 中实现功能性镜头

使用 Ramda 在 Javascript 中实现功能性镜头

透镜机制提供了一种将对象形状与其操作逻辑解耦的方法。它利用 getter/setter 模式“聚焦”到对象的某个子部分,从而隔离该子部分进行读写操作,而不会改变对象本身。

这可以带来多重好处。我们先从镜片的形状解耦特性说起。

将对象的形状解耦,可以方便日后对数据进行重塑,同时最大限度地减少对应用程序中其他代码的影响。例如,考虑一个代表人的对象。

const person = {
  firstName: 'John',
  lastName: 'Doe'
}

现在想象一下,该对象的形状发生变化,使得 ` firstNameand`lastName属性被替换为一个名为 `id` 的单一属性,name该属性本身是一个包含 ` firstand`属性的对象last

const person = {
  name: {
    first: 'John',
    last: 'Doe'
  }
}

任何与该对象交互的代码现在都需要更新以反映对象结构的变化。面向对象编程 (OOP) 通过使用类来避免这种情况,类隐藏了数据的内部结构,并通过 getter/setter API 提供访问。如果类的内部数据结构发生变化,只需要更新该类的 API 即可。Lenses 为普通对象提供了同样的优势。

透镜的另一个优势在于能够在不改变对象本身的情况下对其进行写入。数据不被修改当然是函数式编程(FP)的核心原则之一。问题在于,你处理的数据越大越复杂,就越难在不修改对象的情况下更改深层嵌套的数据。正如我们稍后将看到的,无论数据多么复杂,透镜只需几行代码就能简化这一过程。

最后,透镜是可柯里化可组合的,这使得它们非常适合函数式编程范式。我们将在后面的例子中使用这两个特性。

在Ramda的帮助下,让我们创建一个与人合作的视角firstName

const person = {
  firstName: 'John',
  lastName: 'Doe'
}

我们将从 Ramda 最通用的镜头创建函数lens()开始。如前所述,镜头使用 getter/setter 模式来读取和写入对象的数据。让我们先来创建这些 getter/setter。

const getFirstName = data => data.firstName // getter
const setFirstName = (value, data) => ({    // setter
  ...data, firstName: value
})

然后是镜头本身:

const firstNameLens = lens(getFirstName, setFirstName)

lens()函数接受两个参数,分别是我们之前定义的 getter 和 setter。然后,这个镜头就可以应用到对象上了,在这个例子中,对象是 person 对象。但在应用之前,我想指出几点。

  • 该透镜本身不引用任何数据。这使得该透镜可重用,并可应用于任何数据,只要该数据符合其 getter 和 setter 参数所需的格式即可。换句话说,该透镜仅在应用于具有属性的数据时才有效firstName,该属性可以是人、员工,甚至是宠物。
  • 由于透镜不绑定任何特定数据,因此需要将要操作的数据传递给 getter 和 setter 函数。透镜会获取它所应用的对象,并自动将其传递给提供的 getter 和 setter 函数。
  • 由于函数式编程不允许修改数据,因此 setter 必须返回应用该透镜所作用的数据的更新副本。在本例中,我们将透镜应用于一个 Person 对象,因此透镜的 setter 函数将返回 Person 对象的副本。

让我们来看看如何使用 Ramda 的view()函数通过镜头来读取物体:

view(firstNameLens, person) // => "John"

view()函数接受两个参数:一个透镜和一个要应用该透镜的对象。然后,它执行透镜的 getter 函数,返回透镜聚焦的属性值;在本例中,该属性值为firstName

值得注意的是,它view()是可柯里化的,也就是说,我们可以view()先配置镜头,然后再提供对象。如果您想view()使用 Ramda 的`compose()``pipe()`或其他各种组合函数与其他函数组合,这将特别方便。

const sayHello = name => `Hello ${name}`

const greetPerson = pipe(
  view(firstNameLens),
  sayHello
);

greetPerson(person) // => "Hello John"

现在让我们看看如何使用 Ramda 的set()函数,通过镜头向对象写入数据:

set(firstNameLens, 'Jane', person) 
// => {"firstName": "Jane", "lastName": "Doe"}

set()函数还接受一个透镜和一个要应用该透镜的对象,以及一个用于更新焦点属性的值。如前所述,我们会得到一个焦点属性已更改的对象副本。而且,与 `getFactory()` 函数类似view()set()该函数也是可柯里化的,允许你先使用透镜和值对其进行配置,然后再提供数据。

还有第三个镜头应用函数叫做`over()`,它的作用与 `over()` 类似set(),区别在于它不是直接提供更新后的值,而是提供一个用于更新值的函数。提供的函数会接收镜头 getter 的结果。假设我们要把人的名字大写firstName

over(firstNameLens, toUpper, person)
// => {"firstName": "JOHN", "lastName": "Doe"}

我们还使用了 Ramda 的toUpper()函数。它相当于:

const toUpper = value => value.toUpperCase()

我想回到我们最初的 getter 和 setter 函数,看看如何用更简洁的方式编写它们。

const getFirstName = data => data.firstName // getter
const setFirstName = (value, data) => ({    // setter
  ...data, firstName: value
})

既然我们使用 Ramda 来创建镜头,那么在代码的其他部分也利用 Ramda 的函数就显得顺理成章了。具体来说,我们将使用 Ramda 的prop()函数来替代我们的 getter 方法,使用assoc()函数来替代我们的 setter 方法。

prop()函数接受一个属性名和一个对象,并返回该对象上对应属性名的值。它的工作方式与我们的 getter 函数非常相似。

prop('firstName', person) // => "John"

同样,与大多数 Ramda 函数一样,prop()它是可柯里化的,允许我们使用属性名称对其进行配置,并在稍后提供数据:

const firstNameProp = prop('firstName')
firstNameProp(person) // => "John"

当与镜头一起使用时,我们可以为其配置一个属性名称,让镜头稍后传递其数据。

lens(prop('firstName'), ...)

这也是无点编程风格或隐式编程的一个例子,因为我们在逻辑中没有定义一个或多个参数(在本例中是“人”)。如果你不习惯函数式编程中常见的这种风格,可能很难理解它的工作原理,但如果将其分解开来,就会更容易理解……

当向多参数柯里化函数传递单个参数时,它会返回一个新函数,该函数接受剩余的参数。只有在所有参数都提供后,函数体才会执行并返回结果。因此,如果prop()仅使用属性名称进行配置,我们将收到一个接受数据参数的新函数。这与镜头获取器(lens getter)的本质完全吻合:一个接受数据参数的函数。

assoc()函数的工作方式相同,但它是为写入而非读取而设计的。此外,它还会返回正在写入对象的副本,这正是镜头设置器所需的功能。

assoc('firstName', 'Jane', person)
// => {"firstName": "Jane", "lastName": "Doe"}

与镜头一起使用时,我们可以assoc()只使用属性名称进行配置,让set()函数将值和数据柯里化传递过去。

const firstNameLens = lens(prop('firstName'), assoc('firstName'))

view(firstNameLens, person) // => "John"
set(firstNameLens, 'Jane', person)
// => {"firstName": "Jane", "lastName": "Doe"}

以上是透镜的基础知识,但 Ramda 中还有其他更专业的透镜创建函数。具体来说,包括 ` lensProp()``lensIndex()``lensPath()`。这些函数可能是你在创建透镜时最常用的。通用函数lens()仅在需要进行高度自定义的透镜创建时才会使用。接下来,我们将逐一介绍这些专业的透镜创建函数。

lensProp()函数接受一个参数:属性名称。

const lastNameLens = lensProp('lastName')

就是这样!只需要属性名称就能生成相应的getter和setter:

view(lastNameLens, person) // => "Doe"
set(lastNameLens, 'Smith', person)
// => {"firstName": "John", "lastName": "Smith"}

lensIndex()函数的工作方式与之前类似,lensProp()但它是专门用于聚焦数组索引的,因此,你需要传递索引而不是属性名称。让我们向 Person 对象添加一个数据数组来测试一下。

const person = {
  firstName: 'John',
  lastName: 'Doe',
  phones: [
    {type: 'home', number: '5556667777'},
    {type: 'work', number: '5554443333'}
  ]
}

然后,在安装镜头时……

const firstPhoneLens = lensIndex(0)

view(firstPhoneLens, person.phones)
// => {"number": "5556667777", "type": "home"}

set(
  firstPhoneLens, 
  {type: 'mobile', number: '5557773333'}, 
  person.phones
)
// => [
//  {"number": "5557773333", "type": "mobile"}, 
//  {"number": "5554443333", "type": "work"}
//]

注意,应用镜头时我们需要传入一个参数person.phones。虽然这种方法可行,但并不理想,因为现在我们需要在通用应用程序代码中依赖对象的形状信息,而不是将其隐藏在镜头中。此外,使用函数应用镜头时set(),我们得到的是手机数组,而不是人员对象。这强调了无论你向镜头应用传入什么对象,你得到的都是相同的对象。下一步很可能是将新的手机数组合并回人员对象。当然,这需要以非修改的方式进行……Ramda 可以轻松处理这一点。然而,最好是完全不需要这一步。这就引出了第三个专用镜头,lensPath()它旨在聚焦于嵌套数据。

const homePhoneNumberLens = lensPath(['phones', 0, 'number'])

view(homePhoneNumberLens, person) // => "5556667777"
set(homePhoneNumberLens, '5558882222', person)
// => {
//  "firstName": "John", "lastName": "Doe"
//  "phones": [
//    {"number": "5558882222", "type": "home"}, 
//    {"number": "5554443333", "type": "work"}
//  ]
//}

如您所见,lensPath()该函数接受一个数组,其中包含指向我们想要聚焦的嵌套数据的路径段。每个路径段可以是属性名或索引。由于我们传入的是根 person 对象,因此会返回一个完整的 person 对象副本,其中家庭电话号码已被更改。在我看来,这正是 Lens 功能真正大放异彩的地方。想象一下,如果我们想set()用普通的 JavaScript 实现上述函数的结果,即使使用最新的特性,例如展开和解构赋值,最终结果也可能如下所示:

const [homePhone, ...otherPhones] = person.phones
const updatedPerson = {
  ...person,
  phones: [
    {...homePhone, number: '5558882222'},
    ...otherPhones
  ]
}

与使用透镜的两条线示例相比,这工作量相当大!

镜头最强大的功能之一是能够与其他镜头组合使用。这使得你可以利用现有镜头构建出新的、更复杂的镜头:

const phonesLens = lensProp('phones')
const workPhoneLens = lensIndex(1)
const phoneNumberLens = lensProp('number')

const workPhoneNumberLens = compose(
  phonesLens, 
  workPhoneLens, 
  phoneNumberLens
)

view(workPhoneNumberLens, person) // => "5554443333"

最终效果与使用单个镜头并无太大差别lensPath()。事实上,如果我不需要关注个体phonesLensworkPhoneLens在其他情况下,我可能也会直接使用单个镜头lensPath()。然而,这种方法的优点在于,没有哪个镜头能够完全掌握人物的完整形状。相反,每个镜头只负责跟踪自身形状的各个部分,从而减轻了合成中下一个镜头的负担。例如,如果我们把属性名称改为phonesphoneList我们只需要更新负责该形状部分的镜头(phoneLens),而无需更新恰好与该路径重叠的多个镜头。

以上就是使用 Ramda 在 Javascript 中实现函数式透镜的特性和优势概述。

文章来源:https://dev.to/devinholloway/function-lenses-in-javascript-with-ramda-4li7