38

离线预渲染OPR:0成本接入 媲美SSR效果

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzU0OTExNzYwNg%3D%3D&%3Bmid=2247484894&%3Bidx=1&%3Bsn=689d4cb8b223a26fcee175a76443a63b
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

关注我们 文末有福利

vUjaiqa.png!web

作者简介

Mry6z2Y.png!web

张所勇

转转平台运营中心前端负责人,在前端领域有深入研究,包括:sketch一键切图、前端数据模型化,小程序基础能力建设等多个方面,10年工作经验中,做了2年工程师,5年CEO,3年技术管理,能写点文章,也是2018年度掘金优秀作者。

细数现阶段业内首屏优化方案,主要有:SSR、Prerender、CSR等方案,这些方案的思路几乎都在于将渲染过程放到传统SPA用户端渲染之前,而传统性能优化手段在SPA项目上面收获甚微,原因在于SPA本身的致命缺陷:

SPA方案

在SPA项目的首屏性能上,我们在长期关注和不断探索,期间我们尝试过很多方案,包括:

  • 从减少代码体积角度的:webpack优化、打包优化、tree-shaking等

  • 从减少HTTP请求角度的:接口合并、按需加载、延时加载等各种方法减少请求

  • 从缓存角度出发:离线包、http&浏览器各种缓存使用、dns预解析、dll方案、接口缓存方案等

  • 从数据获取时机角度出发:webWorker预取数据、路由进入过程读取数据等

  • 从减少图片体积和数量出发:使用webp图片、请求域名并行优化、CSS Sprite等

这些方案都能一定程度上降低白屏时间和首屏时间,但收效有限,很难像SSR方案一样大幅降低数据,究其原因,SPA页面渲染过程如下:

aQbUB3Y.png!web

滑动查看图片

从上图可以看到,白屏过程几乎是不可避免的,因为无论如何你去优化代码体积,Vue系列类库和你需要的其他核心类库文件加起来至少有几百K,在加上这些文件执行的时间(实测至少500ms),可能大多数情况,我们白屏时间至少1200ms-1500ms了。

当然,我们可以把骨架屏所需的css放到HTML里面,能尽早的显示出骨架屏(但很多低版本内核需下载&执行完全部script后才会渲染页面),但这并非真正的首屏,即使在性能统计上,也无法直观反馈出首屏的提升。

于是SSR方案成为我们的救命稻草:

SSR方案

我们再看下SSR如何解决这个问题:

FZree2B.png!web

滑动查看图片

SSR方案的优势在于,浏览器下载的HTML当中已经具备了首屏渲染所需的DOM结构和样式,白屏时间几乎等于HTML文件下载时间,而这个时间相比SPA已经很少了,性能数据有显著提升。

那为什么我们不直接用SSR方案呢?

主要原因有四点:

1、SSR项目改造成本高

Vue技术栈的SSR方案主流有两种:官方方案和Nuxt.js,这两种方案相同点都是:

  • 必须把现有webpack各项配置替换成上述两种方案工程

  • 工程所有页面都必须SSR方式的要求实现

  • 必须在自定义的asyncData/preFetch生命周期内获取数据

  • 必须将接口数据使用Vuex管理

或许你认为这个也不难啊,对于一个新项目,确实不难,但对一个老项目来讲,上述的改造成本和测试成本就无比高了,这也是少有老项目改造SSR的原因。

2、SSR性能依赖接口性能

从SSR原理上你可以知道,SSR服务端渲染过程依赖于获取到全部数据才能开始渲染,一旦接口出现延时或超时,那首屏性能也会受到影响。

3、SSR负载能力和扩容能力可能成为瓶颈

几乎是业界公认,node的负载能力相比java等要差一些,相比nginx静态资源服务更差,并且很多公司在node服务器快速扩容上面,目前还没有太多实践和机制保障,虽然可以通过备足服务器来抵抗流量高峰,但毕竟这对应的是成本。

4、SSR无降级方案

