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

sub*_*nid 1 tree functional-programming 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: ...
}
Run Code Online (Sandbox Code Playgroud)

当用户想要添加评论时,我将当前树项传递给我的 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}
    }
}
Run Code Online (Sandbox Code Playgroud)

我的问题:这种方法是否正确?有什么办法可以清理这段代码并使其功能更强大吗?我正在寻找的是这样的:

addComment: (state, args) => ({
    ...state,
    posts: addCommentToReferencedPostItemAndReturnUpdatedPosts(args, state.posts)
})
Run Code Online (Sandbox Code Playgroud)

Sco*_*yet 5

Ramda 有意设计为不修改用户数据。通过引用传递一些东西无济于事;Ramda 仍将拒绝更改它。

一种替代方法是查看是否可以将路径传递到要添加注释的节点。Ramda 可以使用pathwithlensPathover来创建一个将返回新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: {}
// }
Run Code Online (Sandbox Code Playgroud)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script>
<script>const {over, lensPath, intersperse, append} = R            </script>
Run Code Online (Sandbox Code Playgroud)

这里我们使用的路径是[1, 0],表示其中的第二篇文章(索引 1)和第一条评论(索引 0)。

如果路径不够,我们可以编写更复杂的镜头来遍历对象。

我不知道这是否是整体改进,但这绝对是对 Ramda 的更合适的使用。(免责声明:我是 Ramda 的作者之一。)

  • 我非常赞成 hyperapp 的选择:这就是任何功能系统可能会做的事情。可能只是 Ramda 不适合您。它从未被设计为通用实用程序库,只是为了使 JS 中的 FP 代码更容易编写。 (2认同)

Tha*_*you 5

这是一种方法,我们 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 ]
  }
Run Code Online (Sandbox Code Playgroud)

搜索

您可以首先编写一个泛型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 ])
}
Run Code Online (Sandbox Code Playgroud)

让我们找到id大于1- 的所有对象

for (const path of search (state, ({ id = 0 }) => id > 1))
  console .log (path)

// [ "posts", "1" ]
// [ "posts", "2" ]
Run Code Online (Sandbox Code Playgroud)

这些“路径”指向state树中谓词({ id = 0 }) => id > 1)为真的对象。IE,

// [ "posts", "1" ]
state.posts[1] // { id: 2, topic: "Topic B", comments: [] }

// [ "posts", "2" ]
state.posts[2] // { id: 3, topic: "Topic C", comments: [] }
Run Code Online (Sandbox Code Playgroud)

我们将使用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" ]
Run Code Online (Sandbox Code Playgroud)

转变

接下来我们可以编写transformAt它接受一个输入状态对象,o, apath和一个转换函数,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
Run Code Online (Sandbox Code Playgroud)

这些要点对应于上面编号的评论 -

  1. 当查询 ,q是 时None,路径已用完,是时候对t输入对象运行转换o
  2. 否则,通过归纳q不是空的。如果输入 ,o是一个对象,则使用Object.assign创建一个新对象,其中它的新q属性是其旧q属性的变换o[q]
  3. 否则,通过感应q空的,o不是一个对象。我们不能指望q在非对象上查找,因此raise发出信号的错误transformAt被赋予了无效路径。

现在我们可以很容易地写出appendComment它接受一个输入,state一个评论的 id,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
}
Run Code Online (Sandbox Code Playgroud)

Recallsearch生成谓词查询返回 true 的所有可能路径。您必须选择如何处理查询返回多个结果的场景。考虑像这样的数据 -

const otherState =
  { posts: [ { type: "post", id: 1, ... }, ... ]
  , images: [ { type: "image", id: 1, ... }, ... ]
  }
Run Code Online (Sandbox Code Playgroud)

使用searchById(otherState, 1)将获得两个对象,其中id = 1. 在appendComment我们选择只修改第一个匹配项。这是可以修改所有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
        )
Run Code Online (Sandbox Code Playgroud)

