一文详解vue骨架屏优化

H5从输入 URL 到真正看到内容之间经历的时间就是FP(First Paint),也就是白屏时间,当然这个时间越短越好。和首屏相关的除了 FP 还有两个指标,分别称为 FCP (First Contentful Paint,页面有效内容的绘制) 和 FMP (First Meaningful Paint,页面有意义的内容绘制)。如果白屏时间过长,用户体验会大打折扣,如果用户网速差,则FCP会更长。

为了优化首屏渲染时间这个指标,减少白屏时间,这里提供几种办法经供参考

1,加速或减少HTTP请求损耗:使用CDN加载公用库,小图片使用Base64代替等;
2,延迟加载:非首屏图片延迟加载,SPA的组件懒加载等;
3,减少请求内容的体积:开启服务器Gzip压缩,JS、CSS文件压缩合并,SSR直接输出渲染后的HTML等;
4,浏览器渲染原理:尽可能减少阻塞渲染的JS、CSS;
5,优化用户等待体验:白屏使用加载进度条、菊花图、***骨架屏***代替等;

这篇文章主要介绍优化用户等待体验的骨架屏

骨架屏有哪些优势

1,在页面加载初期预先渲染内容,提升感官上的体验。
2,一般情况骨架屏和实际内容的结构是类似的,因此之后的切换不会过于突兀。这点和传统的 Loading 动图不同,可以认为是其升级版。
3,只需要简单的 CSS 支持 (涉及图片懒加载可能还需要 JS ),不要求 HTTPS 协议,没有额外的学习和维护成本。
4, 如果页面采用组件化开发,每个组件可以根据自身状态定义自身的骨架屏及其切换时机,同时维持了组件之间的独立性。

分析Vue页面的内容加载过程

打开 chrome 开发者工具,在“Network”里面调节网速为"Slow 3G",刷新页面,就能看到当JS执行完成后,Vue 会把div#app中的内容整个替换掉,而在这之前会显示本身在div#app中的内容。
现在,我们对于如何在Vue页面实现骨架屏,已经有了一个很清晰的思路 —— 在div#app内直接插入骨架屏相关内容即可。

在项目中添加骨架屏

显然,手动在div#app里面写入骨架屏内容是不科学的,我们需要一个扩展性强且自动化的易维护方案。既然是在Vue项目里,当然希望骨架屏也是一个.vue文件,它能够在构建时由工具自动注入到div#app里面。
插件 vue-server-renderer本用于服务端渲染,但是在这个例子里,我们主要利用它能够把 .vue 文件处理成 html 和 css 字符串的功能,来完成骨架屏的注入,流程如下:
在这里插入图片描述

  1. /src目录下新建一个skeleton.vue文件,里面写相关骨架屏的结构与样式
<template>
    <div class="skeleton page">
        <div class="skeleton-nav"></div>
        <div class="skeleton-swiper"></div>
        <ul class="skeleton-tabs">
            <li v-for="i in 8" class="skeleton-tabs-item"><span></span></li>
        </ul>
        <div class="skeleton-banner"></div>
        <div v-for="i in 6" class="skeleton-productions"></div>
    </div>
</template>
<style>
    .skeleton {
        position: relative;
        height: 100%;
        overflow: hidden;
        padding: 15px;
        box-sizing: border-box;
        background: #fff;
    }
    .skeleton-nav {
        height: 45px;
        background: #eee;
        margin-bottom: 15px;
    }
    .skeleton-swiper {
        height: 160px;
        background: #eee;
        margin-bottom: 15px;
    }
    .skeleton-tabs {
        list-style: none;
        padding: 0;
        margin: 0 -15px;
        display: flex;
        flex-wrap: wrap;
    }
    .skeleton-tabs-item {
        width: 25%;
        height: 55px;
        box-sizing: border-box;
        text-align: center;
        margin-bottom: 15px;
    }
    .skeleton-tabs-item span {
        display: inline-block;
        width: 55px;
        height: 55px;
        border-radius: 55px;
        background: #eee;
    }
    .skeleton-banner {
        height: 60px;
        background: #eee;
        margin-bottom: 15px;
    }
    .skeleton-productions {
        height: 20px;
        margin-bottom: 15px;
        background: #eee;
    }
