对于简聊 React 的一些回忆和反思(初稿)

看到钉钉的功能越来越多了, 前段时间突然想起来以前简聊的事情来.
当前公司跟钉钉的一些风声, 具体也不清楚, 到很多年后才听到了收购的事情.
Slack 具体的玩法我并不清楚, 但是钉钉当前延伸出来的功能给我一些感触,
当年简聊在功能的扩展来说缺了太多围绕聊天的扩展创新, 玩法也不温不火,
当然如果当时有条件一步一步有条理地往外扩展, 也说不准后面是不是会有机会.
我当时也基本上一心在处理 React 的事情, 极少对总体的产品形态提出想法.

单纯从前端代码来说, 我们当时也算很早尝试 React 并且稳定下来了.
经过这些年, React 改变也不少, class 组件到今天用的人比较少了,
而且随着我到不同的公司遇到不同的场景, 代码的侧重也改变了不少.
再当时来说有些东西看着是迷雾, 还要不断前行探索才能知晓,
现在回头看的话, 是比起从前清晰了很多, 那些说是弯路说是妥协的东西.

CoffeeScript, ClojureScript, TypeScript

CoffeeScript 在当年算是 alt js 当中最成熟的一门语言.
就功能来说, 它仅仅是让人更容易些 js 的语法糖而已.
跟 ClojureScript 相比, CoffeeScript 没有原生的不可变数据支持,
跟 TypeScript 相比, CoffeeScript 没有类型系统加持.
上面说的这两个 ReasonML 都有, 可惜到现在 ReasonML 给人感觉都还不够成熟.

就功能来说, CoffeeScript 确实是残缺的,
当时虽然简历了代码规范, 但是很大程度还是依靠人自觉把格式统一起来.
而现在, 无论 ClojureScript 还是 TypeScript 都是通过自动化格式工具写的,
Prettier 的使用基本追平了的 CoffeeScript 带来的编写效率的提升,
而且就团队代码习惯来说, Prettier 比起 CoffeeScript 当然是好多了.
不可变数据的残缺, 导致了一系列的奇怪问题, 后面再说.
没有类型系统确实也不如 TypeScript 适合多人团队.

不过当年 ClojureScript 跟 TypeScript 比较还是很少出现的,
我印象里公司倒是有那个人在研究 TypeScript 这些了,
而 ClojureScript 在当时工具链也还有各种问题, 即便今天也有一些.
不过即便是今天, 由于招人的原因还有工具链特殊, 我还是不觉得能在多人开发当中用起来.
ClojureScript 更多的, 还是让我站在 FP 语言的角度审视 js 生态缺失的功能.

Immutable 和 Immer

前面说的 CoffeeScript 或者 js 没有原生不可变数据的问题,
按照 React 的设计, 不可避免需要用到不可变数据, 否则优化起来会很绕.
现在来说, 有了 Hooks API, useState 把数据一份份拆开了,
这样每个数据更新的时候, 算是通过替换达到了不可变数据的效果,
但是也存在过于分散且触发多次更新的问题. 还是有时候需要不可变的对象来处理.

当时用 Immutable.js 的问题主要就是学习成本,
我最开始从了解这个东西到熟悉了敢用在项目当中也花了不短的时间.
除了写法比较绕以外, 对于前端来说这也是比较陌生的一种用法.
后来即便我用了, 我印象里同事还是有不少的抱怨的, 比较维护着累.
这东西并不是不能掌握, 但是当在 js 当中写起来就是挺烦的.
而且对于第三方代码来说, 只有 JSON 对象时它们接受的数据格式.
那么 Immutable 数据就要在我们代码当中转来转去, 维护就很累.

我后面用 ClojureScript 就不是这样的. Clojure 默认用不可变数据.
这样我在开发当中几乎无时无刻不是直接用不可变数据在编写逻辑,
就是说没有多少需要转换的场景, 脑子里对数据的理解统一而且清晰.
Lodash 当中 updateIn setIn 就是 Clojure 中很平常的用法.
这些 API 在 Immutable 当中更是比比皆是, 只是说 Clojure 做到了语言核心中.

