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

2020年11月21日 阅读数:37
这篇文章主要向大家介绍论前端框架组件状态抽象方案, 基于 ClojureScript 的 Respo 为例,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

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

背景

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

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

React 历史方案

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

组件内存储的状态方便组件自身访问和操做, 是你们十分习惯的写法.
以往的 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 方案和热替换的良好配合状况下, 提供一种可行的抽象方式.