但在这种情况下,我们可能不希望在我们的应用中出现重复的评论。任何类似的查询函数都search可能返回零个、一个或多个结果,必须决定程序在每种情况下的响应方式。


把它放在一起

以下是剩余的依赖项 -

const isArray =
  Array.isArray

const isObject = x =>
  Object (x) === x

const raise = e =>
  { throw e }

const identity = x =>
  x
Run Code Online (Sandbox Code Playgroud)

让我们的第一个新评论追加到id = 2“主题B” -

const state1 =
  appendComment
    ( state
    , 2
    , { id: 4, text: "nice article!", comments: [] }  
    )
Run Code Online (Sandbox Code Playgroud)

我们的第一个状态修订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 ]
}
Run Code Online (Sandbox Code Playgroud)

我们将附加另一条评论,嵌套在该评论上 -

const state2 =
  appendComment
    ( state
    , 4  // <-- id of our last comment
    , { id: 5, text: "i agree!", comments: [] }  
    )
Run Code Online (Sandbox Code Playgroud)

第二次修订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, ... }
    ]
, ...
}
Run Code Online (Sandbox Code Playgroud)

代码演示

在这个演示中,我们将,

  • state1通过修改state添加第一条评论来创建
  • 创建state2通过修改state1添加的第二(嵌套)评论
  • 打印state2以显示预期状态
  • 打印state显示原始状态没有被修改

展开下面的代码段以在您自己的浏览器中验证结果 -

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))
Run Code Online (Sandbox Code Playgroud)


替代方案

上述技术与使用 Scott 提供的镜头的其他(优秀)答案平行。这里的显着区别是我们从一条到目标对象的未知路径开始,找到路径,然后在发现的路径上转换状态。

这两个答案中的技术甚至可以结合使用。search生成可用于创建的路径,R.lensPath然后我们可以使用R.over.

一种更高级的技术就在拐角处潜伏着。这来自于这样一种理解,即编写函数 liketransformAt相当复杂,并且很难正确使用它们。问题的核心是,我们的 state 对象是一个普通的 JS 对象{ ... },它不提供诸如不可变更新之类的功能。嵌套在这些对象中,我们使用[ ... ]具有相同问题的数组, 。

数据结构喜欢ObjectArray设计与可能不符合你自己无数的考虑。正是出于这个原因,您才有能力设计自己的数据结构,使其按照您想要的方式运行。这是一个经常被忽视的编程领域,但在我们开始尝试编写我们自己的领域之前,让我们看看我们之前的智者是如何做到的。

一个例子,ImmutableJS,解决了这个确切的问题。该库为您提供了一组数据结构以及对这些数据结构进行操作的函数,所有这些都保证了不可变的行为。使用图书馆很方便 -

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
}
Run Code Online (Sandbox Code Playgroud)

现在我们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"))
Run Code Online (Sandbox Code Playgroud)

希望我们可以开始将其transformAt视为通用函数。ImmutableJS 包含执行此操作的函数并非巧合,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
      )
Run Code Online (Sandbox Code Playgroud)

令我惊讶的是,即使transformAt是实施精确地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
}
Run Code Online (Sandbox Code Playgroud)

这是高级数据结构的教训。通过使用为不可变操作设计的结构,我们降低了整个程序的整体复杂性。因此,该程序现在可以用不到 30 行简单的代码编写——

//
// 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
}
Run Code Online (Sandbox Code Playgroud)

ImmutableJS 只是这些结构的一种可能实现。还有很多其他的,每个都有自己独特的 API 和权衡。您可以从预制库中进行选择,也可以定制自己的数据结构以满足您的确切需求。无论哪种方式,希望您都能看到精心设计的数据结构所带来的好处,并可能深入了解为什么首先发明了当今流行的结构。

展开下面的代码片段,在浏览器中运行 ImmutableJS 版本的程序 -

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))
Run Code Online (Sandbox Code Playgroud)
<script src="https://unpkg.com/immutable@4.0.0-rc.12/dist/immutable.js"></script>
Run Code Online (Sandbox Code Playgroud)