# 资源渲染

# 前言

网络层面上要优化资源渲染,根本的目的就是尽快让用户看到页面内容,对于常见的 SPA 单页面应用来说,经历以下过程:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
  <link href="xxxx" />
  <title></title>
  <style>
  </style>
</head>

</body>
  <div id="app"></div>
<body>
</html>
  1. 请求首个 HTML 文档,等待 HTML 文档返回,此时网页处于白屏状态。
  2. 依次从上往下解析 HTML 文档,首先解析 head 标签,如果有 css/js 外链资源,将对之后的 HTML 文档造成阻塞。(资源下载
  3. 对 HTML 文档的 body 标签进行解析渲染,因为 SPA 项目中 index.html body 一般只有一个空的容器标签,所以页面依然是白屏。(空标签
  4. body 中存在外链文件加载、JS 解析等过程,导致界面依然白屏。
  5. new Vue({}}).$mount("#app"); 挂载后,界面显示出大体外框(侧边栏、头部导航、底部菜单栏等信息)。
  6. 进入 vue-router,如果使用了代码分割动态加载的话,将发起该路由对应 boundle 文件的请求(如:bill.xxx.chunk.js),该文件下载完成并执行前,页面只有外框。路由文件执行后,页面几百呈现完整内容。(路由跳转新资源下载
  7. 调用 API 获取到业务数据后,填充进页面,展示出最终的页面内容。

简单了解整个流程后,就可以找出哪里导致资源渲染的问题并进行性能优化。

目标读者:想知道如何在网络层面解决资源渲染的问题。

阅读时长:30 min

文章大纲:本文会讲解 SSR、CSR、预渲染和同构的区别以及 Vue 项目中如何集成骨架屏、预渲染。

  • DOM 渲染阻塞
    • 为什么 JS 会阻塞渲染
    • 如何避免 JS 文件阻塞渲染
  • DOM 渲染优化
    • 客户端渲染
    • 服务端渲染
    • 预渲染

# DOM 渲染阻塞

只要是外链资源包括 css、img、js 都有可能阻塞 DOM 渲染的,这里重点说下 JS 阻塞的问题。

# 为什么 JS 会阻塞渲染

当浏览器构建 DOM 的时候,如果在 HTML 中遇到了一个 <script>....</script> 标签,它必须立即执行。如果脚本是来自于外部的,那么它必须首先下载。而下载后,需要等待 JavaScript 引擎执行脚本后,HTML 才会继续解析。

这是有原因的,因为脚本可以改变 HTML 以及它的产物 DOM。比如使用 document.createElement 添加节点改变 DOM 结构,或者使用 document.write() 方法添加 HTML 标签。以及对 DOM 进行查询,如果 DOM 同时还在构建的话,则很可能会返回意外的结果。

# 如何避免 JS 文件阻塞渲染

常见的方式是可以把 JS 资源从 head 标签移动到 body 标签的末尾,避免阻塞。如图所示:在 body 内末尾添加 script 标签

或者使用 defer/async 这对属性。

  • 常规的 script 标签,会阻塞 HTML 的解析。
  • defer 会将 JS 先下载下来,但是会等 HTML 解析完成后,才执行。
  • async,同时进行 HTML 解析与 JS 下载,但 JS 下载完成后立刻停止 HTML 解析并执行 js 代码。

(PS:你可以编写 demo ,通过 performance 工具记录进行验证)

从图中可以看到,不同情况下的脚本处理机制。(HTML 解析——JS 资源下载——JS 执行),最终得出的优化方案采用以下:

  • <script defer src="xxxx.js"></script>
  • 如果需要兼容 IE9 或更老的浏览器,在 body 内末尾添加 script 标签。vuecli 项目就是这样做的。

一般适用 defer 和末尾 body 添加脚本即可,但是对于 async 来说,也有适用它的场景(更加适用于一些监控类的脚本、百度、Google 统计分析脚本)。比如淘宝的首页,就用到多个带有 async 属性的 script 标签。

<script
  charset="utf-8"
  src="https://g.alicdn.com/kg/??home-2017/1.4.21/lib/monitor-min.js"
  async=""
></script>
<script
  src="https://ecpm.tanx.com/ex?i=mm_12852562_1778064_13672849&amp;cb=jsonp_callback_3549&amp;r=&amp;cg=ac749acdc7c177c22d5f7fecf9fcdb01&amp;pvid=aed49a8d9b04a5d3ded63c03c09d10f0&amp;u=https%3A%2F%2Fwww.taobao.com%2F&amp;psl=1&amp;nk=&amp;sk=&amp;refpid="
  async=""
></script>
<!-- ... -->

在实际开发中,defer 用于需要整个 DOM 的脚本,和/或脚本的相对 执行顺序很重要的适合。async 用于独立脚本,例如计数器、广告或网站监控分析,这些脚本弄的相对执行顺序无关紧要。他们都不会阻塞 DOM 的渲染。

# DOM 渲染优化

HTML 文件下载后,由于要等待文件加载、JS 解析等过程,会导致用户长时间处于不可交互的首屏白屏状态,比如 index.html 的 <div id="app">...</div> 的情况,必须等到主 app.js 脚本执行完毕,往容器填充内容。

要如何解决这种白屏现象呢,这种现场是由于客户端渲染导致的。让我们先回顾下前端渲染与后端渲染的区别。

# 两种渲染技术的区别

# 客户端渲染(CSR)

客户端渲染(Client-Side Rendering)模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM。

<!doctype html>
<html>
  <head>
    <title>我是客户端渲染的页面</title>
  </head>
  <body>
    <div id='root'></div>
    <script src='index.js'></script>
  </body>
</html>

根节点里的东西,只有浏览器把 index.js 跑过一遍才知道。

页面上呈现的内容,你在 html 源文件里找不到——这正是它的特点。

# 服务端渲染(SSR)

服务端渲染(Server-Side Rendering)的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。

使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在 html 源文件里也能找到。

服务端渲染解决了什么性能问题呢?

事实上,很多网站是出于效益的考虑才启动服务端渲染的,性能是其次。因为搜索引擎只会爬取得到客户端渲染下的现成东西,虽说如此,能够使用服务端帮忙渲染快速出页面,对网站的首屏加载大大有益。

Vue SSR 指南是如何实现的呢?这里有一个简单的 demo:

const Vue = require("vue");
// 创建一个express应用
const server = require("express")();
// 提取出renderer实例
const renderer = require("vue-server-renderer").createRenderer();

server.get("*", (req, res) => {
  // 编写Vue实例(虚拟DOM节点)
  const app = new Vue({
    data: {
      url: req.url,
    },
    // 编写模板HTML的内容
    template: `<div>访问的 URL 是: {{ url }}</div>`,
  });

  // renderToString 是把Vue实例转化为真实DOM的关键方法
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end("Internal Server Error");
      return;
    }
    // 把渲染出来的真实DOM字符串插入HTML模板中
    res.end(
      `
      <!DOCTYPE html>
      <html lang="en">
        <meta charset="utf-8">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `,
      "utf-8"
    );
  });
});

