这是一种方法,我们 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
它接受一个输入状态对象 、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
这些要点对应于上面编号的评论 -
- 当查询 ,
q
是None
时,路径已用尽,是时候t
在输入对象 上运行转换 , 了o
。
- 否则,通过归纳,
q
不为空。如果输入是一个对象,则使用创建一个新对象,其新属性是其旧属性的转换。o
Object.assign
q
q
o[q]
- 否则,通过归纳,
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
}
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
)
令我惊讶的是,transformAt
even完全按照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>