这节课已经很长时间了,这是至关重要的。至此,我们已经看到可以在不使用任何可变数据的情况下编写整个应用程序,但是在大多数情况下,至少可以这么说。正如我们在上一课中了解到的那样,ClojureScript鼓励将程序编写为被有效代码包围的纯功能性内核,这包括更新状态的代码。作为一种实用的语言,ClojureScript为我们提供了一些用于处理随时间变化的值的构造。
在本课程中:
- 使用原子来管理随时间变化的值
- 观察状态变化并对变化做出反应
- 使用瞬变进行高性能突变
原子
正如我们多次看到的,ClojureScript鼓励我们主要将程序编写为可转换不可变值的纯函数。我们还看到,这可能有点麻烦。遵循实用主义而非纯粹的哲学,ClojureScript提供了一种方便的工具来表示随时间变化的状态:原子。原子是可以在任何时间点保留单个不变值的容器。但是,他们引用的值可以换成另一个值。而且,只要发生这些状态交换,我们的代码就可以观察到。这为我们提供了一种处理随时间变化的状态的便捷方法。
与JavaScript不同,ClojureScript将身份和状态1的思想分开。的身份是一个逻辑实体的引用。该实体可能会随着时间而变化,但即使河流在其间流经的水量不同,它仍会保留其身份,就像河流保持其身份一样。身份可能会随着时间的推移与各种值相关联,这些值就是其状态。原子是我们在CloureScript中用来表示身份的状态容器。
河流随着时间的流逝拥有许多州
用更新状态 swap!
我们可以从中学习到的身份的最琐碎的例子是卑微的计数器。计数器是一种身份,其状态是随时间增加的数字。我们可以通过调用(atom v)
where v
是成为原子初始状态的值来将任何clojure值包装在原子中:
(def counter (atom 0))
由于原子在任何时间点都提供对某个值的引用,因此我们可以通过使用deref
宏或其缩写形式来取消引用它-即获取其引用的不可变值@
。
counter ;; <1>
;; => #object[cljs.core.Atom {:val 0}]
(deref counter) ;; <2>
;; => 0
@counter ;; <3>
;; => 0
取消引用原子
- 原子本身是一个包装值的对象
- 原子可以通过以下方式取消引用
deref
- 给原子加前缀
@
是用于调用的糖deref
当然,为了能够对原子执行任何有用的操作,我们必须能够更新其状态,并且我们将使用该swap!
函数来执行此操作。swap!
需要一个原子和一个变换。该函数将获得原子的当前状态,并应返回其新状态。swap!
自身将返回原子的新状态。的所有其他参数将swap!
作为其他参数传递到转换函数。对于我们的简单计数器,我们可以使用inc
将其递增并一次+
添加1以上。
(swap! counter inc)
@counter
;; => 1
(swap! counter + 9)
@counter
;; => 10
原子是ClojureScript提供一种非常受控制的状态更新机制的方式。当我们取消引用原子时,我们仍然获得一个不变的值,即使原子的状态被更新,我们收到的值也不会改变:
(def creature
(atom {:type "water"
:life 50
:abilities ["swimming" "speed"]}))
(def base-creature @creature) ;; <1>
(swap! creature update :abilities conj "night vision")
@creature ;; <2>
;; => {:type "water"
;; :life 50
;; :abilities ["swimming" "speed" "night vision"]}
base-creature ;; <3>
;; => {:type "water"
;; :life 50
;; :abilities ["swimming", "speed"]}
swap!
在进入新状态之前先解除原子的引用- 之后
swap!
,原子的状态已更改 - 我们得到的初始状态不变
我们还可以提供一个充当验证器的函数,该函数使我们可以使用set-validator!
函数2定义原子中允许哪种值。验证器函数采用的是原子的新值。如果返回false
(或引发错误),则我们尝试的更新将失败并引发错误。例如,为了保证我们永远不能:life
在生物上设置负值,我们可以提供一个验证器来确保此属性:
(set-validator! creature
(fn [c] (>= (:life c) 0)))
(swap! creature assoc :life 10) ;; Ok
(swap! creature assoc :life -1) ;; Throws error
(:life @creature) ;; 10
正如我们刚刚观察到的那样,以一种使验证器返回false的方式更新原子的状态会导致引发异常并且不进行任何更新。验证器在ClojureScript中并不常用,但是像函数的前提条件和后置条件一样,它们可以在开发期间成为有用的工具。
快速复审
swap!
返回什么值?- 验证程序功能如何指示是否应允许状态?
用替换状态 reset!
虽然swap!
对于转换原子的状态很有用,但有时我们只想立即更新原子的整个状态。使用ClojureScript的标准库,这不是一件容易的事:(swap! counter (constantly 0))
。constantly
返回一个函数,该函数每次被调用时始终返回一个特定值,因此,在这种情况下,它返回一个函数,该函数始终返回0(给定任何参数),这将有效地将计数器状态重置为0。但是,此代码不是尽其可能,这就是ClojureScript也提供该reset!
功能的原因。该函数仅获取原子和一个值,并将其设置为原子的新状态。像一样swap!
,它返回新状态:
(reset! counter 0)
@counter
;; => 0
该reset!
功能特别有用,尤其是当我们有一些已知的初始状态要恢复为原始状态时,否则swap!
在实践中会更常用。
观察手表变化
原子最有用的功能之一是能够在原子状态发生变化时得到通知。这是通过add-watch
功能完成的。该函数有3个参数:要监视的原子,唯一标识监视者的关键字和监视函数。watch函数本身采用传递给的关键字,add-watch
原子本身,原子的旧状态及其新状态。在大多数情况下,只有旧状态和新状态是我们感兴趣的。为了弄湿我们,让我们实现一个带有按钮的简单计数器,该按钮可用于增加或减少其值。
观看反原子
(defonce app-state (atom 0)) ;; <1>
(def app-container (gdom/getElement "app"))
(defn render [state] ;; <2>
(set! (.-innerHTML app-container)
(hiccups/html
[:div
[:p "Counter: " [:strong state]]
[:button {:id "up"} "+"]
[:button {:id "down"} "-"]])))
(defonce is-initialized?
(do
(gevents/listen (gdom/getElement "app") "click"
(fn [e]
(condp = (aget e "target" "id")
"up" (swap! app-state inc)
"down" (swap! app-state dec))))
(add-watch app-state :counter-observer ;; <3>
(fn [key atom old-val new-val]
(render new-val)))
(render @app-state)
true))
计数器组件
- 创建一个
atom
保留计数器状态 - 渲染采用当前状态
- 添加一个监视功能,该功能可在状态变化时重新呈现组件
在这个例子中,我们add-watch
用来观察app-state
原子状态的变化。有一个相关的功能,remove-watch
可以注销监视功能。它需要将要观察的原子和标识观察者的关键字除去。如果要在上面的示例中删除观察者,则可以这样调用此函数:
(remove-watch app-state :counter-observer)
挑战
从第20课中获取“通讯录”应用,并对其进行重构以使状态保持原子状态。
暂态
尽管原子是用于管理随时间变化的状态的事实上的工具,但是当我们出于性能考虑需要引入可变性时,瞬变会派上用场。如果我们需要在单个数据结构上连续执行许多转换,则ClojureScript的不可变数据结构并不是性能最高的。每次执行不可变数据结构的转换时,都会创建垃圾,JavaScript的垃圾收集器将需要清除这些垃圾。在这种情况下,瞬变可能非常有用。
可以使用以下transient
函数创建任何矢量,集合或映射的瞬时版本:
(transient {})
;; #object[cljs.core.TransientArrayMap]
对于瞬态工作的API类似于标准收集API,但是改造功能都有!
追加,例如assoc!
,conj!
。但是,读取API与不可变集合相同。瞬态集合可以使用以下persistent!
函数转换回其持久性副本:
(-> {}
transient ;; <1>
(assoc! :speed 12.3)
(assoc! :position [44, 29])
persistent!) ;; <2>
- 将地图转换为瞬态
- 将瞬态映射转换回持久性(不可变)结构
暂态不常用,仅当我们证明一部分代码太慢时才应将其视为性能优化。
明智地使用状态
ClojureScript的状态管理-尤其是原子-为我们提供了强大的功能,可以更自然和直观地对随时间变化的事物进行建模,但是这种能力带来了引入反模式的潜力。如果我们遵循几个简单的准则,则可以确保我们的代码保持清晰和可维护性。
准则1:明确传递原子
为了使函数可测试且易于推理,我们应该始终显式地传入在其上作为参数运行的任何原子,而不是在其范围内对全局原子进行操作:
;; Don't do this
(def state (atom {:counter 0})) ;; <1>
(defn increment-counter []
(swap! state update :counter inc))
;; OK
(defn increment-counter [state] ;; <2>
(swap! state update :counter inc))
- 增加全局反原子
- 增加作为参数传入的计数器原子
尽管这两个函数都不是纯函数(它们都具有变异状态的副作用),但是第二个选项更具可测试性和可重用性,因为我们可以传入任何希望的原子。我们不需要隐式依赖当前的全局状态。
准则2:偏好更少的原子
通常,应用程序应具有更少的原子和更多的数据,而不是为每个状态单独分配一个原子。考虑一次一次转换整个应用程序状态,而不是同步单独的状态,这更简单:
;; Don't do this
(def account-a (atom 100)) ;; <1>
(def account-b (atom 100))
(swap! account-a - 25)
(swap! account-b + 25)
;; OK
(def accounts (atom {:a 100 ;; <2>
:b 100}))
(swap! accounts
(fn [accounts]
(-> accounts
(update :a - 25)
(update :b + 25))))
- 将每个状态表示为一个单独的原子
- 代表我们的“世界”为原子
尽管第二个版本有些冗长,但它的优点是可以在不同步骤之间创建凝聚力,而这些步骤都是“事务”的一部分,它使我们能够创建复杂的状态转换而无需依赖许多单独的输入。正如我们将在下一节中看到的那样,这也是使用Reagent框架时的常见模式。
摘要
在本课程中,我们介绍了管理状态随时间变化的关键功能。如我们所见,我们可以创建完整的应用程序而无需求助于可变性,仅添加少量受控的可变性就可以使我们的代码大大简化。我们大部分时间都在研究如何使用原子来处理可变状态,以及如何观察和响应这些状态变化。我们还简要介绍了瞬态(ClojureScripts集合的可变版本),并了解到虽然它们可以优化性能,但它们并不是良好的通用状态容器。最后,我们研究了一些用于限制对可变状态的使用的准则,这些准则有助于使我们的应用程序可维护和可测试。
- 有关身份和状态的讨论,请参见https://clojure.org/about/state。 ↩
- 可替换地,当通过使地图作为第二个参数,以创建的原子的验证器可被提供
atom
,其中所述:validator
关键点到验证函数:(atom init-val {:validator validator-fn})