</style>
  1. /src目录下再新建一个 skeleton.entry.js 入口文件,指定渲染哪个骨架屏
import Vue from 'vue'
import Skeleton from './Skeleton.vue'
export default new Vue({
    components: {
        Skeleton
    },
    template: '<skeleton />'
})
  1. /build目录下新建一个webpack.skeleton.conf.js打包文件,用来编译打包.vue文件
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = {
    target: 'node',
    entry: {
        skeleton: './src/skeleton.entry.js'
    },
    output: {
        path: path.resolve(__dirname, './dist'),
        publicPath: '/dist/',
        filename: '[name].js',
        libraryTarget: 'commonjs2'
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            },
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            }
        ]
    },
    externals: nodeExternals({
        whitelist: /\.css$/
    }),
    resolve: {
        alias: {
            'vue$': 'vue/dist/vue.esm.js'
        },
        extensions: ['*', '.js', '.vue', '.json']
    },
    plugins: [
        new VueSSRServerPlugin({
            filename: 'skeleton.json'
        })
    ]
}

可以看到,该配置文件和普通的配置文件基本完全一致,主要的区别在于其 target: ‘node’ ,配置了 externals ,以及在 plugins 里面加入了 VueSSRServerPlugin 。在 VueSSRServerPlugin 中,指定了其输出的json文件名。我们可以通过运行下列指令,在 /dist 目录下生成一个 skeleton.json 文件:

webpack --config ./build/webpack.skeleton.conf.js
  1. /src目录下再新建一个 skeleton.js 文件,该文件即将被用于往 index.html 内插入骨架屏
const fs = require('fs')
const { resolve } = require('path')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

// 读取`skeleton.json`,以`index.html`为模板写入内容
const renderer = createBundleRenderer(resolve(__dirname, '../build/dist/skeleton.json'), {
    template: fs.readFileSync(resolve(__dirname, '../index.html'), 'utf-8')
});

// 把上一步模板完成的内容写入(替换)`index.html`
renderer.renderToString({}, (err, html) => {
    fs.writeFileSync(resolve(__dirname, '../index_bundle.html'), html, 'utf-8');
});

该文件中的路径必须用resolve写成绝对路径,可以重新生成一个新的html渲染文件index_bundle.html(生成的html写入index_bundle.html),并将其设为启动文件:在webpack.dev.conf.js文件中将new HtmlWebpackPlugin的template改为’index_bundle.html’。index.html文件需要加入<!--vue-ssr-outlet-->占位符

<div id="app">
 <!--vue-ssr-outlet-->
</div>
  1. 运行 node ./src/skeleton.js,就可以完成骨架屏的注入了,index_bundle.html内容如下
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="apple-mobile-web-app-capable" content="yes" >
    <meta name="format-detection" content="telephone=no, email=no">
    <meta name="apple-mobile-web-app-title" content="优惠商户">
    <meta name="x5-cache" content="enable">
    <meta name="referrer" content="never">
    <!-- <meta name="HandheldFriendly" content="true"> -->
    <meta http-equiv="x-dns-prefetch-control" content="on">
    <meta name="screen-orientation" content="portrait">
    <meta name="x5-orientation" content="portrait">
    <!--  <meta http-equiv="Pragma" content="no-cache" /> -->
    <meta http-equiv="Cache-Control" content="no-cache" />
    <!-- <meta http-equiv="Expires" content="0" /> -->
    <title></title>