一旦node服务故障,页面可能直接就会白屏,很多时候不是重启服务能够解决的,毕竟SSR不是像SPA一样在浏览器看见什么错误去解决或者回滚就可以的,你必须真正解决了故障才能恢复服务,这期间不能很容易的降级为SPA方案。

上述原因当中,最主要阻碍我们用SSR的原因是改造成本。

Prerender方案

Prerender是基于prerender-spa-plugin这个webpack插件实现的,原理如下:

zyaayuE.png!web

滑动查看图片

核心原理就是在webpack打包过程中,通过Puppeteer访问对应路由,抓取html并静态化,再部署cdn。

但业内这种方案使用的比较少,主要原因有:

  • 静态化过程发生在构建环节,用户访问时看到的数据注定是过时的。

  • 这种方案依赖于使用history方式的路由,这对老项目的改造测试成本也不低。

  • 编译时间大幅增加,想想就知道啦。

通过上述分析,我们能看出“最优方案”应该是SSR,不考虑负载能力的话,阻碍我们的只有改造成本了,能否用较低的成本实现跟SSR一样的效果呢?

离线预渲染OPR

晴空一声惊雷,OPR产生了,我们把他命名为离线预渲染OPR(Offline Prerender)。

OPR的渲染过程:

a2Ibyui.png!web

滑动查看图片

不同于SSR在用户访问阶段的渲染,OPR是一个独立于用户访问流程的渲染服务,它通过Puppeteer定期渲染页面并上传cdn,用户访问到的页面将会是纯静态页面,可以说是结合了SSR和Prerender两种方案。

与SSR方案的区别:

  1. 渲染过程独立于用户访问,没有服务器压力,占用资源极小,一台服务器即可完成

  2. 页面几乎不需要任何改动

  3. 渲染出来的页面效果几乎和SSR一致

  4. 可降级为SPA方案

与Prerender方案的区别:

  1. 通过定时渲染,解决Prerender方案数据无法及时更新的问题

  2. 页面几乎不需要任何改动

  3. 对原本项目构架过程无任何影响

OPR方案实现过程

我们简单拆解来看:

01

定时访问页面

我们首先搭建一个node服务,通过schedule机制定期通过Puppeteer访问需要渲染的页面。

02

等待页面渲染

页面渲染是一个动态的过程,我们如何知道页面已经渲染完了呢,Puppeteer其实提供多种方案,但我们最终选用的方案是通过监听公司性能统计埋点发出时机,通过Puppeteer的page.waitForRequest方法可以很容易实现。

03

抓取HTML

你必须清楚一点:我们抓取的是浏览器渲染的HTML,并非你请求到index.html文件内容。

前者你可以理解为,通过浏览器开发者工具,选中html标签,右键拷贝outerHTML。

后者你可以通过浏览器查看下html源码,里面应该只有空白的dom和一些<script>标签。

前者内容可能是这样的:

YrY7buJ.png!web

后者内容是这样的

其实抓取HTML这个动作通过Puppeteer一句代码就可以实现:page.$eval('html', e => e.outerHTML),但抓取到的HTML我们做了很多处理:

OPR渲染标识

为了让页面知道是被OPR渲染出来的,我们将会在HTML里面注入一个变量:__offline_prerender_data__,这个变量既起到标识作用,也可以用来存放一些特殊数据

抓取接口数据

熟悉SSR过程的同学可能知道,SSR会把服务端渲染阶段所需的数据写入到HTML中,用户端渲染时会进行一次数据校验,有了这些数据,用户端也可以尽快的完成二次渲染(下文会讲到)。

在OPR当中也可以提供这样的能力:

如果开发者在配置文件当中设置了useDataCache : true,则我们会监听页面渲染完成之前所有的接口请求,并将数据打入到HTML当中,同时帮你注入一段代码,能让HTML执行时把数据存入localStorage中,供一些接口缓存库来使用

大体代码如下:

AnmIfuB.png!web

04

解决适配问题

