关于 Quamolit 动画方案设计的思考

最近两周在思考 React 动画的问题, 也就是 Quamolit 的代码
之前实现的 Respo 属于山寨 React, 实际上复杂度比起 React 低太多了
回过头想想挺走运, 想清楚了 sorted-map diff 的算法, 然后搞定了
而且类似的算法在 Quamolit 当中也起到了决定性的作用, 所以说走运
Respo 主要是基于缓存和性能优化方面考虑的, 并不涉及动画
Quamolit 则是在 Canvas 上实现 React 并内置动画方案, 性能却很差
当然现在两个项目都只是用于研究, 可用性极低, 还是调整节奏为主

整个事情和 ClojureScript 还是有莫大的联系, 首先就是不可变数据
在我近期的开发当中, 不可变数据, 递归, 一切皆是表达式, 起到巨大作用
cljs 内置的参数检查也算帮我省下了不少排查的时间, 毕竟变量错很难发现
当然, 即便用了 cljs, 变量错, 逻辑错, 语法问题, 还是会消耗很多精力
但是呢, 在语言级别提供的可靠性和灵活性, 这比 JavaScript 是好太多了
最后一点, 思考问题的方式, 特别是可变数据不可变数据的区分, 很有帮助

目前初步探索的代码已经完成, 基于 Cirru, 可以通过下边的命令编译 cljs:
http://github.com/Quamolit/quamolit

brew install boot-clj
boot compile-cirru # 生成 cljs 代码到 src/
boot build-simple # 生成应用到 target/
boot dev # 生成开发环境应用到 target/index.html

然而 cljs 大概阅读起来相当累, 我只是有自己的习惯罢了, Cirru 你懂的...
代码也很难说是好的, 目前功能也不完整, 只是我自己玩玩
这篇文章的话主要是留一个笔记, 整理下自己的思绪和感想, 也许有用
具体的开发计划我有自己做的 todolist 在上边有安排, 放慢一点

回顾 MVC

在熟悉 React 和 Flux 之前, 前端 MVC 是在是无比头疼的一件事情
很早也说了, 被 jQuery 误导, 被可变数据误导, 产生很多奇怪的想法
现在我认为, 事情到底是要设计一套数学模型, 再由程序来完善其实现
早先服务端 MVC 或者客户端 MVC, 都依赖具体实现以及各自的特性
我在前端基于浏览器思考 MVC, 当然也受限 DOM 或者 Canvas
总之呢, 我现在好歹抛开了很多限制, 能够思考一下问题本身

MVC 模型的基本概念很早就摸清楚了, 虽然理解上做了简化
设计数据结构, 基于数据渲染, 定义数据更新方式, 以及处理事件
数据结构在脚本语言里远比在数据库随意, 我了解后者不多
渲染方面, 无论 DOM 还是 Canvas, 前端框架比比皆是, 清楚了
数据更新, 走出可变数据的误区, 主要只是函数式编程技巧
而事件处理, 涉及到屏幕和浏览器, 其实依靠操作系统帮忙完成
把上边几个要素拼接完成, 一个应用就算能做出来了, 听上去简单
实际项目复杂, 应该是在数据同步, 设备细节, 业务等方面有难点
当然, 也有可能 jQuery JavaScript 之类过低的技术栈导致

简单的思路, 我用函数随便写一下大概也能理解:

f1(model)=view  ; 数据映射到视图
f2(view)=UI     ; 视图绘制到屏幕上
h(m1)=m2        ; 怎样更新数据
g(UI, user)=h   ; h 指上一行的 h, 通过 UI 和用户操作判断怎样更新数据

然而实际操作当中很多环节会出现奇怪的问题, 属于实现上的
f1 还好, 只是模板引擎之类有奇怪的地方, 性能或者组件化上问题
f2 我一般认为是 React 或者 Ractive, 但 jQuery 的话已经乱套了
h 可能的问题是, 数据在前端后端都需要更新, 结构还不一样
g 属于 DOM 处理事件的细节, 常用事件代理, 一般都还好
而且, 实际的应用中, 一般很难做到 Single Store, 局部状态难以避免
那么框架层面上怎样用全局状态模拟出局部状态, 就需要考虑了
就算都做好了, 性能呢, 全局刷新诶, 怎样能尽量智能地去掉重复计算?

