React 的问题和我在试验的 Respo

2

这几周没有做多少开发的任务了, 生活节奏慢下来一些, 思考的时间也多点了
不过单页面相关的事情还是很头疼, 清算之前 React 问题还是很多
今天下午在内部分享听了承玉(希望名字不拼错)分享 ant.design 相关的东西获益很多
之前简聊做这方面的尝试然而实力以及投入都不足, 只是停在了中间
支付宝的进展以及推进的策略是足够我敬佩了.. 以后工作中多学习

按照贺老说新技术迭代必不可少而且也是好事, 繁荣的表象之一
然而 Redux 方面的节奏我实在不能满意, 新项目废弃项目节奏太快了
我的理解当中, 函数式必成半个世纪, 理论上大多问题已经定论了
工程当中虽然说涂涂改改, 可是 React 社区的连基础工具链都改来改去
我的担心是 React 掌舵的人太年轻, 我们会跟着他兜很大的弯子
并不是排斥兜圈子, 而是不应该一群人一起去消耗时间, 效率低, 我宁愿自己探索

React 的不足

这里我尝试描述整理一下我认为的 React 的问题
然后, 我自己在尝试 Respo 方案, 中间有一些比之前写业务深一点的反思

服务端渲染, 不能有效利用缓存

我的后端经验不足, 但后端遇到渲染性能问题, 常用的方案就是缓存
比如查询数据库热门信息慢, 比如渲染网站首页慢, 但这种信息不常更新可以设计缓存
React 现在不好做, 服务端接收到两次请求, 调用 React 渲染, 完全是独立的
两次渲染之间不会有任何的优化, 现在社区有的只是基于 Stream 加快传输
即便尝试缓存, 当两个页面有任何细微的不同, 缓存就失效了

这里我认为问题在于, 缓存的粒度在哪里? React 并没有替换细粒度的方案
比如前端当中组件会通过 shouldComponentUpdate 做两次渲染的缓存优化
说明 React 中粒度应该在组件层次, 而不是 renderToString 这样整个页面
我的观点基本上是: shouldComponentUpdate 不够, 需要组件级缓存
至少两次渲染页面当中, 比如某个 <div> 内容不变, 就能够通过缓存做
实际当中每秒就过百次渲染的话, 缓存的场景会非常多, 优化的空间是在的

类似的方案在前端依然能发挥作用, 比如两个 tab 切换, 内容就是重复的
现在 React 通过 shouldComponentUpdate 只能判断两次渲染之间没有更新
如果能扩展到缓存, 那么多次渲染也应该是可行的, 这也有不少性能优化的空间
memoization 是性能优化中惯用的办法, React 社区也有高阶组件在尝试
我相信不久也许能看到更广阔的可能可以优化 React 的渲染, 通过缓存
我猜想 React 代码中解耦会有障碍影响 memoization, 不能也许早用上了

组件 state 抽象方案弱, 代码重用差

应用开发用到组件私有属性的概念很正常, 所以 React 提供了方案很赞
然而长期使用认识到一些问题, 比如说数据和界面类似双向绑定的情况
当然 React 也提供了 mixin 进行双向绑定, 只是说, 这种抽象能力够吗?
比如 <input> onChange 绑定到一个 field 的值, 每次要单独写 setState
或者用 Function.prototype.bind 执行一次 partial evaluation 拿第一个参数
或者更复杂, 跨组件重用 state 相关代码, 那有只能用 mixin 来抽象了
对于 Store, 已经有了十多种 Flux 的改进, 而 State, 几乎还是 mutable 的思路

其次, Component.state 是一个 Object, 但是为什么?
简单场景不能一个 Boolean 解决吗? 复杂场景是一棵树那么为什么又套一个 Object?
即便用了不可变数据, this.state.tree.getIn(['a', 'b']), state 还是可变数据
同样的问题也在 props 上存在 this.props.tree.get('a', 'b') 中 props 也是可变数据
当然 props 仅仅是用于传参, 语法层面换成怎样不影响大局
state 的影响相对严重, 限制了更多的复用代码的可能性

组件 state 影响热替换