<style data-vue-ssr-id="550ef1db:0">
.skeleton {
    position: relative;
    height: 100%;
    overflow: hidden;
    padding: 0.4rem;
 padding: 4vw;
    box-sizing: border-box;
    background: #fff;
}
.skeleton-nav {
    height: 1.2rem;
 height: 12vw;
    background: #eee;
    margin-bottom: 0.4rem;
 margin-bottom: 4vw;
}
.skeleton-swiper {
    height: 4.266666666666667rem;
 height: 42.666666666666664vw;
    background: #eee;
    margin-bottom: 0.4rem;
 margin-bottom: 4vw;
}
.skeleton-tabs {
    list-style: none;
    padding: 0;
    margin: 0 -0.4rem;
 margin: 0 -4vw;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-wrap: wrap;
        flex-wrap: wrap;
}
.skeleton-tabs-item {
    width: 25%;
    height: 1.4666666666666666rem;
 height: 14.666666666666666vw;
    box-sizing: border-box;
    text-align: center;
    margin-bottom: 0.4rem;
 margin-bottom: 4vw;
}
.skeleton-tabs-item span {
    display: inline-block;
    width: 1.4666666666666666rem;
 width: 14.666666666666666vw;
    height: 1.4666666666666666rem;
 height: 14.666666666666666vw;
    border-radius: 1.4666666666666666rem;
 border-radius: 14.666666666666666vw;
    background: #eee;
}
.skeleton-banner {
    height: 1.6rem;
 height: 16vw;
    background: #eee;
    margin-bottom: 0.4rem;
 margin-bottom: 4vw;
}
.skeleton-productions {
    height: 0.5333333333333333rem;
 height: 5.333333333333333vw;
    margin-bottom: 0.4rem;
 margin-bottom: 4vw;
    background: #eee;
}
</style></head>
<body>
<div id="app">
    <div data-server-rendered="true" class="skeleton page"><div class="skeleton-nav"></div> <div class="skeleton-swiper"></div> <ul class="skeleton-tabs"><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li><li class="skeleton-tabs-item"><span></span></li></ul> <div class="skeleton-banner"></div> <div class="skeleton-productions"></div><div class="skeleton-productions"></div><div class="skeleton-productions"></div><div class="skeleton-productions"></div><div class="skeleton-productions"></div><div class="skeleton-productions"></div></div>
</div>
</body>
<!-- built files will be auto injected -->
<script defer src="https://static.cc.cmbimg.com/s/cmb-statistics/1.0.0/cmb-statistics.min.js"></script>
<script defer src="https://static.cc.cmbimg.com/s/cmb-statistics/4.1.0/cmb-statistics.min.js"></script>
</html>

原本的index.html内容为

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="apple-mobile-web-app-capable" content="yes" >
    <meta name="format-detection" content="telephone=no, email=no">
    <meta name="apple-mobile-web-app-title" content="优惠商户">
    <meta name="x5-cache" content="enable">
    <meta name="referrer" content="never">
    <!-- <meta name="HandheldFriendly" content="true"> -->
    <meta http-equiv="x-dns-prefetch-control" content="on">
    <meta name="screen-orientation" content="portrait">
    <meta name="x5-orientation" content="portrait">
    <!--  <meta http-equiv="Pragma" content="no-cache" /> -->
    <meta http-equiv="Cache-Control" content="no-cache" />
    <!-- <meta http-equiv="Expires" content="0" /> -->
    <title></title>
</head>
<body>
<div id="app">
    <!--vue-ssr-outlet-->
</div>
</body>
<!-- built files will be auto injected -->
<script defer src="https://static.cc.cmbimg.com/s/cmb-statistics/1.0.0/cmb-statistics.min.js"></script>
<script defer src="https://static.cc.cmbimg.com/s/cmb-statistics/4.1.0/cmb-statistics.min.js"></script>
</html>

可以看到,骨架屏的样式通过 标签直接被插入,而骨架屏的内容也被放置在 div#app 之间。当然,我们还可以进一步处理,把这些内容都压缩一下。改写 skeleton.js ,在里面添加 html-minifier :

+ const htmlMinifier = require('html-minifier')
...
renderer.renderToString({}, (err, html) => {
+ html = htmlMinifier.minify(html, {
+ collapseWhitespace: true,
+ minifyCSS: true
+ })
 fs.writeFileSync('index.html', html, 'utf-8')
})

