JS `Optic` 库 (如 `monocle-ts`): `Lenses`, `Prisms`, `Traversals` 在不可变数据中的应用

各位靓仔靓女,晚上好!我是你们的老朋友,今天咱们来聊聊一个让你的代码更优雅、更强大的秘密武器——JS Optic 库,特别是 monocle-ts。我们将深入探讨 LensesPrismsTraversals 在处理不可变数据时的妙用。 准备好,我们要起飞了!

Part 1: 不可变数据,你真的了解吗?

在开始之前,咱们先简单回顾一下不可变数据。 啥是不可变数据? 简单来说,就是一旦创建,就不能被修改的数据。 每次你想“修改”它,实际上都是创建了一个新的数据副本。

好处嘛,那可多了去了:

  • 可预测性: 因为数据不会被意外修改,所以更容易理解和调试代码。
  • 并发安全: 在多线程环境中,不可变数据是天然线程安全的,不需要额外的锁机制。
  • 更容易实现撤销/重做: 每次修改都会生成一个新的版本,方便回溯历史状态。

当然,不可变数据也有个小小的缺点: 每次“修改”都会创建新对象,可能会带来性能开销。 但是,现代 JavaScript 引擎已经做了很多优化,加上合理的设计,性能问题通常不是瓶颈。

Part 2: Optic 登场! 告别层层嵌套的噩梦

想象一下,你有一个深层嵌套的对象,就像俄罗斯套娃一样:

const user = {
  id: 1,
  name: "张三",
  address: {
    city: "北京",
    street: "朝阳路",
    location: {
      latitude: 39.9042,
      longitude: 116.4074,
    },
  },
  orders: [
    { id: 101, amount: 100 },
    { id: 102, amount: 200 },
  ],
};

现在,你想修改用户的经度 (longitude)。 你会怎么做?

传统的方法可能是这样:

const updatedUser = {
  ...user,
  address: {
    ...user.address,
    location: {
      ...user.address.location,
      longitude: 116.41, // 修改经度
    },
  },
};

这段代码是不是看起来很臃肿? 每一次修改都需要展开 (spread) 整个对象路径,代码可读性很差,而且容易出错。 想象一下,如果嵌套更深,那简直就是一场噩梦!

这时候,Optic 就派上用场了! Optic 是一种声明式的方式来访问和修改嵌套数据结构。 它可以让你专注于你想做什么,而不是如何做。 就像一个精准的狙击手,可以精确地定位到你想修改的目标。

Part 3: Lenses: 聚焦于单一属性

Lens 是 Optic 中最基础的概念之一。 它可以让你聚焦于一个对象的单个属性。 可以把 Lens 想象成一个放大镜,可以让你清晰地看到对象的某个细节。

monocle-ts 中,你可以这样创建一个 Lens:

import { Lens } from "monocle-ts";

interface User {
  id: number;
  name: string;
  address: {
    city: string;
  };
}

const idLens = Lens.fromProp<User>()("id"); // 创建一个聚焦于 id 属性的 Lens
const cityLens = Lens.fromPath<User, string>(["address", "city"]); // 创建一个聚焦于 address.city 属性的 Lens
  • Lens.fromProp<User>()("id"): 创建一个聚焦于 User 接口的 id 属性的 Lens
  • Lens.fromPath<User, string>(["address", "city"]): 创建一个聚焦于 User 接口的 address.city 属性的 LensfromPath 可以让你访问更深层次的嵌套属性。

有了 Lens,你就可以轻松地读取和修改属性:

const user: User = {
  id: 1,
  name: "张三",
  address: {
    city: "北京",
  },
};

// 读取 id 属性
const userId = idLens.get(user); // userId = 1

// 修改 id 属性
const updatedUser = idLens.set(2)(user); // updatedUser = { id: 2, name: "张三", address: { city: "北京" } }