Redux 出名之后提到这个比较多, 组件有 state, 热替换时会丢失
react-hot-loader 作者想了很复杂的花招对方法做代理才解决掉
也有另外的说法, 认为 state 也可以存放到 Store 当中一起管理
当然马上有其他的考虑, 树的 state 在 Store 中怎样管理才不会乱, 怎样设计?
另外铺平的状态, 往树当中继续传递, 原来担心的多参数问题是不是更严重?
Redux 作者最近撰文提及热替换中的 state, 也认为是过于复杂了

在 Clojure 社区有 Om 各种实现在之前就引发过类似的争论
Om 当中就有用 Atom 存储数据, 一棵树, 其中嵌套存储数据, 通过 Cursor 修改
js 社区也有 Cursor 的实现, 比如 baobab, 达峰说有在生产环境有某个 Cursor 实现
Cursor 的思路是在组件传递数据时带上数据在树当中 path, 以便进行修改
而它的代价是传递的数据不再是纯粹的数据, 并且需要额外的代码封装
...我没有弄清 Cursor 所有细节, 大致就是有方案, 也有一些学习成本使用成本
另外 Cursor 一般不包含管理私有状态, 所以对上面的 state 作用大小有待讨论

这个问题我认为类似 Cursor 的思路是能够用来优化 state 并改善热替换的
只是目前的 React 在处理 state 的问题上并不彻底, 遗留下来比较难办
我在 Respo 当中实际上尝试了一个方案, 做到了几乎一样的 state 管理写法
同时能满足, 热替换时 state 是全局的, 语法上像是私有的, 而且并不复杂
..当然, 我的做法的不足, 可能对性能优化有影响, 还有待探索
能明确的是 state 有方案可以改, 能很好兼顾热替换和写法, 只是现在还不成熟

框架的模块化

React 相对于 Angular 的好处在于 Store, Virtual DOM, DOM 三者之间相互解耦
因而 DOM 可以被替换成 Canvas 或者 Native View, Store 也可以切换
不过, 一般人去替换 DOM 很难对吧, React 有发布 DOM Diff 接口文档吗? 没看到
如果真的是 Git 那样的 Diff, 那么在一台机器 Diff, 另一台机器 Patch, 理论上不难
我认为 React 在这一点上并没有解耦清楚, 但我没有读源码也不好展开说

在 Respo 当中我基本实现了 Virtual DOM 和事件处理的逻辑(但放弃了支持冒泡)
Virtual DOM Diff 和 JSON Diff 有点相似, 加上不可变数据, 或者说 Immutable Data Diff
作为参考可以看 http://jsonpatch.com/https://github.com/intelie/immutable-js-...
那在 Respo 我已经做到了在服务端 Diff, 发送到客户端进行 Patch(网速原因, 一般不实用)
这是对框架进行解耦以及模块化带来的好处, 可以带来更多的可能性
比如说 DOM Diff 放到 service worker 里做, DOM Patch 主线程, 性能可以提高
那么我认为 React 其实没有做到支持这样的可能性

组件当中书写逻辑的语法

我说是的 JSX 中写 if 和 switch 的场景, 相信很多人遇到过
我从前说得少, 因为在 CoffeeScript 和 ClojureScript 当中天然解决掉了
JSX 受到 js 本身的拖累, 两年多了依然偶尔有吐槽说 CoffeeScript 都行 JSX 怎么不行
原因很明显, ES6 ES7 也改不了这个问题, 我只能说我偏好 everything is an expression

本身体积的影响

原本不算大问题, cdnjs 上压缩 89k, 加上 gzip 28k, 单页面动不动 500k 了
但是社区的实现, deku 就小很多了, 还有 react-lite 作者聊到的 preact 3kb
这让 React 本身的体积显得可疑了, 而且影响了在很多场景当中的应用
既然已经出现了小体积版本的兼容方案, 事情就这样了

工具链层叠太多, 没有及时压缩

应该说是 js 的问题, React 大量使用不可变数据和表达式, 这些是 js 支持的弱项
当然也还有热替换, 这种除了可变数据的影响, 还有的打包方面的支持
你只对比 js 和以往的 js 的大概觉得 js 已经很努力了, 除非换一门语言吧
但是 WebAssembly 的出现, 简称 wasm 吧, 浏览器可不只一门语言了
我相信这对于 js 来说将会是很大的冲击, 虽然不致命, 老程序员嘛总不喜欢马上换工具
但是大量其他语言开发者会来写代码编译到 wasm, 而 wasm 本身比 js 更优秀

