使用 React.js 和应用换成构建快速同步应用程序
对大部分应用系统来说,在某种程度上,应用程序的快速加载和及时取得最新数据两个方面同样重要。倾向于积极使用缓存数据,可能会导致提供的数据陈旧;而倾 向于及时获取最新数据,可能会牺牲加载时间。当然,也可以鱼与熊掌兼得,但是可能会需要更多的硬件,更复杂的软件,或两者都需要(意味着一个字:钱)。
如何权衡取决于特定的应用系统和业务要求,本文就是我们的团队使用React.js和应用缓存来解决这一问题的一个实例。
我们从哪里开始
标签是每当你在浏览器上打开一个标签去送出一份慈善捐助的好理由。这是一件很伟大的事——但事实上,我们仅仅点击了一个价值 100,000美元的里程碑来完成慈善捐助——但是,我们有一个疑问。
我们的应用也太慢了。大家都明白这点。当用户更换新的标签页时,他们需要得是速度与连贯性。而且,我们也没有宣布:载入页面的延迟成为了人们关闭标签的首选理由。
我们想让我们的页面除了更有用,还要更好地被接受。但随着我们向页面中加了些附加功能后, 我们的页面载入问题也越来越突出了。因为人们需要我们的 APP 能快速地提供内容信息。
我们正在用 Django 的模板系统做一个交互式服务器来召唤或服务一个页面。当使用者是在快速的网络环境中,而且我们的服务状态是健康的情况下,服务器响应时间是 ~65毫秒,还不是比较惨。然而,如果在你父母的房子*里打开一个标签,或者我们的数据库产生了一个短暂的停顿时,这可能会给你在对其的信任上,泼了一盆冷水。
比较让人烦恼,我应该承认我们所建立的 APP 并没有采用标准的前端框架,除了仅仅是使用了 JQuery。 考虑到我们的 APP 有太多的互动,而且太混乱了。在各种各样的代码类型上,我要怎么才能喜欢它。
我们需要去修改它。
* 我爱你们,老妈、老爸!时代华纳有线电视, 没有太多什么了。
明确我们的需求
当准备去处理这个问题时,我们必须决定优先处理哪些以及放弃哪此需求。在这里我们提出了一些建议:
-
页面必须能快速载入。这是没得讨价还价的。
-
我们的页面必须是非本地 URL。我们提高了 VIA 捐助广告的价格,网络在线广告需要去核识真实性来确保这些广告是够安全的。由于浏览器端的用户页面插件总是将我们的广告移除,以至于网络广告只能使用 http 或 https 协议。
-
我们希望页面中的内容是最新的,但不必是实时性的。我 们通过设备对用户数据进行同步, 并保持完美的体验。我们以分页的形式显示出用户的反馈;例如,我们显示出新用户的统计数据;我们有时也要运行捐助设备来以滚动条的形式显示出 "募集资金" 量。虽然我们愿意去接收一定程度上稍旧的数据(就像页面展示后才提交数据),但理想得是在提交数据的瞬间发生。
-
我们要减少前端混乱的代码。将非优先权最高的代码肃清,这是一件让人兴奋的事。
让我们动起手来实践关于处理这些问题的思路。
一、采用主动的服务器端缓存
我们一度认为应该增加首先扩展服务器端缓存结构,目前我们已经十分依赖Django的低级缓存(low-level cache),它有助于达到我们的目标,但是我们不得不在每次都要写语句来判断是否存在缓存到期或失效情况,我想这张摘自一场精彩演讲(an excellent presentation)的幻灯片能够反映出Django在缓存问题上面临的挑战:
此外,为了更好地从服务器端缓存中获益, 我们的缓存系统看起来是一个多层次的结构:(先是)每个用户完整的页面缓存,然后是用户数据的模块化的缓存,(同时)每当数据变化时还要智能判断数据是否 失效。因为在实施过程中已经遭遇到一些与缓存相关的(系统)错误(bugs),所以我们并不希望继续增加了缓存系统的复杂性。
更重要的是,还存在网络传递差异的问题,例如对一个新的TAB页面来说,在快速和慢速的因特网网络上的表现有着显著的差异,即使将我们的服务响应时间降低到小于1毫秒,对大部分用户而言,这个页面显示的还是不够快的。
不,这样可不行。
二: 在我们的页面上使用应用缓存
"应用缓存? 他不是个douchebag么?"
不,别这么粗鲁!
… 好吧, 也许他是有点儿. 在使用应用缓存之前,充分了解它的怪癖和陷阱是明智的.我们主要关心的是应用缓存会降低我们在调试时的透明度,因为我们服务器在轻便的 请求上没有日志 (接下来我们将解决这个问题). 在代码变更后的另一个与之前不同的小问题是,在两个视图页上应用了这些变更: 它需要一个页面去提示浏览器获取资源, 另一个页面则去使用新的资源.这不是很理想, 但是在我们的案例中是可以接受的. 在一般情况下, 我们的团队在应用缓存的限制下相对没有多少烦恼; 更多我们的app不适用的情况下解决起来会更轻松.
好吧, 也许我们可以与应用缓存合作. 可能这是一个方法在不必通过大量的重构去实现它?
我们快速而粗糙的主意就是,使用 Django 处理视图模版并返回一个html页面来保持我们当前页面的原状.在任何用户数据变更时,浏览器会从服务器和应用缓存那里获取一个重新渲染的页面 .
我们的游戏计划:
-
我们将在当前页面上激活应用缓存, 所以它将会绕过服务器去加载.
-
当一个用户制造了一些数据改动而我们又想保留时, 我们的页面将会使用一个ajax请求去保存数据到数据库里,通常我们就是这么做的.
-
我们将会从应用缓存清单引入一个对用户特殊的版本号,所以对于每个用户来说这份清单都是独一无二的. 当用户更新任意数据时, 我们将会对这个用户的应用缓存清单的内容创建一个新的版本,而且浏览器会知道并获取页面资源来更新.
-
在客户端方面, 我们将会在用户修改任意数据时检查应用缓存并更新.浏览器将会获取用户的缓存清单, 查看已经被处理成一个新版本号的被更改的内容,并且重新获取页面的内容.
-
理论上, 当用户下次浏览这个页面时, 应用缓存会提供一个在服务端重新渲染过的最新的页面.
从好的方面讲,这些选择将会引入极小的工程投资.
一个小缺点:这个选项没起作用。
浏览器获取资源的速度不够快是主要的问题。 如果你在新的标签页修改了数据(例如,在你的便签里添加了一条笔记),然后在几秒内打开了一个新的标签,应用缓存可能还没有获取到你修改的新的页面,显示的依旧是你没添加笔记的旧页面。从用户体验的角度看,这就像是数据丢失 — 即使是技术上的数据延迟 ,也是我们无法接受的。
当多个设备参与时这个问题会变得更严重。如果你在设备A上对你的新标签页有修改,接着在设备B打开一个标签页,保证你得到的是旧的数据。在随后的页面加载之前你都看不到新的数据。
这不是很好。 抱歉,这是个快速而粗糙的选择。
三:面向模板的本地存储和应用程序存储
更简洁地做到这一点,我们可以结合客户端模板使用应用缓存,在本地存储数据。这看起来是个很好的选择,除了应用缓存的“第二页加载”那个问题所出现的糟糕 情况,它是非常快的,并且它还可以清理掉我们的前端(重构...哇?)。作为奖励,我们的新标签页在在线的时候将被访问。
我们选择使用 React.js 作为模板是有一些原因的。最主要的一个就是我们有一些在其他领域使用应用的经验。我们也觉得学习曲线比Angular更浅显些,我们也是严肃地考虑过其他 方案的。说来奇怪,长久以来建立一个前端框架都是在我们已有的jQuery上努力,我们的数据被改变更像是React中的“状态”,这会让我们转换到 React更容易些。
我们还选用了 非死book 的 Flux 构型, 因为我们认可单向的数据流可以让我们的代码更顺理成章. Flux 的调度器也能让我们更加容易的进行数据同步,下面我会对此进行描述.
它是如何运作的
新 的tab一打开,浏览器就会从应用缓存中获取我们的页面。我们的React应用或从Flux存储中获取数据 (1), 后者会去本地存储里抽取数据 (2). React 应用一安装,页面就会被加载 (3, 4). 然后,我们的页面会向应用服务器进行一次Ajax调用 (5), 发送应用的所有数据—其实就是一个带有所有Flux存储数据的对象. 服务器接收到用户所有的本地数据,并使用用户在数据库中的数据对其进行调和 (这会在下面的 "数据同步" 中有更详细的描述), 然后向应用返回最新的数据 (6). 应用会从服务器接收到最新的数据,并且更新每一个Flux存储 (7, 8). Flux 存储一更新,存储会触发一个变化事件 (3), 而 React 组件就会更新他们的状态 (4). 当用户改变了什么东西的时候,就会发起一个动作 (11), 更新存储的数据 (7, 8); 当存储更新并触发变化时间的时候,我们会将数据持久化到本地存储中 (9) 而如果用户在线的话,就将数据持久化到数据库中 (10).
如果你了解过有关 Flux 的东西, 下面这幅图看起来应该会很熟悉:
(抱歉,这图看起来有点乱.)
这 幅数据流图的意思是,我们会向你快速的显示新的tab页,然后在一秒钟左右之内,我们将会用来自服务器的新数据更新你的页面. 这份新数据可能包含你在另外一个设备上对一个窗口小组件做出的变化 (像便条里的内容) , 或者也可能是一个慈善活动中“筹集资金" 实时统计.
有 关使用 Flux 构型最令人惊奇的一件事情就是通过调度器的远程数据流同步时间完全同用户的操作保持一致,使得调试异常的简单. 因为存储是我们应用状态的真实来源, 所以我们可以放心的让应用在存储的数据被远程的数据同步或者用户的输入改变时,仍然可以始终如一地响应. 我们仍然可以再整个应用中保持单项的数据流, 这使得代码理所当然的变得简单很多.
在应用速度和数据实时性两者之间,我们已经找到了理想的平衡.
数据同步
我提到过我们会在每次页面加载的时候向服务器同步你的数据。那我们是如何去实现这个东西的呢?
我们通过为数据“块”的最后一次更新打上时间戳,然后在客户端(的Flux存储)上,以及远程的数据库中保存数据发生变化的时间戳(modified_at),这样的方式来处理同步. 例如,如果在你的一个便条窗口中进行了输入,就会把窗口的modified_at时间戳设置成现在,然后把你的便条内容保存到本地存储中,并入如果条件可能的话,也会保存到远程数据库中. 而后,下次你打开一个tab的时候,我们将会把有关窗口的数据发送到应用服务器,在那里会对跟该窗口相关的客户端时间戳跟数据库中保存的时间戳进行比对,并返回最新的数据.
为了简单起见,我们用Flux存储对象来进行数据的发送和接收. 这让我们可以无痛的用发送自应用的数据更新Flux存储, 因为我们明白它将会被保持最新,并且同我们的存储一样具有相同的数据结构.
我们当前的同步过程肯定是不完美的: 在发生同步冲突的情况下,我们会简单地去获取最新的数据. 对我们而言,这只是一个可以接受的细节状况; 毕竟,我们可不是 Evernote. 即使这会变得不可接受,也可以在以后用更智能的数据合并和用户消息进行解决.
让我们运行得更快一些 ! (或者说与呈现)
加载应用缓存的页面很不错,但在我们向用户展示之前,我们仍然要运行React应用代码,并对所有的组件进行渲染. 对于一个相当大的应用而言,这可能要花上几百毫秒甚至超过1秒.
为了能有一个快速的第一次加载体验, React 提供了一个方便的 renderToString 方法,让你可以先向浏览器发送DOM(让页面先出现) ,然后再连接上所有的侦听器 (让页面可交互). 这样就适应服务器端的预呈现了. 在我们的案例中,我们想是否可以用把它用在客户端上 — 而我们做到了.
每 次我们将数据持久化到本地存储时,我们也会将我们的React应用做成字符串,并将这个字符串保存到本地存储中. 然后,页面一加载,在我们做任何事情之前,我们会从本地存储中加载渲染好的应用并将它放到一个HTML元素中. 换言之,页面只用了3行JavaScript就加载了DOM! 对于我们的应用而言,预呈现减少了大概400毫秒的预加载时间。
"见鬼":挑战和缺陷
没有什么东西是完美的。重构的时候还是有些事情不那么有趣的。
再见吧, JQuery UI
在转换到React过程中的一个速度损失让我们放弃了几乎所有的JQuery UI组件,比如 draggable. 这稍微烦人地让我们花了点时间来重新做之前已经做过的事情. 不过,事实证明我们还是可以依靠不断增长的实用的 开源React组件 来构建我们自身想要的东西.
"为什么, renderToString, 为什么?"
另外一个小的实现上的挑战: 如果你用过React的 renderToString 方法, 你可能已经看到过这个错误:
React attempted to use reuse markup in a container but the checksum was invalid.
当 React在已经有预渲染DOM存在之后渲染它的应用时,它就要预计预渲染好的DOM应该同将要被渲染的DOM相同. 那就意味着你不能让像 Date.now() 和 Math.random() 这样的东西影响到你的DOM. 为了解决这个问题,你将可能要花点时间在你的差异编辑器上面,来比对这两个DOM字符串.
不够灵活的存储数据结构
我们设计为应用同服务器返回的应用数据结构之间的不匹配敞开了大门. 在我们想生产环境推送新的代码之后,你第一次加载的页面视图会包含从应用缓存加载的老应用代码. 不过,从应用服务器返回的同步数据将会是结构化的,而我们的新版本会对其进行构造.
所 以,如果在新版本中我们决定对存储中的一块数据进行重命名或者移除,你的页面就会在新的tab第一次打开时被打断; 老的应用代码不会知道如何去处理它. 在打开下一个tab之前,你的浏览器可能已经获取到了最新的应用代码,并将其放到了应用缓存中,因此页面会运作得很好.
为了防止新tab的打断, 我们需要为我们的存储数据维护一套可靠的内部API. 那样会有点儿痛苦.
说到代码的推送...
如果我们搞砸了,弄坏了应用,每个看到一个破页面的用户都会在我们修复它之前看到一个额外的破页面. 应用程序缓存就会进行恼人的二次重新加载更新。
大家好,结局才会好
切换到React和Flux是一件令人很愉快的事情. 我们的团队发现我们自己重新爱上了前端开发, 而我们做出的变化让新进工程师接触代码库容易了许多。
在用户体验方面,我们的新tab一直在快速推进. 对于拥有优良网络条件的用户而言,这次的版本不会有太多的变化;但是对于其他人,他们是能在发现我们的应用不可用和喜欢上使用它之间发现不同的。
因为Tab需要从横幅广告展示为慈善机构筹集基金的原因,更快的页面加载能增加在用户离开我们的页面之前看到的广告的数量. 这次的版本增加了大约12%的广告展示 (还有对应的筹资收入).
当然,一个快速的应用并不会是一个好的应用; 它只是好的应用不会是一个马上就会让人讨厌的应用. 对于我们而言,它提供了未来更多有趣动人的事情的基础.
-----
这是不是很有趣 ? 你想要通过一个有趣,充满活力的团队工作不 ? 我们在招人哦 ! 还有,如果你本周就在 San Francisco 附近, 我就会在周五的 React.js 推介见面会 上 — 如果你想要一次会谈的话就让我知道吧.
感谢 Ti Zhao 和 Josiah Gaskin 对这篇文章的评论。