现在我们用的是 Immer, 语法比较贴近 JavaScript 原生的操作.
就推广使用来说, 得益于语法简单, 而且加上 Hooks API 封装, 容易多了.
不过从 review 的结果来看, 还是偶尔会出现遗漏.
Immer 毕竟是用了 freeze 强行设定数据不可变, 但看着输出跟 js 又没区别.
就没法保证在场景当中串来串去不会漏掉. 当然也还好比较少.

不可变数据这个事情, 四五年了, 萦绕在 React 社区还是没有消散掉,
我觉得这个作为长期的一个方向, 也不会很容易就有完美的结果了.
如果说期待, 我希望 ReasonML 到时候能把这事情统一掉.
因为 js 总是要兼容老代码的, 不管怎样引入不可变数据, 心理负担总会在.

Less 和 emotion

简聊用的 CSS 预编译方案是 LESS, 当时 teambition 统一的习惯.
总的来说我对这种层层重置的 CSS 规则也没留下多好的印象.
当初 Vjeux 那个 CSS in JS 的演讲发布的时候我是深有感触的,
用了两三年的预编译, 大部分的局限性基本上也碰到了,
我是很期待直接用上 CSS in JS 的方案, 做细粒度的包含代码逻辑的控制.

就当时来说, 我觉得方案是不够成熟的, styled-components 也觉得走得太怪.
在我自己 cljs 的方案里边, 我后来用的是 inline styles, 局部场景够用,
而 inline styles 最主要的问题是浏览器前缀和伪类的控制, 没法用在产品当中.

后来在朋友当中了解到 emotion 的方案相对成熟, 我就尝试了一下,
我最后还是没有用 styled-components 那样的写法,
对于 emotion 我只是当做定义 className 写法来用, 倒不是新版本官网推荐的用法.
如果参考官网, 反而我们的写法显得并不规范, 也不好配合官方 Babel 的优化.
只能说堪堪到了一个解决掉我认为痛点问题的状态,

let styleA = css`
  color: red;
`

let styleB = css`
  background-color: blue;
`

<div className={cx(styleA, styleB)}>red</div>

但是这也却是帮我规避了很多的问题, 也因为是变量所以很好用代码进行控制.

问题主要在团队当中, 以往 LESS 的写法是大家都熟悉的, 有一套习惯.
加上 emotion 官网推荐的是 styled-components, 就造成了不统一.
而我的话没有多少动力去推动这方面的改变了, 这些涉及到不止一个改变的地方.

Mixin 和 Hooks

简聊遗留的代码中组件复用的代码使用的是 Mixin.
实际上能抽取出来的复用的方法并不多, 而且维护性并不算好.
在 Class 上动态注入的方法, 维护当中基本靠约定, 不容易排查.
Mixin 的用法后来对着 createClass 被废弃也跟着废弃了, 改为单继承.

中间用高阶组件进行逻辑抽象的事情大家都经历过了,
现在的结果也都知道了, 随着 Hooks 出来, HOC 基本没人说了.
当初 HOC 我们也试着写过, 我还是参考着同事的代码写的,
但我真心觉得 decorator 的代码很容易写出问题来, 而且我很容易漏掉各种东西.
就逻辑复用来说, HOC 主要是类库作者用, 业务开发几乎不会去乱搞.

现在有了 Hooks 再去审视当初的 Mixin, 就觉得很原始.
Mixin 几乎只是粗暴地讲方法一个个剥离到单个的文件, 事情只是做了一半.
饭馆 Hooks 封装的逻辑较为完整, 复用的场景也多, 也认为较为可靠.
就我们现在来说已经大量使用 Hooks 用在业务抽象当中了,
所以 Hooks 是实实在在对于产品业务有很多帮助的, 而不限于类库开发者.
回顾简聊的代码, Mixin 也算抽了二十多个吧, 这数量是不如 Hooks 的.

Props 和类型

简聊代码当中主要用 PropTypes 进行的组件参数的管理.
后来这些东西渐渐废弃掉了, 有点不知不觉的.
现在应该大部分人都使用的 TypeScript 声明组件的类型了吧,
有了类型的辅助, 即便没用动态的参数校验, 也很难出现那方面的错误.
就这一点来说 TypeScript 为前端带来了不小的改变.

actions-recorder 和 respo-reel