server.listen(8080);
console.log("8080");

服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。除了需要同时改造客户端和服务端外,还另外给服务器带来了渲染的压力,不太建议。

# 预渲染

说完了客户端渲染和服务端渲染,我们再来谈谈预渲染是怎么回事。这里的预渲染是从广义上理解的,在 index.html<div id="app" </div> 填充自定义的内容,在 JS 资源下载之前展示必要的内容。让页面看起来很快,实际的加载速度并没有变化。,这是从用户体验层面上来考虑。

可以通过以下几种方式:

  • loading 动画
  • 预渲染出静态 DOM
  • 骨架屏

# loading 动画

最常见就是在 index.html 页面内联一个 loading 动画。根据需要,loading 动画可以是一个简单的菊花图,也可以很复杂。比如 Google Mail 的加载动画。

# CSR 渲染静态 DOM

除了使用加载动画外,我们可以狭义上的预渲染。

Prerender:预渲染,也就是说在构建时运行客户端应用程序,这样将它的初始状态捕获为静态 HTML。

要如何实现这个功能呢?

以 Vue 项目为例,可以通过 Prerender-spa-plugin webpack 插件进行实现。

配置如下:

const PrerenderSPAPlugin = require("prerender-spa-plugin");
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
const path = require("path");

module.exports = {
  configureWebpack: {
    plugins:
      process.env.NODE_ENV === "production"
        ? [
            new PrerenderSPAPlugin({
              staticDir: path.resolve(__dirname, "./", "dist"),
              routes: ["/"],
              renderer: new Renderer({
                injectProperty: "__PRERENDER_INJECTED",
                inject: {
                  prerendered: true,
                },
                renderAfterDocumentEvent: "app.rendered",
              }),
            }),
          ]
        : [],
  },
};

其原理是:在构建的时候会启动一个 node 服务器,使用无头浏览器 puppeteer,然后进行 dom 爬取,最后生成一个 html 页面,比如 /home 页面,会生成对应 index.html,在服务器配置匹配到 /home 路由时,强行访问这个生成的 html 即可。

对旧的 vuecli 项目如果使用的是 hash 路由,预渲染需要改成 history 模式,改动成本挺多,需要仔细衡量。因此引入 prerender 方案,最好在新项目中使用。

# 骨架屏

有些内容无法预渲染,需要根据用户操作动态生成,用户产品、用户账单等,则可以采用骨架屏,也就是代替掉菊花图。

实现骨架屏方案:

  • 使用图片代替,图片可以参考 semantic 这里 https://semantic-ui.com/collections/grid.html#relaxed
  • 自动生成。
  • 手动编写。
# 手动编写

如何在 Vue 项目中进行处理呢?

const path = require("path");
const SkeletonWebpackPlugin = require("vue-skeleton-webpack-plugin");
module.exports = {
  configureWebpack: {
    plugins: [
      new SkeletonWebpackPlugin({
        webpackConfig: {
          entry: {
            app: path.join(__dirname, "./src/skeleton/index.js"),
          },
        },
        // SPA 下是压缩注入 HTML 的 JS 代码
        minimize: true,
        // 服务端渲染时是否需要输出信息到控制台
        quiet: true,
        // 根据路由显示骨架屏
        router: {
          mode: "history",
          routes: [
            {
              path: "/",
              skeletonId: "skeleton-home",
            },
            {
              path: "/about",
              skeletonId: "skeleton-about",
            },
          ],
        },
      }),
    ],
  },
};

可以查看 sketelon/demo02。

# 自动生成

方案一:可以采用 element 的 page-skeleton-webpack-plugin,但它只支持 vue 项目路由采用 history 模式。这个东西实践 demo 没跑成功,后面实践看看。注意的是,这个仓库已经不维护了,可能存在不少坑。

方案二:半自动方案,采用 cli 命令自动生成脚手架,再嵌入到 vue 页面中。

1. 全局安装,npm i draw-page-structure – g

2. dps init 生成配置文件 dps.config.js

3. 修改 dps.config.js 进行相关配置

4. dps start 开始生成骨架屏

总结来说,骨架屏目前没有比较稳定的方案。

# 总结

本文只要从构建角度说明了 DOM 渲染阻塞以及渲染如何优化,更细粒度的渲染,比如代码层面如何优化,放到渲染篇。

# 参考资料