Quamolit 的渲染方案

Quamolit 一年前的冬天就写过一个版本了, 功能差不多
只是动画部分没处理好, 这次我用 Respo 当中的手法加强一下
React 我们知道由于受限于 DOM, 图形的功能并不灵活
当然, 有 SVG, 很多图案能做出来, 而且更实用.. 只是不够灵活
如果能控制 Canvas 各种 Path, 有好的办法管理, 那么灵活得多
另外一个是 TransitionGroup 的问题, 其功能不够强大
虽然有 react-motion, 但是设计得也太 tricky 了, 不好上手
考虑到动画是逐帧刷新的, Canvas 显然是更合适的模型

问题在于 Canvas API 只是基础 API, 抽象起来累, 绘制曲线更是
好的思路是比如 react-canvas 这样, 用声明式的写法描述
那么一个组件或者一个元素类似函数词法作用域, 隔离一份数据
然后这些组件和元素可以进行组合, 形成复杂的应用
隔离主要通过 ctx.save() ctx.restore() 配合完成
save 保存当前配置为一个栈, restore 可以退回到前一个栈
加上一些语法糖, 就能实现一个元素一个功能, 并处理好子元素

数据结构设计, 数据更新, 不多说了, 前端没有后端复杂
只是在数据同步的问题上, 多台设备状态不一致, 以后还是难点
Quamolit 的代码主要还是渲染, 还有事件处理上
Quamolit 是基于 Canvas 实现的, 比 DOM 会复杂一点
好在 Canvas API 还算丰富, 虽然有点狼狈, 但整个还是走得通的
整体的思路就是声明式地写组件, 同时方便处理动画

Quamolit 中动画的理解

首先在 Respo 模型中, store 和 states 是全局的
也就是说通过 store 和 states, 可以基于组件代码重绘界面
大致是 f(store, states) = view, 而动画并不包括在其中
应该改成 g(store, states, time) = view 才能有动画
这个事情仔细理解还是有难点, 界面通过以上三个参数难道足够了?
想象一下, 有个任务, 点击删除按钮, 会有删除动画, 点击完成有完成动画
而且两个动画可能由于操作太快叠加, 那么, 这种动画明显参数不够用
而动画实际上很每个操作有关, 或者说和 UI 的前一个状态有关
所以, 动画明确带有内部状态的, 甚至应该算是私有的状态

可能不严谨但大致表达出点, 动画应该是这样的:

f(store, states, time, view0) = view1

每一个新的 view, 与它的上一个状态有关, 以及之前的三个参数
而 view 本身的状态, 我考虑下来还是用"速度"这个概念好理解
回到前面的任务的例子, 有"删除"和"完成"两个按钮, 两种动画
实际当中可能是 N 中动画, 有可能因为奇怪的原因同时有多个在跑
那么, 要确定在时间 tx 界面的样子, 必须知道所有 N 个事件的信息
比如说 t1 时完成, t2 时删除, t3 别的事情, 事件每个在代码里到要考虑
但是用速度的话, 假设最初有 v0, 经过 t1 变成 v1, 然后是 v2, v3
这样, 永远关注速度就好了. 当然 v 可能有多个维度

在 Quamolit 当中我设置是 instant 这个数据包含 view 的状态
就是说 instant 中有速度 v, 有当前状态比如 d, 这样就能用了
基本功能我发了一条微博演示, 看上去和普通的 DOM 做的差不多:
http://weibo.com/1651843872/DrFWnlYTi

  • mount 过程的动画

  • unmount 过程延迟删除节点, 并且动画

  • props 或者 state 改变

其他种类的动画, 比如循环, 比如视差, 思路已经有了, 细节还得看
第一版 Quamolit 已经考虑过一些问题, 所以算是积攒下来了