至此,Vue页面接入骨架屏已经完全实现了。

加快浏览器对骨架屏的渲染

在 HTML 下载完毕之后,浏览器仍然需要等待样式(index.css)下载完毕才开始渲染骨架屏。 这是由于浏览器构建渲染树需要 DOM 和 CSSOM,因此 HTML 和 CSS 都是会阻塞渲染的资源。这在大部分场景下都是合情合理的,毕竟让用户看到内容之后,再加载样式会导致前后闪烁(FOUC)的问题。

但是骨架屏所需的样式已经内联在 HTML 中,供前端渲染内容使用的 CSS 显然不应该阻塞骨架屏的渲染。

解决方案:异步加载样式表

  1. 让浏览器仅仅请求下载样式表,但完成后并不会应用样式,也就不会阻塞浏览器渲染了。如果想在下载完成后应用样式,可以在 onload 回调函数中修改 rel 的值为 stylesheet,像正常阻塞样式表一样应用。 另外,由于浏览器支持度问题(Android4.4以下),降级使用polyfill。
```html
<link rel="preload" href="path/to/mystylesheet.css" as="style" onload="this.οnlοad=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="path/to/mystylesheet.css"></noscript>
<script>
/*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */
(function(){ ... }());
</script>
  1. 在 Vue 项目中,虽然异步加载的样式表不会阻塞骨架屏的渲染,但是当前端渲染内容替换掉骨架屏内容时,必须保证此时样式表已经加载完毕,否则真正有意义的页面内容将出现 FOUC。所以必须要保证 Vue 实例在异步样式表加载完毕后进行挂载,如果此时样式还没有完成,我们把挂载方法放到全局,等到样式加载完成后再调用。
```js
app = new App();
  window.mountApp = () => {
  app.$mount('#app')
};
if (window.STYLE_READY) {
  window.mountApp()
}

然后使用 ,当加载完成时,如果发现全局有 mountApp,就执行 onload

```html
<link rel='preload' href='index.css' as='style' onload='this.οnlοad=null;this.rel='stylesheet';window.STYLE_READY=1;window.mountApp&&window.mountApp();'>
  1. 配合 HTMLWebpackPlugin 使用。在生成 SPA 时,通常会使用 HTMLWebpackPlugin,这个插件根据开发者传入的模板生成最终的 HTML,当我们开启了 inject 选项时,会自动插入 <link><script>。在实现上述思路时,需要作出一些修改。在模板中手动加入针对 JS 和 CSS 的 <link ref='preload'>
```html
<head>
<% for (var jsFilePath of htmlWebpackPlugin.files.js) { %>
  <link rel="preload" href="<%= jsFilePath %>" as="script">
<% } %>
<% for (var cssFilePath of htmlWebpackPlugin.files.css) { %>
  <link rel="preload" href="<%= cssFilePath %>" as="style" onload="this.οnlοad=null;this.rel='stylesheet';window.STYLE_READY=1;window.mountApp&&window.mountApp();">
  <noscript><link rel="stylesheet" href="<%= cssFilePath %>"></noscript>
<% } %>
<script>
  /*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */
  (function(){ ... }());
</script>
</head>

由于不需要插件自动插入 <link>,可以编写一个简单的 Webpack 插件,监听 HTMLWebpackPlugin 的事件,过滤掉 CSS。这样插件就不会自动插入 <link> 导致重复插入了。

```js
module.exports = class OmmitCSSPlugin {
  constructor() {}
  apply(compiler) {
    compiler.plugin('compilation', (compilation) => {
      compilation.plugin('html-webpack-plugin-alter-asset-tags',
        (args, cb) => {
          args.head = args.head.filter((link) => link.attributes.rel !== 'stylesheet')
          cb(null, args)
        }
      )
    })
  }
}

根据不同的路由展示不同的骨架屏。主要思路是使用正则匹配 window.location.pathname,对应显示不同的骨架屏。

发布了48 篇原创文章 · 获赞 27 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_41480546/article/details/105102833