1. Introduction
我们已经将第一个effect提取到我们的架构中。让我们回顾一下我们刚刚完成了什么。
2. Recap
在我们看来,我们有一个副作用,将最喜欢的质数保存到磁盘上,我们知道我们需要一些方法来控制。一方面,它是不可测试的代码,另一方面,我们发现简化视图的一个有用方法是将所有的逻辑移动到我们的reducer中,并简单地让视图负责向store发送用户操作。
因此,我们很天真地将副作用代码移动到reducer中。这在技术上完成了任务,但却破坏了reducer的所有良好的可测试性和可理解性。
因此,我们回顾了从之前的章节中学到的关于副作用的一些经验教训,特别是我们经常可以通过引入一个新的输出到函数的边界,表示我们想要执行的effect,而不实际执行它。
这导致我们将reducer签名更改为一个返回void-to-void闭包的函数,该函数可以保存副作用工作,而无需实际执行它,从而将执行工作的责任传递给store。
在修正了一些编译器错误之后,我们终于能够将保存工作封装在一个闭包中,并让store库为我们运行它,而不是在reducer中运行它。最重要的是,像这样改变reducer签名并没有阻止我们仍然拥有可组合reducer和store,这是这种类型架构背后的真正力量。
这是我们在架构中引入effect的第一步,这是一个简单的effect。我们还有几个步骤要做,因为我们需要建模更复杂的effect。 例如,加载最喜欢的质数列表与保存有一点不同。保存主要是一个“射后不理”的操作,我们只运行工作,不需要向reducer反馈发生了什么事情。
然而,加载做了一些工作来从磁盘加载数据,然后我们想要将这些工作反馈给reducer。
所以让我们来看看我们需要如何改变这个影响模型来获得这种能力。
3. Synchronous effects that produce results
我们现在想把这个加载的副作用到我们的reducer。
Button("Load") {
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return }
self.store.send(.loadedFavoritePrimes(favoritePrimes))
}
当点击加载按钮时,我们已经有一个action被发送到store,但它并不完全正确。视图正在做附带的工作来获取最喜欢的质数数组,然后在操作中将数据发送到store。我们想把这个副作用移动到reducer上,所以让我们把加载按钮改为发送一个action到store。
Button("Load") {
self.store.send(.loadButtonTapped)
为了让它起作用,我们需要在action enum中引入一个新的action:
public enum FavoritePrimesAction {
// …
case loadButtonTapped
我们需要在reducer中处理这种情况:
case .loadButtonTapped:
但是我们在这里做什么呢?我们想做之前在视图中做的所有工作。
case .loadButtonTapped:
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return }
self.store.send(.favoritePrimesLoaded(favoritePrimes))
不过,我们不想直接做这项工作。我们想把它包装成一个effect closure,这样reducer就不会执行副作用,而由store执行。
case .loadButtonTapped:
return {
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return }
self.store.send(.favoritePrimesLoaded(favoritePrimes))
}
🛑 Use of unresolved identifier ‘self’
但这也不完全正确。我们想把所有这些工作的结果发送到store,但是在reducer中,我们没有self或store可供我们使用。
当这个逻辑在视图中时,它运行effect,并向store发送一个action,让它知道应该更改状态。现在,我们已经移动到我们的reducer的effect,我们需要重新考虑的事情,因为我们需要提供一种方式,采取一个effect的结果,并将其直接返回到reducer!
遗憾的是,这意味着我们目前建模effect的方式(如()-> Void闭包)并不十分正确。它需要多一点,以便能够使改变reducer的未来状态。我们已经有了一个action,favoritePrimesLoaded,它就是这样做的,所以它听起来不是向Void发射effect,而是需要给它们返回一个可以影响状态的action的能力。
这意味着也许我们的effect类型应该有以下签名:
public typealias Effect<Action> = () -> Action
这意味着在Effect闭包中发生的工作可以只对完成完成effect所需的最小工作感兴趣,然后通过将其包装在action中将工作结果发送回reducer。
但这并不是我们想要的形状,因为并不是所有的effect都会产生需要反馈到系统中的数据。 例如,我们现有的effect是将最喜欢的质数写入磁盘。也许我们想要的是让effect返回一个可选的action,这样fire-and-forget的effect就可以返回nil,这些会被store忽略。
public typealias Effect<Action> = () -> Action?
我们可以相应地更新我们的reducer签名。
public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect
🛑 Reference to generic type ‘Effect’ requires arguments in <…>
通过提供Action泛型。
public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect<Action>
send函数再次需要更新。
public func send(_ action: Action) {
let effect = self.reducer(&self.value, action)
effect()
⚠️ Result of call to function returning ‘Action?’ is unused
这是一个很好的警告。现在调用effect将返回一个需要处理的值,因此我们应该处理它
public func send(_ action: Action) {
let effect = self.reducer(&self.value, action)
let action = effect()
这是一个可选的操作,所以我们可以先尝试打开它。
public func send(_ action: Action) {
let effect = self.reducer(&self.value, action)
if let action = effect() {
}
我们可以将这个action反馈回发送。
public func send(_ action: Action) {
let effect = self.reducer(&self.value, action)
if let action = effect() {
self.send(action)
}
这是我们得到的一种反馈循环:当一个effect运行时,如果它产生了一个action,我们可以直接将它反馈给store。
视图方法还没有编译。无操作闭包现在必须显式返回一个空操作。
return { nil }
我们的reducer合成函数需要更新。第一:combine。
public func combine<Value, Action>(
_ reducers: Reducer<Value, Action>...
) -> Reducer<Value, Action> {
return { value, action in
let effects = reducers.map { $0(&value, action) }
return {
for effect in effects {
effect()
}
}
⚠️ Result of call to function returning ‘Action?’ is unused
这和之前的警告是一样的,这是一个很好的警告,因为它让我们知道我们没有处理effect可能产生的action。
return {
for effect in effects {
let action = effect()
}
}
让我们记住这里返回的是一个(**)-> Action?**闭包。
在这个闭包中,我们需要返回一个可选的操作。
严格来说,我们已经有一个了,所以我们可以马上还回去。
return { () -> Action? in
for effect in effects {
let action = effect()
return action
}
}
这不是我们想要的,因为这意味着我们将只执行第一个effect,并且只有它的可选操作可以反馈到reducer。这意味着以后产生的任何其他影响将被忽略。
相反,我们可以运行每个effect并跟踪最终产生的非零操作。
return { () -> Action? in
var finalAction: Action?
for effect in effects {
let action = effect()
if let action = action {
finalAction = action
}
}
return finalAction
}
但这也很奇怪,因为尽管我们运行了所有的effect,但我们只提供了一系列effect产生的最后一个action,这意味着我们可能会错过一些应该反馈给store的action。
4. Combining multiple effects that produce results
看来我们的reducer函数的形状还是不太对。
public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect<Action>
返回一个动作的单个effect不太足够,因为当我们把还原器结合在一起时,我们被迫丢失一些信息。
相反,如果我们的简化程序可以返回effect数组,我们就可以解决这个问题。
public typealias Reducer<Value, Action> = (inout Value, Action) -> [Effect<Action>]
这破坏了send,因为我们现在必须循环每个effect并在将其返回到发送方法之前展开它。
public func send(_ action: Action) {
let effects = self.reducer(&self.value, action)
effects.forEach { effect in
if let action = effect() {
self.send(action)
}
}
}
在视图方法中,我们现在可以返回一个空数组,而不是nil的无操作闭包。
return []
现在我们应该能够重新定义combine函数了。effect现在以数组的形式返回,因此映射到reducer上并收集它们的所有effect的结果导致了effect的嵌套数组。
public func combine<Value, Action>(
_ reducers: Reducer<Value, Action>...
) -> Reducer<Value, Action> {
return { value, action in
let effects = reducers.map { $0(&value, action) }
///let effects: [[() -> Action?]]
每当您遇到这种嵌套问题,即映射一个值进一步嵌套它时,我们可以转向flatMap! 因此,通过将map更新为flatMap,我们可以将reducer映射到effect数组中,然后将它们展开为effect浅数组。
let effects = reducers.flatMap { $0(&value, action) }
这正是我们要返回的。
return effects
5. Pulling local effects back globally
What about pullback?
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
_ reducer: @escaping Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>
) -> Reducer<GlobalValue, GlobalAction> {
return { globalValue, globalAction in
guard let localAction = globalAction[keyPath: action] else { return {} }
🛑 Cannot convert return expression of type ‘() -> ()’ to return type ‘[() -> GlobalAction]’
这里我们可以将空闭包替换为空数组。
guard let localAction = globalAction[keyPath: action] else { return [] }
对于第二个错误,我们必须处理这样一个事实:effects现在具有返回操作的能力,在这里我们同时处理局部操作和全局操作。
let effect = reducer(&globalValue[keyPath: value], localAction)
return effect
🛑 Cannot convert return expression of type ‘[() -> LocalAction]’ to return type ‘[() -> GlobalAction]’
我们可以将effect重命名为localEffects,因为它现在是一个返回局部操作的局部效果数组。
let localEffects = reducer(&globalValue[keyPath: value], localAction)
我们需要以某种方式将返回局部操作的局部effects 数组转换为返回全局操作的全局effects 数组。让我们试试map。每次闭包运行时,它都会传递一个局部effect。
localEffects.map { localEffect in
}
我们知道我们需要返回一个全局effect,所以我们可以返回一个全新的闭包来描述这个effect。
localEffects.map { localEffect in
return { () -> GlobalAction? in
}
}
但我们如何产生全球effect呢? 我们需要这个闭包返回一个可选的全局操作,最好是从可选的局部操作返回。我们可以通过执行局部effect来获得可选的局部动作。
localEffects.map { localEffect in
return { () -> GlobalAction? in
let localAction = localEffect()
}
}
如果我们不能解包它,我们可以返回一个nil全局操作。
localEffects.map { localEffect in
return { () -> GlobalAction? in
guard let localAction = localEffect() else { return nil }
}
}
在这个guard之后,我们有一个诚实的地方行动,但我们所处的闭包需要一个全局行动。我们怎么做呢?
幸运的是,我们有一个可写的键路径可以做到这一点!为了获得一个可变的全局操作,我们可以进行复制。
localEffects.map { localEffect in
return { () -> GlobalAction? in
guard let localAction = localEffect() else { return nil }
var globalAction = globalAction
}
}
从这里开始,我们可以使用键路径下标嵌入一个局部操作。
localEffects.map { localEffect in
return { () -> GlobalAction? in
guard let localAction = localEffect() else { return nil }
var globalAction = globalAction
globalAction[keyPath: action] = localAction
}
}
最后,我们可以从全局effect中返回全局action。
localEffects.map { localEffect in
return { () -> GlobalAction? in
guard let localAction = localEffect() else { return nil }
var globalAction = globalAction
globalAction[keyPath: action] = localAction
return globalAction
}
}
我们最终可以返回结果。
return localEffects.map { localEffect in
编译器对pullback很满意。让我们花点时间来解释一下发生了什么因为这个函数做了很多事情。
非常棒的是,我们为action enum生成的enum属性带有setter,这样我们就可以从它们中获得可写键路径,这正是我们实现这个pullback所需要的。
pullback函数是我们将作用于局部状态和行动的reducer转换为作用于全局状态和行动的reducer的方法。它通过构建一个全局reducer,当全局state和action到来时,它使用关键路径提取局部state和action,本地reducer运行它,然后使用关键路径来填补新局部状态回到全局状态。
除此之外,运行局部reducer现在会产生一个局部effects数组,即可以将局部操作发送回系统的效果。我们可以通过运行局部effect,获取产生的局部action,并使用可写键路径将其嵌入到全局action中,从而将局部effect转换为全局effect。将此转换应用于局部effect数组中的每个effect,就完成了这个函数的实现。
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
_ reducer: @escaping Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>
) -> Reducer<GlobalValue, GlobalAction> {
return { globalValue, globalAction in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = reducer(&globalValue[keyPath: value], localAction)
return localEffects.map { localEffect in
return { () -> GlobalAction? in
guard let localAction = localEffect() else { return nil }
var globalAction = globalAction
globalAction[keyPath: action] = localAction
return globalAction
}
}
}
}
pullback做了很多,但这都是很自然的机械工作。在实现这个函数的时候,我们没有太多的选择。它基本上总是一个用关键路径将局部内容转换为全局内容的过程。
我们在这个文件中还有一个东西需要更新,那就是logging的高阶减速器。
public func logging<Value, Action>(
_ reducer: @escaping Reducer<Value, Action>
) -> Reducer<Value, Action> {
return { value, action in
let effect = reducer(&value, action)
let newValue = value
return {
🛑 Cannot convert return expression of type ‘() -> ()’ to return type ‘[() -> Action?]’
我们需要做一些改动。reducer现在返回一个effects数组。
return { value, action in
let effects = reducer(&value, action)
我们需要在这些effects之前加上我们的 logging effect。
public func logging<Value, Action>(
_ reducer: @escaping Reducer<Value, Action>
) -> Reducer<Value, Action> {
return { value, action in
let effects = reducer(&value, action)
let newValue = value
return [{
print("Action: \(action)")
print("Value:")
dump(newValue)
print("---")
return nil
}] + effects
}
}
6. Working with our new effects
好吧! 可组合架构现在已经完全构建好了,但是最受欢迎的质数模块还没有。
首先,我们可以更新签名,以在其操作类型上返回一个effects泛型数组。
public func favoritePrimesReducer(
state: inout [Int], action: FavoritePrimesAction
) -> [Effect<FavoritePrimesAction>] {
现在我们有个effect需要解决。我们可以将no-op闭包更新为no-op数组。
case let .deleteFavoritePrimes(indexSet):
for index in indexSet {
state.remove(at: index)
}
return []
case let .loadedFavoritePrimes(favoritePrimes):
state = favoritePrimes
return []
savebuttontap effect需要包装在一个数组中,并返回nil,以表示它是一个射后不理的操作,不会将任何结果返回给store。
case .saveButtonTapped:
let state = state
return [{
let data = try! JSONEncoder().encode(state)
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
try! data.write(to: favoritePrimesUrl)
return nil
}]
loadbuttontap也是类似的情况。我们可以先把它包装在一个数组中:
case .loadButtonTapped:
return [{
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return }
self.store.send(.favoritePrimesLoaded(favoritePrimes))
}]
以前我们依赖于对store的访问,现在我们可以直接从effect返回此操作,以便store稍后可以对其进行执行。
return .favoritePrimesLoaded(favoritePrimes)
最后,我们现在需要返回之前纾困的nil。
else { return nil }
该模块现在处于构建顺序中,所以我们应该能够运行我们的playground,在那里一切都像以前一样工作,但现在所有的副作用都由store执行,而我们的reducer仍然是一个纯粹的函数。
一个关于移动这个effect的工作到reducer的缺点是我们的reducer已经变得相当大。
当我们支持更多的actions和更多的effects时,我们面临着reducer变得巨大和难以阅读的风险。
一个常见的解决方法是把这个effect提取出来给小的私人助手函数,这样我们的reducer就可以保持漂亮和简洁。例如,我们可以将保存效果提取到一个私有函数中。
private func saveEffect(favoritePrimes: [Int]) -> Effect<FavoritePrimesAction> {
return {
let data = try! JSONEncoder().encode(favoritePrimes)
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
try! data.write(to: documentsUrl.appendingPathComponent("favorite-primes.json"))
return nil
}
}
我们可以从reducer中调用它。
case .saveButtonTapped:
let state = state
return [saveEffect(favoritePrimes: state)]
我们还可以移除在效果闭包中访问状态所需的let state = state。这以前是必需的,因为state是可变的,而且不允许从逃逸闭包访问它。但在我们的saveEffect函数中,我们可以显式地表示我们想要一个不可变的整数数组,所以现在我们可以直接将state传递给它。
case .saveButtonTapped:
return [saveEffect(favoritePrimes: state)]
负载效应可以类似地提取:
private let loadEffect: Effect<FavoritePrimesAction> = {
let documentsPath = NSSearchPathForDirectoriesInDomains(
.documentDirectory, .userDomainMask, true
)[0]
let documentsUrl = URL(fileURLWithPath: documentsPath)
let favoritePrimesUrl = documentsUrl
.appendingPathComponent("favorite-primes.json")
guard
let data = try? Data(contentsOf: favoritePrimesUrl),
let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
else { return nil }
return .favoritePrimesLoaded(favoritePrimes)
}
And called out to in the reducer:
case .loadButtonTapped:
return [loadEffect]
通过提取这些effects,我们已经使我们的reducer更加简洁。将复杂的内联效果重构到提取的帮助程序中总是很容易的。
7. What’s unidirectional data flow?
又提取了一个effect。让我们再花一点时间来回顾一下我们所取得的成就。
我们想从我们的视图中提取磁盘effect的加载,并以某种方式在reducer中建模。我们很快意识到这种effect与我们之前处理的effect不太一样。保存的效果本质上是“射后不理”,它只是完成了自己的工作,之后不需要通知任何人任何事情。
然而,加载effect需要以某种方式将加载的数据反馈到reducer中,以便我们能够作出反应。这导致我们将签名从一个空到空的闭包重构为一个空到可选的动作闭包。这允许effect做最少的必要工作来完成工作,然后通过发送另一个动作将结果返回到reducer。 然后,store成为这些effects的解释器,首先运行reducer,收集所有想要执行的效果,遍历该错误以执行effects,然后将effects产生的任何操作发送回store。
这就是人们所说的“单向数据流”。数据只会以一种方式被改变:一个动作进入reducer,它允许reducer改变状态。如果你想通过一些副作用来改变状态,你别无选择,只能构建一个新的动作,然后反馈给reducer,只有这样你才有能力改变。
这种类型的数据流是非常容易理解的,因为您只有一个地方可以查看状态如何发生突变,但它的代价是需要添加额外的操作,以便将效果effects返回到reducer中。这就是为什么许多UI框架,包括SwiftUI,提供了一些方法来避开严格的单向风格,以便简化使用,就像他们使用双向绑定一样,但这可能以使数据通过UI的方式复杂化为代价。