镜头:是什么以及如何使用
在这篇文章中,我想向你展示函数式编程中的 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"
}
]
}
}
}
};
在应用程序的不同位置访问这种结构中的数据会导致大量重复操作,并且当数据结构发生变化(无论出于何种原因)时,可能会导致难以发现的错误。
因此,让我们探索一种解决此问题的替代方法:Lenses。
镜头
透镜用于以安全且不可变的方式访问和操作数据。其实对象上的访问器(getter 和 setter)也是如此,这并不花哨,也没什么特别的。透镜真正强大(也很酷)的地方在于它们可以直接组合。这意味着什么呢?如果你上过数学课,你就会知道函数可以相互组合,也就是说,如果你有一个函数 f,
那么你可以定义 f 与 g 的组合为 f(g )
,其含义就是f(g) = f(g)
。
那么,我们如何在 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));
我们可以用三种(或更多种)方式定义更高阶的组合:
// 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!"
我们现在知道如何组合函数了。你可能已经注意到,当组合函数的参数和返回值类型相同时,函数组合的效果最佳。
让我们定义一个用于获取某个地点学生信息的组合式 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)
); // []
如果你还在听,你可能已经注意到,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)
};
即使这只是一个只有两行的函数体,也需要一些时间才能理解它的含义,所以我将一步一步地解释:
- 调用时
createComposableGetterSetter使用 getter 和 setter 函数作为参数,我们得到实际的值composableGetterSetter。 - 我们的函数
composableGetterSetter会toGetterAndSetter接收一些数据作为输入,并返回一个包含一个对象get和一个set方法的对象。我们返回一个函数,该函数期望目标数据作为其唯一参数。 - 我们通过调用(1)并使用(2)中的目标数据,并将返回值传递给函数来构造 GetterSetter 对象
toGetterAndSetter。 - 我们使用 GetterSetter 对象的
set()方法,其返回值是通过调用 setter (4)并传入构造的 GetterSetter 对象的值(我们调用它getterSetter.get()只是为了简单地检索此值)和 targetData(我们期望 setter 将返回一个新版本,targetData其焦点值设置为来自的返回值getterSetter.get())。 - 我们返回(5)
getterSetter.set(...)中返回的值(又是一个 GetterSetter 对象)。
到 GetterAndSetter
我们现在已经定义了createComposableGetterSetter函数。接下来还需要定义另一个toGetterAndSetter函数,它将用于从目标获取数据或向目标设置数据。让我们定义toSetAccessors第一个函数:
const toSetAccessors = data => ({
get: () => data,
set: newData => toSetAccessors(newData)
});
这个简单的函数会为我们创建一个对象,每当我们想要给目标对象设置数据时,都会用到set这个对象。每当它的方法被调用并传入新数据时,它都会创建一个包含新数据的自身实例,并返回这个实例。
接下来是toGetAccessors函数:
const toGetAccessors = data => ({
get: () => data,
set() {
return this;
}
});
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
})
);
我们注意到这里有一些重复代码,所以让我们重构一下:
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
现在我们可以开始构图(和使用)镜头了:
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: [] } } } }
由于这种方法过于冗长,我们来定义一些辅助函数:
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
);
让我们试着使用一下我们的辅助函数:
// 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
); // -> ...
这种方法使得更新深度嵌套的不可变数据结构变得轻而易举。为了进一步简化操作,您可以定义lensIndex(index: number)透镜lensPath(path: Array<string|number>)创建辅助函数。lensIndex然后,该辅助函数用于聚焦数组值。它会创建透镜,专注于深度嵌套的对象属性和数组索引,并为您lensPath预先创建和组合透镜lensProp。lensIndex
镜头的更多应用领域
镜头非常适合在各种值之间进行转换,例如货币、温度、单位(公制单位到英制单位,反之亦然)、清理用户输入、解析和字符串化 JSON 等等。
尽情尝试和体验各种镜头吧(别忘了看看Runkit Notebook)。如果我说的有些话你没听懂,欢迎随时提问!
我很乐意回答任何问题 :)
文章来源:https://dev.to/misterwhat/lenses-the-what-and-how-2g9g