我们在服务端puppeteer里面模拟的设备环境跟用户实际设备肯定不一样,那我们就要解决样式适配问题:

a、在head里面注入rem刷新代码

b、将页面当中的px转换为rem

方案比较简单,这里就不贴代码了。

05

去除无用内容

我们将HTML当中无用的内容去掉了,包括:

aE7bi2f.png!web

06

对比

我们期望降低一些更新cdn的频率,因此我们把渲染好的HTML会和上次渲染的HTML进行对比,如果内容一致就不会重复渲染,其中主要有两种情况导致渲染不一致:

a:过程中页面有新上线

这时两次的HTML里<script>地址不一样了,必须会再次上线

b:页面当中数据发生了变化

  • 需要上线情况:接口数据变化了

  • 不需要上线情况:页面当中重要程度不高的数据变化,比如倒计时、购买人数等

为了减少上述不需要上线情况导致的上线,开发者可以在对应DOM上加上offline-prerender-tag-nodiff样式名,OPR在diff的过程就不会比对这块的DOM。

07

上传cdn

OPR会把渲染好的页面根据url地址上传到cdn,例如:

转转图书首页:https://m.zhuanzhuan.com/open/ZZBook/ index.html #/Book/Home

会被上传到cdn:https://m.zhuanzhuan.com/open/ZZBook/ index-Book-Home.html #/Book/Home

为什么不是index.html,这样有几个好处:

a:将OPR地址和SPA地址区分开

我们只需要将入口地址替换为

https://m.zhuanzhuan.com/open/ZZBook/index-Book-Home.html#/Book/Home

用户就可以访问到OPR渲染的页面了,如果用户访问到原本的index.html,那只是访问到原本的SPA页面,只不过渲染速度会慢一点而已。

b:降低风险

试想下,如果我们把文件写入成index.html,那如果用户首次访问的是/Book/Mine的路由,用户会很奇怪,我访问的是个人中心页面,为什么你要先给我展示首页呢。

同时,也防止了OPR服务发生了意外而导致所有页面都无法访问的风险。

二次渲染

上文讲过,用户访问SSR渲染的页面和OPR渲染的页面,都会发生两次渲染:

  1. 浏览器渲染HTML当中的DOM(我们叫首次渲染,注意此时页面是静态的,无法交互)

  2. Vue还会重新渲染并维护一份虚拟DOM,并把虚拟DOM和HTML当中的DOM进行一次混合(因为两个DOM完全一致,你完全看不到发生变化,但是可以进行交互了,我们简称二次渲染)

必须要有二次渲染,为什么?

因为首次渲染仅仅是DOM层面的展示,我们必须把Vue整体逻辑赋予DOM里,否则就没有各种事件,用户将无法交互。

SSR和OPR两种方式首次渲染过程都是一样的,但二次渲染有很大的区别:

SSR:

会先从html当中拿到数据,然后进行$mount渲染挂载,这里面Vue代码给SSR定制了一个特殊流程:会尝试跟页面当中DOM进行混合,如果完全一致就直接复用DOM,把虚拟DOM混合到页面中:

OPR:

OPR项目的二次渲染没有想象的那么简单,他很难做到复用DOM,主要因为:

OPR页面很难做到DOM完全一致

因为SSR项目是拿到所有数据进行一次性渲染,无论是服务端生成HTML的时机还是用户端二次渲染的时机都是完全一样的,数据也是完全一样的,因此能渲染出完全一样的DOM,如果虚拟DOM和页面中DOM有一丝毫差异,Vue都会删除掉页面已有DOM再使用新的DOM。

SPA页面会有个逐渐使用和渲染的过程,例如:

VBrMZnN.png!web

在OPR或者SPA页面中渲染是多次完成的:

1、当ready=true时先完成第一次渲染,同时开始渲染goodsList组件

2、goodsList会触发第二次渲染

而第一次渲染时DOM和页面当中并不一致,那么旧的DOM就直接被抛弃掉了。

