还在用 redux 全家桶么? 不如试试更轻量更简单的结构化 hook?

2,015 阅读18分钟

前言

最近一段时间, 国内社区基于 React hook 提炼出了一些用于代替 redux 的状态管理库, 基本思路都是利用 hook 来包装 state 和操作 state 的一些方法, 例如 hox.

本文提到的结构化 structured-react-hook 也是其中一种思路.

正文

Redux 的起 与 落

我在 16 年的时候开始关注 redux, redux 作者在一次分享中展示了时间旅行的惊人创意, 通过利用单一 store 和 flux 相结合的特性来实现可回溯的单一数据流, 着实震惊了社区, redux 本身代码简练, 借鉴了函数是和一些语言的特性, 在当时的应用状态管理领域是一个绝妙的创意, 在之后的几年, redux 的设计理念深入人心, 引导着大量前端工程师在工程中去实践单一数据流的状态管理策略. 而我就是其中一员.

我从 16 年到 19 年持续不断的研究如何在工程中更好的实践 redux, 这段时间社区内也不断涌现配套的操作库, 例如链接 react 的 react-redux, 用于重构 selector 的 reselector, 快速书写 action 的 redux-action 还有大量操作异步的, saga, promise, 作者自己写的 thunk 等, 社区一度非常繁荣, 包括支付宝出的 dva 更是从框架层面对这些库进行了一次全面的封装.

但随着大量工程实践的实施, redux 的问题开始不断涌现, reducer 切片的问题, 单一 store 不利于应用拆分和集成的问题, 状态管理的 local 和 集中式管理的边界问题, 这很像春秋战国, 百家争鸣, 但谁也不能说服谁, 对于诸子百家来说讨论的都是如何治理天下的策略, 而对于 redux 社区来说, state 究竟该如何划分, 异步同步的代码该如何整合, 社区繁荣带来的工程上的分裂进一步加剧了这个问题.

这里我不得不提出一个我个人理解的, 技术和工程上的一些区别. 以我个人的经验来看, 从技术上讲, redux 是一个很棒的设计, 是一种高度可扩展, 简练的状态管理技术, 并且 UI 无关的.

但从工程角度讲, redux 并不是一个好选择, 因为技术上追求的优雅, 高性能, 高扩展性在工程上来看都是有很高的成本的. 我们经常说我们工程代码像一坨屎, 但是就是这坨屎, 看起来用了不那么好的技术, 比如早期的 jQuery, Backbone 等, 但对于工程师来说, 解决问题, 保障质量和稳定性, 追求性价比才是核心, 和技术研究者为了追求极致的不计成本相比, 工程师更具有商业头脑, 需要充分考虑实施方案的长短期成本, 这和做技术是大不相同的, 但当下前端社区, 虽然称呼自己为前端工程师, 但是在工程领域几乎缺乏讨论, 我们往往把眼光聚焦在那些吸引人的, 宛如艺术品的技术上, 却忽略了作为工程师自身最核心的要求. 当下无数屎一样的工程里又有哪个前端工程师是无辜的呢?

因此从工程角度来看, redux 有很多致命性的问题, 比如单一 store 的设计就和工程实施过程相违背, 软件工程的代码是随着时间的推移不断堆砌而成的, 当下的软件研发模式, 很难或者几乎不可能在整个软件的生命周期的开端就能识别或者设计出一个包含业务全生命周期的状态树, 这几乎是不可能的. 但是 redux 的设计要求你在一开始尽可能的设计完美的状态树, 这会带来大量的冗余设计, 并且有可能导致过度设计.

从单体应用到微服务, 软件架构具有显著的生长性, 和过去动则开发 1-3 年不等的超大型项目相比, 现代软件研发的过程更像是一种动态维护而不是静态设计, 我们基于一个确定的大方向, 动态的进行软件研发工作, 确保代码的增长不会偏离既有轨道, 但是包含的业务抽象是极度不确定的.

我举个例子, 过去搞软件研发的都是项目制, 基本上软件公司承保甲方的需求然后进行设计开发交付, 这个甲方可能是政府也可能是大企业, 因为合同是确定的, 所以业务方向是不会变的. 但是现在的软件研发, 今天业务上说我们要搞教育, 可能产品还没上线, 公司就转型做电商了, 那么业务上的变化体现到软件, 到代码层面其实是要求你能够尽可能复用既有的开成果, 于是我们有了一些 UI 组件库一些可复用的模块或者工具函数, 但 90% 以上的代码我们是很难复用到新业务上的, 即使在产品上来看两者相差不大.

这种业务的不确定性对软件工程的设计提出了更高的要求, 也导致我们从设计从业务架构上无法只通过静态的去看待和分析.

你为什么需要 redux

虽然 redux 在官网上明确了并不是所有的应用开发都需要 redux 或者一套数据流状态管理的方案, 但从实际的工程开发来看, 只要是一个连续迭代的业务, 通常都离不开一套状态管理方案, 所以为什么我们要使用 redux 这样的库来从组件中剥离 state?

要回答这个问题我们不妨来看看在不使用 redux 的情况下, 我们直接使用 react 提供过的内置状态管理会遇到什么问题.

在 react 提供 hook 之前, class 组件通过实例来管理 state, 是唯一可行的状态管理策略, 那么从工程角度来看, 状态不应该是支离破碎的, 应该是具有一定的业务抽象的, 或者说撇开 UI 不谈, 我们聊业务逻辑, 聊到状态, 都会提到 model 的概念, 即将一组状态和状态相关的操作方法封装到一个 model 里这是典型的面向对象思维, 即将现实世界中的事物拆解成不同的对象, 明确对象的属性和操作属性的方法. 如果学过 Java 的应该对这种设计方式不会陌生, 但在前端工程的实践里, 这种设计方式并不是完全有效的, 或者说 Component 和 Model 之间不是完全统一的.

例如当我们提到 Component 的时候, 我们会认为 Button, Table 这些是一个组件, 但是当我们提到 Model 的时候, 你会说 User, Detail 这些是 Model, 在抽象上这两者并不是很容易统一的, 无论是你把 User 塞进一个 Button 还是让 Table 绑定到 Detail 都会影响彼此的代码复用.

软件工程本质上就是研究如何复用既有代码来提高软件开发的效率, 因此影响代码复用可以视为软件工程的第一问题.

因为对于 Button 来说, 他可以是任何 Model 的 Button, 同理对于 User 来说, 用户中心可以是带 Button 的也可以没有. 只有某些情况下我们可能达成统一, 比如一致的 登录组件, 业务上不做任何要求, 登录组件内置 state, 同时兼容和支持各种不同的场景.

这也是当下前端软件工程中常用的代码复用思路, 即封装一个具有标准业务特征的组件, 我称之为 标准化组件

但实际开发中我们 90%以上的代码是无法通过标准化组件来复用过的, 因此你需要一种手段能够将 state 从 Component 中剥离出来独立管理, 从而让更多的 Component 能够复用一部分逻辑, 这就是你需要 redux 的原因.

什么是数据流, 为何需要让数据流单向

研究软件工程的乐趣在于, 当你发现了一种新的软件工程模式, 解决了一些问题, 就会有新的问题冒出来, 即没有银弹, 当我们使用 redux 从 react 中剥离了 state, 并将这些 state 统一的管理起来, 通过 action 结合 reducer 来将操作 state 的逻辑封装到一个巨大的 store 的内部.

事实上只通过 reducer 来封装对 state 的操作逻辑是一个理想的完美的乌托邦.

和在 Component 里内置 state 相比, 剥离后的 state 具有更高的复用性, 但是带来的问题是多个 Component 都会使用这些 state, 当你的应用足够大, 十几个或者上百个 Component 共同去操作这些 state, 这些 state 的变更又会引发这些 Component 的渲染, 这种从数据到试图的变化和控制流向就是我们所说的数据流.

让我们把数据流看成是一条大河, 如果有人告诉你这条大河里的水流不是一个方向的, 即不是自西向东的, 而是乱七八糟的, 你能想象那种场景么? 河里会形成大量的漩涡, 这些漩涡能吞噬过往的船只和河里的生物, 同样的, 如果数据流的流向不是一个单一的可预测的方向, 同样会形成各种数据漩涡, 它会让你的代码无法被预测和分析, 一旦发生 bug, 你将无法有效的定位原因. 这就是为什么我们要强调数据流单向的理由.

使用 redux 并不能有效的解决数据流的问题

在这里我不得不再次提出上面的概念, 技术和工程并不是一回事, redux 提供了一种技术手段让你的数据流可以单向, 前提是你严格按照他所设定的方式来控制 state, 这是一种不计成本的理想化的技术思维, 站在工程的角度我觉得这是不可能的, 因为这样的成本是不可接受的.

对于一个由多人协作完成的前端项目, 说服并要求团队成员按照一致的风格, 严格按照 redux 的方式来写代码会变成异常灾难, 大量的冗余的 action type 和强制将逻辑封装到 reducer 这都会导致工程上维护的灾难, 并且很多工程问题没有答案

  • action type 如何命名?
  • reducer 处理不了的逻辑写在哪?
  • 对于 state 什么时候是 local 什么时候应该放在 store 里
  • 如何控制重构的成本

而且就像本问题提到的你需要 redux 最重要的一个原因在于 react 没有提供一种剥离 state 的手段, 当你使用 redux 的时候不得不为此引入 react-redux 来解决这个问题, 但反过来想想, 是不是很像因为浏览器没有什么什么, 于是我们需要一个垫片来兼容一下. 因此当 react 正式发布 hook, 并且提出了 useReducer 这个 hook 我就意识到, redux 的使命完成了, 也结束了.

hook 的价值

react hook 正如 hook 自身所包含的意思, react 提供了一种原生的内置的插槽来设法剥离 State 和 Component, 废除生命周期是个很棒的主义, 没有了生命周期, 你就可以借助 hook 在 react 的渲染周期内插入 state 的操作来影响视图, 这比 react-redux 的方式要强太多了.

从技术上说一个 useReducer 就可以替换掉 redux 和 react-redux, async/await 的原生能力可以干掉那些 thunk promise saga, 整个 redux 生态被完全替换了, 并且还解决了单一 store 过于理想化的问题. 配合 useContext, 你可以抽象任何自定义的 store, 让全局 store 和局部 store 有机的组合在一起, 而不是通过兼容的方式

一切都很棒, 唯一看起来美中不足的就是, useReducer 依然有 redux 的一些臭毛病, 反人性的 dispatch 调用方法和一堆不知道该怎么命名的 action type 以及 reducer 到底是个啥的灵魂拷问.

现在我们还需要一种抽象手段, 来管理各种用 hook 封装的数据模型. 不然你就会陷入一些工程上的陷阱

function useUserInfo(){
	const [username, setUserName] = useState('jacky')
    const [age, setAge] = useState(18)
    return {
        username,
        age,
    	changeUserInfo(username, age){
        	setUserName(username)
            setAge(age)
        }
    }
}

这里展示了一个利用 hook 封装的 userInfo 模型, 返回的对象包含了基础的状态和操作方法, 利用自定义 hook 很容易做到这一点, 不过因为 react 的限制你没办法再封装一层, 就像 oo 中的继承那样, 而 hox 可以帮你突破这种限制...

不过我并不赞同突破自定义 hook 限制来实现高阶 hook 这种事, 因为你可能会陷入 hoc 时代的嵌套地狱

这段示例代码在技术上看没什么问题, 但是在工程上这种组织方式可能会带来结构冲突, 比如你的同事喜欢这样封装

function useUserInfo(){
	const [username, setUserName] = useState('jacky')
    const [age, setAge] = useState(18)
    return {
        state:{username, age}
    	changeUserInfo(username, age){
        	setUserName(username)
            setAge(age)
        }
    }
}

也可能有人喜欢这样

function useUserInfo(){
	const [username, setUserName] = useState('jacky')
    const [age, setAge] = useState(18)
    return {
        state:{username, age}
    	onUserInfoChange(username, age){
        	setUserName(username)
            setAge(age)
        }
    }
}

总之在技术上这些方式并没有什么不同, 但是工程上他们返回的对象结构都不一样, 这种结构冲突会导致大家写出来的代码无法有效的集成在一起, 甚至于你都无法在返回对象的基础上再做些什么.

这显然不是个技术问题, 但这确实当下大多数团队存在的工程问题

代码结构的不一致

用结构化 hook 来解决工程问题

为了解决代码结构冲突的问题, 我们需要让代码结构保持一致, 同时又能解决状态管理, 单向控制的技术问题, 为此我们团队研发了一个状态管理框架, 在这个框架中, 结构化是核心命题. 让我们来看下代码示例

import createStore from 'structured-react-hook'

const storeConfig = {
  name:'userInfo',
  initState:{
    username: 'jacky',
    age: 18,
  }
  controller:{
    onUserInfoChagne(username, age){
      this.rc.setState{
        username,
        age
      }
    }
  }
}

const useUserInfo = createStore(storeConfig)

function UserInfo(){
  const userInfo = useUserInfo()
  
  return(
    <form>
      <input value={userInfo.username}/>
      <input value={userInfo.age}>
      <button onClick={userInfo.controller.onUserInfoChange}> 修改用户信息 </button>
    </form>
  )
}

structured-react-hook 在利用 useReducer 的基础上提供了一种固化结构的 model 抽象方案, 对于一个 model 来说最基础的包含两个属性 initState 和 controller, 和一个 name 标识. 关于 structured-react-hook 更详细的用法和隔离差异逻辑的 membrane 模式, 感兴趣的可以看下 GitHub 上的文档 github.com/kinop112365…

在这里我就不多赘述了, 我更多想聊的是结构化, 或者说我理解在标准化组件之外的新的一种组件组织形式, 我管它叫结构化组件

结构化组件

前端发展 10 年, 技术上日新月异, 在造轮子和技术创新上独占鳌头, GitHub JavaScript 开源项目数量常年霸榜, 但在软件工程领域的讨论却非常非常的少, 在我看来我们其实已经到了一个不仅聊技术, 更要聊工程的时候了, 如果光聊前端技术, 你会发现一个很有意思的特征, 即工作 2 年的优秀的前端在技术上的所知所想要超过那些工作 5 年, 甚至 5 年以上的前端, 但如果你带过团队, 你会有一种感受, 如果单纯从做业务, 做工程的角度, 你更愿意把活交给那些有经验的人, 虽然他们可能对新技术不那么敏感, 虽然他们思考的也不多, 甚至从来没在掘金上写过文章, 是沉默的大多数, 如果掘金作者年龄有统计的话, 我相信肯定平均年龄肯定不高.

为什么会这样?

在我看来, 因为前端技术的更替周期很快, 并且前端技术在解决同一问题上会有无数种解决方案, 都是画页面, 裸写 css, css 框架, css in js, scss, less, react style 等等, 每一种技术都有自己的闪光点, 如果你够聪明, 自己捣鼓一个, 手写一个 react 也未尝不可. 但是如果你是一个工作了 3-5 年的工程师, 你对这种情况会感到绝望.

为什么解决一个问题要有这么多种方案? 如果说有的技术方案是进步, 但有一些那纯粹就是平行造轮子, 横着发展. 站在工程角度看, 技术的多样性无疑给团队的招聘, 项目维护带来了巨大成本, 同时拖了业务后腿, 我相信大多数人都有过从 react 迁 vue 或者换了份工作又从 vue 迁 react 的经历, 甚至还有 Backbone, jQuery , angular 等等各种不同技术栈的项目, 而且即便是统一成了 react 你会发现也没有想象中的那么整齐划一, 还有一堆的 react 生态等着你去挑选, 当一个团队足够大的时候, 你甚至找不到两个在结构上相似的项目.

我们通常说的, 代码屎山, 技术栈不统一, 老代码, 代码无法复用等等其实都是工程问题. 前端工程这些年在某些领域得到了一些发展, 但是相比在前端技术上的讨论, 这部分声音大多数都只在于各个大厂的内部, 对于掘金这样的开放社区几乎无人讨论.

比如淘宝这些年一直研究如何快速的构建各种 h5 页面, 其实背后就是同类型代码复用的工程问题引出来的, 但是为什么大厂的方案最终只能在大厂里用? 答案是因为各个技术方案给出的代码结构, 最终的输入结构不一致.

就拿 LowCode 来说, 你翻一翻网上开放的 LowCode 平台, 他们对代码生成前的结构约定都是不同的, 并且在生成技术上也采取了完全不同的结构, 所以大量的重复开发就在这种高度非结构化的环境中产生了.

另外最重要的一点, 我们对前端代码的复用目前主流策略还是标准化组件, 即要求组件具有一致的使用场景, 就像模具一样被制造出来, 但是对组件内部的结构却不做任何约束, 比如 antd 的 table 组件, element 的 table 组件, 如果从产品上看, 这两者区别不大, 感觉可以融合一下 (没有技术背景的产品都只看表面) 但实际上内部的代码结构可能大相径庭.

结构化趋同的另一个重要点在于, 大量不同的结构无法形成有效的讨论和交流, google 的 webcompoent 在技术上可能不是一个好方案, 但是在工程师眼里, 我觉得这是一个更具性价比, 更有利于推动前端技术发展的方案, 相比 react 和 vue webcompoent 更强调结构的一致性, 而不是技术的标准性.

就拿源码讨论来说, 你读 react 源码, 我读 vue 源码, 咱们应该是没啥可聊的. 但为什么这么多工作多年的前端工程师做了这多类似的业务, 却从来没有人能够抛出些什么引发讨论? 其实道理一样, 因为大家都是做 saas 但是背后的技术栈差异太大, 我们迷失在对技术的学习研究, 为了不被淘汰不断的学习新的技术, 而忽略了在工程问题上的思考和提炼, 于是造就了社区中沉默的大多数. 工作年限越长越沉默.

而对于刚毕业的新人, 正式热情和精力最高涨的时候, 恰恰成了社区发声的主力.

但这并不是一个良性的方式, 新人代表这技术创新和热情, 而有经验的老人们也应该输出工程领域的经验提炼更好的设计模式, 新老循环, 前端才能走向一个更好的未来.

后话

如果你对 structured-react-hook, 以及软件工程, 代码复用, 我提到的结构化组件感兴趣, 欢迎随时联系我们. 同时如果你对这方面的技术和实践感兴趣, 也欢迎加入我们.

我们是 税友的 公共体验技术部 😁