当我们在实施前端性能优化的时候,为了更好地解决白屏问题,现在大家可能会逐渐放弃了前端异步渲染(CSR),而选择尝试 SSR 或 PWA 等为优化的方向,甚至考虑切换至 Weex 技术栈下解决问题。然而,这些优化的手段是否能完全解决“白屏”的问题呢?
本文将通过信息流场景下正文“闪开”优化的技术选型及策略演进,给大家提供 一种通过 Web 技术实现 Native 体验的但又区别于 SSR、CSR 以及 PWA 的渲染思路——NSR,并以前端开发者的视角来阐述其背后的设计及思考。
闪开:即用户一点即开,表示极致的 Web 页面加载体验。
NSR:Native side rendering,即由客户端(Native 侧)实现页面结构的拼接,进而实现页面渲染的处理技术。
SSR:Server side rendering,即服务端渲染,由服务器完成页面的 html 结构拼接的页面处理技术。
CSR:Client side rendering,即客户端渲染,指的是由页面在用户的浏览器环境中通过执行 JS 完成页面结构拼装的渲染处理技术。
PWA:Progressive Web Apps,渐进式 Web 应用。
图中左边为优化后的效果
技术指标:T2 秒开率从 44% 提升到 91.4%,T2 绝对值从 1515ms 降低到 320ms;正文加载错误率降低 97%; 闪开率 (0.3s 打开比例) 占比 79%。
备注:T2 指标定义为网页开始加载到首屏渲染结束,这是由 UC 内核自定义的技术统计指标。
当谈论性能优化的时候,必定要考虑对应的场景,如果不交代一下优化目标的情况和背景,可能不太好理解我们为啥会设计 NSR 渲染机制。
首先,我们的目标场景如下图所示:
信息流的内容主要分类图文、视频、图集等几大类。其中图文是信息流最为成熟的内容形式,有约三分之二的下发内容属于此类型,而本文主要讨论的是对于图文类型内容页的优化,我们的目标是图文页的打开体验能达到“闪开”。
当然,我们的优化并不是从 0 开始的,之前做过一轮大规模的体验优化,基本上前端常用的优化手段都用了,而且包括了部分客户端的优化:
正文 Ajax 的预加载:在列表点击进入正文的同时,将正文的 Ajax 数据请求发出去,打开正文后通过请求复用获得 Native 拿到的数据;
首图的预加载:在列表下拉刷新后,由客户端将可视范围内的正文的首图提请下载本地缓存,正文渲染时复用此缓存。
以上是我们早在 2016 年曾做过的优化,当时将 T1(绘制首帧的时间) 性能从全网 2 秒优化到 1 秒左右,刚优化完的 T1 秒开率一度到达了 90%+。
但历史的优化也遗留了一些未解决的问题,而随着业务的迭代,秒开比例逐步降低到了“闪开”优化前的 70% 左右。
不是我们没有考虑过对主文档进行缓存控制,原因有以下 2 个:
一是,在信息流场景下不同的文章页面 url 是变化的,而同一个用户是不太可能重复访问同一篇文章的,对用户访问过的文章进行缓存,收益不大;
二是,由于风险管制的原因,已经下发到客户端的资讯类内容需要有 1 分钟内可被回收的能力,这是一条强制的政策规则,如果主文档或者 Ajax 请求被强缓存了,那么就会影响内容的回收。
当然,这两个问题也是可以解决的,解决方案依赖服务端和客户端提供配套的处理机制,在历史优化的过程中该机制还未成熟,因此没有开启文档或 ajax 请求的缓存。
我们曾思考将正文 Weex 化的可行性,并且在商品导购类页面上采用了 Weex 去落地,也拿到了比较不错的结果,然而正是由于这一尝试,我们否定了将信息流内容页迁移到 Weex 技术体系的可能。
之所以不选择 Weex,有以下 5 个原因:
跨端:除了 UC 端内,正文还需要考虑第三方 APP 打开的场景,如外渠、分享等,正文页面的运行环境是非常多变的;
跨平台:Weex 兼容 IOS、安卓和 H5 的成本不可忽视,其降级 H5 的体验需要定向优化,比如左右滑屏、视差滚动、复杂动画;
排版:Weex 处理富文本内容有短板,排版的表现力满足不了正文场景的诉求,而解决此问题的成本是比较高的;
性能:虽然 Weex 性能好于 H5(好的最主要原因是 Weex 资源是离线的),但其本质是依赖于 JSBridge,性能与 Native 仍有差距,当我们要求对齐 Native 的体验时,Weex 也要彻底解决页面打开的闪白问题;
成本:正文要覆盖端外没有 Weex 容器的场景,必然要很好地兼容 H5 回退的情况,并且体验必须不能低于现有的 H5 体验。也就是如果选择 Weex,总体成本必然大于只维护 H5 版本的情况,而且业务是不停在迭代的,如果用 Weex 重构整个 H5 体系,其风险和成本几乎是不可控的。
因此,容器选择是显而易见的,只有 H5 一条路。在 H5 容器下,我们只需要重点解决性能的问题即可。
在上面的背景中,我们提到了信息流正文是采用 CSR 模式渲染的,在这个模式下遗留了几个没有解决的问题。既然 CSR 存在问题,而前端擅长的手段还有 PWA 或 SSR 都可选项。当然,结果最开始已经说了,我们既没选择 PWA,也没有选择 SSR,原因分别是:
不选择 PWA 的原因
PWA 对信息流业务有用的 ServiceWorker;
ServiceWorker 的启动和保活的成本很大 ;
ServiceWorker 的缓存——CacheStorage,本质上也是 HttpCache,在内核角度并不是最快的,而且如果我们使用 SW 对正文 Cache 后,如何更新和回收成为一个必须解决的问题。
既然 PWA 并不适合信息流场景,SSR 就应该纯前端性能优化的最优手段了吧?那么,它能对齐 Native 的性能吗?
理论上,从点击到 Window 动画结束即可完成渲染,而过程中要消除白屏的感觉,还需要在滑动出来之后尽快有内容。当然不一定是整页,但好的体验应该是尽快让用户看到内容,而此过程耗时要小于等于300 毫秒
!
如果这一标准标准去衡量 SSR 后的正文加载,在没有其他优化手段的辅助的前提下,SSR 也几乎不太可能达成 300ms 完成加载和渲染,因为即便不考虑页面渲染绘制的耗时,可能主文档或 Ajax 请求的网络耗时就不止 300ms 了。
为了达到 300ms,我们可以需要将页面从点击到渲染的整个流程进行拆分,将其分为三个阶段:
Native 的耗时 ≈ WebWindow 创建 + WebView 初始化
网络的耗时 ≈ DNS + TCP(SSL) + 服务端渲染耗时 (如果采用 SSR 的话) + Document 下载
渲染的耗时 ≈ Ajax(CSR) + JS scripting(CSR 耗时) + 浏览器 Painting(内核)
问题 1: WebWindow 和 WebView 的创建耗时可以优化吗?
答:理论上可以优化,但这是一个 Native 要解决的问题。
问题 2: 页面资源的加载耗时可否消除?
答: 这个问题显然是可以解决的,资源离线就好了,但前端自己解决不了。
问题 3: 页面渲染的耗时可以优化吗?
答:这个问题前端应该可以进行优化,但可能纯前端的手段可能还不够。
在上述的文章中,笔者已对于 Weex 在正文场景下应用的优劣进行了分析,其的性能优势主要是由于渲染资源的离线,如果正文同样也基于离线缓存,那么应该可以有很不错的表现,而这一判断在与 UC 内核同学进行深度沟通后得到确认,并且内核团队也全力配合我们进行优化。
在文章最开始的时候,笔者曾对“NSR 预渲染”做了一个定义解析,即由客户端(Native 侧)实现页面结构的拼装,进而实现页面渲染的处理技术。这是 UC 内核提供的一种优化思路或方法论。
从浏览器内核的角度,性能最好的页面就是——在用户访问的那一刻,页面所需的“资源”不再依赖网络而从内核的内存级缓存中获取,然后直接交给渲染引擎执行最后的处理(如下图所示),这就是理论上的完成页面渲染的最短路径了。
这里就涉及资源如何不从网络获取的问题,也就是“离线”。
很显然,这里需要我们对于“离线”概念有一个正确理解,并不是说完成页面渲染的整个过程都完全不依赖于网络,它更多是一种优化的指导思想,需要开发者理解并往这个方向去优化。
当然,前端的开发者应该很容易理解这样一个公式:
UI 页面 = F 模板 (Data 数据)
通常意义上,前端开发者所说的“渲染”,其实指的是将页面分拆为数据和模板,然后根据一定的规则拼接形成完整 HTML 结构的过程。
如果这个过程在浏览器中完成,我们则称之 CSR!如果在服务端完成,那么就是 SSR!
按这个思路,我们为什么不能将这个过程放在 APP 客户端上完成呢?现在的用户手机动辄好几 GB 的内存,它的运算能力可能和 10 年前的电脑差不多!
很显然,理论上没有问题。
从信息流场景上看,在用户访问页面之前完成页面渲染素材的准备,当用户点击访问的那一刻页面所需的资源已经处于 Ready 的状态,这样的时机是存在的。
如上图所示,在用户在访问正文之前,都有大量的时间和空间给我们对页面所需的模板和数据进行预处理,只不过这些工作只能在 Native 侧进行,也就是如果要实现可以在用户触达正文前的模板和数据的组装,需要在 Native 侧建立以下的机制:
除了要实现正文的模板离线机制和数据预加载外,Native 还需要处理好以下 2 件事情:
页面是动态的,即 URL 是变化的,需要实现一种页面与模板的匹配机制,这是一个“多对一
”的关系;
在 APP 侧实现一个类似 SSR 的本地渲染服务
,而且在信息流场景下是常住的服务,因为有很多页面需要动态组装 HTML 结构。
针对以上的诉求,UC 信息流客户端进行架构的调整和重新设计,架构如下:
页面与模板的匹配,是 APP 客户端对 URL 进行拦截,然后根据 URL Path 进行规则匹配,参数则通过表达式的方式进行解析匹配(在最先上线的版本是根据 URL Path 匹配,而参与表达式则是更通用的版本),然后通过内核提供的 API 接口将主文档及其子资源设置到内核内存中,匹配流程如下:
而资源预处理服务则会包含一个类似 SSR 的 JS 运行时服务。在安卓下则是由 V8 提供的 JS-Runtime,在 iOS 下则是 JS-Core,这样客户端就可以执行前端所提供的 JS-bundle,进而计算出正文的 HTML 结构。
以上,说了这么一大堆理论和过程,不过基本都是浏览器内核和客户端外壳要干的事情。这里开始,终于到了前端需要出力的时候了!
首先,需要明确一下前端离线资源该如何产出。
这里所说的离线资源,其实就是“静态模板”,页面中相对固定的部分资源,它需要包含 HTML 模板、CSS、JS 等正文首屏依赖的核心资源。对于异步的 JS,体积比较大,我们也一并打包在离线包中。在实际的页面访问中,首屏的核心资源是直接设置到内存缓存,而异步资源则是使用 HTTP cache(磁盘缓存)。
除了前端页面渲染过程所需的资源外,针对 NSR 需求要解决以下两个问题:
HTML 模板需要一个数据占位,以便 NSR 中被替换为期望的结果;
设计并构建一个可运行在 CSR、SSR 以及 NSR 场景的 JSBundle,也就三端同构的 JS。
先看结果,离线包的构成如下图所示:
资源以文件的 md5 结果为文件名,并通过 JSON 文件来描述这些资源,以便 APP 进行解析匹配,而三端同构的 js bundle 被标记为 renderjs
类型。
另外,前端模板中则在需要插入页面节点或数据的位置,与客户端约定了 注释标签作为替换占位标记。
JSON 描述文件示例如下:
{
"module": "wmmobile",
"version": "3.3.21.0",
"res": {
"545f196bb7bca3e98a8ed6c4d9211374": {
"url": "https://mparticle.uc.cn/article.html",
"type": "html"
},
"b5cdc529c83e0ca79231d08f39ab6ac3": {
"url": "https://mparticle.uc.cn/article_org.html",
"type": "html"
},
"ead6512d3d7c940ece8c916c3dc8b44f": {
"url": "https://image.uc.cn/s/uae/g/1y/infoflow/assets/js/IflowPageWemediaSsr.ea402c65.js",
"type": "renderjs"
},
"b7a4a62b3fb8eb99e75f323ba865db77": {
"url": "https://image.uc.cn/s/uae/g/1y/infoflow/assets/js/Article.lazy.4ff65a3e.js"
},
"ce8d98fcd6d93f5c63c34cc4f4ccf80f": {
"url": "https://image.uc.cn/s/uae/g/1y/infoflow/assets/css/IflowPageWemedia.cc645f39.css"
},
"692fd503bfcc28b3ed745d9531f4fd00": {
"url": "https://image.uc.cn/s/uae/g/1y/infoflow/assets/js/IflowPageWemedia.b88e2b3b.js"
}
}
}
这个文件以及其他离线资源的生成则需要配套的工程构建体系,因此闪开优化的过程中,前端的第一件事情是要改造前端的构建体系。有经验的同学应该都会明白,一旦改造前端项目的构建体系,意味着你可能要调整前端的架构,甚至要重构。这并不是一件简单的事情。
在对前端架构动手前,我们得想一下动手之后的样子。
在前面笔者已交代过,图文页面除了信息流场景外,还有很多其他的场景,我们也期望能尽可能的优化,但期望代码还是同一一套,道理相信大家也应该懂的:一是没有那么多人力去维护,二则是 ROI 问题。
那么,同构是必然的,也是必须的。它们分别的作用如下图所示:
对于前端的改造,是首屏的 JS-Bundle 要同构,并且体积也要足够小。那么,前端要怎么做?
事实上,UC 信息流正文业务是由于最开始有不同技术团队分别维护,再加上数据来源不统一等原因,导致技术架构有很重的历史包袱,在 2017 年我们进行了一轮架构的优化——前端微服务,它的优缺点如下:
优点:
解决了不同类型页面相同功能的代码逻辑统一问题;
每个 SDK 微服务均可自发布,优化了功能迭代发布上线的效率;
有效降低了业务的迭代维护成本。
缺点:
SDK 微服务架构需要大量的前置依赖,对性能带来了一定负向影响;
每个 SDK 之间是串行加载,存在一定概率加载不成功的问题;
由于每个 SDK 是独立的代码仓库,单点维护成本是比较低的,但多仓库的代码管理又成为了新问题。
既然有那么多缺点,那为什么还要这么设计?
背后的原因是 UC 信息流的业务进行了组织结构的重整,而使得在大半年的时间内实际维护正文业务的只有 2 个正职同学——笔者和另一名同学,而在一年以前还是 6 人,后来自媒体业务也交接到信息流组,但维护的人力投入并未增加,维护项目迭代的总人数从原来 2 团队共 9 人,变成了 2 人 + 4 个的外包!
难为无米之炊,其实是技术方案向成本妥协的结果,虽然可以有效解决页面功能统一以及开发人力成本的问题,但却给性能和体验留下隐患。
对于闪开优化的诉求,我们期望结果是 “首屏要极致的性能”。从理论上,不使用任何框架,直接使用原生 JS 处理首屏部分,性能才是最优的。但原生 JS 由于没有框架的约束,在持续迭代过程对维护者的能力要求较高(可理解为人力成本较高),而非首屏体验则不需要追求性能上的极致,因此持续迭代的过程应该用框架来进行约束。
目前,市面上适合用于移动内容展示页面的热门框架主要是 Vue 或 React。考虑到前端团队的能力模型以及前端组所负责的其他 Weex 业务,主要采用 Rax 体系框架,对于 React 开发体系的应用已非常成熟,因此基于信息流这一特定场景,我们自研了 PureJSX
框架。
最终的前端架构如下:
正文技术架构将从下至上分成四层(在架构模式上与 UCWeex/Rax 体系基本类似):
1. 底层:UFE + Lerna + Webpack + TypeScript + Tslint
前端工程化统一到我们自研的工程体系 UFE 架构体系下,目录则采用 Lerna(monorepo)架构来进行管理和约束。
2. 框架:PureJSX + PreactJS
自研 PureJSX 框架,实现首屏业务的无前置框架依赖,支持 JSX 和 SSR 渲染,以解决首屏的性能问题;
非首屏的则采用 PreactJS 框架,但不引入 Redux,必要性不是很大。
3. 组件层:UI 组件和 Widget 组件
PreactJS 和 PureJSX 的组件均遵循统一的 Base 基类,组件可互转,纯 UI 组件或函数组件直接使用
规范上,通过 TypeScript 和 TSLint 类型检测来进行约束。
4. 发布管理层:超音速 + FEA 平台 + 云构建
超音速平台:用于发布 H5 离线包,将 UFE 工程套件生成的离线资源下发到 App 端内;
FEA 管理平台:这是信息流前端团队自研的前端资源发布管理平台,在这里的作用是用于不支持离线包或未命中离线包资源的线上兜底版本的模板发布,支持对资源的灰度、回滚等;
对于原有的 SDK 微服务,在过渡期间我们则通过构建工具对其上线发布进行支持,因此前端的架构调整是渐进式的,在优化的开始阶段只需要对首屏逻辑进行重构即可。
理想与现实之间总是有差异的。架构设计可以很完美,但现实能不能落地则是另一回事。
信息流正文是一个有非常重历史包袱的项目,为了避开历史版本的兼容问题,上线的优化代码或模板是针对具备离线包能力的版本进行了切割,通过在服务端进行新旧版的模板匹配,在 UC12.0 以上版本上启用新模板,并与产品沟通后确认不再对旧版本进行新功能的迭代,只进行必要的维护。
同时,在新版本的重构中,为了达到首屏的最小化依赖,我们做了以下的事情:
在逻辑上将原来的如 Vue、Zepto、Lazyload 等基础类库剥离后,新加入的 Preact 类库在异步 chunk 中才会引入,不影响首屏;
在首屏的最小化模块中,其代码书写上仅使用兼容 ES5 的语法,保障首屏不再需要引入 Polyfill,而非首屏部分则不做限制;
重新定义首屏的功能,将原来包含 Dom 事件绑定、统计上报等逻辑全部移到非首屏的 Chunk 中。
其中,在作者的关注状态上,如果原来没有状态缓存而用户又有状态更新的,则关注区块的 UI 状态会发生变化而重绘(用户肉眼可见),我们说服了产品接受了这一点,因为这样做可以让正文不再依赖任何的前置请求和类库,进而极大地简化首屏的逻辑。
很显然,我们所采用优化策略是与场景及历史背景息息相关,同样要达到闪开的体验,其实还有其他的手段或策略,甚至实施起来更为简单。
事实上,我们也尝试了 SSR 的方案,即页面结构有服务端生成,客户端把页面主文档预加载到本地并设置到内核内存缓存中,不过如果采用此方案则需要解决两个问题:
服务端算力问题此问题会延伸为机器部署、服务稳定性、可用性、QPS 压力等服务端的问题。这其实是大多数前端并不擅长的,在项目启动和执行过程,我们并没有那么多精力去处理这些问题,因此在 UC 端内我们设计了 NSR,而 SSR 则作为非信息流场景下的补充方案。
预加载带来的网络峰值问题此问题的本质是成本。UC 信息流是一个超大体量的业务,如果使用 SSR 渲染页面结构再通过网络加载完整页面,那么服务器和流量成本就可能会显著增加,而 NSR 则是一种成本更低的选择。
从整个优化过程来看,“闪开”优化其实是为了解决前端页面的性能问题,我们也拿到了想要的结果。但全部的优化策略中,需要 Native(外壳和内核) 来提供相应的配套机制,如果仅仅从前端开发的视角来优化,可能再怎么努力结果达不到最好。
但是,这个项目是从前端开始到前端结束,过程需要用前端的视角进行串联和策略设计,这对于前端开发者而言最大的挑战有以下几点:
充分理解浏览器的渲染原理和缓存机制;
要跳出前端范畴来思考整体的最优策略,要清楚不同端能做和该做的事项;
优化手段不局限于已有的经验,也不局限于前端已有的优化手段,敢于怀疑 SSR 或 PWA 在优化场景下的适用性。
根据本文所提供的思路,我们不妨重新思考一下页面优化到底是什么?
事实上,我们所做的所有事情都是基于以下两点:
一个公式: UI 页面 = F 模板 (Data 数据)一个理论: 将模板和数据分拆,并尽可能保障在用户触达前获取,然后根据场景选择合适的组装“地点”。
基于以上的方法论,在信息流场景下我们根据业务场景而设计了 NSR,并推动了客户端和内核提供配套的处理机制,可能许多开发团队并不具备如此深度定制的能力,但这并不影响开发者重新认识和理解 Web 性能优化这件事情。
诚然,在不同场景和背景下优化的策略设计并不是一成不变的,笔者期望通过对于信息流场景的 Web 性能优化的探索和实践,为前端开发者提供一种新参考或思路。
当今是云原生与物联网的时代,产业进入高速发展阶段,对技术架构、操作系统以及交付模式都提出更高的要求。ArchSummit 全球架构师峰会深圳站特别开设免费技术专场,探讨在云原生时代下,从架构、软件技术、支撑平台、组织运作等方面如何进行系统性建设,以及云化 DevOps 工具链架构及设计细节和建设过程中的心得体会,阅读原文或识别下图二维码报名参会。