1

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

2 回答 2

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 ]
  }

搜索

您可以从编写一个泛型开始,该泛型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)为真的对象。IE,

// [ "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它接受一个输入状态对象 、oapath和一个转换函数,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不为空。如果输入是一个对象,则使用创建一个新对象,其新属性是其旧属性的转换。oObject.assignqqo[q]
  3. 否则,通过归纳q不是空的,也不o对象。我们不能指望在非对象上查找,因此向该对象发出错误信号时给出了无效路径。qraisetransformAt

现在我们可以很容易地写出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
}

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

const otherState =
  { posts: [ { type: "post", id: 1, ... }, ... ]
  , images: [ { type: "image", id: 1, ... }, ... ]
  }

使用searchById(otherState, 1)会得到两个对象 where 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
        )

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


把它放在一起

这是剩余的依赖项 -

const isArray =
  Array.isArray

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

const raise = e =>
  { throw e }

const identity = x =>
  x

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

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显示原始状态没有被修改

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

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))


替代选择

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

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

一种更高级的技术正潜伏在拐角处。这源于这样的理解,即编写类似transformAt的函数相当复杂,而且很难正确地编写它们。问题的核心是,我们的状态对象是一个普通的 JS 对象,{ ... }它没有提供不可变更新之类的特性。嵌套在这些对象中,我们使用[ ... ]具有相同问题的数组。

Object类似的数据结构Array在设计时考虑了无数可能与您自己不匹配的考虑因素。正是出于这个原因,您有能力设计自己的数据结构以按照您想要的方式运行。这是一个经常被忽视的编程领域,但在我们开始尝试编写自己的代码之前,让我们看看我们之前的智者是如何做到的。

一个例子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
}

现在我们写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"))

希望我们可以开始将其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
      )

令我惊讶的是,transformAteven完全按照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
}

这是高级数据结构的教训。通过使用为不可变操作设计的结构,我们降低了整个程序的整体复杂性。结果,现在可以用不到 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
}

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

于 2019-09-18T20:57:49.327 回答
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: {}
// }
<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)。

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

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

于 2019-09-14T20:42:40.797 回答