骨架屏
优化用户等待体验
page-skeleton-webpack-plugin
一,使用vue-server-renderer
为了简单起见,我们使用vue-cli搭配webpack-simple这个模板来新建项目:
vue init webpack-simple vue-skeleton
这时我们便获得了一个最基本的Vue项目:
. ├── package.json ├── src │ ├── App.vue │ ├── assets │ └── main.js ├── index.html └── webpack.conf.js
安装完了依赖以后,便可以通过npm run dev去运行这个项目了。但是,在运行项目之前,我们先看看入口的html文件里面都写了些什么。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8">
<title>vue-skeleton</title>
</head> <body>
<div id="app"></div> <script src="/dist/build.js"></script> </body>
</html>
可以看到,DOM里面有且仅有一个div#app,当js被执行完成之后,此div#app会被整个替换掉,因此,我们可以来做一下实验,在此div里面添加一些内容:
<div id="app"> <p>Hello skeleton</p> <p>Hello skeleton</p> <p>Hello skeleton</p> </div>
打开chrome的开发者工具,在Network里面找到throttle功能,调节网速为“Slow 3G”,刷新页面,就能看到页面先是展示了三句“Hello skeleton”,待js加载完了才会替换为原本要展示的内容。
现在,我们对于如何在Vue页面实现骨架屏,已经有了一个很清晰的思路——在div#app内直接插入骨架屏相关内容即可。
显然,手动在 div#app 里面写入骨架屏内容是不科学的,我们需要一个扩展性强且自动化的易维护方案。既然是在Vue项目里,我们当然希望所谓的骨架屏也是一个 .vue 文件,它能够在构建时由工具自动注入到 div#app 里面。
首先,我们在/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 { 25%; height: 55px; box-sizing: border-box; text-align: center; margin-bottom: 15px; } .skeleton-tabs-item span { display: inline-block; 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> |
接下来,再新建一个 skeleton.entry.js 入口文件:
1
2
3
4
5
6
7
8
9
|
import Vue from 'vue' import Skeleton from './Skeleton.vue' export default new Vue({ components: { Skeleton }, template: '<skeleton />' }) |
在完成了骨架屏的准备之后,就轮到一个关键插件vue-server-renderer登场了。该插件本用于服务端渲染,但是在这个例子里,我们主要利用它能够把.vue文件处理成html和css字符串的功能,来完成骨架屏的注入,流程如下:
根据流程图,我们还需要在根目录新建一个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
46
|
const 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({ 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 文件:
1
|
webpack --config ./webpack.skeleton.conf.js |
这个文件在记载了骨架屏的内容和样式,会提供给vue-server-renderer使用。
接下来,在根目录下新建一个skeleton.js,该文件即将被用于往index.html内插入骨架屏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
const 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' ) }) |
注意,作为模板的html文件,需要在被写入内容的位置添加<!--vue-ssr-outlet-->占位符,本例子在div#app里写入:
<div id="app"> <!--vue-ssr-outlet--> </div>
接下来,只要运行 node skeleton.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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
<html lang= "en" > <head> <meta charset= "utf-8" > <title>vue-skeleton</title> <style data-vue-ssr-id= "742d88be:0" > .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 { 25%; height: 55px; box-sizing: border-box; text-align: center; margin-bottom: 15px; } .skeleton-tabs-item span { display: inline-block; 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></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> <script src= "/dist/build.js" ></script> </body> </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-skeleton-webpack-plugin
我们希望在构建时渲染 skeleton 组件,将渲染 DOM 插入 html 的挂载点中,同时将使用的样式通过 style 标签内联。这样在前端 JS 渲染完成之前,用户将看到页面的大致骨架,感知到页面是正在加载的。
我们当然可以选择在开发时直接将页面骨架内容写入 html 模版中,但是这会带来两个问题:
- 开发 skeleton 与其他组件体验不一致。
- 多页应用中多个页面可能共用同一个 html 模版,而又有独立的 skeleton。
下面我们将看看插件在具体实现中是如何解决这两个问题的:
具体实现步骤:
1、我们用vue-cli 直接构建一下项目跑起来(具体怎么构建就不说了)
2、进去当前项目,执行命令 : npm install vue-skeleton-webpack-plugin
3、我们在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
|
<template> <div class= "skeleton-wrapper" > <header class= "skeleton-header" ></header> <section class= "skeleton-block" > <img src= "data:image/svg+xml;base64..." > <img src= "data:image/svg+xml;base64..." > </section> </div> </template> <script> export default { name: 'skeleton' } </script> <style scoped> .skeleton-header { height: 40px; background: #1976d2; padding:0; margin: 0; 100%; } .skeleton-block { display: flex; flex-direction: column; padding-top: 8px; } </style> |
4、创建入口文件:entry-skeleton.js
1
2
3
4
5
6
7
8
|
import Vue from 'vue' import Skeleton from './Skeleton' export default new Vue({ components: { Skeleton }, template: '<Skeleton />' }) |
5、我们在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
|
'use strict' ; 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({ whitelist: /.css$/ }), plugins: [] }) |
然后在webpack.dev.conf.js和webpack.prod.conf.js分别加入
1
2
3
4
5
6
|
const SkeletonWebpackPlugin = require( 'vue-skeleton-webpack-plugin' ) // inject skeleton content(DOM & CSS) into HTML new SkeletonWebpackPlugin({ webpackConfig: require( './webpack.skeleton.conf' ), quiet: true }) |
然后就完成了。
延伸:
1、vue-skeleton-webpack-plugin 可以 使用多个 骨架屏 ,具体的可以查看官网地址:https://github.com/lavas-project/vue-skeleton-webpack-plugin
三,page-skeleton-webpack-plugin
优势:
- 支持多种加载动画
- 针对移动端 web 页面
- 支持多路由
- 可定制化,可以通过配置项对骨架块形状颜色进行配置,同时也可以在预览页面直接修改骨架页面源码
- 几乎可以零配置使用
安装:
1
2
|
npm install --save-dev page-skeleton-webpack-plugin npm install --save-dev html-webpack-plugin |
配置:
第一步:配置插件,详细配置可参考官方文档
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
const HtmlWebpackPlugin = require( 'html-webpack-plugin' ) const { SkeletonPlugin } = require( 'page-skeleton-webpack-plugin' ) const path = require( 'path' ) const webpackConfig = { entry: 'index.js' , output: { path: __dirname + '/dist' , filename: 'index.bundle.js' }, plugin: [ new HtmlWebpackPlugin({ // Your HtmlWebpackPlugin config }), new SkeletonPlugin({ pathname: path.resolve(__dirname, `${customPath}`), // 用来存储 shell 文件的地址 staticDir: path.resolve(__dirname, './dist' ), // 最好和 `output.path` 相同 routes: [ '/' , '/search' ], // 将需要生成骨架屏的路由添加到数组中 }) ] } |
第二步:修改 HTML Webpack Plugin 插件的模板
在你启动 App 的根元素内部添加
1
2
3
4
5
6
7
8
9
10
11
12
|
<!DOCTYPE html> <html lang= "en" > <head> <meta charset= "UTF-8" > <title>Document</title> </head> <body> <div id= "app" > <!-- shell --> </div> </body> </html> |
第三步:界面操作生成、写入骨架页面
- 在开发页面中通过 Ctrl|Cmd + enter 呼出插件交互界面,或者在在浏览器的 JavaScript 控制台内输入toggleBar 呼出交互界面。
-
点击交互界面中的按钮,进行骨架页面的预览,这一过程可能会花费 20s 左右时间,当插件准备好骨架页面后,会自动通过浏览器打开预览页面,如下图。
uniapp中使用骨架屏
插件:
https://ext.dcloud.net.cn/plugin?id=256#detail
小程序使用骨架屏
地址:https://github.com/jayZOU/skeleton
基于skeleton组件的骨架屏生成及其简单,主要有以下几个步骤。
- 1、下载组件到项目中
- 2、配置json文件,允许使用组件
- 3、构建基本页面骨骼
- 4、在wxml中引入组件并设置相关类
实践
1、这一点简单,跳过。
2、这一点也简单,在json文件中,添加如下代码:
1
2
3
|
"usingComponents" : { "skeleton" : "/component/skeleton/skeleton" }, |
3、构建基本页面骨骼,也就是填充默认数据(模拟数据)。
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
|
<view class= 'container skeleton' > <view class= 'row' wx: for = "{{studentList}}" wx:key= "{{index}}" > <image class= 'skeleton-radius' src= '{{item.avatarUrl}}' mode= 'widthFix' ></image> <view> <text class= 'nickName skeleton-rect' >{{item.class}}-{{item.nickName}}</text> <text class= 'skeleton-rect' >{{item.detailInfo}}</text> </view> </view> </view> /* pages/student/student.wxss */ page{ border-top: solid 2rpx #999; } .container{ padding: 10rpx 20rpx; } .row{ display: flex; flex-direction: row; justify-content: flex-start; align-items: center;
height: 100rpx; 690rpx; padding: 10rpx 10rpx; margin-top: 20rpx; font-size: 26rpx; color: #666; } .row > image{ 100rpx; height: 100rpx; border-radius: 50%; margin-right: 20rpx; } .nickName{ display: block; color: #333; font-size: 30rpx; margin-bottom: 16rpx; } |
这样就完成了页面骨骼的构架,接下来就是在wxml中引入组件并设置相关类了。
4、在wxml中引入,可以在头部或者底部引入,如下:
1
2
3
4
|
<skeleton selector= "skeleton" loading= "spin" bgcolor= "#FFF" wx: if = "{{showSkeleton}}" ></skeleton> |
接下来就是设置相关类了,主要要设置3个类,分别是:skeleton、skeleton-radius和skeleton-rect。
skeleton就是作用范围,相当于vue中的el:“#app” 的范围,一般设置给最底部的view即可。
skeleton-radius:设置为圆形的骨架
skeleton-rect:设置为长方形的骨架
注意:这里的skeleton-radius和skeleton-rect渲染出来的骨架大小,是受默认的填充的元素大小影响的。
完整的wxml代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<!--pages/student/student.wxml--> <skeleton selector= "skeleton" loading= "spin" bgcolor= "#FFF" wx: if = "{{showSkeleton}}" ></skeleton> <view class= 'container skeleton' > <view class= 'row' wx: for = "{{studentList}}" wx:key= "{{index}}" > <image class= 'skeleton-radius' src= '{{item.avatarUrl}}' mode= 'widthFix' ></image> <view> <text class= 'nickName skeleton-rect' >{{item.class}}-{{item.nickName}}</text> <text class= 'skeleton-rect' >{{item.detailInfo}}</text> </view> </view> </view> |
然后在js中的data中加入showSkeleton变量,设置一个定时器结束(用来模拟网络请求的等待过程)。
然后运行就可以看见效果了。
js中的data 和 onLoad
1
2
3
4
5
6
7
8
9
10
11
12
13
|
data:{ //... showSkeleton: true , //... }, onLoad: function (options) { const that = this ; setTimeout(() => { that.setData({ showSkeleton: false }) }, 5000) }, |