我熟悉了 cljs, 可以拿来对比. React 需要不可变数据, 需要表达式, 需要热替换, 高阶函数对吧
这些直接通过 cljs 语言上默认支持, 而且热替换和压缩打包工具链很简单
我录过视频用 cljs 搭建热替换和打包的场景, 非常简单
http://www.tudou.com/programs/view/kWScM...
在 js 中完成这些需要整合相当多的工具, 得学吧, 得认得坑吧, 得升级吧, 很麻烦
虽然 cljs 并不是实际项目的首选, 但直接能对比出来 js 生态有多么破碎
而且 js 是标准, 存在各种各样的方案相互兼容有大量的成本, 这套工具链并不让人舒服

而且 cljs 像我们展示了一种可能性, 加入当初 js 就是按照 Scheme 设计的话会怎样?
对, 技术本身还是很复杂, 但是复杂都复杂在语言内部, 怎么解析怎么编译怎么执行
现在 React 复杂在整个环境怎么搭建上, 做不到安装两个命令语言能跑全部搞定
很多人吐槽 React 使用成本高, 我认为就是因为中间大量直接直接暴露没有封装导致的

Redux 的复杂度, Store 的设计不够清晰, 站队不明确

Redux 引入了很多复杂的概念, 后来我自己做 actions-recorder 熟悉了一遍
整个原理本来很简单, 单个的复杂 Store 更新, 加上调试工具, 这样
在 Redux 当中引入了中间件, 接着大量的内容都以中间件的形式抽象
这个时候又引入了大量的新概念和新组件. 非常难跟进
函数式编程虽然啰嗦, 但是好歹各种论文堆了几十年才演化出来
Redux 才一两年工夫就弄出各种名词, 很让我怀疑是不成熟的方案混杂在里面

另一方面, Redux 又像服务端的方案, 中间件, 单一数据, 类似服务端和数据库嘛
考虑到我浅薄的后端开发经验, 对这种模仿本身不做分析
但是前端形成一套数据方案之后, 相当于又一个数据库, 那么它和服务端数据库关系怎样?
按说两个数据库之间, 主从同步会是一种关系吧, 就像 Meteor 给的方案
或者 Falcor 也给出了方案可以智能处理数据的抓取, 减少开发成本
而 Redux 本身形成了复杂的生态, 却没有站出来说清楚这个问题, 怎么回事?
我认为方案已经比要解决的问题还有复杂了, 对 Redux 打一个问号

尝试方案 Respo

这些点, 我认为 React 存在问题, 但并不认为是漏洞, 而仅仅是不足
我的意思是官方为了照顾更大的局没有对这些方面做优化, 因而不足
而且我也没有明确的方案可以把我认为的问题可以干掉
React 官方仓库空间兼容性, 稳定性, 性能, 跨平台, 复用, 各种因素非常多多需要考虑
我在 Respo 上做的尝试, 只是试着去解决某些问题, 下面细说

现在 GitHub 上 respo 有关的代码仓库包含这一些:

代码我用的是 ClojureScript 和 Cirru, 绕过了前面提到的 js 很多的问题
当然, 实际上也创造了更多问题, 比如 cljs 部署方案不完善, 学习成本, Cirru 问题等等
但是好处在于代码基本上通过 cljs 中的不可变数据做抽象和解耦
我贴 cljs 大概会吓跑很多人... 但为了做说明还是要贴啊, 主要看注释吧

; removed code for namespace and styles

; 处理事件的函数, 其实需要的是里边一层函数
; 外一层函数只是为了传递数据, 因为 this 去掉了, 所以 props 和 state 需要改成手动传入
(defn handle-toggle [task state]
  (fn [simple-event dispatch mutate] (dispatch :toggle (:id task))))
; 通过 dispatch 发送 action, 不需要一层层传递, 直接能用
; mutate 类似于 set-state, 但功能不一样, 看后边的 :get-state 和 :update-state

(defn handle-change [task state]
  (fn [simple-event dispatch mutate]
    (dispatch :update {:id (:id task), :text (:value simple-event)})))

(defn handle-remove [task state]
  (fn [simple-event dispatch mutate] (dispatch :rm (:id task))))

