什么是骨架屏?
骨架屏(Skeleton Screen)是指在页面数据加载完成前,先给用户展示出页面的大致结构(一般是灰色占位图,可以搭配一些动效),在拿到接口数据后渲染出实际页面内容然后替换掉。
比起简单的全局loading,这种灰色占位图描绘了页面的大致结构,对用户的心理会有一定的缓解。在数据回传之后,过滤也会更加流畅,给人一种页面内容”已经渲染出一部分“的感觉,用户体验更佳。
骨架屏的内容可以是base64的图片url,也可以是自行编写的dom结构,如果是dom结构,还可以自行实现一些动效,如光源从左扫到右,用动效来吸引用户,让用户忘记白屏时间。
近几年越来越多的应用采用骨架屏Skeleton,如:饿了么H5、知乎等。
骨架屏的实现方案
目前有以下几种实现方案:
- 利用ssr实现
根据编写的vue文件,手动生成,比较麻烦,不推荐 - vue-skeleton-webpack-plugin
根据编写的vue文件,按照路由给对应的页面设置,推荐单页面使用 - page-skeleton-webpack-plugin
只支持history
模式,利用 Puppeteer 读取页面结构,生成骨架屏页面。实测生成的不太稳定,还在研究中。 - dps
可以对任意的页面(包括dev页面),生成对应的骨架屏,推荐多页面使用
利用ssr实现
原理:事先在 <div id="app"></div>
中间添加骨架屏dom,等接口数据获取完毕后会自动替换调结构,达到渲染的目的。
使用
vue-cli
初始化添加
src/skeleton.entry.js
1
2
3
4
5
6
7
8
9import Vue from 'vue'
import Skeleton from './Skeleton.vue'
export default new Vue({
components: {
Skeleton
},
template: '<skeleton />'
})添加
src/Skeleton.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63<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>安装 vue-server-renderer
1
npm install vue-server-renderer -D
添加
build/webpack.skeleton.conf.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46const path = require('path')
const webpack = require('webpack')
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({
allowlist: /\.css$/
}),
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
plugins: [
new VueSSRServerPlugin({
filename: 'skeleton.json'
})
]
}根目录下添加
skeleton.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14const fs = require('fs')
const { resolve } = require('path')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
// 读取`skeleton.json`,以`index.html`为模板写入内容
const renderer = createBundleRenderer(resolve(__dirname, './dist/skeleton.json'), {
template: fs.readFileSync(resolve(__dirname, './index.html'), 'utf-8')
})
// 把上一步模板完成的内容写入(替换)`index.html`
renderer.renderToString({}, (err, html) => {
fs.writeFileSync('index.html', html, 'utf-8')
})修改入口文件 index.html
1
2
3<div id="app">
<!--vue-ssr-outlet-->
</div>修改 package.json
1
"skeleton": "webpack --config ./webpack.skeleton.conf.js"
执行webpack
1
npm run skeleton
此时,在dist目录会生成一个
skeleton.json
文件自动重写
index.html
1
node skeleton.js
此时,
index.html
中的ssr占位符就被替换成了骨架屏dom启动/编译
1
2
3npm run dev
npm run build
分析:这种方式不好之处在于骨架屏dom是手动生成的,如果修改UI,更换了骨架屏,又需要重新生成,比较麻烦。
vue-skeleton-webpack-plugin
单页面生成单骨架屏
安装插件
1
npm i vue-skeleton-webpack-plugin -D
添加
src/entry-skeleton.js
1
2
3
4
5
6
7
8
9import Vue from 'vue';
import Skeleton from './Skeleton';
export default new Vue({
components: {
Skeleton
},
template: '<skeleton />'
});添加
src/Skeleton.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34<template>
<div class="skeleton-wrapper">
<header class="skeleton-header"></header>
<section class="skeleton-block">
<img
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTA4MCAyNjEiPjxkZWZzPjxwYXRoIGlkPSJiIiBkPSJNMCAwaDEwODB2MjYwSDB6Ii8+PGZpbHRlciBpZD0iYSIgd2lkdGg9IjIwMCUiIGhlaWdodD0iMjAwJSIgeD0iLTUwJSIgeT0iLTUwJSIgZmlsdGVyVW5pdHM9Im9iamVjdEJvdW5kaW5nQm94Ij48ZmVPZmZzZXQgZHk9Ii0xIiBpbj0iU291cmNlQWxwaGEiIHJlc3VsdD0ic2hhZG93T2Zmc2V0T3V0ZXIxIi8+PGZlQ29sb3JNYXRyaXggaW49InNoYWRvd09mZnNldE91dGVyMSIgdmFsdWVzPSIwIDAgMCAwIDAuOTMzMzMzMzMzIDAgMCAwIDAgMC45MzMzMzMzMzMgMCAwIDAgMCAwLjkzMzMzMzMzMyAwIDAgMCAxIDAiLz48L2ZpbHRlcj48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIj48dXNlIGZpbGw9IiMwMDAiIGZpbHRlcj0idXJsKCNhKSIgeGxpbms6aHJlZj0iI2IiLz48dXNlIGZpbGw9IiNGRkYiIHhsaW5rOmhyZWY9IiNiIi8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCA0NGg1MzN2NDZIMjMweiIvPjxyZWN0IHdpZHRoPSIxNzIiIGhlaWdodD0iMTcyIiB4PSIzMCIgeT0iNDQiIGZpbGw9IiNGNkY2RjYiIHJ4PSI0Ii8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCAxMThoMzY5djMwSDIzMHpNMjMwIDE4MmgzMjN2MzBIMjMwek04MTIgMTE1aDIzOHYzOUg4MTJ6TTgwOCAxODRoMjQydjMwSDgwOHpNOTE3IDQ4aDEzM3YzN0g5MTd6Ii8+PC9nPjwvc3ZnPg==">
<img
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTA4MCAyNjEiPjxkZWZzPjxwYXRoIGlkPSJiIiBkPSJNMCAwaDEwODB2MjYwSDB6Ii8+PGZpbHRlciBpZD0iYSIgd2lkdGg9IjIwMCUiIGhlaWdodD0iMjAwJSIgeD0iLTUwJSIgeT0iLTUwJSIgZmlsdGVyVW5pdHM9Im9iamVjdEJvdW5kaW5nQm94Ij48ZmVPZmZzZXQgZHk9Ii0xIiBpbj0iU291cmNlQWxwaGEiIHJlc3VsdD0ic2hhZG93T2Zmc2V0T3V0ZXIxIi8+PGZlQ29sb3JNYXRyaXggaW49InNoYWRvd09mZnNldE91dGVyMSIgdmFsdWVzPSIwIDAgMCAwIDAuOTMzMzMzMzMzIDAgMCAwIDAgMC45MzMzMzMzMzMgMCAwIDAgMCAwLjkzMzMzMzMzMyAwIDAgMCAxIDAiLz48L2ZpbHRlcj48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIj48dXNlIGZpbGw9IiMwMDAiIGZpbHRlcj0idXJsKCNhKSIgeGxpbms6aHJlZj0iI2IiLz48dXNlIGZpbGw9IiNGRkYiIHhsaW5rOmhyZWY9IiNiIi8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCA0NGg1MzN2NDZIMjMweiIvPjxyZWN0IHdpZHRoPSIxNzIiIGhlaWdodD0iMTcyIiB4PSIzMCIgeT0iNDQiIGZpbGw9IiNGNkY2RjYiIHJ4PSI0Ii8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCAxMThoMzY5djMwSDIzMHpNMjMwIDE4MmgzMjN2MzBIMjMwek04MTIgMTE1aDIzOHYzOUg4MTJ6TTgwOCAxODRoMjQydjMwSDgwOHpNOTE3IDQ4aDEzM3YzN0g5MTd6Ii8+PC9nPjwvc3ZnPg==">
</section>
</div>
</template>
<script>
export default {
name: 'skeleton'
};
</script>
<style scoped>
.skeleton-header {
height: 152px;
background: grey;
margin-top: 60px;
width: 152px;
margin: 60px auto;
}
.skeleton-block {
display: flex;
flex-direction: column;
padding-top: 8px;
}
</style>配置 skeleton router
自行配置router,方便访问调试骨架屏,略。添加
build/webpack.skeleton.conf.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25;
const path = require('path')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const nodeExternals = require('webpack-node-externals')
function resolve(dir) {
return path.join(__dirname, dir)
}
module.exports = merge(baseWebpackConfig, {
target: 'node',
devtool: false,
entry: {
app: resolve('../src/entry-skeleton.js')
},
output: Object.assign({}, baseWebpackConfig.output, {
libraryTarget: 'commonjs2'
}),
externals: nodeExternals({
allowlist: /\.css$/
}),
plugins: []
})在
build/webpack.dev.conf.js
和build/webpack.prod.conf.js
中分别加入插件配置1
2
3
4
5
6
7const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin')
// plugins:
new SkeletonWebpackPlugin({
webpackConfig: require('./webpack.skeleton.conf'),
quiet: true
}),修改
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32import Vue from 'vue'
import App from './App'
import router from './router'
import './assets/common.css'
Vue.config.productionTip = false
/* eslint-disable no-new */
/* new Vue({
el: '#app',
router,
components: { App },
template: '<App/>'
}) */
// 改写
let app = new Vue({
router,
components: { App },
template: '<App/>'
})
window.mountApp = () => {
app.$mount('#app')
}
if (process.env.NODE_ENV === 'production') {
if (window.STYLE_READY) {
window.mountApp()
}
} else {
window.mountApp()
}修改
index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>skeleton-demo</title>
<% 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>!function(t){"use strict";t.loadCSS||(t.loadCSS=function(){});var e=loadCSS.relpreload={};if(e.support=function(){var e;try{e=t.document.createElement("link").relList.supports("preload")}catch(t){e=!1}return function(){return e}}(),e.bindMediaToggle=function(t){function e(){t.media=a}var a=t.media||"all";t.addEventListener?t.addEventListener("load",e):t.attachEvent&&t.attachEvent("onload",e),setTimeout(function(){t.rel="stylesheet",t.media="only x"}),setTimeout(e,3e3)},e.poly=function(){if(!e.support())for(var a=t.document.getElementsByTagName("link"),n=0;n<a.length;n++){var o=a[n];"preload"!==o.rel||"style"!==o.getAttribute("as")||o.getAttribute("data-loadcss")||(o.setAttribute("data-loadcss",!0),e.bindMediaToggle(o))}},!e.support()){e.poly();var a=t.setInterval(e.poly,500);t.addEventListener?t.addEventListener("load",function(){e.poly(),t.clearInterval(a)}):t.attachEvent&&t.attachEvent("onload",function(){e.poly(),t.clearInterval(a)})}"undefined"!=typeof exports?exports.loadCSS=loadCSS:t.loadCSS=loadCSS}("undefined"!=typeof global?global:this);</script>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>启动/编译
1
2
3npm run dev
npm run build
单页面生成多骨架屏
什么是多骨架屏,顾名思义。多个骨架屏,可以针对不同的页面设置不同的骨架屏,实际上这种才是比较合理的。比如底部有四个tab,都作为一级入口,用户随便从哪个入口进入都可以看到对应的骨架屏。
安装插件
1
npm i vue-skeleton-webpack-plugin -D
添加
src/skeleton.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import Vue from 'vue';
import Skeleton1 from './components/skeleton/Skeleton1';
import Skeleton2 from './components/skeleton/Skeleton2';
export default new Vue({
components: {
Skeleton1,
Skeleton2
},
template: `
<div>
<skeleton1 id="skeleton1" style="display:none"/>
<skeleton2 id="skeleton2" style="display:none"/>
</div>
`
});添加骨架屏
添加components/skeleton/Skeleton1.vue
和components/skeleton/Skeleton2.vue
,内容略配置 skeleton router
配置router,方便访问骨架屏,便于调试,略添加
build/webpack.skeleton.conf.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39;
const path = require('path')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const nodeExternals = require('webpack-node-externals')
const utils = require('./utils')
const config = require('../config')
const isProduction = process.env.NODE_ENV === 'production'
const sourceMapEnabled = isProduction
? config.build.productionSourceMap
: config.dev.cssSourceMap
function resolve(dir) {
return path.join(__dirname, dir)
}
let skeletonWebpackConfig = merge(baseWebpackConfig, {
target: 'node',
devtool: false,
entry: {
app: resolve('../src/entry-skeleton.js')
},
output: Object.assign({}, baseWebpackConfig.output, {
libraryTarget: 'commonjs2'
}),
externals: nodeExternals({
whitelist: /\.css$/
}),
plugins: []
})
// important: enable extract-text-webpack-plugin
skeletonWebpackConfig.module.rules[0].options.loaders = utils.cssLoaders({
sourceMap: sourceMapEnabled,
extract: true
}),
module.exports = skeletonWebpackConfig在
build/webpack.dev.conf.js
和build/webpack.prod.conf.js
中分别加入插件配置1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin')
// plugins:
new SkeletonWebpackPlugin({
webpackConfig: require('./webpack.skeleton.conf'),
quiet: true,
minimize: true,
router: {
mode: 'hash',
routes: [
{
path: '/',
skeletonId: 'skeleton1'
},
{
path: '/user',
skeletonId: 'skeleton2'
}
]
}
}),启动/编译
1
2
3npm run dev
npm run build
此时,访问 / 可以看到skeleton1,访问 /user 可以看到skeleton2
多页面生成多骨架屏
通过 Puppeteer
运行页面,自动生成骨架屏。
待补充…
page-skeleton-webpack-plugin
安装插件
1
npm i page-skeleton-webpack-plugin -D
因为内部使用
puppeteer
,安装比较慢,可能会失败。如果失败,采用cnpm
来安装,先cnpm install
,再用cnpm
安装该插件引入插件 build/webpack.deve.conf.js
1
2
3
4
5
6
7const { SkeletonPlugin } = require('page-skeleton-webpack-plugin')
new SkeletonPlugin({
pathname: path.resolve(__dirname, '../shell'), // 开发环境中点击保存按钮生成的骨架屏代码的保存路径
staticDir: path.resolve(__dirname, '../dist'), // 打包时生成的骨架屏的静态资源文件(官方文档指导要和webpack打包输出目录一致)
routes: ['/'] // 需要生成骨架屏的路由(和项目中路由配置的path一致,history模式)
})本地运行
1
npm run dev
提示找不到 webpack-log
安装 webpack-log
1
npm i webpack-log -D
开发环境运行
1
npm run dev
生成
shell
在chrome控制台输入toggleBar
,在页面顶部会显示一个区域,点击它就会开启一个窗口http://****/preview.html
选择对应的route生成html,可以修改和保存
上面的一系列操作都是在开发环境中进行实践的,目的是为了生成骨架屏的代码。那现在就需要将骨架屏应用到生产环境中。
生成环境写入骨架屏配置,和
dev
环境一致在根模板
index.html
中添加注释<!-- shell -->
1
2
3<div id="app">
<!-- shell -->
</div>这里需要注意
webpack的html-webpack-plugin有一项关于压缩移除注释的配置,手脚架在生成项目的时侯,这个配置项默认设置为true,即移除模板中的注释。但是在骨架屏这里,这个注释是必须存在的。因此我们需要将这个压缩移除注释的配置项修改为false,即保留注释,否则在后面的项目打包部署后,骨架屏是不会生效的。1
2
3
4
5new HtmlWebpackPlugin({
minify: {
removeComments: false // 压缩移除注释的配置项修改为false
}
})打包编译
1
npm run build
其他:
dps
可以截取任何页面,生成骨架屏结构,根据已经完成UI的页面生成骨架屏。
安装插件
1
npm i draw-page-structure -g
初始化,生成
dps.config.js
1
dps init
在
config
文件中可以设置想要生成的页面、dom
、出口地址,以及一些个性化的配置启动
1
dps start
完成之后就会在当前目录生成一个骨架屏,可以将生成的结构直接拷贝到我们需要的页面。
ssr方案
vue-skeleton-webpack-plugin插件方案
插件vue-skeleton-webpack-plugin
插件page-skeleton-webpack-plugin
插件dps
其他相关