开篇之前先介绍一下场景。信息流是一个基于用户兴趣使用算法将用户感兴趣的新闻内容推荐给用户的一种业务。这种业务带有非常特色的场景就是用户有一个“永远”都刷不完的推荐流列表,点击列表中的新闻之后可以跳转到其详情页中查看新闻的正文内容。列表一般都是由客户端原生去实现的,而详情页这块由于新闻内容结构的复杂性,一般还是会使用 h5 来实现。这样就对我们 h5 的性能提出了要求,我们必须在用户切换的时候将切换的白屏时间尽量减少,这样才能提高用户的阅读体验。
本文就将为大家讲述一下我们是如何实现性能优化达到“闪开”的效果的。我们可以先看看效果,下图左边是正常版本,而右边的是优化后的版本。对比之下可以发现即使我已经悄咪咪的先点击左边的手机,同一篇新闻右边的打开速度明显比左边的要快很多。接下来就让我们看看这个是如何做到的吧!
目前现状
众所周知,网页中内容渲染往往根据渲染方式可以分为后渲染和前端渲染两种方式,最近几年由前端渲染又演化出了同构渲染,也就是大家经常说的 SSR。这几种渲染方式的主要优缺点大概整理了主要有如下几个方面。
- 后端渲染:
- 优势:服务端直出首屏性能好,SEO好
- 劣势:交互逻辑复杂需要两端维护结构
- 前端渲染:
- 优势:前端交互易维护,数据渲染分离
- 劣势:首屏性能问题以及 SEO 问题
- 同构渲染:
- 优势:首屏性能好,SEO 好,一份代码多端运行
- 劣势:代码维护成本,服务器性能和维护成本增加
当然本篇文章不是来讲各种渲染方式的优缺点的,主要是说因为种种原因我们的项目最后使用了前端 JS 渲染的方式。而 JS 渲染带来的性能问题主要是由于数据接口请求返回以及前端 JS 资源获取所带来的网络问题。为了解决这两个问题,一方面我们采用了服务端将数据注入到页面全局变量中的方式避免了数据请求,另一方面我们使用了 localStorage 缓存的方式将前端资源做了 LS 缓存避免了二次打开之后的前端资源请求,从而提高了前端渲染的首屏性能。
思考优化方案
虽然我们避免了前端渲染的一些问题对首屏的性能做了优化,但还远远不够。那目前还有哪些点可以进行优化呢?简单的整理了下可以有如下两个方面:
- 首次进入以及线上代码有更新之后还是需要下载前端资源
- 服务端页面的 ttfb 相应还有优化的空间
- 客户端 WebView 打开的速度和性能还有优化的空间
从上面两个优化点我们可以看到所有的优化还是网络的优化,主要还是在移动端网络对性能的影响是远远大于其他方面的。那么是否有什么方案能够让我们免去这些网络请求呢,最终我们给的答案就是详情页本地化。通过本地化方案,我们将平均 820ms 的首屏渲染时间优化到了 260ms,整整提高了三倍多!
详情页本地化就是客户端不走网络请求打开新闻的方案,解决上文中列举的所有网络请求相关的优化点。它除了能为我们带来首屏性能的进一步提升之外,由于它不走网络请求的特性,也为我们解决了复杂网络环境下页面劫持导致的详情页白页打不开的问题。同时还为我们带来了无网络环境下的离线阅读新闻的能力。
本地化实现
由于我们的这面是纯 JS 渲染的,所以我们一个最终的详情页主要是由新闻数据
和静态页面
两者构成的。
鉴于对服务端的依赖非常的少,和大部分的 SPA 页面一样,本质上只要在客户端将我们的前端页面提前下载下来就能正常打开了。
详情页 = 静态页面 + 新闻数据
数据预下发
而如何在用户还没有打开新闻之前客户端就能把我们的页面资源下载下来呢?这里就不得不提一下我们的场景,因为在我们的信息流场景中,用户永远是通过流点击进入到详情页中。而在客户端的流中是需要加载服务端数据的,所以在这个时候其实我们就可以告知客户端让其提前下载好模板。当然大家不要忘记,除了页面之外我们还要有新闻数据,为了实现纯离线化同时也避免新闻数据接口的请求,在列表中还会将每条新闻的详细数据下发下去,保证必备要素的本地化。
如图所示,在列表请求的接口中,服务端会将需要缓存的静态页面地址以及每条新闻对应的新闻数据全部下发给客户端,客户端接收到请求之后会进行模板的下载。
客户端行为
需要的东西下发下去之后剩下的就是客户端进行渲染了。正常来说除了模板页面之外,服务端还需要下载其他相关的静态资源,然后启动一个 HTTP 服务将页面和资源文件进行关联,关联之后将数据注入到页面之后打开页面。但这对客户端的要求就非常多了,为了将客户端的工作量降低,我们将所有需要使用的静态资源通过编译内联到 HTML 文件内,客户端通过字符串拼接的形式将数据注入到页面的全局变量中。
如图所示所有静态资源都被标记了 inline
属性,我们的编译工具在读取到这个属性后会将当前资源给内联到 HTML 中。同时大家注意到该模板不是以 <html>
开头的,而是有一些截断。这是为了给客户端提供注入数据空间,客户端通过模板字符串拼接的形式将新闻数据注入到全局变量中最终完成整个新闻页面的获取。前端代码中则直接使用 __INJECT_DATA_FROM_CLIENT_DONT_MODIFY__
全局变量获取注入的数据。
页面的更新
上面就是一套完整的本地化下发并打开的流程了,总的来讲就分为四步:
- 前端将页面处理成真·单页应用
- 服务端在列表时将数据和本地化模板下载地址通过接口下发给客户端
- 客户端获取到模板下载地址后进行下载
- 当用户打开新闻的时候客户端将数据和模板进行拼接打开即可
但是只要有资源的分发就会涉及到资源的同步更新问题,我们的本地化模板也是一样。在我们的线上更新的时候如何让客户端知晓并触发更新行为,也是我们需要去考虑的问题。实际上大家在前两张截图中可以看到,为了解决这个问题,我们是在服务端下发的接口中还增加了一个 version
字段,用来标记当前 HTML 的版本。而当前端进行代码发布的时候,我们的发布系统会有一个类似 npm 的 postpublish
的钩子,利用这个钩子我们告诉服务端发布成功更新版本号。最后,当客户端接收到新的版本号的时候则会重新下载新的模板,完成一次本地模板的更新。
跨域问题
在前端页面中,Cookie 和 LocalStorage 等大量的特性是和域名相关的,而不巧的是我们的页面中都有使用,所以跨域也是我们需要考虑到的问题。我们知道,本质上此种方案下客户端相当于使用 WebView 打开了一个本地页面,而在 Android 系统中 WebView 打开本地页面的话有三种方法:
- loadUrl:本质上使用
file:///temp.html
的形式打开一个本地文件 URL - loadData:和
loadUrl
类型,好的地方在于不需要写成文件,可以直接加载页面字符串,不过此时加载完之后页面的 URL 是about:blank
- loadDataWithBaseURL:和
loadData
类似,好的地方在于提供了参数能够设置当前 URL 地址
从描述中可以看到,很明显最后一种 loadDataWithBaseURL
才是我们需要的。客户端通过这个方法加载,设置当前页面的 URL 为真实线上 URL,对于前端来说基本上就和线上环境无异了,本地化和线上 Cookie 和 LocalStorage 的共享都没有问题。不过这里需要注意,第一个参数 baseUrl 仅能管住当前页面,如果页面做了 history.pushState()
等前进后退操作的话当前页面地址又会变成 about:blank
,此时需要再设置最后一个参数 historyUrl
才行。
后记
本文给大家讲述了实现本地化离线阅读的方案。除了以上列举的问题,我们还碰到了一些细微的问题。例如我们发现在网络不好的情况下客户端可能会下载模板失败缓存了不完整的代码,所以我们增加了模板的 md5 值一并下发给客户端用来校验模板是否下载完全。又如上文说了模板的更新,实际上内容也会有更新,特别是一些新闻的实时性会有比较高的要求,为了解决这个问题,我们会在页面打开后再次去检查一下文章的状态,如果发生变量会切换至线上版本用来规避这个问题。除了这些之外我们还做了完备的云控后退方案,能在方案出问题的时候完美回退到普通版本。
其实大家可以看到,本地化只是我们在特定场景下决绝性能问题的一种特定思路。它并不是使用于所有的场景,所以我在文章开头也特别强调了一下我们的应用场景方便大家去理解。但是我们只要理解这种方案的精髓,我相信在其它的一些特定场合总能发挥它的威力。