通过引用更新树结构中的项目并返回更新的树结构

时间:2019-09-14 08:12:29

标签: functional-programming tree ramda.js hyperapp

我目前正在使用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)
})

2 个答案:

答案 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

这些项目要点对应于以上编号的注释-

  1. 当查询qNone时,路径已耗尽,是时候在输入对象t上运行转换o了。
  2. 否则,归纳q 为空。如果输入o是一个对象,请使用Object.assign创建一个新对象,其中新的q属性是其旧q属性{{1}的变换}。
  3. 否则,归纳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

希望我们可以开始将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"))视为通用函数。 ImmutableJS包含用于执行此操作的函数transformAtgetIn-

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可以将pathlensPathover结合使用,以创建一个版本,该版本将返回一个新的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的作者之一。)