// 使用 modify 函数,基于当前值进行修改
const incrementId = idLens.modify((id) => id + 1);
const incrementedUser = incrementId(user); // incrementedUser = { id: 2, name: "张三", address: { city: "北京" } }

Lens 的核心方法:

方法 描述
get(s: S) 从类型为 S 的对象中获取焦点属性的值。
set(a: A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中焦点属性的值已设置为 a
modify(f: (a: A) => A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中焦点属性的值已通过函数 f 进行修改。

Part 4: Prisms: 处理可能不存在的值

Prism 类似于 Lens,但它用于处理可能不存在的值。 比如,一个可选属性,或者一个联合类型。

想象一下,你有一个 Result 类型,它可能是 SuccessFailure:

interface Success<A> {
  readonly _tag: "Success";
  readonly value: A;
}

interface Failure {
  readonly _tag: "Failure";
  readonly error: string;
}

type Result<A> = Success<A> | Failure;

const success = <A>(value: A): Success<A> => ({ _tag: "Success", value });
const failure = (error: string): Failure => ({ _tag: "Failure", error });

const result1: Result<number> = success(10);
const result2: Result<number> = failure("Something went wrong");

现在,你想从 Success 结果中提取 value 属性。 如果是 Failure 结果,则什么也不做。 使用 Prism 可以优雅地解决这个问题:

import { Prism } from "monocle-ts";

const successPrism = <A>(): Prism<Result<A>, A> =>
  Prism.fromPredicate((s: Result<A>): s is Success<A> => s._tag === "Success");

const numberSuccessPrism = successPrism<number>();

// 获取 value 属性 (如果存在)
const value1 = numberSuccessPrism.getOption(result1); // value1 = Some(10)
const value2 = numberSuccessPrism.getOption(result2); // value2 = None

// 修改 value 属性 (如果存在)
const updatedResult1 = numberSuccessPrism.modify((value) => value * 2)(result1); // updatedResult1 = { _tag: "Success", value: 20 }
const updatedResult2 = numberSuccessPrism.modify((value) => value * 2)(result2); // updatedResult2 = { _tag: "Failure", error: "Something went wrong" }

Prism 的核心方法:

方法 描述
getOption(s: S) 尝试从类型为 S 的对象中提取焦点值。如果提取成功,则返回 Some(value);否则,返回 None
reverseGet(a: A) 将焦点值 a 注入到类型为 S 的对象中。这通常用于创建新对象。
modify(f: (a: A) => A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中焦点值已通过函数 f 进行修改(如果存在)。如果焦点值不存在,则返回原始对象。
modifyOption(f: (a: A) => A) modify 类似,但返回 Option<S>。如果焦点值存在并已成功修改,则返回 Some(updatedS);否则,返回 None
set(a: A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中焦点值已设置为 a(如果可能)。如果焦点值不存在,则返回原始对象。
setOption(a: A) set 类似,但返回 Option<S>。如果焦点值可以设置为 a,则返回 Some(updatedS);否则,返回 None
isSome(s: S) 检查类型为 S 的对象是否包含焦点值。
asSome(s: S) 如果 isSome(s) 返回 true,则将类型为 S 的对象转换为具有焦点值的类型 A
compose<B, C>(bc: Prism<B, C>): 将两个 Prism 组合在一起,创建一个新的 Prism

Part 5: Traversals: 遍历多个元素

Traversal 是最强大的 Optic 之一。 它可以让你遍历一个集合中的多个元素,并对它们进行修改。 就像一个辛勤的园丁,可以遍历整个花园,修剪每一朵花。

想象一下,你有一个订单列表,你想给所有超过 150 元的订单打 8 折:

import { Traversal } from "monocle-ts";

interface Order {
  id: number;
  amount: number;
}

const orders: Order[] = [
  { id: 101, amount: 100 },
  { id: 102, amount: 200 },
  { id: 103, amount: 300 },
];

// 创建一个遍历 Order 数组的 Traversal
const orderTraversal = Traversal.fromTraversable<Array<Order>, Order>(Array);

// 创建一个 Lens,聚焦于 Order 对象的 amount 属性
const amountLens = Lens.fromProp<Order>()("amount");

// 定义一个函数,用于给超过 150 元的订单打 8 折
const discount = (order: Order): Order =>
  order.amount > 150 ? amountLens.set(order.amount * 0.8)(order) : order;

// 使用 Traversal 和 Lens 来修改订单列表
const discountedOrders = orderTraversal.modify(discount)(orders);

console.log(discountedOrders);
// 输出:
// [
//   { id: 101, amount: 100 },
//   { id: 102, amount: 160 }, // 200 * 0.8
//   { id: 103, amount: 240 }, // 300 * 0.8
// ]

Traversal 的核心方法:

方法 描述
modify(f: (a: A) => A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中所有焦点元素已通过函数 f 进行修改。
set(a: A) 创建一个函数,该函数接受类型为 S 的对象,并返回一个新对象,其中所有焦点元素已设置为 a
get(s: S) 获取所有焦点元素的值的数组。
getAll(s: S) get 相同,获取所有焦点元素的值的数组。
modifyF<F>(F: Applicative<F>)(f: (a: A) => Kind<F, A>): (s: S) => Kind<F, S> 使用 Applicative Functor F 修改所有焦点元素。这允许你执行更复杂的操作,例如异步修改或基于副作用的修改。
foldMap<M>(M: Monoid<M>)(f: (a: A) => M): (s: S) => M 使用 Monoid M 将所有焦点元素的值折叠成一个单一的值。这允许你对所有焦点元素执行聚合操作,例如求和或计数。
traverse<F>(F: Applicative<F>): <B>(f: (a: A) => Kind<F, B>) => (s: S) => Kind<F, S> 使用 Applicative Functor F 遍历所有焦点元素,并将它们转换为类型 B。这类似于 modifyF,但允许你更改焦点元素的类型。

Part 6: 组合 Optic: 构建强大的管道

Optic 的强大之处在于它可以组合。 你可以将多个 LensPrismTraversal 组合在一起,构建一个强大的管道,精确地定位到你想修改的目标。

还记得之前的 user 对象吗? 我们可以创建一个 Lens 来聚焦于用户的经度:

import { Lens } from "monocle-ts";

interface User {
  id: number;
  name: string;
  address: {
    city: string;
    street: string;
    location: {
      latitude: number;
      longitude: number;
    };
  };
  orders: Order[];
}

interface Order {
  id: number;
  amount: number;
}

const addressLens = Lens.fromProp<User>()("address");
const locationLens = Lens.fromProp<{
  city: string;
  street: string;
  location: {
    latitude: number;
    longitude: number;
  };
}>()("location");
const longitudeLens = Lens.fromProp<{
  latitude: number;
  longitude: number;
}>()("longitude");

// 组合 Lens
const longitudeLensFromUser = addressLens
  .composeLens(locationLens)
  .composeLens(longitudeLens);

const user: User = {
  id: 1,
  name: "张三",
  address: {
    city: "北京",
    street: "朝阳路",
    location: {
      latitude: 39.9042,
      longitude: 116.4074,
    },
  },
  orders: [
    { id: 101, amount: 100 },
    { id: 102, amount: 200 },
  ],
};

// 修改经度
const updatedUser = longitudeLensFromUser.set(116.41)(user);

console.log(updatedUser.address.location.longitude); // 输出: 116.41

这段代码看起来更简洁,更易于理解。 你可以轻松地修改对象的任意深度的属性,而无需编写大量的展开代码。

Part 7: 实战演练:一个更复杂的例子

让我们来看一个更复杂的例子。 假设你有一个博客文章列表,每篇文章都有作者信息和评论列表。 你想给所有作者是 "张三" 的文章的所有评论都加上 " (已审核)" 的后缀。

import { Lens, Traversal } from "monocle-ts";

interface BlogPost {
  id: number;
  title: string;
  author: {
    name: string;
  };
  comments: Comment[];
}

interface Comment {
  id: number;
  text: string;
}

const blogPosts: BlogPost[] = [
  {
    id: 1,
    title: "Optic 入门",
    author: { name: "张三" },
    comments: [
      { id: 101, text: "写得真好!" },
      { id: 102, text: "受益匪浅!" },
    ],
  },
  {
    id: 2,
    title: "不可变数据",
    author: { name: "李四" },
    comments: [
      { id: 201, text: "学习了!" },
      { id: 202, text: "很有帮助!" },
    ],
  },
  {
    id: 3,
    title: "函数式编程",
    author: { name: "张三" },
    comments: [
      { id: 301, text: "深入浅出!" },
      { id: 302, text: "强烈推荐!" },
    ],
  },
];

// 创建 Lens
const authorLens = Lens.fromProp<BlogPost>()("author");
const nameLens = Lens.fromProp<{ name: string }>()("name");
const commentsLens = Lens.fromProp<BlogPost>()("comments");
const textLens = Lens.fromProp<Comment>()("text");

// 创建 Traversal
const blogPostTraversal = Traversal.fromTraversable<BlogPost[], BlogPost>(Array);
const commentTraversal = Traversal.fromTraversable<Comment[], Comment>(Array);

// 组合 Optic
const authorNameLensFromBlogPost = authorLens.composeLens(nameLens);
const commentTextLensFromComment = textLens;

// 定义函数
const addSuffix = (comment: Comment): Comment =>
  commentTextLensFromComment.set(comment.text + " (已审核)")(comment);

// 使用 Optic 修改数据
const updatedBlogPosts = blogPostTraversal
  .filter((post) => authorNameLensFromBlogPost.get(post) === "张三")
  .composeTraversal(commentsLens.composeTraversal(commentTraversal))
  .modify(addSuffix)(blogPosts);

console.log(updatedBlogPosts);
// 输出:
// [
//   {
//     id: 1,
//     title: "Optic 入门",
//     author: { name: "张三" },
//     comments: [
//       { id: 101, text: "写得真好! (已审核)" },
//       { id: 102, text: "受益匪浅! (已审核)" },
//     ],
//   },
//   {
//     id: 2,
//     title: "不可变数据",
//     author: { name: "李四" },
//     comments: [
//       { id: 201, text: "学习了!" },
//       { id: 202, text: "很有帮助!" },
//     ],
//   },
//   {
//     id: 3,
//     title: "函数式编程",
//     author: { name: "张三" },
//     comments: [
//       { id: 301, text: "深入浅出! (已审核)" },
//       { id: 302, text: "强烈推荐! (已审核)" },
//     ],
//   },
// ]

这个例子展示了 Optic 在处理复杂数据结构时的强大能力。 通过组合 LensTraversal,我们可以轻松地定位到目标元素,并进行修改。

Part 8: 总结与展望

今天,我们一起探索了 JS Optic 库,特别是 monocle-tsLensesPrismsTraversals 的应用。 我们学习了如何使用它们来处理不可变数据,告别层层嵌套的噩梦,编写更优雅、更强大的代码。

Optic 作用 适用场景
Lens 聚焦于一个对象的单个属性 访问和修改对象的确定属性
Prism 处理可能不存在的值 (可选属性,联合类型) 处理可能不存在的属性,例如可选属性或联合类型
Traversal 遍历一个集合中的多个元素,并对它们进行修改 对集合中的多个元素进行批量修改

当然,Optic 还有很多高级用法,例如 IsoGetterSetter 等,等待你去探索。

记住,学习 Optic 需要时间和实践。 不要害怕尝试,多写代码,你就会逐渐掌握它的精髓。

希望今天的分享对你有所帮助。 祝你编程愉快! 咱们下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注