通过伪 SSR 降低白屏时间

2019-01-12#CODING

参考了美团白屏优化思路 ,通过直接输出页面静态 HTML,达到降低白屏时间的目的。

公司的前后端分离方式是通过在 nginx 那里分流 client 静态资源和 web 服务接口实现的。

这种方式的优点很明显,http api 和前端开发互不干扰,有了文档就可以两头并行开发。

缺点也明显,web 服务完全不管 view 这一层了,所有页面都是 CSR 渲染模式。输出的 html 文档大概都长这个样子:

1<head> 2 <meta charset="utf-8" /> 3 <link rel="manifest" href="/static/vod_h5_ceremony_snapshot/manifest.json" /> 4 <title>React App</title> 5 <link href="/main.6d8bc2c7.chunk.css" rel="stylesheet" /> 6</head> 7<body> 8 <div id="root"></div> 9 <script src="vendor.js"></script> 10 <script src="common.js"></script> 11 <script src="app.js"></script> 12</body> 13

虽然通过懒加载,可以缩短头部 CSS 和 JS 的加载解析时间,但是在 `app.js` 成功被执行之前,页面都是白屏一片。

那么能不能在 `<div id="root"></div>` 中间塞进去一些东西以降低白屏带来的不适呢?

比如弹出 loading 菊花、显示页面占位轮廓。**既然都能塞菊花和占位图,为什么不能拉线上渲染好的 html 结构塞进去呢?**这就是开头提到的美团提供的思路了。

预渲染的基本流程

首先我们需要有线上可访问的页面,然后把 html 结构拿下来。但如果页面没上线怎么办?这时可以退一步想,渲染一个线上的 html 文档需要什么条件?其实就是两个:

  1. 线上的数据,即线上的接口。
  2. 线上的 JS 逻辑。
  3. 读取 html 文档的客户端。

关于第一点,一个页面如果要上线了,那线上的接口必然已经准备好。在本地搭建一个服务,把接口代理到线上去即可。

第二点,可以就着构建好的静态资源启动一个静态服务器,等待预渲染的客户端来获取。

第三点,那要通过什么工具去获取静态服务器的 html 文档呢?最好的选择应该就是 headless Chrome 了。Google 已经有了一个很好的封装,叫 Puppteer。

其实第一点和第二点完全可以放在同一个 web 服务去实现,第三点就直接使用 Puppteer。

整个流程其实已经比较清晰,大概如下:

  1. 构建项目的静态资源到某个目录,比如是 `dist` 目录。
  2. 启动 web 服务,静态资源的请求就响应 `dist` 目录里面的资源,api 的请求直接代理到线上。
  3. 启动 Puppteer,根据路由配置依次请求各路径,获取 html 文档保存。

Puppteer 的处理

上面第三点其实很粗略,Puppteer 的逻辑里还有很多不明了的问题:

  1. 路由配置从哪里获取?
  2. 获取的 html 文档要怎么保存?
  3. 直接把获取来的 html 文档保存是否会有问题?要做额外处理吗?

路由配置

为了读取路由配置,我们的做法是约束路由配置的写法。因为 `react-router` 的路由可以写得散步各处,所以必须要求把所有路由都声明式的写在一个文件里。

加上所有路由的配置其实都长得差不多:

1const routes = [ 2 { 3 path: "/sandwiches", 4 component: Sandwiches 5 }, 6 { 7 path: "/tacos", 8 component: Tacos, 9 routes: [ 10 { 11 path: "/tacos/bus", 12 component: Bus 13 }, 14 { 15 path: "/tacos/cart", 16 component: Cart 17 } 18 ] 19 } 20] 21

很容易通过正则匹配解析出来。

HTML 文档保存位置

文章开头说的,我厂的前后端分离方式导致了 web 服务完全不管 view 层了。为了用户直接打开某个路由的时候,能返回预渲染好的 html 文档,单页面应用的路由模式必须要改为 history 的。

**但是在没有 web 服务的情况下,怎么做到 history 模式呢?**突然想到了 hexo 之类的静态站点生成工具的做法。比如我写了一篇日期为 2019-01-01,题目为 test 的文章。

hexo 的做法是帮我生成了这样的目录:

`2019
└── 01
    └── 01
        └── test
            └── index.html
`

最后这篇文章的访问路径就是:`/2019/01/01/test/` 。同理,单页面应用里每个路由也可以生成这样的目录,预渲染的模板就放在最里层的目录里,比如:`/foo/bar` 路由,那就可以生成:

`foo
└── bar
    └── index.html        
`

HTML 文档的保存内容

一开始我也没多想,直接抓取线上的 HTML 保存就完事了。后来跑了一下 Lighthouse,发现首屏渲染时间反而比没有优化的时候变长了。

这时才醒悟,其实很多项目都会用到按需加载。按正常流程构建出来的文档可能只会链入必须要加载的 JS。但是等到线上的文档完全渲染好了之后,按需加载的 JS 的 script 标签 也会出现在 DOM 树里了。

直接抓下来,就导致了异步加载的 JS 也变成同步加载的了。还有 CSS 也同理。所以head 标签和 body 尾部 script 标签,只取构建产出的,而不是线上的。

除此之余还发现另外一个问题。那些内容很多很长的页面,就只是 html 的体积也相当可观,有的有个几百 KB。真的有必要把所有内容都抓取下来吗?其实这些预填的内容只是起到过渡的掉过而已,性质就类似于菊花和骨架图。

我们的做法是以最长屏幕的设备为准,只取第一屏的 html 文本

所以一个路由的预渲染模板最后的内容由 3 部分组成:

`构建产出的 head
     +
  第一屏 html
     +
构建产出的 script
`

最后把整个 dist 目录丢到 CDN 上,第一次路由的请求就会直接到某个预渲染的模板了,之后的路由变换就由前端来接管了。

缺点

想法虽然美好,但是却有不少问题。

最明显的就是,路由只能是静态的,动态路由是没办法创建目录和保存模板的。

还有就是模板闪烁的问题,比如现在预渲染的是网易云音乐上七里香这首歌的信息详情。之后不管是发如雪,还是青花瓷,用户都会先看到七里香闪烁一下。当然后面还附带着浪费流量这件事情,用户看到七里香,那意味着七里香的专辑封面之类的静态资源已经加载过了,但是它其实不需要的。

可能的最终形态

这个方案还在继续探索中,有一天还和同事开玩笑,这玩意儿的最终形态大概就是一个自动骨架图工具吧…

20190426 补充

项目经过一段时间的停止,最近又开始开发。方向也最终确定到「自动骨架图工具」上面,调研到了饿了么的这个 Webpack 插件:page-skeleton-webpack-plugin

它的思路是在本文的基础上做了「自动化骨架生成」这一步。

概括来说是获取到了页面渲染完毕的 html 之后,注入一段 JS 和 CSS 到 puppteer 页面中,这段 JS 会操作 DOM,把各种节点转为骨架图结构,再把这个骨架图结构导出,存到项目目录。

最后构建目录的时候,把骨架图结构结构插入到 html 模板中,得出最终 html 文件。

可是自动生成的骨架效果不保证绝对适合,怎么办?这个插件还提供了骨架预览功能和骨架代码编辑能力。如果对骨架图不满意,自己编辑骨架代码就是了。

综合来说,就是提供了自动生成骨架的能力,也保留手动修正的余地,这个思路很好。

🩷
0
👍
0
😄
0
🙁
0