乐趣区

关于clojurescript:论前端框架组件状态抽象方案-基于-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 计划和热替换的良好配合状况下, 提供一种可行的形象形式.

退出移动版