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

镜头:是什么以及如何使用

镜头:是什么以及如何使用

在这篇文章中,我想向你展示函数式编程中的 lens 是什么,如何使用它们,以及最重要的是:如何编写你自己的 lens 实现。

简而言之,
透镜是可直接组合的访问器。继续阅读,了解它们的工作原理以及如何编写自己的透镜。

我在 Runkit 上为你创建了一个 Notebook,其中包含了所有示例以及另一种替代实现。你可以随时尝试(在阅读本文之前、期间或之后)。链接在此: https ://runkit.com/mister-what/lenses

引言

我们先来描述一下问题。假设你有以下数据结构,其中列出了员工的所在地和职位。

const locations = {
  berlin: {
    employees: {
      staff: {
        list: [
          {
            name: "Wiley Moen",
            phone: "688-031-5608",
            id: "cdfa-f2ae"
          },
          {
            name: "Sydni Keebler",
            phone: "129-526-0289",
            id: "e0ec-e480"
          }
        ]
      },
      managers: {
        list: [
          {
            name: "Cecilia Wisoky",
            phone: "148-188-6725",
            id: "9ebf-5a73"
          }
        ]
      },
      students: {
        list: [
          {
            name: "Kirsten Denesik",
            phone: "938-634-9476",
            id: "c816-2234"
          }
        ]
      }
    }
  },
  paris: {
    employees: {
      staff: {
        list: [
          {
            name: "Lucius Herman",
            phone: "264-660-0107",
            id: "c2fc-55da"
          }
        ]
      },
      managers: {
        list: [
          {
            name: "Miss Rickie Smith",
            phone: "734-742-5829",
            id: "2095-69a7"
          }
        ]
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

在应用程序的不同位置访问这种结构中的数据会导致大量重复操作,并且当数据结构发生变化(无论出于何种原因)时,可能会导致难以发现的错误。
因此,让我们探索一种解决此问题的替代方法:Lenses。

镜头

透镜用于以安全且不可变的方式访问和操作数据。其实对象上的访问器(getter 和 setter)也是如此,这并不花哨,也没什么特别的。透镜真正强大(也很酷)的地方在于它们可以直接组合。这意味着什么呢?如果你上过数学课,你就会知道函数可以相互组合,也就是说,如果你有一个函数 f,f: X → Y, g: Y → Z那么你可以定义 f 与 g 的组合为 f(g ) g \circ f: \ X \rightarrow Z,其含义就是f(g) = f(g) (g \circ f)(x) = g(f(x))

那么,我们如何在 JavaScript 中表示一个合成呢?很简单,就像这样:

function compose(g, f) {
    return function(x) {
        return g(f(x));
    }
}

// or with fat-arrow functions:
const compose = (g, f) => x => g(f(x));
Enter fullscreen mode Exit fullscreen mode

我们可以用三种(或更多种)方式定义更高阶的组合:

// recursive version
const compose = (...fns) => x =>
  fns.length
    ? compose(...fns.slice(0, -1))(
        fns[fns.length - 1](x)
      )
    : x;

// iterative version
const composeItr = (...fns) => x => {
  const functions = Array.from(
    fns
  ).reverse();
  /* `reverse` mutates the array,
    so we make a shallow copy of the functions array */
  let result = x;
  for (const f of functions) {
    result = f(result);
  }
  return result;
};

// with Array.prototype.reduce
const composeReduce = (...fns) => x =>
  fns.reduceRight(
    (result, f) => f(result),
    x
  );

// use it!
console.log(
  compose(
    x => `Hello ${x}`,
    x => `${x}!`
  )("World")
); // -> "Hello World!"

Enter fullscreen mode Exit fullscreen mode

我们现在知道如何组合函数了。你可能已经注意到,当组合函数的参数和返回值类型相同时,函数组合的效果最佳。

让我们定义一个用于获取某个地点学生信息的组合式 getter:

const studentsAtLocation = compose(
    (students = {}) => students.list || [],
    (employees = {}) => employees.students,
    (location = {}) => location.employees
  );

const locationWithName = locationName => (
  locations = {}
) => locations[locationName];

const getBerlinStudents = compose(
  studentsAtLocation,
  locationWithName("berlin")
);

const getParisStudents = compose(
  studentsAtLocation,
  locationWithName("paris")
);

console.log(
  getBerlinStudents(locations)
); // [ { name: 'Kirsten Denesik', ... ]

console.log(
  getParisStudents(locations)
); // []
Enter fullscreen mode Exit fullscreen mode

如果你还在听,你可能已经注意到,getter 函数的提供顺序似乎有点反常。我们将通过使用接受一个 getter 作为参数并返回一个 getter 的函数来解决这个问题。这种模式(传递一个函数并返回一个函数)允许我们通过传递一个接受一个值并返回一个 getter/setter 对的函数来组合 getter/setter 函数。让我们来看看它是如何实现的:

const createComposableGetterSetter = (
  getter, // (1)
  // -- getter(targetData: TargetData): Value
  setter // (4)
  // -- setter(value: Value, targetData: TargetData) => TargetData
) => toGetterAndSetter => targetData => { // (2)
  const getterSetter = toGetterAndSetter(
    getter(targetData)
  ); // (3)
  /**
   * toGetterAndSetter is called with
   * "data" as argument
   * and returns a GetterSetter object:
   * @typedef {
   *  {
   *    get: function(): *,
   *    set: function(newData: *): GetterSetter
   *  }
   * } GetterSetter
   *
   */
  return getterSetter.set(
    setter(
      getterSetter.get(),
      targetData
    )
  ); // (5)
};
Enter fullscreen mode Exit fullscreen mode

即使这只是一个只有两行的函数体,也需要一些时间才能理解它的含义,所以我将一步一步地解释:

  1. 调用时createComposableGetterSetter使用 getter 和 setter 函数作为参数,我们得到实际的值composableGetterSetter
  2. 我们的函数composableGetterSettertoGetterAndSetter接收一些数据作为输入,并返回一个包含一个对象get和一个set方法的对象。我们返回一个函数,该函数期望目标数据作为其唯一参数。
  3. 我们通过调用(1)并使用(2)中的目标数据,并将返回值传递给函数来构造 GetterSetter 对象toGetterAndSetter
  4. 我们使用 GetterSetter 对象的set()方法,其返回值是通过调用 setter (4)并传入构造的 GetterSetter 对象的值(我们调用它getterSetter.get()只是为了简单地检索此值)和 targetData(我们期望 setter 将返回一个新版本,targetData其焦点值设置为来自的返回值getterSetter.get())。
  5. 我们返回(5)getterSetter.set(...)返回的值(又是一个 GetterSetter 对象)

到 GetterAndSetter

我们现在已经定义了createComposableGetterSetter函数。接下来还需要定义另一个toGetterAndSetter函数,它将用于从目标获取数据或向目标设置数据。让我们定义toSetAccessors第一个函数:

const toSetAccessors = data => ({
  get: () => data,
  set: newData => toSetAccessors(newData)
});
Enter fullscreen mode Exit fullscreen mode

这个简单的函数会为我们创建一个对象,每当我们想要给目标对象设置数据时,都会用到set这个对象。每当它的方法被调用并传入新数据时,它都会创建一个包含新数据的自身实例,并返回这个实例。

接下来是toGetAccessors函数:

const toGetAccessors = data => ({
  get: () => data,
  set() {
    return this;
  }
});
Enter fullscreen mode Exit fullscreen mode

GetAccessor 对象应该只允许检索其自身的数据。尝试设置新数据时,它只会返回自身的实例。这样一来,创建后就无法对其进行修改。

使用可组合的 GetterSetter(Lenses)

现在我们将创建三个 ComposableGetterSetter(又称 lens),看看它们是如何工作的,以及使用它们来检索值或更改数据(以不可变的方式)需要什么。

镜头制作

我们将创建三个透镜,分别聚焦于属性“paris”、“employees”和属性“students”。
我们将使用默认值进行getter操作(以避免异常),并使用对象展开来保持setter操作的不可变性。

const parisLens = createComposableGetterSetter(
  obj => (obj || {}).paris,
  (value, obj) => ({
    ...obj,
    paris: value
  })
);

const employeesLens = createComposableGetterSetter(
  obj => (obj || {}).employees,
  (value, obj) => ({
    ...obj,
    employees: value
  })
);

const studentsLens = createComposableGetterSetter(
  obj => (obj || {}).students,
  (value, obj) => ({
    ...obj,
    students: value
  })
);
Enter fullscreen mode Exit fullscreen mode

我们注意到这里有一些重复代码,所以让我们重构一下:

const lensProp = propName =>
  createComposableGetterSetter(
    obj => (obj || {})[propName],
    (value, obj) => ({
      ...obj,
      [propName]: value
    })
  );

// we can now create lenses for props like this:

const parisLens = lensProp("paris");

const employeesLens = lensProp(
  "employees"
);

const studentsLens = lensProp(
  "students"
);

const listLens = lensProp("list"); // needed to get the list of students

Enter fullscreen mode Exit fullscreen mode

现在我们可以开始构图(和使用)镜头了:

const parisStudentListLens = compose(
  parisLens,
  employeesLens,
  studentsLens,
  listLens
);

const parisStudentList = parisStudentListLens(
  toGetAccessors
)(locations).get();

console.log(parisStudentList);
// -> undefined, since there is no list of students for paris defined.

const locationsWithStudentListForParis = parisStudentListLens(
  _list => toSetAccessors([])
  // ignore current list and replace it with an empty array
)(locations).get();

console.log(
  locationsWithStudentListForParis
);// -> { ..., paris: { employees:{ ..., students: { list: [] } } } }

Enter fullscreen mode Exit fullscreen mode

由于这种方法过于冗长,我们来定义一些辅助函数:

const view = (lens, targetData) =>
  lens(toGetAccessors)(
    targetData
  ).get();

const over = (
  lens,
  overFn /* like the `mapf` callback in `Array.prototype.map(mapf)`.
    i.e.: You get a value and return a new value. */,
  targetData
) =>
  lens(data =>
    toSetAccessors(overFn(data))
  )(targetData).get();

const set = (lens, value, targetData) =>
  over(
    lens,
    () =>
      value /* we use `over` with a `overFn` function, 
        that just returns the value argument */,
    targetData
  );
Enter fullscreen mode Exit fullscreen mode

让我们试着使用一下我们的辅助函数:

// using get, set, over:

const locationsWithStudentListForParis = set(
  parisStudentListLens,
  [],
  locations
);

const locationsWithOneStudentInParis = over(
  parisStudentListLens,
  (list = []) => [
    ...list,
    { name: "You", setVia: "Lens" }
  ],
  locations
);

const locationsWithTwoStudentInParis = over(
  parisStudentListLens,
  (list = []) => [
    ...list,
    { name: "Me", setVia: "Lens" }
  ],
  locationsWithOneStudentInParis
);

// logging the results:

console.log(
  view(parisStudentListLens, locations)
); // -> undefined

console.log(
  view(
    parisStudentListLens,
    locationsWithStudentListForParis
  )
); // -> []

console.log(
  view(
    parisStudentListLens,
    locationsWithTwoStudentInParis
  )
); // -> [ { name: 'You', setVia: 'Lens' }, { name: 'Me', setVia: 'Lens' } ]

console.log(
  view(
    parisStudentListLens,
    locationsWithOneStudentInParis
  )
); // -> [ { name: 'Me', setVia: 'Lens' } ]

console.log(
  locationsWithTwoStudentInParis
); // -> ...

Enter fullscreen mode Exit fullscreen mode

这种方法使得更新深度嵌套的不可变数据结构变得轻而易举。为了进一步简化操作,您可以定义lensIndex(index: number)透镜lensPath(path: Array<string|number>)创建辅助函数。lensIndex然后,该辅助函数用于聚焦数组值。它会创建透镜,专注于深度嵌套的对象属性和数组索引,并为您lensPath预先创建和组合透镜lensProplensIndex

镜头的更多应用领域

镜头非常适合在各种值之间进行转换,例如货币、温度、单位(公制单位到英制单位,反之亦然)、清理用户输入、解析和字符串化 JSON 等等。

尽情尝试和体验各种镜头吧(别忘了看看Runkit Notebook)。如果我说的有些话你没听懂,欢迎随时提问!

我很乐意回答任何问题 :)

文章来源:https://dev.to/misterwhat/lenses-the-what-and-how-2g9g