action-recorder 我在前面有留文章详细讲了, 是我改出来的名字.
原型的话, 是 Elm 的 hot code swapping 吧, 还有 Redux 的调试工具.
当时具体时间我记不清了, Elm 那些 Demo 大家应该都看过, Redux 也有些风声,
加上当时社区有不少人在追这方面的工具链, 我也就自己做了一些尝试.
actions-recorder 最初因为实现的问题, 在内部试用性能还挺差, 不过还好马上修复了.
actions-recorder 是为了简聊定制的类库, 基于 Immutable.js , 算是耦合了.
后来 Redux DevTools 大概做了更完整的版本, 我不清楚用的人有多少.

actions-recorder 的核心原理是所有的 actions 都存储下来,
后续可以通过切换 actions 和 updater 重新计算, 回放整个应用的状态.
这同时也推导出一个要求, 就是 didMount 等等操作时, 不可以发出 actions.
这个要求当时对我造成了不小的影响, 毕竟在 didMount 时 dispatch actions 挺常见的.
为了迎合这一套方案, 我花了不小的心思对相关逻辑进行了不小的改造.
特别大的一块就是切换也没请求数据的行为, 都跟 didMount 脱离了.

actions-recorder 这套重构当时也是也没有太多的难点, 逐渐就完成了.
从效果来说, 我认为还是不错的, 请求从 didMount 分离, 就是从 render 分离,
数据在路由切换前就开始请求, 时间上提早了一些, 界面上也避免了多个分离的 loading.
但是从坏的一面来说, 这种约束对于后续的开发维护来说是不友好的.
我印象当中我同事后面补代码的时候比较容易会破坏这个规则,
而且有了这些限制, 设计逻辑也累了许多. 谁不喜欢在 didMount 直接请求数据啊.

站在 actions-recorder 的角度, 回放 actions 好处比较明显,
当时有几个不好排查的 bug, 在 actions-recorder 的工具当中很容易定位,
因为每个 action 前后的数据状态都在, diff 可以直接看, 很明确.
但是一旦 didMount 时会发送 actions, 就导致回溯时总会有重复的 actions 发出,
这些 actions 也不好追踪, 那么调试的方便就被破坏掉了.
这些好处对我而言有用, 但是对团队来说却未必真的好, 现在看看确实问题太多.

后面我用 ClojureScript 写的代码当中, 对于 Respo 的小应用, 时间起来比较轻松.
由于 Respo 我在设计实现时基本上杜绝了 didMount 发 actions 的可能性,
所以天然就的是满足前面的约束, 而我用 respo-reel 类库很容易做到这一点.
另外 respo-reel 跟 cljs 纯函数的特性配合, 更新 updater 的效果也比较好.
某种程度说这个就是另一套独立的技术路线了.

就目前公司中而言, 我完全放弃了 actions-recorder 的方案(也没有用 Redux 那套).
当存在大量的伴随 didMount 进行局部的 actions dispatching 的时候,
actions-recorder 的方案就显得没有多少意义, 特别是还跟 Hooks 相关联.
actions-recorder 也要求数据尽量存储在全局, 这一点在当前的场景也不合适.
特别是个人开发大量的子页面, 各自有各自的数据, 跟简聊的场景就很不一样了.
虽然我最初有考虑复用部分 actions-recorder 的思路过来, 但仔细想总没有多少好处,
只能说是场景不同, 加上那些前车之鉴吧.

脱离 Elm 的全局状态的方案的话, actions 对我来说就没有太大的意义了.
如果是 ReasonML 那样通过类型能定义 action 方便做 switch 的话倒还好,
在 TypeScript 中用 Action 效果并不多, 而且还多一层抽象.
反而不如直接用方法去操作那少有的全局状态来得好维护了.

全局数据的使用

简聊的场景比较特殊一些, group 和 messages 是全局的数据, user 也是.
一方面 WebSockets 会推送新数据过来, 这些默认就是全局处理的, 而不是局部,
另一方面聊天应用就是围绕消息和用户展开的, 消息全局存储也有意义.

目前公司的场景是各种页面表单图表基本上是各自从服务器请求数据,
虽然形态上是单页面应用, 实际上子页面之间或者全局复用的数据可以说少得可怜.
这样大量的状态也就是在各个组件自己去做维护了, 并且状态也非常多.
大相径庭的场景. 当然状态很多对于调试来说也不大友好. 可现状就是这样.

