vue-cli添加骨架屏

什么是骨架屏?

骨架屏(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,等接口数据获取完毕后会自动替换调结构,达到渲染的目的。

  1. 使用 vue-cli 初始化

  2. 添加 src/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 />'
    })
  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
    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>
  4. 安装 vue-server-renderer

    1
    npm install vue-server-renderer -D
  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
    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({
    allowlist: /\.css$/
    }),
    resolve: {
    alias: {
    'vue$': 'vue/dist/vue.esm.js'
    },
    extensions: ['*', '.js', '.vue', '.json']
    },
    plugins: [
    new VueSSRServerPlugin({
    filename: 'skeleton.json'
    })
    ]
    }
  6. 根目录下添加 skeleton.js

    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')
    })
  7. 修改入口文件 index.html

    1
    2
    3
    <div id="app">
    <!--vue-ssr-outlet-->
    </div>
  8. 修改 package.json

    1
    "skeleton": "webpack --config ./webpack.skeleton.conf.js"
  9. 执行webpack

    1
    npm run skeleton

    此时,在dist目录会生成一个 skeleton.json 文件

  10. 自动重写 index.html

    1
    node skeleton.js

    此时,index.html 中的ssr占位符就被替换成了骨架屏dom

  11. 启动/编译

    1
    2
    3
    npm run dev

    npm run build

分析:这种方式不好之处在于骨架屏dom是手动生成的,如果修改UI,更换了骨架屏,又需要重新生成,比较麻烦。

vue-skeleton-webpack-plugin

单页面生成单骨架屏

  1. 安装插件

    1
    npm i vue-skeleton-webpack-plugin -D
  2. 添加 src/entry-skeleton.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import Vue from 'vue';
    import Skeleton from './Skeleton';

    export default new Vue({
    components: {
    Skeleton
    },
    template: '<skeleton />'
    });
  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
    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>
  4. 配置 skeleton router
    自行配置router,方便访问调试骨架屏,略。

  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({
    allowlist: /\.css$/
    }),
    plugins: []
    })
  6. build/webpack.dev.conf.jsbuild/webpack.prod.conf.js 中分别加入插件配置

    1
    2
    3
    4
    5
    6
    7
    const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin')

    // plugins:
    new SkeletonWebpackPlugin({
    webpackConfig: require('./webpack.skeleton.conf'),
    quiet: true
    }),
  7. 修改 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
    32
    import 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()
    }
  8. 修改 index.html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!DOCTYPE html>
    <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>
  9. 启动/编译

    1
    2
    3
    npm run dev

    npm run build

单页面生成多骨架屏

什么是多骨架屏,顾名思义。多个骨架屏,可以针对不同的页面设置不同的骨架屏,实际上这种才是比较合理的。比如底部有四个tab,都作为一级入口,用户随便从哪个入口进入都可以看到对应的骨架屏。

  1. 安装插件

    1
    npm i vue-skeleton-webpack-plugin -D
  2. 添加 src/skeleton.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import 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>
    `
    });
  3. 添加骨架屏
    添加 components/skeleton/Skeleton1.vuecomponents/skeleton/Skeleton2.vue,内容略

  4. 配置 skeleton router
    配置router,方便访问骨架屏,便于调试,略

  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
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    'use strict';

    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
  6. build/webpack.dev.conf.jsbuild/webpack.prod.conf.js 中分别加入插件配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    const 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'
    }
    ]
    }
    }),
  7. 启动/编译

    1
    2
    3
    npm run dev

    npm run build

此时,访问 / 可以看到skeleton1,访问 /user 可以看到skeleton2

多页面生成多骨架屏

通过 Puppeteer 运行页面,自动生成骨架屏。

待补充…

page-skeleton-webpack-plugin

  1. 安装插件

    1
    npm i page-skeleton-webpack-plugin -D

    因为内部使用 puppeteer,安装比较慢,可能会失败。如果失败,采用 cnpm 来安装,先 cnpm install,再用 cnpm 安装该插件

  2. 引入插件 build/webpack.deve.conf.js

    1
    2
    3
    4
    5
    6
    7
    const { SkeletonPlugin } = require('page-skeleton-webpack-plugin')

    new SkeletonPlugin({
    pathname: path.resolve(__dirname, '../shell'), // 开发环境中点击保存按钮生成的骨架屏代码的保存路径
    staticDir: path.resolve(__dirname, '../dist'), // 打包时生成的骨架屏的静态资源文件(官方文档指导要和webpack打包输出目录一致)
    routes: ['/'] // 需要生成骨架屏的路由(和项目中路由配置的path一致,history模式)
    })
  3. 本地运行

    1
    npm run dev

    提示找不到 webpack-log

  4. 安装 webpack-log

    1
    npm i webpack-log -D
  5. 开发环境运行

    1
    npm run dev
  6. 生成 shell
    在chrome控制台输入 toggleBar,在页面顶部会显示一个区域,点击它就会开启一个窗口 http://****/preview.html

  7. 选择对应的route生成html,可以修改和保存

  8. 上面的一系列操作都是在开发环境中进行实践的,目的是为了生成骨架屏的代码。那现在就需要将骨架屏应用到生产环境中。

  9. 生成环境写入骨架屏配置,和 dev 环境一致

  10. 在根模板 index.html 中添加注释 <!-- shell -->

    1
    2
    3
    <div id="app">
    <!-- shell -->
    </div>

    这里需要注意
    webpack的html-webpack-plugin有一项关于压缩移除注释的配置,手脚架在生成项目的时侯,这个配置项默认设置为true,即移除模板中的注释。但是在骨架屏这里,这个注释是必须存在的。因此我们需要将这个压缩移除注释的配置项修改为false,即保留注释,否则在后面的项目打包部署后,骨架屏是不会生效的。

    1
    2
    3
    4
    5
    new HtmlWebpackPlugin({
    minify: {
    removeComments: false // 压缩移除注释的配置项修改为false
    }
    })
  11. 打包编译

    1
    npm run build
  12. 其他:

    参数配置
    设备列表

dps

可以截取任何页面,生成骨架屏结构,根据已经完成UI的页面生成骨架屏。

  1. 安装插件

    1
    npm i draw-page-structure -g
  2. 初始化,生成 dps.config.js

    1
    dps init

    config 文件中可以设置想要生成的页面、dom、出口地址,以及一些个性化的配置

  3. 启动

    1
    dps start

    完成之后就会在当前目录生成一个骨架屏,可以将生成的结构直接拷贝到我们需要的页面。

ssr方案
vue-skeleton-webpack-plugin插件方案
插件vue-skeleton-webpack-plugin
插件page-skeleton-webpack-plugin
插件dps
其他相关