关于 React Hooks 的一些使用经验和换角度反思

本文基于 https://reactjs.org/docs/hook... 功能展开

算算时间都要一年半了, React 在 2018 年推出 Hooks, 引发了热议.
印象里就是在群里面, 我就很纠结里边的黑魔法太奇怪了.. 看得小心翼翼的.
然后看着别人研究代码, 提出类似的实现之类的, 或者各种解释.
慢慢地很多不同的声音也发出来, 特别是迷之闭包, 很多人都中招了出来吐槽.
再后来, React Hooks 蔓延开来, 连 Vue 社区也开始模仿.. 看来是真重要了.
由于我没有动力去深入 React 完整的实现, 所以对细节也只是处在一个大致的了解的状态.

对于 Hooks 想要解决的问题, 我大致是认同的, React 此前的扩展功能太僵化了.
高阶组件, 虽然在 FP 里面常见的用法, 实际引入 React 搞成了 decoration 满天飞,
..太奇怪了, 一个 FP 里引入的概念, 用大量的 OOP 和 mutable 方法来实现.
而且去掉了 mixin 机制之后, React 复用逻辑的问题感觉就是个坑, 重复代码挺多的.
虽然我是不信任 React 搞这种黑魔法推翻以前的一些宣传口号的, 但是...
Hooks 确认可以帮我解决 class based 组件难以做好的逻辑复用的问题.

我在公司里推 Hooks 的时间比较晚, 已经是在活动听到别人折腾 Hooks 之后了.
最开始是 Ajax 代码复用, 网上当初那个例子很明显, 现在我们也抄了很多这种.
但是后来, 让我感受到最大变化的, 还是发现 Hooks 对我们的 Form 组件的改善,
可以先看例子 http://fe.jimu.io/meson-form/...
大致上说, 就是用 Hooks 抽离状态的话, 复用场景更加灵活, 超出组件层面...

这篇文章是我几个跟 Hooks 相关的想法梳理, 线索有点乱, 看章节吧.

如果能用 Macros 实现 Hooks?

我们知道 Hooks 最开始就明确说了, useState 等 API 调用, 跟依赖相关, 且不能用包在 if 里.
原因也不难理解, React 组件运行过程中要逐个追踪, 条件语句会破坏这个逻辑.
我用 ClojureScript 的时间挺长了的, FP 当中一般的玩法我是知道的,
一般来说, 为了代码的"引用透明", FP 当中会避免到处存在内部状态.
一个用 Hooks API 维护状态的函数组件, 本身居然有这样的状态, 很脱离 FP 常规的玩法.
而且这个, 虽然现在是习惯了, 但不能用 if 总归是在有一些限制的, 令人忌惮.

换个角度的话, 也不是说 FP 里面就没有这种状态的性的东西.
而是说, FP 当中状态是习惯于显式跟普通的计算区分开的,
你要用状态, 就明确声明这边有个状态, 大家调用都注意区分. 不然, 那就是纯的计算.
或者, 切换一个层次说, 你这不是代码, 而是 DSL, 这个 DSL 提供新的一层抽象.
DSL 相当于构建一套原先的代码之上的一层新的语义, 那无所谓 FP 不 FP 了.
可以把深层的逻辑通过复杂的手段约束在 DSL 语义内部, 上层就是使用 DSL 描述.
应该说我理解 React Hooks 就是这样的一个状态, 语言之上的 DSL.

作为 DSL 说的话, 我觉得 Svelte Vue 那样倒是更自然了.
倒不是说它们提供的方案一定比 React 好, 但是有一个编译阶段, DSL 就更完整,
首先 DSL 某个语义未必是一个函数就能实现的, 可能需要增加比较绕的代码,
其次, 提供语义也就意味着对用户的术语有约束, 就需要编译阶段做检查做语义的验证,
但 React Hooks 这边, 这就是 runtime 插入功能, 剩下让人们自己去约束了.

当然, 我持有的观点, 有一点是 JavaScript 没有 Macros, 限制了 Hooks 的设计.
我用 ClojureScript 作为 Lisp, 比较容易修改语法树, 展开简单的代码,
(应该说 Lisp 这种, 能力也远不如 Babel 甚至 Svelte 那么强大, 只能说方式很廉价.)
Hooks 的设计, 在增强功能的同时, 很大程度是想着保留 API 的简洁.
如果有 Macros 可以用的话, Hooks 可以考虑的写法还有很多,
比如说定义组件的时候写成,

(defcomponent c-demo [x y] (div {} (str x y)))

