在HumbleUI中,有一个功能齐全的 OOP 系统,为低级组件实例提供支持。我知道,在 Clojure 中我们不应该谈论它,这是亵渎神明的。但是...
看。组件(我们在 Humble UI 中称它们为节点,因为它们的作用与 DOM 节点相同)具有状态。简单明了。没有办法绕过它。所以我们需要一些有状态的东西来存储它们。
它们也有行为。同样,这是不可避免的。状态和行为协同工作。
仍然不是 OOP 的案例:可能是映射和函数。
(def node []{:state (volatile! state):measure (fn [...] ...):draw (fn [...] ...)})
但还有更多需要考虑的。
代码重用
许多节点共享相同的模式:例如,包装器是“包装”另一个节点的节点。padding包装器:
[ui/padding {:padding 10}[ui/button "Click me"]]
还有center:
[ui/center[ui/button "Click me"]]
所以也是如此rect(它在子项后面绘制一个矩形):
[ui/rect {:paint ...}[ui/button "Click me"]]
前两个在子元素的定位方式上有所不同,但在绘制和事件处理上相同。第三个具有不同的绘制函数,但布局和事件处理相同。
我想写AWrapperNode一次并让其余节点重用它。
现在 — 你可能会想 — 这仍然不是 OOP 的情况。只需提取一堆函数,然后挑选即可!
;; shared library code(defn wrapper-measure [...] ...)(defn wrapper-draw [...] ...);; a node(defn padding [...]{:measure (fn [...]<custom measure fn>):draw wrapper-draw}) ;; reused
这具有自由选择的额外好处:您可以混合和匹配来自不同父级的实现,例如从包装器测量并从容器中提取。
部分代码替换
有些函数会调用其他函数!真是令人惊讶。
一个方向很简单。例如,Rect 节点可以先绘制自身,然后调用父节点。我们通过将一个函数包装到另一个函数中来解决这个问题:
(defn rect [opts child]{:draw (fn [...](canvas/draw-rect ...);; reuse by wrapping(wrapper-draw ...))})
但现在我想用另一种方式来实现:父级定义包装行为而子级仅替换其中的一部分。
例如,对于 Wrapper 节点,我们总是希望保存和恢复绘图周围的画布状态,但绘图本身可以由子节点重新定义:
(defn wrapper-draw [callback](fn [...](let [layer (canvas/save canvas)](callback ...)(canvas/restore canvas layer))))(defn rect [opts child]{:draw (wrapper-draw ;; reuse by inverse wrapping(fn [...](canvas/draw-rect ...)((:draw child) child ...)}))})
我不确定你是怎么想的,但是对我来说,它开始感觉有点太高调了。
另一个选择是传递“this”并使共享函数在其中查找实现:
(defn wrapper-draw [this ...](let [layer (canvas/save canvas)]((:draw-impl this) ...) ;; lookup in a child(canvas/restore canvas layer))))(defn rect [opts child]{:draw wrapper-draw ;; reused:draw-impl (fn [this ...] ;; except for this part(canvas/draw-rect ...)((:draw child) child ...)}))
开始感觉像 OOP 了,不是吗?
面向未来
最后的问题:我希望 Humble UI 用户能够编写自己的节点。请注意,这不是默认界面,但如果有人想要/需要使用低级界面,为什么不呢?我希望他们拥有我拥有的所有工具。
问题是,如果将来我添加了另一种方法怎么办?例如,当一切开始时,我只有:
-measure-draw-event
最后,我添加了-context、、-iterate和-*-impl这些版本。没人能保证我将来不再需要另一个。
现在,使用 map 方法,问题在于没有节点。节点写为:{:draw ...:measure ...:event ...}
当我添加一个方法时不会突然有一个context方法。
这就是 OOP 解决的问题!如果我控制根实现并向其中添加更多内容,那么无论何时编写节点,每个人都会得到它。
看起来怎么样
我们仍然有正常的协议:
(defprotocol IComponent(-context [_ ctx])(-measure ^IPoint [_ ctx ^IPoint cs])(-measure-impl ^IPoint [_ ctx ^IPoint cs])(-draw [_ ctx ^IRect rect canvas])(-draw-impl [_ ctx ^IRect rect canvas])(-event [_ ctx event])(-event-impl [_ ctx event])(-iterate [_ ctx cb])(-child-elements [_ ctx new-el])(-reconcile [_ ctx new-el])(-reconcile-impl [_ ctx new-el])(-should-reconcile? [_ ctx new-el])(-unmount [_])(-unmount-impl [_]))
然后我们有基类(抽象类):
(core/defparent ANode[^:mut element^:mut mounted?^:mut rect^:mut key^:mut dirty?]protocols/IComponent(-context [_ ctx]ctx)(-measure [this ctx cs](binding [ui/*node* thisui/*ctx* ctx](ui/maybe-render this ctx)(protocols/-measure-impl this ctx cs)))...)
请注意,父母也可以有字段!承认吧:我们都使用 Clojure 来编写更好的 Java。
然后我们有中间抽象类,一方面,重用父类行为,但也在需要时重新定义它。例如
(core/defparent AWrapperNode [^:mut child] :extends ANodeprotocols/IComponent(-measure-impl [this ctx cs](when-some [ctx' (protocols/-context this ctx)](measure (:child this) ctx' cs)))(-draw-impl [this ctx rect canvas](when-some [ctx' (protocols/-context this ctx)](draw-child (:child this) ctx' rect canvas)))(-event-impl [this ctx event](event-child (:child this) ctx event))...)
最后,叶子节点几乎是正常的deftype,但是它们从父母节点中提取基本的实现。
(core/deftype+ Padding [] :extends AWrapperNodeprotocols/IComponent(-measure-impl [_ ctx cs] ...)(-draw-impl [_ ctx rect canvas] ...))
底层几乎没有什么魔法。父类实现只是复制到子类中,字段与子类的字段连接起来,等等。
再次强调,这不是最终用户将使用的界面。最终用户将编写如下组件:
(ui/defcomp button [opts child][clickable opts[clip-rrect {:radii [4]}[rect {:paint button-bg)}[padding {:padding 10}[center[label child]]]]]])
但所有这些rect/padding/center/label最终都会被实例化为节点。哎呀,甚至你的button意志也会变成FnNode。但你不需要知道这一点。
另外,提醒一下:所有这些解决方案,就像 Humble UI 本身一样,目前都在进行中。不保证它会一直保持这种状态。
结论
我听说 OOP 最初是专门为 UI 发明的。可变对象具有大部分共享但有时不同的行为,与对象范式完美匹配。
好吧,现在我知道了:即使在今天,无论你如何开始,最终你都会得出相同的结论。
希望您觉得这篇文章有趣。如果您有更好的想法,请告诉我。

