问题
I am currently learning functional programming using HyperappJS (V2) and RamdaJS. My first project is a simple blog app where users can comment on posts or other comments. The comments are represented as a tree structure.
My state looks something like this:
// state.js
export default {
posts: [
{
topic: `Topic A`,
comments: []
},
{
topic: `Topic B`,
comments: [
{
text: `Comment`,
comments: [ /* ... */ ]
}
]
},
{
topic: `Topic C`,
comments: []
}
],
otherstuff: ...
}
When the user wants to add a comment I pass the current tree item to my addComment-action. There I add the comment to the referenced item and return a new state object to trigger the view update.
So, currently I'm doing this and it's working fine:
// actions.js
import {concat} from 'ramda'
export default {
addComment: (state, args) => {
args.item.comments = concat(
args.item.comments,
[{text: args.text, comments: []}]
)
return {...state}
}
}
My question: Is this approach correct? Is there any way to clean up this code and make it more functional? What I am looking for would be something like this:
addComment: (state, args) => ({
...state,
posts: addCommentToReferencedPostItemAndReturnUpdatedPosts(args, state.posts)
})
回答1:
Here's an approach where we 1) locate the target object in your state tree, and then 2) transform the located object. Let's assume that your tree has some way to id
the individual objects -
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
You could start by writing a generic search
which yields the possible path(s) to a queried object -
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 ])
}
Let's locate all objects where id
is greater than 1
-
for (const path of search (state, ({ id = 0 }) => id > 1))
console .log (path)
// [ "posts", "1" ]
// [ "posts", "2" ]
These "paths" point to objects in your state
tree where the predicate, ({ id = 0 }) => id > 1)
, is true. Ie,
// [ "posts", "1" ]
state.posts[1] // { id: 2, topic: "Topic B", comments: [] }
// [ "posts", "2" ]
state.posts[2] // { id: 3, topic: "Topic C", comments: [] }
We will use search
to write higher-order functions like searchById
, which encodes our intentions more clearly -
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
for (const path of searchById(state, 2))
console .log (path)
// [ "posts", "1" ]
transform
Next we can write transformAt
which takes an input state object, o
, a path
, and a transformation function, 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
These bullet points correspond to the numbered comments above -
- When the query,
q
, isNone
, the path has been exhausted and it's time to run the transformation,t
, on the input object,o
. - Otherwise, by induction,
q
is not empty. If the input,o
, is an object, usingObject.assign
create a new object where its newq
property is a transform of its oldq
property,o[q]
. - Otherwise, by induction,
q
is not empty ando
is not an object. We cannot expect to lookupq
on a non-object, thereforeraise
an error to signal to thattransformAt
was given an invalid path.
Now we can easily write appendComment
which takes an input, state
, a comment's id, parentId
, and a new comment, 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
}
Recall search
generates all possible paths to where the predicate query returns true. You have to make a choice how you will handle the scenario where a query returns more than one result. Consider data like -
const otherState =
{ posts: [ { type: "post", id: 1, ... }, ... ]
, images: [ { type: "image", id: 1, ... }, ... ]
}
Using searchById(otherState, 1)
would get two objects where id = 1
. In appendComment
we choose only to modify the first match. It's possible to modify all the search
results, if we wanted -
// 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
)
But in this scenario, we probably don't want duplicate comments in our app. Any querying function like search
may return zero, one, or more results and you have to decide how your program responds in each scenario.
put it together
Here are the remaining dependencies -
const isArray =
Array.isArray
const isObject = x =>
Object (x) === x
const raise = e =>
{ throw e }
const identity = x =>
x
Let's append our first new comment to id = 2
, "Topic B" -
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
Our first state revision, state1
, will be -
{ 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 ]
}
And we'll append another comment, nested on that one -
const state2 =
appendComment
( state
, 4 // <-- id of our last comment
, { id: 5, text: "i agree!", comments: [] }
)
This second revision, state2
, will be -
{ 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, ... }
]
, ...
}
code demonstration
In this demo we will,
- create
state1
by modifyingstate
to add the first comment - create
state2
by modifyingstate1
to add the second (nested) comment - print
state2
to show the expected state - print
state
to show that the original state is not modified
Expand the snippet below to verify the results in your own browser -
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))
alternate alternative
The techniques described above are parallel to the other (excellent) answer using lenses provided by Scott. The notable difference here is we start with an unknown path to the target object, find the path, then transform the state at the discovered path.
The techniques in these two answers could even be combined. search
yields paths that could be used to create R.lensPath
and then we could update the state using R.over
.
And a higher-level technique is lurking right around the corner. This one comes from the understanding that writing functions like transformAt
is reasonably complex and it's difficult to get them right. At the heart of the problem, our state object is a plain JS object, { ... }
, which offers no such feature as immutable updates. Nested within those object we use arrays, [ ... ]
, that have the same issue.
Data structures like Object
and Array
were designed with countless considerations that may not match your own. It is for this reason why you have the ability to design your own data structures that behave the way you want. This is an often overlooked area of programming, but before we jump in and try to write our own, let's see how the Wise Ones before us did it.
One example, ImmutableJS, solves this exact problem. The library gives you a collection of data structures as well as functions that operate on those data structures, all of which guarantee immutable behaviour. Using the library is convenient -
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
}
Now we write transformAt
with the expectation that it will be given an immutable structure -
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"))
Hopefully we can begin to see transformAt
as a generic function. It is not coincidence that ImmutableJS includes functions to do exactly this, getIn
and 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
)
To my surprise, even transformAt
is implemented exactly as updateIn
-
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
}
This the lesson of higher-level data structures. By using structures designed for immutable operations, we reduce the overall complexity of our entire program. As a result, the program can now be written in less than 30 lines of straightforward code -
//
// 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
}
ImmutableJS is just one possible implementation of these structures. Many others exist, each with their unique APIs and trade-offs. You can pick from a pre-made library or you can custom tailor your own data structures to meet your exact needs. Either way, hopefully you can see the benefits provided by well-designed data structures and perhaps gain insight on why popular structures of today were invented in the first place.
Expand the snippet below to run the ImmutableJS version of the program in your browser -
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))
<script src="https://unpkg.com/immutable@4.0.0-rc.12/dist/immutable.js"></script>
回答2:
Ramda is intentionally designed not to modify user data. Passing something by reference won't help; Ramda will still refuse to alter it.
One alternative is to see if you can pass the path to the node to which you want to add the comment. Ramda can use a path
with lensPath and over to make a version that will return a new state
object, something like this:
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>
Here the path we use is [1, 0]
, representing the second post (index 1) and the first comment (index 0) within it.
We could write more a more sophisticated lens to traverse the object if the path is not enough.
I don't know if this is an overall improvement, but it's definitely a more appropriate use of Ramda. (Disclaimer: I'm one of the authors of Ramda.)
来源:https://stackoverflow.com/questions/57933657/update-item-in-tree-structure-by-reference-and-return-updated-tree-structure