Router-view 背后的想法

原帖在论坛发了一遍 http://react-china.org/t/router-view/2940

什么是 router-view

router-view 是我为简聊开发的一个路由组件
本来自己写的, 后来用 CoffeeScript 重构放到 teambition 团队维护了
https://github.com/teambition/router-view
原本简聊用的是 react-router, 但我还是冒险替换掉了
从结果看, 好处达到了, 但可维护性并不满意

这篇文章我当然是想解释一遍 router-view 究竟好在哪值得冒险
特别是组件背后的对于路由的理解, 这是对整体架构至关重要的
另外 router-view 受到 elm 和 redux 影响实际上不小
前面的文章介绍的 actions-recorder 也是 router-view 的肇因
而 router-view 初稿时 redux-router 还没发布, 谈不上借鉴

路由的类别

在 actions-recorder 或者 redux 的观念当中, single store 非常明确了
特别是涉及到整个 store 的回溯, 这一点必须优先保证的
然而在路由问题上, 当时发现了问题, 就是调试回溯不能将路由纳入控制
这主要是在调试工具的可用性上大打折扣了, 所以我开始重新思考
目标主要是路由可以被 single store 所控制, 以及回溯

早在 2013 年秋冬, 我和寸志讨论 Backbone 路由问题时就想到过
Backbone 的路由简单精悍, 但对于嵌套的路由实现比较吃力
当时我们觉得界面是随着路由渲染的, 那就渲染呗. 然而不知道怎么实现
Backbone 的路由类似事件, 能绑定 controller 方法, 然后操作
这是可以去调用渲染, 只是这样终究只是调用, 不是普通的渲染过程
回头看我认为这是对服务端路由的模仿, 带一点误解在里边

以我 React 的开发经验再审视路由, 我认为前端的路由就是一个 View
比如说, 让你实现一遍地址栏, 前进后退按钮, 用 React, 简单吧
我写一下伪代码:

React.createClass
  displayName: 'addressbar'

  propTypes:
    router: React.PropTypes.string # 表示路由的数据或者字符串
    onChange: React.PropTypes.func # 路由更新的事件
  
  getInitialState: ->
    history: [@props.router] # 历史记录, 用于返回
    pointer: 0 # 在历史记录的位置上切换

  onBack: -> # 处理
  onForward: -> # 处理
  onChange: -> # 处理

  render: -> # 两个按钮, 一个输入框
    div null,
      div null, onClick: @onBack, '<'
      div null, onClick: @onForward, '>'
      textarea value: @props.router, onChange: @onChange

从这个角度看, 路由就是和组件基本一致的, 包含一下一些特征:

  • 根据一个当前的状态渲染, 状态改变时调用回调函数

  • 有对应界面, 以及交互

  • 内部有私有状态

只是区别在于, 地址栏是浏览器原生实现的, 要去封装, 需要些奇技淫巧
那我说想的重要的一点就是, 路由属于 MVC 的 V, 而不是 C
...补充一下, 或者说地址栏是 V, 因为路由确实包含一些别的东西, 继续下文

Single Source of Truth (SSOT)

回到数据流的角度, 也就是 SSOT, 同样也是 single store 所陈述的问题
如果路由是个独立于 single store 存在的部分, 那么它是什么角色?
store 作为 Model 控制着界面的状态以及显示, 可是路由也有这个功能
所以我认为, 明确前面的地址以后, 那么路由的当前状态是属于 store 的

这里说的路由其实一直很模糊, 而且在各种框架里也显得很不一样
那这里, 我按照 MVC 把路由进行拆解, M 是状态, V 是地址栏, 很明确
而 C 是对 M 进行操作的代码, 即便在 React 中也模糊, 这里不细化
而 router-view 给出的方案, 就是对地址栏进行封装, 对 V 进行明确
而 M 自然作为 single store 的一部分, 附着在 Model 当中

因而在我的方案当中, View, 也就是地址栏, 大概就是组件的形态了:

React.createElement addressbar,
  route: store.get('router') # 当前状态
  onPopstate: (info, event) -> # 回调函数
  rules: routes # 一些路由规则
  inHash: false # 是否使用 Hash 的路由
  skipRendering: false # 处理一些特殊的渲染情况

而路由中对应 Model 的数据, 我用更方便操作的对象来表示:

initialStore =
  message: {}
  topics: {}
  router:
    name: 'topic'
    data: {topicId: "c4d6a940d"}
    query: {}

这样做之后, View 和 Model 都成了整个大的 View 和 Model 的部分
于是应用的整体也就往 single store 更靠拢了一步
之前的路由不受回溯控制的问题, 自然而然得到了解决

作为试验, 你可以打开 http://r.nodejs-china.org/
然后通过 Command+Shift+a 快捷键打开调试工具
找到 "router" 字段, 然后选择左侧的 Action 位置, 来尝试效果
或者直接看开发组件用的 Demo http://router-view.mvc-works.org/

不足

最主要的问题是 Model 和 View 分离之后, 封装特殊逻辑不方便了
现在 Store 和 Component 当中分别有 router 代码, 很多需要手写
具体我在下面展开:

首先是路由的嵌套写法问题, 本来 router-view 是带来了好处的
因为路由状态是用数据存储的, 任意深度或者奇怪的嵌套都能写
只需要在想判断的 render 代码里加上 switch, 后面就轻松实现了
事实上越是复杂的路由, switch 就会越长, 对可读性有不小的影响
特别是和 react-router 的声明式写法相比. 还好, 只是观感的差别

其次是初次加载, 或者切换时, 自动计算路由结果的问题
对比 react-router 直接声明, 在 router-view 里不好做
因为初始化时需要把地址栏的信息翻译到 Store 的对象上去
这中间存在一些啰嗦的代码, 而且为了 Store 独立, 不能随意抽象

前面主要是影响代码风格和长度, 其实还有渲染的问题
更明确地说是浏览器处理机制的影响, 就是 popstate 事件不能取消
https://developer.mozilla.org/en-US/docs/Web/Events/popstate
想象一下, 简聊通过后退按钮切换话题, 中间可能需要抓取对应数据
为了界面显示准确, 我们用在先发请求抓取数据, 完成后切换路由渲染页面
然而浏览器默认行为是点击后退直接改路由, 这就导致了状态不一致
可能出现的问题是路由出现多次的切换, 破坏掉历史记录的机制
无奈只能加 skipRendering 参数, 在加载过程允许界面状态不一致

另外还有个意外的 Hash 地址的问题, 也算实现上
基于 Hash 的路由事件, 除了不能取消, 甚至 JavaScript 代码都能触发事件
于是需要在加载过程当中屏蔽掉地址栏回调事件.. 总之很奇怪
但我想这个问题过于冷僻, 应该很少有人会遇到了

总结

从整体效果看, router-view 是比 react-router 控制起开更灵活的
然而从可维护性上, 加上是自己从头实现的, 相当不完善
我推荐看我文章的同学尝试跑 Demo, 观察单向数据流是怎样运作的
但是想在生成环境用, 至少先看懂组件不到两百行的代码
addressbar.coffee 是组件部分, path 是路由的匹配逻辑

我认为 React 应用的核心就是单向数据流怎样设计
以及, 所有的 Store 的部分, 所有的 View 的部分, 怎样契合这套数据流
梳理清楚单向数据流之后, 路由就是浏览器实现不好对付的特例罢了

阅读 3.3k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

630 人关注
252 篇文章
专栏主页