然后由 Macros 系统展开成函数, 并且由执行环境诸如几个变量(影响到 ts 什么我就不管了),

(defn c-demo [x y]
  (fn [use-state use-effect internals]
    (div {} (str x y))))

我们目前需要全局引用 useState useEffect 然后心里去想着那是运行时的东西如何如何,
但是从上面的展开例子, 从 Macros 的角度理解, 很容易知道这些是运行环境控制的操作,
这样就没有原先 React Hooks 那种让人产生些许错觉的写法了.
(有可能语法展开跟函数执行的区别对非 Lisp 程序员比较难区分... 大致按 Babel 去想吧.)

另一个是状态追踪的问题 Hooks API 依赖的是 useState 的顺序.
我个人比较倾向于用名称去控制这多个状态, 在用户端有更多控制能力.
而这部分在跟 Vue 和 Svelte 对比会发现 React 这边能设计出更多的花样.(没去了解具体实现)
而 React 使用函数作为唯一的手段, 就显得非常, 或者还是逃不脱被 DSL 要求存在的限制.
当然话说回来, React Hooks 单调的方式能被设计出来满足这么多功能, 也是超乎我想象了的.
同时也由于抽象手段单一, 就非常依赖 runtime 内部实现奇奇怪怪的手法去支持功能.

Respo 拙劣的山寨

Respo 是我个人项目使用的 cljs 之上的 Virtual DOM 方案. 并不基于 React.
这样我就有机会试验我自己思考的一些组件和状态的抽象方案.
Respo 里边, 为了保存热替换过程中组件的状态, 设计了用户态的"状态树"的概念,
用户在定义组件的时候, 大致上对一个 Todolist 会在全局构建和存储这样的树形结构:

{
  :data {}
  :todolist {
    :data {:input "xyz..."}
    "task-1-id" {:data {:draft "xxx..."}}}
    "task-2-id" {:data {:draft "yyy..."}}}
    "task-2-id" {:data {:draft "zzz.."}}}
}

组件代码当中需要比较啰嗦的声明,

; for app
(comp-todolist (>> states :todolist))

; for single task
(comp-task (>> states (:id task)) task)

另外具体使用还要比较啰嗦的声明,

(let [state (or (:data states) {:input ""}]) "TODO whole list")

(let [state (or (:data states) {:draft ""}]) "TODO Task")

以及在组件层级之间传递 cursor 的路径位置, 以便发起更新...
相比 React 任何一种多了很多的代码, 只能说为了热替换稳定做了非常拙劣的模仿,
然后, 再次之后, 我也能在这个方案上, 也提供类似 Hooks API 的状态抽象,

(defn use-menu-feature [states options]
  ; TODO
  {:ui "TODO",
   :effect-fn "TODO",
   :edit-fn "TODO"})

(menu-A (use-menu-feature (>> states :menu-a) {}))
(menu-B (use-menu-feature (>> states :menu-b) {}))

这是一个脱离了 Macros 和编译方案, 也脱离了 React 内部状态黑魔法的方案,
而这样简单啰嗦的方案, 切切实实也能模仿出 Hooks 核心的一些功能来.
(当然在整体功能上, 跟 React 不能比, 而且要实现的话也依赖 runtime 做.)

这个例子对于 React Hooks 本身没有什么帮助或者阐释,
主要是从另一个角度去看, 作为一个前端 MV* 方案, 怎么看待其中的核心需求和实现.
各种方案在各种需求点的路径探索以及取舍, 脱离一下限定的视角, 会有其他主意.
或者说, 没有 Hooks 甚至没有 React 时, 这种逻辑复用你通过何种方式达成?

业务当中内秉的状态

回到 Hooks 本身, 我目前在的业务当中探索使用的方案, 大致可以参考 Form 这个例子,

let formItems: IMesonFieldItem[] = [
  { type: 'input', name: "name", label: "名字", required: true },
  { type: 'select', name: "city", options: selectItems, label: "城市" },
];

let [formElements, onCheckSubmit, formInternals] = useMesonItems({
  initialValue: {},
  items: formItems,
  onSubmit: (form) => {
    console.log('After validation:', form);
  },
});

// 返回的 formElements 用于 UI 渲染,
// formInternals 包含几个方法, 比如 resetForm 重置组件状态

或者也参考弹出提示的这个例子,

let [ui, waitConfirmation] = useConfirmModal();

let onClick = async () => {
  let result = await waitConfirmation({
    text: "节点可能包含子节点, 包含子元素, 删除节点会一并删除所有内容.",
  });
  console.log("result", result);
}

// 点击调用 onClick 时, 修改 confirmModal 的内部状态, 打开/关闭
return <div>
  <button onClick={onClick}>Confirm</button>
  {ui}
</div>

相较于 React 组件以往的抽象方案来说, Hooks API 暴露出来了一个从前我们熟悉的功能.
就是: 可以修改抽象模块的内部状态了.
以往 React 宣传的, 组件作为抽象方式, 内部的状态是封闭的, 外部不应该去操作.
比如就一个弹窗的 Modal, 你就需要外边有个 visible 的状态去控制, 传进 props, 显示, 还是不显示,
但是有了 Hooks, 这时候你可以把状态封进 Hooks API 内部, 然后暴露回调函数去操作.
可是你又明显能知道, 这边封装了一个状态.
至少从前 React 并不鼓励这种状态, 或者应该说, 此前并不存在简单的这样的功能.

反观我们实际业务当中, 局部状态封装却是很常见的东西,
比如 visible, 以往我们通过插件去实现的时候, 不就是把状态藏在插件内部的吗,
然后得到一个 .toggle() 的方法, 然后可能还会多个地方去 if (modal.visible) {} 一次次探测.
当 React 提出需要一个父组件存一个 visible 还有加一个 onChange 回调的时候, 让人觉得很怪异,
最终我们希望暴露给业务当中使用的时候, 明显 .toggle() 才是极为简短清晰的方案.

顺着这个思路, 怪异的事情来了, jQuery 时代, 我们欢快地 .toggle(),
React 来说, 说 rethink, 然后我们加了 visibleonChange, 然后 React 真香,
现在基于 Hooks 方案, 封装局部状态的方案又开启了... 历史又陷入螺旋了.

回过头去说 jQuery 插件形式的抽象, 是不是好呢, 显然当时使用的体验并不好,
由于有状态, 可能多个位置都想要去控制这个状态, 就需要多次判断状态, 要开还是关,
React 所做的, 首先有一点, 状态集中数据这边来, 尽量控制单向自动流动,
同时通过 data-driven 的思路和实现, 减少手动同步的状态的工作量.
这一点来说, React Hooks 比起当初 jQuery Plugin, 是要方便很多的.

其他

我一直有种感觉, React 选择 FP 这条路的时候, 其实没那么清晰,
探索了那么多方案, 强行把函数式的一些说法套用到前端这边来, 可能很多人都不能理解准确吧,
你说是 stateless 还是 state isolation, 细小的差异, React 这边并没有梳理清楚.
真的函数式语言, PureScript Elm 怎么写的前端, 真的是 React 这样子的吗?
我为了概念稍微准确点, 转向了 ClojureScript, 实际用下来 React 跟它还是差异挺大的.
在状态这个事情上, React 有了这次的调整, 未来谁说得准要不要再调整一次呢.

或者就是问一句, 当我们拿 React 往 FP 去套的时候, 是否要把 Component 往 Function 去套呢,
让 Components 跟 Functions 那样能高阶组合, 传递闭包之类的?
我觉得从现在看, Component 不是对应 Function, Component 是 DSL 中的一个抽象,
这个抽象可以继续扩展出 states, effects 等等, 比起 Function 复杂太多.
我们用 FP 手段去提升整个 MV* 方案, 是用在具体实现和优化的层面, 并非取代组件这个观念.

就我在 ClojureScript 的使用经验而言, FP 能提供非常强大的表达能力, 便于开发,
但是代价是大量的内存申请, 而这也是 React 不得不努力去做性能优化的原因.
对 FP 语言来说, 这样的优化要大量再语言层面实现, 编译器, runtime, 打包, 到处优化,
对于 JavaScript 来说, 把自己优化成另一门语言, 显然是不切实际的事情.
所以我对 JavaScript 生态之上的 React, 始终认为是存在隐患而且越走路越窄的.

至于 Virtual DOM 带来的 Model->View 那种 date-driven 的模式,
换个装逼的说法 "DOM 更新方案的自动化", 从 Angular/React 真的普及到了整个前端领域.
这个是实打实的提升. 现在混坑爹很难想象谁还会去用主打手动更新 DOM 的前端框架了.
而在此之上的状态管理, 简直是一片混战, 即便在 React 生态内部, Mobx 也是横生枝节.
基于不可变数据的, 基于 observable 的, 可能大家就是相互看不惯吧...

阅读 1.1k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

497 人关注
251 篇文章
专栏主页