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 字符串的功能,来完成骨架屏的注入,流程如下:
- 在
/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>
- 在
/src
目录下再新建一个 skeleton.entry.js 入口文件,指定渲染哪个骨架屏
import Vue from 'vue'
import Skeleton from './Skeleton.vue'
export default new Vue({
components: {
Skeleton
},
template: '<skeleton />'
})
- 在
/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
- 在
/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>
- 运行 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 显然不应该阻塞骨架屏的渲染。
解决方案:异步加载样式表
- 让浏览器仅仅请求下载样式表,但完成后并不会应用样式,也就不会阻塞浏览器渲染了。如果想在下载完成后应用样式,可以在 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>
- 在 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();'>
- 配合
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
,对应显示不同的骨架屏。