那如果上述页面我们把结构写的更简单一点可以吗,比如所有组件都平铺开,不用v-if控制,所有数据都使用缓存数据,并且同步读取数据不能包含异步流程,这样理论上能完全复用DOM,但现实中是不太可能的,业务当中逻辑远比想象的负责,我们当然也不希望业务进行大量改造,这就违背OPR的初衷了。

因此OPR方案选择的二次渲染解决方案是: 延时挂载DOM

这个方案的核心在于,只要你在new Vue的过程中,不传el参数,那么Vue就会在没有挂载到页面的DOM当中完成渲染和虚拟DOM的构建工作,那我们只要在一个合适的时机把DOM挂到页面上就行了,这个合适的时机是什么呢?

页面DOM和虚拟DOM接近一致

我们先说下“挂载”是怎样的过程: 简而言之就是把HTML当中DOM删掉,再把虚拟DOM的句柄window.vm.$el插入body中

大家可能会担心有性能损耗吧,实际上这个DOM删除和插入的过程非常快,对于浏览器进程资源消耗非常少。

再回到挂载时机,如果页面差异过大的时候挂载会怎么样?那用户会感到页面闪动,因为有局部样子不一致,这个应该比较好理解,所以我们要在两个DOM接近一致的时候做挂载操作

延迟挂载时机

为了能知道何时两个DOM接近一致,我们借鉴了淘宝的性能统计方案中的首屏结束时机的计算方案:

我们通过MutationObserver来监听虚拟DOM的变化,每次变化时计算一次DOM分数,当DOM分数和页面已有DOM分数接近的时候,我们去挂载DOM

具体逻辑更复杂一点:

Bb2Qfii.png!web

滑动查看图片

当两个DOM几乎一致的时候,再去挂载DOM,用户就几乎感觉不到页面闪动了。

同时参考上文,如果使用了接口缓存机制,就减少DOM趋近一致的时间了。

其他优化

为了更进一步提升首屏速度,我们在OPR方案上还做了一些优化:

01

CSS tree-shaking

我们抓取的HTML里面会有很多style标签,几乎可以认为,你曾在vue里面写了多少个style标签,head里面就至少有这么多标签,例如:

JRB7Vjv.png!web

这些标签里面有大量重复的样式,如果打入HTML将增大文件体积,我们对此进行了tree-shaking,可以讲原本5000行+的css代码精简到1000+,具体如何实现,我们将会在以后的文章中讲解。

02

将script改为async加载模式

在此之前,你可能需要了解下普通script、defer、async有什么区别。

简而言之,

  • 普通script:  按书写顺序下载、执行,阻碍DOM渲染,阻碍DOMContentLoaded时间

  • defer script:并行下载,按照书写顺序执行,不阻碍DOM渲染,阻碍DOMContentLoaded时间

  • async script:并行下载,谁先下载完谁先执行,不阻碍DOM渲染,不阻碍DOMContentLoaded时间

为什么我们要用async,在某项低版本iOS内核中,会等待页面DOMContentLoaded才开始渲染页面,如果用普通script或者defer模式,会延迟页面渲染时间。

03

降级策略

如果出现任何问题,为了保证用户可访问,我们会做出降级方案,也很简单:

抓取HTML本身的文本内容,同步到cdn,相当于把OPR模式退回到SPA了,这个策略在OPR方案中会有相应的检测方式和自动切换机制。

效果

OPR方案已经在我司多个业务接入,可以把原本首屏在1500ms-4000ms的SPA页面 下降到300ms-800ms ,可以说是效果显著了~

最后说明

OPR方案目前应用于我司各业务 无需登录态 的首页及重要列表渲染,下一阶段我们将解决对登录态有依赖的页面渲染,欢迎读者与我司交流探讨。

文末福利

转发本文并留下评论,我们将抽取第10名留言者(依据公众号后台顺序)送出转转纪念T恤一件:

3Ab2uuy.png!web

扫描二维码

关注我们

一个有意思的前端团队


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK