vue require图片_一文详解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 字符串的功能,来完成骨架屏的注入,流程如下:

a23a474e09b2e25fbc2dca50da25bf27.png

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>

2. 在`/src`目录下再新建一个 skeleton.entry.js 入口文件,指定渲染哪个骨架屏

import Vue from 'vue'
import Skeleton from './Skeleton.vue'
export default new Vue({
    components: {
        Skeleton
    },
    template: '<skeleton />'
})

3. 在`/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

4. 在`/src`目录下再新建一个 skeleton.js 文件,该文件即将被用于往 index.html 内插入骨架屏

const fs = require('fs')
const { resolve } = require('path')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
const htmlMinifier = require('html-minifier')

// 读取`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) => {
    html = htmlMinifier.minify(html, {
        collapseWhitespace: true,
        minifyCSS: true
    });
    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>

5. 运行 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>

可以看到,骨架屏的样式通过 <style></style> 标签直接被插入,而骨架屏的内容也被放置在 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. <link rel='preload'> 让浏览器仅仅请求下载样式表,但完成后并不会应用样式,也就不会阻塞浏览器渲染了。如果想在下载完成后应用样式,可以在 onload 回调函数中修改 rel 的值为 stylesheet,像正常阻塞样式表一样应用。 另外,由于浏览器支持度问题(Android4.4以下),降级使用polyfill。

```html
<link rel="preload" href="path/to/mystylesheet.css" as="style" onload="this.onload=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>
```

2. 在 Vue 项目中,虽然异步加载的样式表不会阻塞骨架屏的渲染,但是当前端渲染内容替换掉骨架屏内容时,必须保证此时样式表已经加载完毕,否则真正有意义的页面内容将出现 FOUC。所以必须要保证 Vue 实例在异步样式表加载完毕后进行挂载,如果此时样式还没有完成,我们把挂载方法放到全局,等到样式加载完成后再调用。

```js
app = new App();
  window.mountApp = () => {
  app.$mount('#app')
};
if (window.STYLE_READY) {
  window.mountApp()
}
```

然后使用 <link rel='preload'>,当加载完成时,如果发现全局有 mountApp,就执行 `onload`

```html
<link rel='preload' href='index.css' as='style' onload='this.onload=null;this.rel='stylesheet';window.STYLE_READY=1;window.mountApp&&window.mountApp();'>
```

3. 配合 `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.onload=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> 导致重复插入<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`,对应显示不同的骨架屏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值