我目前正在使用HyperappJS(V2)和RamdaJS学习函数式编程。我的第一个项目是一个简单的博客应用程序,用户可以在其中评论帖子或其他评论。注释以树结构表示。
我的状态看起来像这样:
// state.js
export default {
posts: [
{
topic: `Topic A`,
comments: []
},
{
topic: `Topic B`,
comments: [
{
text: `Comment`,
comments: [ /* ... */ ]
}
]
},
{
topic: `Topic C`,
comments: []
}
],
otherstuff: ...
}
当用户想要添加评论时,我将当前树项传递给我的addComment-action。在那里,我将注释添加到引用的项目中,并返回一个新的状态对象以触发视图更新。
所以,目前我正在这样做,并且工作正常:
// actions.js
import {concat} from 'ramda'
export default {
addComment: (state, args) => {
args.item.comments = concat(
args.item.comments,
[{text: args.text, comments: []}]
)
return {...state}
}
}
我的问题:这种方法正确吗?有什么方法可以清理此代码并使它更具功能性?我正在寻找的是这样的:
addComment: (state, args) => ({
...state,
posts: addCommentToReferencedPostItemAndReturnUpdatedPosts(args, state.posts)
})
答案 0 :(得分:4)
这是一种方法,我们1)在状态树中定位目标对象,然后2)转换所定位的对象。假设您的树有某种方法可以id
单个对象-
const state =
{ posts:
[ { id: 1 // <-- id
, topic: "Topic A"
, comments: []
}
, { id: 2 // <-- id
, topic: "Topic B"
, comments: []
}
, { id: 3 // <-- id
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
搜索
您可以先编写一个通用的search
,它会产生到查询对象的可能路径-
const search = function* (o = {}, f = identity, path = [])
{ if (!isObject(o))
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
让我们找到id
大于1
-
for (const path of search (state, ({ id = 0 }) => id > 1))
console .log (path)
// [ "posts", "1" ]
// [ "posts", "2" ]
这些“路径”指向state
树中谓词({ id = 0 }) => id > 1)
为真的对象。即
// [ "posts", "1" ]
state.posts[1] // { id: 2, topic: "Topic B", comments: [] }
// [ "posts", "2" ]
state.posts[2] // { id: 3, topic: "Topic C", comments: [] }
我们将使用search
来编写像searchById
这样的高阶函数,它可以更清晰地编码我们的意图-
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
for (const path of searchById(state, 2))
console .log (path)
// [ "posts", "1" ]
转换
接下来,我们可以编写transformAt
,它接受一个输入状态对象o
,一个path
和一个转换函数t
-
const None =
Symbol ()
const transformAt =
( o = {}
, [ q = None, ...path ] = []
, t = identity
) =>
q === None // 1
? t (o)
: isObject (o) // 2
? Object.assign
( isArray (o) ? [] : {}
, o
, { [q]: transformAt (o[q], path, t) }
)
: raise (Error ("transformAt: invalid path")) // 3
这些项目要点对应于以上编号的注释-
q
为None
时,路径已耗尽,是时候在输入对象t
上运行转换o
了。 q
不为空。如果输入o
是一个对象,请使用Object.assign
创建一个新对象,其中新的q
属性是其旧q
属性{{1}的变换}。o[q]
不是 为空,而q
是不是是一个对象。我们不能期望在非对象上查找o
,因此q
出现错误,以信号通知raise
被赋予了无效的路径。 现在,我们可以轻松编写transformAt
,其中包含一个输入appendComment
,一个注释的ID state
和一个新的注释parentId
-
c
调用const append = x => a =>
[ ...a, x ]
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt // <-- only transform first; return
( state
, [ ...path, "comments" ]
, append (c)
)
return state // <-- if no search result, return unmodified state
}
生成 all 到谓词查询返回true的可能路径。您必须做出选择,如何处理查询返回多个结果的情况。考虑像-
search
使用const otherState =
{ posts: [ { type: "post", id: 1, ... }, ... ]
, images: [ { type: "image", id: 1, ... }, ... ]
}
将得到两个对象,其中searchById(otherState, 1)
。在id = 1
中,我们仅选择修改 first 匹配项。如果需要,可以修改appendComment
结果的 all -
search
但是在这种情况下,我们可能不想在我们的应用程序中重复注释。任何类似// but don't actually do this
const appendComment = (state = {}, parentId = 0, c = {}) =>
Array
.from (searchById (state, parentId)) // <-- all results
.reduce
( (r, path) =>
transformAt // <-- transform each
( r
, [ ...path, "comments" ]
, append (c)
)
, state // <-- init state
)
的查询功能都可能返回零,一个或多个结果,并且您必须决定程序在每种情况下的响应方式。
放在一起
剩余的依赖项-
search
让我们将我们的第一个新评论附加到const isArray =
Array.isArray
const isObject = x =>
Object (x) === x
const raise = e =>
{ throw e }
const identity = x =>
x
,“主题B” -
id = 2
我们的第一个状态修订版本const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
将是-
state1
然后我们将添加另一个评论,该评论嵌套在该评论上-
{ posts:
[ { id: 1
, topic: "Topic A"
, comments: []
}
, { id: 2
, topic: "Topic B"
, comments:
[ { id: 4 //
, text: "nice article!" // <-- newly-added
, comments: [] // comment
} //
]
}
, { id: 3
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
第二个修订版本const state2 =
appendComment
( state
, 4 // <-- id of our last comment
, { id: 5, text: "i agree!", comments: [] }
)
将是-
state2
代码演示
在此演示中,我们将
{ posts:
[ { id: 1, ...}
, { id: 2
, topic: "Topic B"
, comments:
[ { id: 4
, text: "nice article!"
, comments:
[ { id: 5 // nested
, text: "i agree!" // <-- comment
, comments: [] // added
} //
]
}
]
}
, { id: 3, ... }
]
, ...
}
以添加第一条评论来创建state1
state
以添加第二条(嵌套的)注释来创建state2
state1
以显示预期状态state2
以表明原始状态未修改展开以下代码段,以在您自己的浏览器中验证结果-
state
替代方案
上述技术与使用Scott提供的镜头的其他(出色)答案平行。值得注意的区别是,我们从目标对象的未知路径开始,找到路径,然后在发现的路径上转换状态。
这两个答案中的技术甚至可以结合起来。 const None =
Symbol ()
const isArray =
Array.isArray
const isObject = x =>
Object (x) === x
const raise = e =>
{ throw e }
const identity = x =>
x
const append = x => a =>
[ ...a, x ]
const search = function* (o = {}, f = identity, path = [])
{ if (!isObject(o))
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const transformAt =
( o = {}
, [ q = None, ...path ] = []
, t = identity
) =>
q === None
? t (o)
: isObject (o)
? Object.assign
( isArray (o) ? [] : {}
, o
, { [q]: transformAt (o[q], path, t) }
)
: raise (Error ("transformAt: invalid path"))
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt
( state
, [ ...path, "comments" ]
, append (c)
)
return state
}
const state =
{ posts:
[ { id: 1
, topic: "Topic A"
, comments: []
}
, { id: 2
, topic: "Topic B"
, comments: []
}
, { id: 3
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
const state2 =
appendComment
( state1
, 4
, { id: 5, text: "i agree!", comments: [] }
)
console.log("state2", JSON.stringify(state2, null, 2))
console.log("original", JSON.stringify(state, null, 2))
产生可用于创建search
的路径,然后我们可以使用R.lensPath
更新状态。
更高级别的技术正潜伏在角落。这源于一种理解,即像R.over
这样的编写函数相当复杂,并且很难正确地实现它们。问题的核心是我们的状态对象是普通的JS对象transformAt
,它不提供不变更新等功能。嵌套在这些对象中的是我们使用的数组{ ... }
,它们存在相同的问题。
诸如[ ... ]
和Object
之类的数据结构在设计时考虑了无数与您不匹配的考虑因素。因此,您有能力设计自己的数据结构,以所需的方式运行。这是一个经常被忽视的编程领域,但是在我们尝试编写自己的程序之前,让我们先看看明智的人是怎么做到的。
一个示例ImmutableJS解决了这个 exact 问题。该库为您提供了数据结构以及在这些数据结构上运行的函数的集合,所有这些保证了不可变的行为。使用该库很方便-
Array
现在我们写
并期望它会被赋予不变的结构-const append = x => a => // <-- unused
[ ...a, x ]
const { fromJS } =
require ("immutable")
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt
( fromJS (state) // <-- 1. from JS to immutable
, [ ...path, "comments" ]
, list => list .push (c) // <-- 2. immutable push
)
.toJS () // <-- 3. from immutable to JS
return state
}
transformAt
希望我们可以开始将
视为通用函数。 ImmutableJS包含用于执行此操作的函数const isArray = // <-- unused
Array.isArray
const isObject = (x) => // <-- unused
Object (x) === x
const { Map, isCollection, get, set } =
require ("immutable")
const transformAt =
( o = Map () // <-- empty immutable object
, [ q = None, ...path ] = []
, t = identity
) =>
q === None
? t (o)
: isCollection (o) // <-- immutable object?
? set // <-- immutable set
( o
, q
, transformAt
( get (o, q) // <-- immutable get
, path
, t
)
)
: raise (Error ("transformAt: invalid path"))transformAt
和getIn
-
setIn
令我惊讶的是,甚至
也被准确地实现为const None = // <-- unused
Symbol ()
const raise = e => // <-- unused
{ throw e }
const { Map, setIn, getIn } =
require ("immutable")
const transformAt =
( o = Map () // <-- empty Map
, path = []
, t = identity
) =>
setIn // <-- set by path
( o
, path
, t (getIn (o, path)) // <-- get by path
)transformAt
-
updateIn
这是高级数据结构的课程。通过使用为不变操作设计的结构,我们降低了整个程序的整体复杂性。结果,该程序现在可以用不到30行的简单代码编写-
const identity = x => // <-- unused
x
const transformAt = //
( o = Map () // <-- unused
, path = [] //
, t = identity //
) => ... //
const { fromJS, updateIn } =
require ("immutable")
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn // <-- immutable update by path
( fromJS (state)
, [ ...path, "comments" ]
, list => list .push (c)
)
.toJS ()
return state
}
ImmutableJS只是这些结构的一个可能的实现。存在许多其他方法,每种方法都有其独特的API和取舍。您可以从预制库中进行选择,也可以自定义定制自己的数据结构来满足您的确切需求。无论哪种方式,希望您都能看到经过精心设计的数据结构所带来的好处,并且也许可以深入了解为什么首先发明了当今流行的结构。
展开下面的代码片段,以在浏览器中运行程序的ImmutableJS版本-
//
// complete implementation using ImmutableJS
//
const { fromJS, updateIn } =
require ("immutable")
const search = function* (o = {}, f = identity, path = [])
{ if (Object (o) !== o)
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn
( fromJS (state)
, [ ...path, "comments" ]
, list => list .push (c)
)
.toJS ()
return state
}
const { fromJS, updateIn } =
Immutable
const search = function* (o = {}, f = identity, path = [])
{ if (Object (o) !== o)
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn
( fromJS (state)
, [ ...path, 'comments' ]
, list => list .push (c)
)
.toJS ()
return state
}
const state =
{ posts:
[ { id: 1
, topic: 'Topic A'
, comments: []
}
, { id: 2
, topic: 'Topic B'
, comments: []
}
, { id: 3
, topic: 'Topic C'
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
const state2 =
appendComment
( state1
, 4
, { id: 5, text: "i agree!", comments: [] }
)
console.log("state2", JSON.stringify(state2, null, 2))
console.log("original", JSON.stringify(state, null, 2))
答案 1 :(得分:3)
Ramda旨在不修改用户数据。通过引用传递某些内容无济于事; Ramda仍然会拒绝更改它。
一种选择是查看是否可以将 path 传递到要向其添加注释的节点。 Ramda可以将path
与lensPath
和over
结合使用,以创建一个版本,该版本将返回一个新的state
对象,如下所示:
const addComment = (state, {text, path}) =>
over (
lensPath (['posts', ...intersperse ('comments', path), 'comments']),
append ({text, comments: []}),
state
)
const state = {
posts: [
{topic: `Topic A`, comments: []},
{topic: `Topic B`, comments: [{text: `Comment`, comments: [
{text: 'foo', comments: []}
// path [1, 0] will add here
]}]},
{topic: `Topic C`, comments: []}
],
otherstuff: {}
}
console .log (
addComment (state, {path: [1, 0], text: 'bar'})
)
//=> {
// posts: [
// {topic: `Topic A`, comments: []},
// {topic: `Topic B`, comments: [{text: `Comment`, comments: [
// {text: 'foo', comments: []},
// {text: 'bar', comments: []}
// ]}]},
// {topic: `Topic C`, comments: []}
// ],
// otherstuff: {}
// }
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script>
<script>const {over, lensPath, intersperse, append} = R </script>
这里我们使用的路径是[1, 0]
,代表其中的第二条帖子(索引1)和其中的第一条评论(索引0)。
如果路径不足,我们可以编写更复杂的lens来遍历对象。
我不知道这是否是整体改进,但绝对是对Ramda的更适当的使用。 (免责声明:我是Ramda的作者之一。)