router-view 和 ruled-router

由于前面说的 actions-recorder 的限制, 就要去所有全局状态都要在 store 统一维护,
比较重要的一块状态, 就是路由状态, 当时我也走到了 redux-router 的前面.
我设计了 router-view 模块, 改变了路由在 React 中的角色, 使用全局数据控制.
这是个定制化的方案, 也没必要展开说了.
另外这个 router-view 跟后来 Vue 说的 router-view 还不一样, 具体也没看.

不过有一点是后面影响我比较深的, 就是我认为 react-router 界面和路由耦合很有问题,
router-view 是定义 JSON 的路由规则对路径进行解析, 然后交给页面渲染,
这根 react-router 通过 Context 层层处理的方式完全不一样.
router-view 应该说更接近于后端解析和处理路由的方式, 先解析, 再当做数据处理.
解析完成以后, 处理数据对于 React 来说是非常清晰也很好排查问题的.
这个在 react-router 那样的耦合严重的方案当中就显得分散而且琐碎了.

这个思路导致后面为了解决公司面对的嵌套层级过深难以维护的问题时, 我直接选择了改换路由.
ruled-router是从 router-view 延伸的对于嵌套路由做更深层适配的方案.
后面发现配合 TypeScript 的类型还能做很多的定制, 就继续深入做了代码生成.
关于细节, 前面发文章讲过, 不再展开了.

但这边也还有个问题, 就是定制过深之后, 整个方案完全是自成体系了,
这个对于招聘进来的新人来说, 首先有一个学习门槛的问题,
再者虽然跟 TypeScript 做了配合, 但是有些地方并不很直观, 有一些思考的成本.
这个毕竟不是社区大量的投入打磨的方案, 也没有大量的文档教程支持, 导致局面有点奇怪.
如果是 Facebook 或者阿里, 搞出一套方案, 是有能力往整个社区推广的,
而我们作为小厂, 自己用就是自己的培训成本, 完全需要自己操心了.

请求的抽象

请求这个坑也算是针对简聊的场景专门设计的方案, 来源是 Netflix 的 data graph.
Netflix 具体细节我记不清了, 当时有个演讲专门说的.
思路跟 GraphQL 类似, 也是方案自动处理请求的依赖关系, 前端把相关依赖整合起来.
当年 GraphQL 非常不完善, 而且没有跟对简聊做数据推送的场景,
经过这么多年了, 社区很多公司跟进了 GraphQL 的方案, 做了很多探索.
我所在的公司后端是 Go, 而且业务较重, 我这边也就失去了跟进 GraphQL 的可能.
当前使用的方案较为原始, 但是也尽量在用生成 API 代码的方案配合 Hooks 做一些抽象.

简聊当时的数据依赖的抽象很稚嫩的, 也是因为场景简单, 所以走下去了. 可维护性也一般般.
从后面 GraphQL 的路线进行对比, 很明显, 数据抽象这个事情需要后端帮忙.
后端微服务之间很容易通过多个请求进行数据聚合, 性能优化办法也很多.
这些等到前端再想办法做抽象的, 已经算太晚了, 限制也很多, 效果也很弱.

但总体说我认为数据自动处理依赖在未来也还会是会被经常触及和深入挖掘的场景.
GraphQL 不是最终方案, 至少我们的场景就不够用. 当然更多还是靠后端了.

Form 场景

简聊因为是聊天室, 当时对于 Form 的需求很少, 或者说只有特定的一块,
当时的需求是后端想要通过 JSON 配置表单, 前端渲染就好了.
所以当时给出的也只是 React 根据 JSON 做简单的 Form 响应的方案.

后来到了别的公司, 接触到后台管理大量使用 Form 的场景, 我发现自己忽视了这整理一大块.
我这边后来开始了 meson-form 模块继续针对业务场景定制 Form 的方案.
这就跟简聊当时面对的简单的场景很不一样了, 而且 Form 后面还会有各种重逻辑的变种.

匆匆忙忙和代码重构