由于框架整体采用声明式写法, 而动画部分需要操作状态
于是存在一个声明式转换过程式的问题, 把数据变化转化为函数调用
虽然上边像模像样写了个公式, 但是具体实现很难说清楚
只能说大体上套用了 DOM Diff 的代码, 遇到 add rm 时进行函数调用
以及 props state 的改变, 也是进行类似的检查然后调用函数
好在项目整体上都是基于不可变数据和少量 Atom 完成的

然后是事件的问题, 其实我做了过度的简化, Canvas 本来就不好做
要确定点击位置属于哪个元素, 肯定需要算法做反向的映射
比如手工计算界面的矩阵变换, 得到原始的点, 好吧, 对我来说极难
好在浏览器提供了一套 Hit Regions API, 通过像素点的事件返回标记
这样在 Click 事件时能从 event.region 拿到预先写入的字符串
然后通过自己查询节点树, 能完成从事件到元素监听器的映射
可惜 Hit Regions 目前浏览器并不是默认开启的, 只能作为试验使用

示例

此外除了 Hit Regions 的问题, 实际上文本输入, 性能等各有问题
Quamolit 只是研究用的玩具, 用来严重这套思路是可行的
即便有朝一日 API 能用了, 我相信别人也会自己实现方案出来
说了那么多, 我主要是花了很长时间想通了动画状态怎么处理的问题
至于能不能扩展到更复杂的界面动画, 我只是看到希望, 并不确定
希望这套手法有用. 至于代码, 我写的代码能耐不怎么样

下面当前版本的 Quamolit 实现一个可以切换颜色的按钮例子
也就是那套微博上, todolist 任务左边切换完整状态的按钮
我用 Cirru 写的, 下面是生成的 cljs, 以及手工添加的注释

(ns quamolit.component.task-toggler ; 命名空间, 无视
  (:require [hsl.core :refer [hsl]]
            [quamolit.util.iterate :refer [iterate-instant tween]] ; 我写了辅助函数
            [quamolit.alias :refer [create-component group rect]])) ; 创建组件用的

(defn style-toggler [done-value] ; 样式对应 Canvas API, done-value 属于 0~1000
  {:w 40,
   :h 40,
   :fill-style
   (hsl
     (tween [360 200] [0 1000] done-value) ; 我写的辅助函数, 取中间的插值
     80
     (tween [40 80] [0 1000] done-value))})

(defn handle-click [task-id]
  (fn [event dispatch] (dispatch :toggle task-id))) ; Respo 风格的 dispatch action

; instant, 也就是组件状态, 初始化的代码
(defn init-instant [args state]
  (let [done? (first args)]
    {:numb? true, :done-value (if done? 0 1000), :done-velocity 0}))
; numb? 是标记组件删除用的, 这个例子里没有, 但 unmount 再动画结束, 根据它删除
; done-value, 把 done? 的 true/false 转成数字, 以便插值
; done-velocity 速度, 默认是 0

; props 和 state 更新时用的
(defn on-update [instant old-args args old-state state]
  (let [old-done? (first old-args) done? (first args)]
    (if (not= old-done? done?) ; props 里的 done? 如果改变的话, 甚至速度
      (assoc
        instant
        :done-velocity
        (if (> (:done-value instant) 500) -3 3)) ; 我用了 3 或者 -3 的速度
      instant)))

; tick 是时间循环, elapsed 是两次时间循环的时间差
(defn on-tick [instant tick elapsed]
  (iterate-instant instant :done-value :done-velocity elapsed 1000 0))
; iterate-instant 是辅助函数, 根据 value 和 velocity 算下个位置, 1000 和 0 是上下界

; render 函数, 依赖 props, state, instant. 而 view0 是在框架中处理的
(defn render [done? task-id]
  (fn [state mutate]
    (fn [instant]
      (comment .log js/console "done:" instant)
      (rect
        {:style (style-toggler (:done-value instant)), ; 颜色根据 instant 显示
         :event {:click (handle-click task-id)}}))))

; 创建组件
(def component-toggler
 (create-component
   :task-toggler
   {:init-instant init-instant,
    :on-tick on-tick,
    :on-update on-update,
    :render render}))

再看下创建更大的组件的例子, 这个 task 引用了上边的 toggler:

(ns quamolit.component.task
  (:require [hsl.core :refer [hsl]]
            [quamolit.alias :refer [create-component group rect]]
            [quamolit.render.element :refer [translate alpha input]]
            [quamolit.util.iterate :refer [iterate-instant]]
            [quamolit.component.task-toggler :refer [component-toggler]])) ; 引用

(def style-block {:w 300, :h 40, :fill-style (hsl 40 80 80)})

(defn handle-input [task-id task-text]
  (fn [event dispatch]
    (let [new-text (js/prompt "new content:" task-text)]
      (dispatch :update [task-id new-text]))))

(defn style-input [text]
  {:w 400, :h 40, :fill-style (hsl 0 0 60), :text text})

(def style-remove {:w 40, :h 40, :fill-style (hsl 0 80 40)})

(defn handle-remove [task-id]
  (fn [event dispatch] (dispatch :rm task-id)))

; 初始化 instant, 注意 presence, index, 和对应的 velocity, 另外是 numb? 判断是否可删除
(defn init-instant [args state]
  (let [index (last args)]
    {:presence-velocity 0,
     :index index,
     :presence 0,
     :numb? false,
     :index-velocity 0}))

; mount 和 init 其实是重复的, 在考虑是否合并
(defn on-mount [instant args state at-place?]
  (assoc instant :presence-velocity 3))

; tick 依然是时间循环里对 instant 做更新, 借助 -> 这个 Macro 简化写法
(defn on-tick [instant tick elapsed]
  (comment .log js/console "on tick data:" tick elapsed)
  (let [v (:presence-velocity instant)
        new-instant (-> instant
                     (iterate-instant
                       :presence
                       :presence-velocity
                       elapsed
                       1000
                       0) ; 区间是 [0, 1000]
                     (iterate-instant
                       :index
                       :index-velocity
                       elapsed
                       (:index-target instant) ; 变化的区间是这个 target, 尽管有点怪...
                       (:index-target instant)))]
    (if (and (< v 0) (= 0 (:presence new-instant))) ; v 小于零, 说明正在消失
      (assoc new-instant :numb? true) ; 完全消失的时候, 设置 numb? 为 true, 可删除
      new-instant)))

; props 和 state 改变时调用
(defn on-update [instant old-args args old-state state]
  (comment .log js/console "on update:" instant old-args args)
  (let [old-index (last old-args) new-index (last args)]
    (if (not= old-index new-index) ; index 改变时, y 轴位置变化, 所以加动画
      (assoc
        instant
        :index-velocity
        (/ (- new-index (:index instant)) 300)
        :index-target
        new-index)
      instant)))

(defn on-unmount [instant tick]
  (.log js/console "calling unmount" instant)
  (assoc instant :presence-velocity -3)) ; unmount 时速度反向, 消失中

(defn render [task index]
  (fn [state mutate]
    (fn [instant]
      (comment .log js/console "watch instant:" instant)
      (group ; 看下从这里开始, 元素嵌套的写法, 跟 DOM 区别挺大的
        {}
        (alpha
          {:style {:opacity (/ (:presence instant) 1000)}}
          (translate
            {:style
             {:y (- (* 60 (:index instant)) 140), ; translate 手动布局中... y 位置按照 instant 算
              :x (let [x (* 0.04 (- (:presence instant) 1000))] x)}} ; x 位置按照 instant 算
            (translate
              {:style {:x -200}}
              (component-toggler (:done? task) (:id task))) ; 调用 toggler 组件了
            (translate
              {:style {:x -140}}
              (input
                {:style (style-input (:text task)),
                 :event
                 {:click (handle-input (:id task) (:text task))}}))
            (translate
              {:style {:x 280}}
              (rect
                {:style style-remove,
                 :event {:click (handle-remove (:id task))}}))))))))

(def component-task
 (create-component
   :task
   {:on-mount on-mount,
    :init-instant init-instant,
    :on-tick on-tick,
    :on-update on-update,
    :render render,
    :on-unmount on-unmount}))

大致这样, 后面会试着写点有意思的例子出来

相关文章
相关标签/搜索