(def task-component
 {:name :task, ; 增加命名方便内部分析, 还有某些场合判断是否 DOM 换了

  ; 对于 hash-map 来说更新简单是 merge, 这是函数, 可以自定义
  ; 自己定义的写法是 (fn [old-store param1 param2] (some-fn old-store))
  :update-state merge,

  ; 初始的 state 自己定义, 只要 update-state 能处理即可
  :get-state (fn [task] {:draft ""}),
  :render
  (fn [task] ; render 嵌套两层函数, 前面传递 props, 直接用函数参数的写法就好了
    (fn [state] [:div ; Clojure 当中表示 Virtual DOM 的数据, 写法奇怪, 我知道
                 {:style style-task}
                 [:div
                  {:on-click (handle-toggle task state), ; 前面的事件处理函数, 传入依赖, 返回函数
                   :style (style-toggle (:done? task))}]
                 [:input
                  {:placeholder "Describe the task",
                   :value (:text task),
                   :style style-input,
                   :on-change (handle-change task state)}]
                 [:div
                  {:on-click (handle-remove task state),
                   :style style-remove}]]))})
; removed some code of namespace and styles

(defn handle-change [props state]
  (fn [simple-event dispatch mutate]
    (mutate {:draft (:value simple-event)}))) ; mutate 可以传多个参数给 update-state

(defn handle-add [store state]
  (fn [simple-event dispatch mutate]
    (dispatch :add (:draft state))
    (mutate {:draft ""})))

(def todolist-component
 {:name :todolist,
  :get-state (fn [store] {:draft ""}),
  :render
  (fn [store]
    (fn [state]
      (let [tasks store]
        [:div
         {:style style-todolist}
         [:div
          {:style style-header}
          [:input
           {:placeholder "new task",
            :value (:draft state),
            :style style-input,
            :on-input (handle-change store state)}]
          [:span
           {:inner-text "Add",
            :on-click (handle-add store state),
            :style style-add}]]
         [:div
          {}
          (->>
            tasks
            (map (fn [task] [(:id task) [task-component task]])) ; 嵌套 task-component 组件
            (into (sorted-map)))]]))),
  :update-state merge})

前面整理的问题主要是在 Respo 一起思考的, 所以有尝试去解决:

服务端渲染, 不能有效利用缓存

没有实现, 但是预留了方案. 思路是对创建组件的函数做 memoization
cljs 中能直接判断参数数据是否一致, 深匹配, 有性能开销, 但 cljs 语言内估计有策略
我判断组件的所有 props 和 state 都相等, 就能够复用已有的 Virtual DOM
具体要有复杂的应用才能进行测试和改进

组件 state 抽象方案弱, 代码重用差

get-state update-state 两个方案可以自己定义, 一定程度上复用了代码
目前看来复用的代码并不多, 只是某些情况一个组件一个值就好了
我还有尝试下复杂的场景当中怎样利用这个优势进行抽象

组件 state 影响热替换

在 Respo 当中全局的 states 用例子写出来是下面这样
key 是数字组成的 vector, 值用 x 表示的地方是 get-state 返回的值:

{
  [0] x
  [0 1] x
  [0 1 1] x
  [0 1 1 1] x
  [0 2] x
}

states 是跟 Store 分开的, 但也是放在全局的, 然后隐式地传给每个组件
热替换测试可用, 清除多余的 state 也是能做到的. 可行性已经完成.

框架的模块化

注意 respo-client, 我把 Diff 的工作放在 respo 中, 而把 Patch 拆出来了
也就是说能实现服务端 Diff, 前端 Patch 这样的行为
我还在考虑将来在 Canvas 上实现客户端的可能性
其实 Canvas 渲染可以粗暴实现, 捕捉事件加上定位很困难, 实际上我很难做出来

本身体积的影响

cljs 打包一起就有 29k, 毕竟编译了整个语言, 不过还好不可变数据在里边
对于前端来说有些大, 带来新问题

组件当中书写逻辑的语法

cljs 解决了

工具链层叠太多, 没有及时压缩

cljs 本身作为一门编译到 js 的语言就有些问题, 只是没有 js 那样分裂的情况
所以是工具链层叠不多, 但是单层的语言编译有点复杂

Redux 的复杂度, Store 的设计不够清晰, 站队不明确

没有涉及. 我只是内置了 dispatch 方案方便了内嵌组件进行事件分发
另外的更新函数以及 Atom 更新后的通知, 直接用 cljs 语言完成了
Atom 类型数据有类似 Object.observe 的机制, 能直接监听数据变化调用操作的
但对于具体 Store 应该怎样, 还要继续思考一些方案

