vue-SSR 相信大家都不陌生, 与传统 SPA 相比, 服务器端渲染 (SSR) 能够具备更好的 SEO, 方便搜索引擎爬虫抓取工具可以直接查看完全渲染的页面, 除此之外, SSR 能够在更短的时间内渲染出页面内容, 通过在服务端填充数据吐出到客户端的方式, 让用户有更好的用户体验.
前言
基于 VueSSR 的页面优化常有, 而针对 VueSSR 的再优化不常有. 前段时间有幸作为 宇宙无敌上级特派看门员 参加了前端 tweb 大会, 听取了腾讯视频 Web 高级工程师 lucien(段隆贤) 分享了针对 SSR 场景下的一些优化, 由于笔者之前也有在项目中实现 SSR 渲染, 所以也针对 Vue-SSR 的优化进行了实践和归纳总结, 并且在前人的基础上进行了新的优化尝试, 当然, 不同的项目不同的场景下, 优化效果, 优化方案可能不尽相同, 需要读者们自行选取~(本文将讨论常见的 SSR 优化方案以及笔者个人的优化尝试)
CSR 与 SSR 的区别
首先, 还是要不厌其烦地过一遍 CSR 和 SSR 的区别, 在理清整个流程后, 才能发现性能瓶颈以及关键耗时在哪里.
CSR 一般由静态资源服务器 (CDN) 等直接返回 html 资源, 之后浏览器解析 HTML 加载 CSS,JS 资源(CSS 加载结束后页面会尽快进行首屏渲染 FP),JS 依赖加载结束后, Vue 实例初始化, 拉取页面数据, 页面渲染(FMP).
SSR 由 Node.JS 服务器来直出页面, 请求到达后端后, 后端拉取 CGI 接口数据, 根据直出 bundle 生成 render 对象, render 对象将执行客户端代码构建 VDOM, 生成 HTML string, 填充进模板 HTML, 返回 HTML 资源, 浏览器解析后加载 CSS,JS 资源,(在 CSS 加载结束后触发 FP 和 FMP),Vue 实例初始化, 接管后端直出的 HTML, 页面可响应.
(以下流程图引自: https://www.jianshu.com/p/10b6074d772c)
时序图
(注: FP 即 First paint, 首屏渲染, 可能是没有数据的状态. FMP 即 First meaningful paint, 处于已经渲染数据的状态. 可交互: 页面数据填充结束且可响应. )
SSR 存在的缺陷:
1, 对服务器提出更高的要求, 生成虚拟 DOM 如果相对较长的运行和计算耗时;
2, 由于 CGI 拉取和 vdom 直出后才吐出 HTML 页面, FMP 虽然提前了, 但是 FP 相对延迟了;
3, 相比 CSR,SSR 渲染后, 由于仍然需要进行依赖, vue 初始化, 页面可交互时间并没有较大改善.
常见优化方法
虽然 SSR 仍有许多不足之处, 但是也不是没有改善的空间.
一, 缓存优化
1, 页面级别缓存: vuessr 官网给我们提供了一种方法, 如果页面并非千人千面, 总是为所有用户渲染相同的内容, 我们可以利用名为 micro-caching 的缓存策略, 来大幅度提高应用程序处理高流量的能力. 这通常在 Nginx 层完成, 也可以在 Node.JS 中实现.
2, 组件级别缓存: 通过对组件设置 serverCacheKey 的方式, 如果组件 serverCacheKey 相同, 将复用之前渲染的组件产物, 不需要重新渲染. 具体是类似这样的:
- export default {
- name : 'myComponent' , // 必填选项
- props: [ 'item' ],
- serverCacheKey: props => props.item.id,
- render (h) {
- return h ( 'div' , this.item.id)
- }
- }
3,CGI 接口缓存: 如果部分 CGI 接口返回的数据是固定的, 我们可以在 node 后端拉取 CGI 的时候, 设置 CGI 缓存, 缓存至 memcache 或其他轻量存储服务, 当然, 你也需要设置好缓存更新策略.
二, 代码实现优化
1, 减少组件嵌套层次, 优化 HTML 结构: 由于组件最初需要在 node 后端进行 VDOM 计算和渲染, 优化组件层次结构, 减少过深曾经的 DOM 嵌套, 可以减少 VDOM 计算耗时.
2, 减少首页渲染数据量: 根据业务调整用户首屏可见的所需渲染的数据, 其他数据懒加载或异步加载.
三, 资源加载
1, 流式传输: vuessr 官网给我们介绍了一种方法, render 对象会暴露 renderToStream 方法, 把原有的直出结果以流的形式输出, 让我们可以更快的响应数据到客户端, 能减少首屏渲染时间, 更早开始加载页面资源.(流式传输需要在 asyncData 执行结束后开始, 否则没有数据, 这意味着流失传输受限于 CGI 拉取耗时)
2, 分块传输: lucien 大佬在 tweb 大会上给我们带来了新的思路, 由模板的语法树, 分析代码的上下文, 分析数据和模板间的依赖, 用异步数据分割模板, 分块逐步输出.(相比流式传输, 前置位的 CGI 数据一旦 ready, 就会渲染输出, 而不需要等待所有的 gi 拉取到后才开始渲染输出, 但是该方案改造成本较大)
一张图说明白这两者的区别:
四, 改造 SSR 算法
SSR 算法改造: 在 tweb 大会上 lucien 给我们介绍了一个新的思路, 改造直出算法, 不用 vue-loader 而用自研的 aga-loader, 将 vdom 渲染转换为字符串模板, 具有更高的渲染性能.
性能提高的同时, 由于没有完整的组件运行环境, 也带来了部分语法上的约束, 同时, 也不支持 vuex.
思考
看到这里, 读者们应该对 SSR 了如如来神掌且熟悉了常见的优化方法, 但是回头思考一下, Vue-SSR 的优化无非是在 CGI 拉取 和 VDOM 直出渲染 上下功夫, 因为这两者就是 node 后端最耗时的步骤, 其次, 由于这种耗时会同步阻塞页面的 FP, 所以更进一步的方法是流式输出或分块, 减少首屏渲染时间.
然而, 但是并不是所有的 CGI 都能缓存, 类似拉取用户个人信息的 CGI 就无法缓存, SSR 算法改造成本大, 约束也大. 再看看流式传输和分块传输, 两者虽然都对 FP 时间优化了, 但流式传输受限于 CGI 拉取时间, 分块传输改造成本大. 而且两者存在的一个共性问题, 那就是可交互时间仍然没有优化.
当然, 这里并不是要否定所有的优化方法, 而是方法各有优劣, 比较优缺点大家才能根据自己的业务需求和优化场景选取合适的优化方法. 受流式传输和分块传输的启发, 我们能不能在这上面下功夫? 在请求到来时, 先返回一份完整的 HTML 空页面, 让客户端更快的 FP, 其次, 后端拉取 CGI 和渲染 VDOM 与 前端拉取 CSS,JS 资源 两者同步进行, 之后再吐出直出的 HTML string 与 页面 store, 再次渲染页面, 这样的话 FP 提前了, 和 CSR 的 FP 时间一毛一样, 其次, FMP 相比 CSR 大大提高, 更重要的是, 由于 JS 资源的加载让 Vue 初始化触发的更早, 意味着页面可响应时间也会提高.
为了阐明这种区别, 我们看一下流程图:
新方案探索与实践
先吐空页面, 之后再吐直出后的数据, 但是关键是怎么让直出后的数据再渲染上去, 同时不要让 JS 先执行了, 导致页面直接变成 CSR 了.
思考历程: 不要让 JS 执行, 等直出数据回来了再执行, 这可咋办, 笔者最初想实现一个 JS 加载控制器, 不通过 script 来引入 JS, 而是自己去拉取 JS 代码, eval 函数执行, 这样 JS 的执行控制权就在自己手上了, 但是有几个问题, eval 函数解析只是把字符串当 JS 来执行, 那错误上报就会出问题, 接了 sentry 错误上报是基于 JS 文件, 错误行列来定位的, 除此之外, Ajax 来拉取 JS 代码会不会存在性能问题, 和浏览器加载 JS 资源速度上是否存在差异? 还有第三方 JS 不能直接 Ajax 拉取, 需要设置跨域头. 于是笔者开始换一种思路, 能不能给每个 JS 文件包裹一层函数, 通过 setTimeout(fn,0)的方式来延迟调用, 但是这又有问题, 有多个 JS 文件且文件已经是打包好了的, 改了 JS 文件, map 映射不就乱了吗? 错误上报不就乱了吗?
源码在自己手里, 为啥不直接在源码上提供一个调用入口, 来触发 JS 执行, 最后直出的时候吐出 < script>Windows.render()<script > 来控制 JS 执行不就可以了吗?
开始改造
客户端改造:
原有的直出存在 entry-client 和 entry-server 两个 JS 文件, 分开两个入口各自打包, 我们需要改造的是 entry-client, 让其可控制, 开头笔者只是对 new App()和 mount 包裹了一层函数, 但是后来发现, 第三方 JS 依赖执行了, 其实如果你明白 webpack 的打包原理, 那么 require 的时候就会触发相应的依赖执行, 我们要在 entry-client 之外再包一层来控制. 新增 entry-runner 文件:
- Windows .__GLOBAL_RENDER__ = function ( ) {
- require ( "./entry-client.js" );
- console .log( "__GLOBAL_RENDER__ ENDING" ); // eslint-disable-line
- };
以这个为打包入口, 加载完 JS 后就不会执行了(当然, 还会执行 webpack 的运行时代码, 控制 chunk 执行的, 忽略不计)
改造服务端直出代码:
- module .exports = function handle ( req,res ) {
- res.writeHead( 200 , {
- 'Content-Type' : 'text/html' ,
- });
- res.write(FP_html.replace( /<\/body>[\s\S]*<\/HTML>/g , '' ));
- // ...other code
- }
FP_html 是客户端打包的时候生成的 index.HTML, 里面已经插入好了 CSS,JS 依赖, 你只需要把尾部 body 和 HTML 的结束标签去掉.
接下来是在直出后吐出直出数据.
- const context =
- {
- url
- : req.REQUEST.pathname
- }
- ;
- const HTML = await
- renderToString(context);
- const html_render = HTML.match(
- /(
- <divdata-server-rendered [\ s \ S ]*<\/ div>
- )[\s\S]*( <script> Windows .__INITIAL_STATE__[\s\S]*injected -->)/g
- );
- const innerHTML = RegExp .$ 1
- ;
- const state = RegExp .$ 2
- ;
- res.write(
- `
- <script>
- document.body.innerHTML = \`
- $ {
- innerHTML
- }
- \`
- </script>
- $ {
- state
- }
- <script> setTimeout( () =>
- {
- Windows.__GLOBAL_RENDER__ && Windows.__GLOBAL_RENDER__()
- }
- ,0)
- </script>
- </body>
- </HTML>
`
);
res.end();
通过正则提取出渲染结果 HTML 以及 store, 之后 write 吐会给前端, innerHTML 会覆盖第一次吐给前端的页面中的 div#App, 接下来 state 即全局的 store 初始化, 最后 setTimeout 控制 Windows.__GLOBAL_RENDER__执行即可, 因为 vue 判断是否直出是根据 div 以及全局的 store 是否初始化来判断的, 所以我们这样做没有问题. 其次, 为了优先触发一次 FMP, 我们需要通过 setTimeout 的方式调用全局渲染方法.
接下来我们来比较一下 CSR,SSR 以及改造后的效果:
CSR:
SSR:
优化后的 SSR:
各 项数据对比:
类型 \ 指标
| FP
| FMP
| 可交互时间
|
CSR
| 0.4s
| 1.1s
| 1.2s
|
SSR
| 0.7s
| 0.7s
| 1s
|
优化
SSR
| 0.4s
| 0.6-
0.7s
| 0.8s
|
可以明显的看到, 优化后的 SSR,FP 时间跟 CSR 一样, 让我们的首屏渲染更快了(可优先渲染页面骨架图), 其次, FMP 时间跟 SSR 相差不大, 最后是可交互时间, 由于 JS 依赖较早开始加载, 所以页面直出结束后可马上执行 vue 初始化逻辑, 所以可交互时间缩短到 0.8s.
我们找到了一种成本不是很高, 不仅优化了 FP,FMP 时间还优化了可交互时间的方法!
最后
文章看到了这里, 相信你对 Vue-SSR 有了更加深刻的认识和了解, 本文比较了 CSR 和 SSR, 并总结归纳了 Vue-SSR 的常见方法, 最后在新的方案上进行尝试, 达到了一定程度上的优化. 优化方案各有优劣, 也有成本开销, 根据自己业务需求来选择合适的优化方法, 才是最有效的. 希望本文能给你带来帮助~ 也欢迎讨论其他方法~
来源: http://www.tuicool.com/articles/ei6vi2B