简聊那段时间让我形成了一个观念, 就是架构调整很少会被分配出时间来做,
所以基本上就经常要我们自己私下调研方案还有见缝插针一点点去做了.
在执行层面上就基本上这样了, 而且要一点点拆成小的任务, 不然不好推进.
而大的重构并不是说没有, 但是时间上只能尽量自己去匀自己去控制了.
而当时由于 React 生态各种东西不成熟, 而且有时候直接破旧立新, 就比较被动,
我为了能争取到主动, 花了很多心思, 但是最终反而开始脱离大部队的倾向.
现在的借我就是我倒向了 ClojureScript, 很多方案脱离社区更加严重了.

我在饿了么的精力, 现在我也不太确定, 大公司对技术研究和投入能做到怎样的节奏.
就我在小公司的话, 由于场景和业务相对单一, 我要花很长时间面对同样几个问题,
所以我可以花很多零碎的精力定制出相对深入的方案, 考虑的场景也单一一些,
而在具体操作的要尽量考虑和已有方案短期共存长期尽量替换的思路,
在后续的开发当中穿插着逐步把老的方案和实现一点点替换掉, 或者干脆不替换.
这个在具体实行的时候是非常琐碎的, 但涉及的代码也不会少.

后面我看到社区别人给出的方案, 特别是大厂给的方案, 封装做得较为完善甚至到多余.
就技术方案来说, 我很少能给出那么完善的方案, 因为打磨的时间和需求都相对少,
我的方案服务的人群也没有那么多, 公司也匀不出那么多时间让我专心去做.
但换个角度说这样持续演进而且封装不完整的方案, 对于他人接手来说显然并不好.
这个方案需要我持续 review 和提醒保证执行不出错, 而不是别人比较容易加入维护和扩展的.
这些事情说起来是要我作出转变, 规范和严谨, 但在我的角度条件又不够充足的.
将来说如果我去了大厂的话, 是否会有足够精力在一块东西上打磨呢, 我心里是有问号的.

但现在再换个角度来说由于我大量基于 ClojureScript 的研究和展开, 可以说自成一体了,
这些方案和 JavaScript 内在的那些偏离还有纠葛, 也让我操很多的新,
操心的结果对于 ClojureScript 受众以外的人群可能还不大被理解,
就这个事情算下来也是让我比较有的头疼的, 也不大乐意到被纠结在这个上边.

其他

简聊的代码多年前我就没参与了, 而且公司转向其他方向, 后面大家都知道了.
我当初离开的时候除了原来几个原因, 也因为当时 teambition 还是 Backbone 为主,
我心理上比较反对在享受了 React 的方案之后再去体验一遍 Backbone 开发的痛苦,
而且在 FP 路线走得太远特别是孤立行走太远的情况下,
加上 leader 们也不会给我足够支持去推动 React 方面的事情, 我也有点觉得无力.
后来 teambition 转 React 的事情知道的人很多, 中间的坎坷也不少,
当时我在一家用 Vue 的公司自己心思也在 ClojureScript 上边, 只能远远围观了下,
虽然事后有打听过, 但是也没机会跟前同事深入去谈那些东西, 后面关联的少了.

平心而论虽然简聊场景脱胎于 teambition, 但是后者数据规模复杂太多,
原先积累的那些工具链和方法论, 是否能在其中应用, 我心里是没底的,
而且 Lisp 程序员这种跟着场景做定制的心态, 到了不一样的规模给出的方案变化也会不小.
虽然参与的有不少当时简聊的成员, 但我也不认为我的方法论有留下多少.
从后面的研究看, 那些方法论跟 ClojureScript 的契合度只多不少,
即便我是在快离开的时候才渐渐整个人倒向 ClojureScript 阵营的...
现在很多的思路在 Respo 相关工具链中还存留着, 个人项目也还在, 但毕竟变化不少了.

另外那些延续到了后面公司的用法, 随着配合 TypeScript 的努力, 也改变了不少,
而且从后面接触的大量后台管理的表单的场景看, 简聊的单页面才是比较特殊的场景.
再说后面我也更多接触到前端场景的复杂度了, 甚至移动端和小程序我都还没开始...
除了小程序, WebAssembly 那边的事情才开了个口.. 前端真是好复杂.

阅读 496

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

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