Update item in tree structure by reference and return updated tree structure

前提是你 提交于 2020-03-05 06:23:50

问题


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 -

  1. When the query, q, is None, the path has been exhausted and it's time to run the transformation, t, on the input object, o.
  2. Otherwise, by induction, q is not empty. If the input, o, is an object, using Object.assign create a new object where its new q property is a transform of its old q property, o[q].
  3. Otherwise, by induction, q is not empty and o is not an object. We cannot expect to lookup q on a non-object, therefore raise an error to signal to that transformAt 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 modifying state to add the first comment
  • create state2 by modifying state1 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

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!