论文读得少, 代码读得也不多, 不知道术语是什么, 只好造了一个,
英文表述 "Successive Rendering", 模仿 "Progressive Rendering",
简单说, 就是 React 那样的性能优化方案, 我发现是个通用的办法,
在 React 当中, 做 Diff 之前需要生成 Virtual DOM, 这很消耗性能,
而递进渲染通过简单的办法有效使用的缓存, 能大大提高性能.
原理
具体怎样呢, 先要看 Virtual DOM 渲染的性能问题是怎么造成的,
Virtual DOM 展开, 按照体积会出现大量的内存申请, 同时 GC 压力
Diff 过程递归遍历, 随着树的深度增加检查的次数, 也会因为递归堆积内存
在 React 当中, 我们知道 shouldComponentUpdate
检查可以用来优化,
通过对参数的检查, 可以复用上次渲染的 Virtual DOM 数据, 解决问题 1,
然后由于复用, 可以通过引用一致直接发现没有改变, 避免了问题 2 的深度遍历.
特别是自己实现过一遍, 发现具体的做法真的是非常巧妙.
在考虑将该方案移植到 Cumulo 的过程当中, 我在思考服务端是否也能用,
因为 Cumulo 当中尝试在服务端做数据的展开, 然后进行 Diff,
这样也就有同样的内存申请和递归检查的问题, 需要用类似的办法优化.
我发现, 至少当能对于树形的结构做规则的遍历时, 这个方法就适用,
我这里说的规则, 就是说, 新的旧的两棵树, 当中的一个节点, 需要能对应.
以 HashMap 为例
比如说, 一份数据首先是这样的, 两个 key, 其中一个还是 HashMap:
(def a0 {:a 1
:b {:h 1
:i 2}})
经过一次操作, 出现了 :c 222
的数据:
(def a1 {:a 1
:b {:h 1
:i 2}
:c 222})
那么渲染 a1
时, 就能明确知道每个节点在上次渲染当中的对应位置,
比如这里的 :b
, 发现位置是 [:b]
数据没有改变, 就可以直接复用.
这个讲法应该很好懂, 实际上情况复杂很多, 不能直接从数据看出来.
我要借助一个类型标签 Twig, 在树当中标记出可以缓存的位置,
而这个 Twig 类型的 HashMap, 存储了数据和渲染函数, 可以用于判断:
(defn f [x] x)
(:a 1
:b
#Twig {:name :b
:args '({:h 1
:i 2})}
:render f
:data nil)
实际上代码当中的做法是, 先生成以上这一份数据, 但是到 Twig 位置停下,
可以看到 f
和 args
没有被 apply
, 只是分开保存了数据,
注意 :data
是存放 apply
的结果的, 刚开始这里会是 nil
.
同时, 上一次渲染也会有一个这样的结构, 对应的 :data
就是有数据的了.
那么, 这里就可以通过判断 f
和 arg
相对上次是否有改变, 而使用缓存.
代码实现
为了更好地解释, 我创建了一个 Gist 来完整演示,
; (defn create-twig [])
; (defn twig? [])
; (defn render-bunch [])
; 定义初始数据
(def store-ref
(atom {:a 0
:b {:h 0
:i 1
:j {:x 0
:y 1}}}))
; 名为 child 的一个数据片段
(def twig-child
(create-twig :child
(fn [x]
(assoc x :xx 11))))
; 名为 container 的一个数据片段
(def twig-container
(create-twig :container
(fn [store] {:a (:a store)
:b (dissoc (:b store) :j)
:c (twig-child (get-in store [:b :j]))})))
; Running
(println "Create cache:")
; 第一次渲染, 缓存为 nil, 生成缓存
(def tree-0 (render-bunch (twig-container @store-ref) nil))
(println "\nFully reuse cache:")
; 第二次渲染, 数据没有改变, 直接复用整个 Twig container
(def tree-01 (render-bunch (twig-container @store-ref) tree-0))
(println "\nUpdate data, partially reuse cache:")
; 第三次渲染, 数据局部改变, Twig container 重新计算, Twig child 能够复用
(swap! store-ref assoc-in [:b :h] 100)
(def tree-1 (render-bunch (twig-container @store-ref) tree-0))
这段代码运行结果会打印出下面的 log, 验证前面对于缓存的设计:
=>> lumo render.cljs
Create cache:
Full rendering: :container ({:b {:j {:y 1, :x 0}, :h 0, :i 1}, :a 0})
Full rendering: :child ({:y 1, :x 0})
Fully reuse cache:
Reusing cache: #cljs.user.Twig{:name :container, :args ({:b {:j {:y 1, :x 0}, :h 0, :i 1}, :a 0}), :render (fn []), :data nil}
Update data, partially reuse cache:
Full rendering: :container ({:b {:j {:y 1, :x 0}, :h 100, :i 1}, :a 0})
Reusing cache: #cljs.user.Twig{:name :child, :args ({:y 1, :x 0}), :render (fn []), :data nil}
细节可以去读 Gist 当中的代码, 不详细解释了, 其实很短.
这样也就保证了 Diff 过程能匹配引用, 从而提供性能. 这里就不深入了.
性能的讨论
演示的代码只是提供了针对 HashMap 递进渲染的过程, 这远远不够,
实际开发当中 HashMap 是远远不够的, 特别是数组的使用问题.
在 Clojure 中主要是 Sequence 和 Vector. Sequence 有时候就说 List 了,
关系比较复杂, 要看Lo姐的文章.. http://clojure-china.org/t/lo...
总之区别是 Vector 支持随机访问适合从尾部操作, 复杂效能不高,
而 Sequence 基本上是链表, 适合从头部操作, 否则性能降低很快.
而 Sequence 跟 Vector 这样的特性, 就无法很好定位了,
比如说 Vector 中间出现增删的情况, 就很难检查出来,
在 Diff 算法当中有 LCS 算法递归检测, 然而对于高性能场景未必好,
而且 LCS 优化后的写法比较复杂, 我没有掌握... 结果也写不出来.
所以目前对于这类数据的检测, 只好做一些假设, 提高某些场景的性能,
比如 Vector 按头部的 index 匹配, Sequence 按尾部 index 匹配.
这样在常用的操作场景当中是能尽量高性能, 而不管其他场景.
初次之外, Clojure 当中还有 HashSet 做了处理, 但是效率也不高.
实际上 Clojure 数据类型可以很复杂, 超出了我这个 js 程序员的想象.
从数据结构角度看, 更复杂的类型也可以用指针构造出来, 只能说更复杂,
而要对那样的场景做支持, 保证递进渲染总是能较好地利用上次渲染结果, 挺难的.
我现在只能做到方案能跑, 比从前有显著改名而已.
小结
所以这篇文章主要讲的是 React 启发的递进渲染方案的扩展使用的问题,
React 基于 parent/children 结构, 类似 HashMap, 比较好处理,
所以我就把策略移植过来用对于 HashMap 先实现了一遍,
然后写了一些衍生, 以便能对 Vector, Sequence, HashSet 做支持,
虽然没有很好解决性能问题, 但是总体上能保证方案正常进行了.
这种算法主要的效果是大量复用内存, 减少了申请内存和深度遍历的情况,
这样, 我就可能在服务器上运用此类方案, 而不像原来有那么大的开销了.
这个方案在 Respo 的 Virtual DOM 渲染当中的已经验证了实用性,
再考虑 Cumulo 的场景, 递进渲染作为函数式编程的一个优化方案也是不错的,
FP 经常将惰性计算和缓存作为优化手段, 因而我认为这段代码还会继续被用到.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。