总结关于 Respo 目前状态

Respo 主要是个尝试项目, 而且 cljs 加上没有文档, 估计没有人想看
但还是整理一下目前做的很不好的地方以便参考:

  • 模块化之后, 挂载应用涉及到很多代码, 所以当前 API 没有美化的情况下很复杂

  • mutate 函数没有封装组件信息, 导致子组件不能操作父组件

  • Diff 算法是通过 sorted-map 来简化的, 影响了写列表的体验, 有视频解释

  • 调试很难, cljs 没有实现 core.typed 这个 Clojure 已有的方案, 我也没做手工处理

  • DevTools 只是尝试, 还不能用

  • cljs 前面说了, 设计很好, 但并不主流, 生产环境很多细节处理远不如 Webpack 等

好消息是上面的 wanderlist 我用 Respo 实现而且使用了很多天, 说明方案小应用能跑了
说真的, cljs 能自动检查变量, 虽然类型不能, 但已经比我写的 coffee 代码可靠多了
我后边还会基于 Respo 做一些小的 Toy App 同时添加一些语法糖方便使用
有兴趣 cljs 的同学, 上边链接给过了, 我录了视频介绍怎么入门 cljs
我也希望对 cljs 有了解的同学了解一下 Respo, 作为对 Reagent 的一个补充
我的方案里热替换, inline CSS 也挺溜, 改界面效率不会 Webpack 差, 好歹用了这么多天
最重要的是, 现在我如果对 React 有不满, 至少我可以想办法在 Respo 里做出来给人看


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

Saviio · 2016年04月03日

来和题老师交流下意见...

其实我有些略不理解服务端渲染这点,因为按照“传统”一些的考量(或许是我又落伍了...),不都是入口页借助服务端渲染,后续事件由客户端响应么。那么从这点考虑上来说,似乎服务端要不要缓存意义并不大。而且如果要保证客户端和服务端都能渲染,那必然要求客户端的state数据可以即时同步到服务端上,一来要设计同步方案,二来又有带宽的占用,好像又增加了一个隐性的要求,但随之而来的优势是什么呢?

而组件当中书写逻辑的语法这一点上来说,我觉得React已经比Angular好用十倍了,写ng-if这样的标签实在是不自然。如果是语言的缺陷,是不是一定程度上可以靠现有的生态来做拓展,比如sweetjs?

至于Redux的话,我其实觉得它更像一个客户端的缓存方案来桥接Component和State两者,而不是一个数据库这样的落地数据存储方案,这也就解释了为什么redux不给出抓取数据的解决方案了,所以可能类比redis会更合适一些?

js生态确实挺凌乱的,但cljs有很大的认知成本,我觉得这可能是比生态凌乱更难跨的一道坎。

回复

题叶 作者 · 2016年04月03日

服务端渲染这个应该是理解思路不同, 我是做单页面应用出身的, 想的是通过服务端渲染来提升单页面应用的优势, 而 React 却存在限制. 具体场景当然还是 React 做首屏的服务端渲染, 其余在前端渲染, 这样更加适合单页面. 我这里说的缓存仅仅是渲染缓存, 仅仅是程序执行过程当中的缓存, 和对数据本身的缓存不一样. 不确定完全对应你提的疑问.

书写逻辑, ES 规范目前看不到希望, 可能的方案当然是语法编译, 不管是什么方式的编译.

Redux 的问题我没有明确的答案.

cljs 成本非常高, 而且部署问题解决得不好. 当然我已经学会了 cljs 所以问题主要在后者.

回复

Saviio · 2016年04月03日

这样说来的话,我直觉上觉得瓶颈更应该在传输效率以及降低round trip的cost而不是由哪端去做渲染上,诚然服务端的渲染是显然要快,但是客户端的渲染已经没有明显的感知了。Rendering由客户端去做这样反而是降低服务端压力的一件事。

至于渲染缓存这事,没理解错的话,你指的是纯粹的、无状态的渲染下的缓存?

回复

题叶 作者 · 2016年04月04日

初次渲染是浏览器问题, 只能从服务器端做优化.

嗯, 按照 React 的套路, 都是尽量往无私有状态的情况设计的.

回复

载入中...