3

原文一个月前发布在简聊(https://jianliao.com/)博客上, 这边做一下备份
https://jianliao.com/blog/jian-liao-shou-ping-xing-neng-you-hua-fang-an-xie-ji-lu/


Single Store 的重要性

首先整个改进方案的基础是 Redux 提出的 Single Store 架构
按照 Redux 的理念对应用进行抽象以后, 架构回归到 MVC 非常原始的理念,
也就是: 一个 Model, 一个 View, 以及剩下的 Controller 代码

我认为存在问题的方案是对象化的封装, 特别是每个 View 存在独立的类似 MVC 的对象,
具体来是简聊早期使用的 Backbone 架构, 数据分散在各个 Collection 当中, 难以管理

到了 Redux 的架构中, 只有一个 Store, 对数据进行统一管理就方便多了
而 immutable-js 的加入, 更让整个架构的数据流变得非常清晰
Redux 方案里, 数据层是 Store, 界面是 React Component 组成的 View
而 Controller 的只能由 Actions 和 Reducer 来承担, 这里只是做个类比

为了从简单的数据结构和函数构建复杂的应用, 每个部分都要进行抽象和复合
首先 View 借助 React Components 从小到大很灵活地进行组合
而 Store 通过 Map 和 Reducer 函数也能组合(其实这一点我们没有贯彻)
此外, 应用和服务端存在数据同步的需求, 也需要考虑抽象(后面讨论). 整体架构大致就是这些

数据界面的分离

Redux 最知名的是它的 Time Travel Debugging 功能, 也就是记录 Actions 和 Store 进行回溯. 实际上这也是检验"数据界面分离是否充分"这样一个架构是否完善的一个考验
当一个单页面应用能自由地回滚数据状态而不引发异常, 才可以更有信心地说应用的行为和数据流非常清晰, 很好预测
同时, 界面和数据没有复杂的双向的操作, 特别是渲染界面时导致数据更新
React 组件任意地渲染更新界面, 而且数据不受影响, 应用才不会走向混乱

数据同步问题

在实际的应用编写当中, 切换页面的加载数据过程, 存在具体的问题
早先我们的架构当中切换路由, 是先切换界面, 然后界面初始化时加载数据
但是这个做法就违背上面定下来的方案了, 就是渲染过程存在数据操作
另一个实际的影响是, 请求数据的逻辑是在组件挂载时才调用的, 并不好优化
设想一下当我们想加载数据, 却要先去渲染界面, 这样的架构是否清晰?
当然, 对我来说最头疼的还是前面的, 阻碍我从更高层次对架构进行优化的问题

比如说简聊切换话题, 点击话题, 地址改变, 就需要加载数据和渲染界面
按照默认的 react-router 的行为, 地址改变将直接导致界面重绘
也就是新的话题的界面马上就被渲染出来,然而次数话题的数据还没有请求到
话题的组件被迫渲染没有数据的话题, 等到数据加载完成, 再重新渲染一遍
这个界面很多行为超出控制, 难以优化, 特别是路由不受控制
正确的流程, 应当是先加载数据, 再渲染界面, 以及加载过程做一些提示

基于上边的架构设计和具体问题, 简聊采用了自己实现的路由组件
这个路由组件的行为通过 Single Store 中的数据控制, 以及相应的 loading 状态
现在版本的简聊, 点击话题, 会先标记 loading 状态, 同时在后台发起数据请求,
请求完成, 重置 loading 状态的同时, 请求到的数据在界面上被现实出来
以及, 现在也可以对数据请求操作进行合并, 这点将方便未来的优化
包括切换团队, 现在也能做到优化, 达成先加载数据然后渲染界面的效果

为了处理好这个过程, 还需要知道的是, 对应的路由需要哪些数据
比如说, 在话题 A 里边, 我们需要 topicA, memberA, team1, contact1
而在话题 B 里边, 我们需要 topicB, memberB, team1, contact1 的数据
从 A 切换到 B, 就需要分析本地混存已有哪些数据, 缺少那些数据
然后才能准确地抓取缺少的数据, 在界面需要的数据抓取完成时开始渲染
Facebook Relay 就是以此为目的的一套方案, 只是并不适合我们
因此项目中自己实现了简单的数据依赖分析代码, 达成了这个目的

首屏渲染的花招

先说简聊有一个针对断网和重新连接做的特殊处理, 会强制更新一遍本地的数据
具体说就是清楚掉内存缓存的旧的消息和话题, 或者或标记所有的缓存失效
然后, 会按照前文描述的方案分析当前需要的数据, 进行一次数据抓取
其实这个过程的关键是, 需要根据路由分析出当前界面需要哪些数据
因为简聊主要的状态是存储在 Store 当中, 至少在架构上不存在障碍
经过这样的操作, 网络重新连接以后, 简聊可以恢复到一个同步更新的状态

而首屏加载是相似的问题, 用户一段时间没有登录, 这时需要进行一次同步
区别只是在于, 首屏加载本地并没有对应缓存, 需要拿到数据才能开始渲染
抓取更新数据的问题, 前面讲的网络重连的代码完全可以重用
然后是缓存, 跳得远一点, 前面说了, 简聊是 Single Store, 主要数据存放在一起的
于是, 就很有可能, 把 Single Store 存储下来, 作为下次页面打开的缓存使用
也就是说, 相当于关闭浏览器时把一切缓存下来, 下次打开时一切复原到关闭时的情景
而实际上用户通过 jianliao.com 访问应用, 很可能就命中这个缓存了

基于这样的出发点, 简聊在在关闭应用时将 Store 转化为 JSON 字符串存进 localStorage
应用再次打开时, 如果条件合适命中缓存, 就直接将缓存渲染出来
并且在后台分析依赖开始抓数据, 最终在获得新数据之后再次更新界面
对于用户来说, 浏览器输入 jianliao.com 就立即开始渲染, 省去不少的等待时间
如果不是太长时间没有登录简聊, 新数据更新的界面也不会太明显
最直接的效果就是很多情况下简聊可以更快地打开了, 也就更方便

渲染性能优化

在这个花招使用的前后, 简聊加载的顺序发生了一些变化

之前: 加载资源, 运行代码, 请求数据, 渲染界面
之后: 加载资源, 运行代码, 渲染界面, 后台请求数据, 局部更新界面

去掉了基数和波动较大的网络请求时间, 剩下的就是 JavaScript 执行和渲染的时间了
想要让简聊首屏更快地出现在用户面前, 就要开始优化启动和渲染速度

通过 Chrome 的 Timeline 调试工具, 我逐步收集到的大概有这些点
主要是 DOM reflow 的开销, 不稳定然而通常导致较长的时间消耗
(下面的列表是我主要基于记忆列出的, 并不是完整的):

  • getBoundingClientRect 调用, 关系到一些菜单定位(可以优化)

  • focus 操作, 有时会触发 DOM 的 reflow(有的不能优化, 但可以延时处理)

  • scrollTop 读取和操作比较明显(然而部分调用不能优化, 否则影响到用户体验)

  • jQuery 初始化过程会读写 DOM, 可能触发 reflow (目前还不能去掉)

  • React 初始化过程会做一些 DOM 的探测(无法优化)

  • rangy 初始化过程有 DOM 读写(无法优化)

  • favico.js 初始化有 DOM 的读写(无法优化)

问题的关键当然是 DOM 操作触发了一些 reflow 的问题
其次 JavaScript 初始化各种 timer 和复杂计算也会消耗时间
但实际上纯 JavaScript 本身性能, 只要代码不写出问题, 也不会慢了
只是对 DOM, 还是要关心挺多, 这方面直接推荐网上的资料了:

http://www.phpied.com/rendering-repaint-reflowrelayout-restyle/
https://gist.github.com/paulirish/5d52fb081b3570c81e3a

考虑到这是剩下的主要是 CPU 密集的计算, 实际上受到 CPU 性能影响较大
CPU 性能越好, 渲染也就越快. 至少现在已经部分地去掉了网络慢的影响
另外, 我们目前对渲染做的优化只是初步, 相信后续的深入优化以后初次渲染的性能还会有一些提升


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者