论前端框架组件状态抽象方案, 基于 ClojureScript 的 Respo 为例

Respo 是本文作者基于 ClojureScript 封装的 virtual DOM 微型 MVC 方案.
本文使用的工具链基于 Clojure 的, 会有一些阅读方面的不便.

背景

Backbone 以前的前端方案在文本作者的了解之外, 本文作者主要是 React 方向的经验.
在 Backbone 时期, Component 的概念已经比较清晰了.
Component 实例当中保存组件的局部状态, 而组件视图根据这个状态来进行同步.
到 React 出现, 基本形成了目前大家熟悉的组件化方案.
每个组件有局部状态, 视图自动根据状态进行自动更新, 以及专门抽象出全局状态.

React 之外还有 MVVM 方案, 不过本文作者认为 MVVM 偏向于模板引擎的强化方案.
MVVM 后续走向 Svelte 那样的静态分析和代码生成会更自然一些, 而不是运行时的 MVC.

React 历史方案

React 当中局部状态的概念较为明确, 组件挂载时初始化, 组件卸载时清除.
可以明确, 状态是保存在组件实例上的. Source of Truth 在组件当中.
与此相区别的方案是组件状态脱离组件, 存储在全局, 跟全局状态类似.

组件内存储的状态方便组件自身访问和操作, 是大家十分习惯的写法.
以往的 this.state 和现在的 useState 可以很容易访问全局状态.
而 React 组件中访问全局状态, 需要用到 Context/Redux connect 之类的方案,
有使用经验的会知道, 这中间会涉及到不少麻烦, 虽然大部分会被 Redux 封装在类库内部.

Respo 是基于 ClojureScript 不可变数据实现的一个 MVC 方案.
由于函数式编程隔离副作用的一贯的观念, 在组件局部维护组件状态并不是优雅的方案.
而且出于热替换考虑, Respo 选择了全局存储组件状态的方案, 以保证状态不丢失. (后文详述)

本文作者没有对 React, Vue, Angular 等框架内部实现做过详细调研,
只是从热替换过程的行为, 推断框架使用的就是普通的组件存储局部状态的方案.
如果有疑点, 后续再做讨论.

全局状态和热替换

前端由 react-hot-loader 率先引入热替换的概念. 此前在 Elm 框架当中也有 Demo 展示.
由于 Elm 是基于代数类型函数式编程开发的平台, 早先未必有明确的组件化方案, 暂不讨论.
react-hot-loader 可以借助 webpack loader 的一些功能对代码进行编译转化,
在 js 代码热替换过程中, 先保存组件状态, 在 js 更新以后替换组件状态,
从而达到了组件状态无缝热替换这样的效果, 所以最初非常惊艳.
然而, 由于 React 设计上就是在局部存储组件状态, 所以该方案后来逐渐被废弃和替换.

从 react-hot-loader 的例子当中, 我们得到经验, 代码可以热替换, 可以保存恢复状态.
首先对于代码热替换, 在函数式编程语言比如 Elm, ClojureScript 当中, 较为普遍,
基于函数式编程的纯函数概念, 纯函数的代码可以通过简单的方式无缝进行替换,
譬如界面渲染用到函数 F1, 但是后来 F1 的实现替换为 F2, 那么只要能更新代码,
然后, 只要重新调用 F1 计算并渲染界面, 就可以完成程序当中 F1 的替换, 而没有其他影响.

其次是状态, 状态可以通过 window.__backup_states__ = {...} 方式保存和重新读取.
这个并没有门槛, 但是这种方案, 怕的是程序当中有点大量的局部状态, 那么编译工具是难以追踪的.
而函数式编程使用的不可变数据特性, 可以大范围规避此类的局部状态,
而最终通过一些抽象, 将可变状态放到全局的若干个通过 reference 维护的状态当中.
于是上述方案才会有比较强的实用性. 同时, 全局状态也提供更好的可靠性和可调试性.

抽象方法

Respo 是基于 cljs 独立设计的方案, 所以相对有比较大的自由度,
首先, 在 cljs 当中, 以往在 js 里的对象数据, 要分成两类来看待:

  • 数据. 数据就是数据, 比如 1 就是 1, 它是不能改变的,
    同理 {:name "XiaoMing", :age 20} 是数据, 也是不可以改变的.
    但这个例子中, 同一个人年龄会增加呀, 程序需如何表示年龄的增加呢,
    那么就需要创建一条新的数据, {:name "XiaoMing", :ago 21} 表示新增加的.
    这是两条数据, 虽然内部实现可以复用 :name 这个部分, 但是它就是两条数据.
  • 状态. 状态是可以改变的, 或者说指向的位置是可以改变的,
    比如维护一个状态 A 为<Ref {:name "XiaoMing", :age 20}>,
    A 就是一个状态, 是 Ref, 而不是数据, 需要获取数据要用 (deref A) 才能得到.
    同理, 修改数据就需要 (reset! A {...}) 才能完成了.
    所以 A 就像是一个箱子, 箱子当中的物品是可以改变的, 一箱苹果, 一箱硬盘,
    你有一个苹果, 那就是一个苹果, 你有一个箱子, 别人在箱子里可能放苹果, 也可能放硬盘.

基于这样的数据/状态的区分, 我们就可以知道组件状态在 cljs 如何看到了.
可以设置一个引用 S, 作为一个 Ref, 内部存储着复杂结构的数据.
而程序在很多地方可以引用 S, 但是需要 (deref S) 才能拿到具体的数据.
而拿到了具体的数据, 那就是数据了, 在 cljs 里边是不可以更改的.

(defonce S (atom {:user {:name "XiaoMing", :age 20}}))

便于跟组件的树形结构对应的话, 就会是一个很深的数据结构来表示状态,

(defonce S (atom {
   :states {
     :comp-a {:data {}}
     :comp-b {:data {}}
     :comp-c {:data {}
              :comp-d {:data {}}
              :comp-e {:data {}}
              :comp-f {:data {}
                       :comp-g {:data {}}
                       :comp-h {:data {}}}}}}))

定义好以后, 我们还要解决后面的问题,

  • 某个组件 C 怎样读取到 S 的状态?
  • 某个组件 C 怎样对 S 内的状态进行修改?

基于 mobx 或者一些 js 的方案当中, 拿到数据就是获取到引用, 然后直接就能改掉了.
对于函数式编程来说, 这是不能做到的一个想法. 或者说也不可取.
可以随时改变的数据没有可预测性, 你创建术语命名为 X1, 可以改的话你没法确定 X1 到底是什么.
在 cljs 当中如果是 Ref, 那么会知道这是一个状态, 会去监听, 使用的时候会认为是有新的值.
但是 cljs 中的数据, 拿到了就认为是不变了的.
所以在这样的环境当中, 修改全局状态要借助其他一些方案. 所以上边是两个问题.

当然基于 js 的使用经验, 或者 lodash 的经验, 我们知道修改一个数据思路很多,
借助一个 path 的概念, 通过 [:states :comp-a] 就可以修改 A 组件的数据,
同理, 通过 [:states :comp-c :comp-f :comp-h] 可以修掉 H 组件的数据.
具体修改涉及 Clojure 的内部函数, 在 js 当中也不难理解, lodash 就有类似函数.

本文主要讲的是 Respo 当中的方案, 也就是基于这个 cljs 语言的方案.
这个方案当中基本上靠组件 props 数据传递的过程来传递数据的,
比如组件 A 会拿到 {:data {}} 这个部分, A 的数据就是 {},
而组件 C 拿到的是包含其子组件的整体的数据:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {}}}}

尽管 C 实际的数据还是它的 :data 部分的数据, 也还是 {}.
不过这样一步步获取, 组件 H 也就能获取它的数据 {} 了.

在修改数据的阶段, 在原来的 dispatch! 操作的位置, 就可以带上 path 来操作,

(dispatch! :states [[:comp-c :comp-f :comp-h], {:age 21}])

在处理数据更新的位置, 可以提取出 path 和 newData 在全局状态当中更新,
之后, 视图层重新渲染, 组件再通过 props 层层展开, H 就得到新的组件状态数据 {:age 21} 了.

从思路上说, 这个是非常清晰的. 有了全局状态 S, 就可以很容易处理成热替换需要的效果.

使用效果

实际操作当中会有一些麻烦, 比如这个 [:comp-c :comp-f :comp-h] 怎么拿到?
这在实际当中就只能每个组件传递 props 的时候也一起传递进去了. 这个操作会显得比较繁琐.
具体这部分内容, 本文不做详细介绍了, 从原理出发, 办法总有一些, 当然是免不了繁琐.
cljs 由于是 Lisp, 所以在思路上就是做抽象, 函数抽象, 语法抽象, 减少代码量.
写出来的效果大体就是这样:

(defonce *global-states {:states {:cursor []}})

(defcomp (comp-item [states]
 (let [cursor (:cursor states)
       state (or (:data states) {:content "something"})]
   (div {}
    (text (:content state))))))

(defcomp comp-list [states]
  (let [cursor (:cursor states)
        state (or (:data states) {:name "demo"})]
   (div {}
      (text (:name "demo"))
      (comp-item (>> states "task-1"))
      (comp-item (>> states "task-2")))))

其中传递状态的代码的关键是 >> 这个函数,

(defn >> [states k]
  (let [cursor, (or (:cursor states) [])]
    (assoc (get states k)
           :cursor
           (conj cursor k))))

它有两个功能, 对应到 states 的传递, 以及 cursor 的传递(也就是 path).
举一个例子, 比如全局拿到的状态的数据是:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {:h 0}}}}

我们通过 (>> states :comp-f) 进行一层转换, 获取 F 组件的状态数据,
同时 path 做了一次更新, 从原来的没有(对应 []) 得到了 :comp-f:

{:data {}
 :cursor [:comp-f]
 :comp-g {:data {}}
 :comp-h {:data {:h 0}}}

到下一个组件传递参数时, 通过 (>> states :comp-h) 再转化, 取得 H 的状态数据,
同时对应给 H 的 cursor 也更新成了 [:comp-f :comp-h]:

{:data {:h 0}
 :cursor [:comp-f :comp-h]}

通过这样的方式, 至少在传递全局状态上不用那么多代码了.
同时也达到了一个效果, 对应组件树, 拿到的就是对应自身组件树(包含子组件)的数据.

当然从 js 用户角度看的话, 这种方式是有着一些缺陷的,
首先代码量还是有点多, 初始化状态写法也有点怪, 需要用到 or 手动处理空值,
而 React 相比, 这个方案的全局数据, 不会自动清空, 就可能需要手动清理数据.
另外, 这个方案对于副作用的管理也不友好, 譬如处理复杂的网络请求状态, 就很麻烦.
由于 cljs 的函数式编程性质, 本文作者倾向于认为那些情况还会变的更为复杂, 需要很多代码量.

就总体来说, 函数式编程相对于 js 这类混合范式的编程语言来说, 并不是更强大,
当然 Lisp 设计上的先进性能够让语言非常灵活, 除了函数抽象, macro 抽象也能贡献大量的灵活度,
但是在数据这一层来说, 不可变数据是一个限制, 而不是一个能力, 也就意味着手段的减少,
减少这个手段意味着数据流更清晰, 代码当中状态更为可控, 但是代码量会因此而增长.
那么本文作者认为最终 js 的方式是可以造出更简短精悍的代码的, 这是 Lisp 方案不擅长的.
而本文的目的, 限于在 cljs 方案和热替换的良好配合情况下, 提供一种可行的抽象方式.

阅读 1.7k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

630 人关注
253 篇文章
专栏主页