Vue页面骨架屏

33

在开发webapp的时候总是会受到首屏加载时间过长的影响,主流的解决方法是在载入完成之前显示loading图效果,而一些大公司会配置一套服务端渲染的架构来解决这个问题。考虑到ssr所要解决的一系列问题,越来越多的APP采用了“骨架屏”的方式去提升用户体验。

小米商城:
小米商城

一、分析Vue页面的内容加载过程

vue项目中的入口index.html只有简单的内容:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <title>Document</title>
</head>
<body>
    <div id="root">        
    </div>
    <script type="text/javascript" src="bundle.js"></script></body>
</body>
</html>

当js执行完之后,会用vue渲染成的dom将div#root完全替换掉。
我们在div#root中加入模拟骨架屏,在Chrome开发者工具调整网速:

<div id="root">
    这里是骨架屏
</div>


由此可知,将骨架屏内容直接插入div#root中即可实现骨架屏。

二、使用vue-server-renderer来实现骨架屏

我们需要骨架屏也是一个单独的.vue文件,因此我们需要用到vue-server-renderer。对vue服务端渲染有所了解的同学一定知道,这个插件能够将vue项目在node端打包成一个bundle,然后由bundle生成对应的html。
首先是生成项目:

.
├── build
│   ├── webpack.config.client.js
│   └── webpack.config.server.js
├── src
│   └── views
│        ├── index
│        │   └── index.vue
│        ├── skeleton
│        │   └── skeleton.vue
│        ├── app.vue
│        ├── index.js
│        └── skeleton-entry.js
├── index.html
└── skeleton.js
└── package.json

vue的服务端渲染一般会用vue-server-renderer将整个项目在node端打包成一份bundle,而这里我们只要一份有骨架屏的html,所以会有一个单独的骨架屏入口文件skeleton-entry.js,一个骨架屏打包webpack配置webpack.config.server.js,而skeleton.js作用是将webpack打包出来的bundle写入到index.html中。

//skeleton-entry.js
import Vue from 'vue'
import Skeleton from './views/skeleton/skeleton.vue'

export default new Vue({
  components: {
    Skeleton
  },
  template: '<skeleton />'
})
//webpack.config.server.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = {
  mode: process.env.NODE_ENV,
  target: 'node',
  entry: path.join(__dirname, '../src/skeleton-entry.js'),
  output: {
    path: path.join(__dirname, '../server-dist'),
    filename: 'server.bundle.js',
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      }    
    ]
  },
  externals: Object.keys(require('../package.json').dependencies),
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  },
  plugins: [
    new VueLoaderPlugin(),
    new VueSSRServerPlugin({
      filename: 'skeleton.json'
    })
  ]
}

其中骨架屏的webpack配置因为是node端,所以需要target: 'node' libraryTarget: 'commonjs2'。在VueSSRServerPlugin中,指定了其输出的json文件名。当执行webpack会在/server-dist目录下生成一个skeleton.json文件,这个文件记载了骨架屏的内容和样式,会提供给vue-server-renderer使用。

//skeleton.js
const fs = require('fs')
const path = require('path')

const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

// 读取`skeleton.json`,以`index.html`为模板写入内容
const renderer = createBundleRenderer(path.join(__dirname, './server-dist/skeleton.json'), {
  template: fs.readFileSync(path.join(__dirname, './index.html'), 'utf-8')
})

// 把上一步模板完成的内容写入(替换)`index.html`
renderer.renderToString({}, (err, html) => {
  fs.writeFileSync('index.html', html, 'utf-8')
})

注意,作为模板的html文件,需要在被写入内容的位置添加<!--vue-ssr-outlet-->占位符,本例子在div#root里写入:

<div id="root">
 <!--vue-ssr-outlet-->
</div>

最后执行node skeleton就能实现vue的骨架屏。
最终的index.html:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <title>Document</title>
<style data-vue-ssr-id="a7049cb4:0">
.skeleton[data-v-61761ff8] {
  position: relative;
  height: 100%;
  overflow: hidden;
  padding: 15px;
  box-sizing: border-box;
  background: #fff;
}
.skeleton-nav[data-v-61761ff8] {
  height: 45px;
  background: #eee;
  margin-bottom: 15px;
}
.skeleton-swiper[data-v-61761ff8] {
  height: 160px;
  background: #eee;
  margin-bottom: 15px;
}
.skeleton-tabs[data-v-61761ff8] {
  list-style: none;
  padding: 0;
  margin: 0 -15px;
  display: flex;
  flex-wrap: wrap;
}
.skeleton-tabs-item[data-v-61761ff8] {
  width: 25%;
  height: 55px;
  box-sizing: border-box;
  text-align: center;
  margin-bottom: 15px;
}
.skeleton-tabs-item span[data-v-61761ff8] {
  display: inline-block;
  width: 55px;
  height: 55px;
  border-radius: 55px;
  background: #eee;
}
.skeleton-banner[data-v-61761ff8] {
  height: 60px;
  background: #eee;
  margin-bottom: 15px;
}
.skeleton-productions[data-v-61761ff8] {
  height: 20px;
  margin-bottom: 15px;
  background: #eee;
}
</style></head>
<body>
    <div id="root">
        <div data-server-rendered="true" class="skeleton page" data-v-61761ff8><div class="skeleton-nav" data-v-61761ff8></div> <div class="skeleton-swiper" data-v-61761ff8></div> <ul class="skeleton-tabs" data-v-61761ff8><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li><li class="skeleton-tabs-item" data-v-61761ff8><span data-v-61761ff8></span></li></ul> <div class="skeleton-banner" data-v-61761ff8></div> <div class="skeleton-productions" data-v-61761ff8></div><div class="skeleton-productions" data-v-61761ff8></div><div class="skeleton-productions" data-v-61761ff8></div><div class="skeleton-productions" data-v-61761ff8></div><div class="skeleton-productions" data-v-61761ff8></div><div class="skeleton-productions" data-v-61761ff8></div></div>
    </div>
</body>
</html>

看下效果:
图片描述
效果还是阔以的。

尾声

文章开头小米商城手机页面就是用的这样的方法,不同的是它的骨架屏是一个base64的图片。

更多关于vue-server-renderer内容请戳vue-ssr

文章相关代码已经同步到Github,欢迎查阅~

你可能感兴趣的

25 条评论
随风飘扬 · 2018年07月02日

index.html里面已经成功注入内容了,运行也能够出来骨架层,但是如何控制在我首页内容加载完成逐步替换里面的骨架块?可否提供下思路

+1 回复

0

这个思路就和这个完全不一样了

技术哥 · 2018年07月02日
0

你在手机上试了吗可以出来效果吗

技术哥 · 2018年07月02日
0

可以出来,但是就是不是我想象中的那种效果

随风飘扬 · 2018年07月18日
Thanos · 2018年05月23日

@RThong 您好,我想问下,文章最前面提到的“服务端渲染的架构”是什么意思?望解答

回复

0
天体家族流 · 2018年05月23日
1

就是客户端与服务端中间加一层node服务用于直出html模板

RThong 作者 · 2018年05月24日
0

简单点说就是用流量换前端内存,同时seo优化

Dylan · 5月14日
OBKoro1 · 2018年05月24日

你好, 请问是在vue-cli中做这个预渲染的吗? 有没有更具体一些的步骤,尝试自己把文件都复制了,也没有成功。

回复

0

我没有用vue-cli,我刚重新上传了下代码,你现在再重新下下,要先npm install,然后执行npm run build,再执行node skeleton,再执行npm run dev

RThong 作者 · 2018年05月24日
0

按照步骤 最后只是显示一个骨架页面 没有看到页面切换的效果

大boss · 2018年05月25日
0

@妞炖男爵 npm run dev打包完之后访问localhost:8080/

RThong 作者 · 2018年05月26日
xiaoxiao · 2018年05月30日

多页面应用可以利用骨架屏吗

回复

0

多页面是一个请求对应一个页面,加载速度快没必要用啊

RThong 作者 · 2018年05月30日
慢思考快行动 · 2018年05月30日

我的webpack配置了,但是dist目录下没有生成对应的文件,求助~~~

回复

0

因为用了webpack-dev-server,你不用的话就有了

RThong 作者 · 2018年05月30日
0

不行,我只有webpack.base.cof.js里面的一个 `{

    test: /\.js$/,
    loader: 'babel-loader',
    include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
  },`

这个地方用到过,我删除了也还是没有创建你说的文件.苦恼

慢思考快行动 · 2018年05月30日
0

@慢思考快行动 我根据我的webpack配置,把package.json里面dev的webpack-dev-server改成webpack,然后webpack.config.client.js里面的devServer去掉

RThong 作者 · 2018年05月30日
小笼包 · 2018年09月13日

你好,我发现骨架屏消失以后,我的dom还没渲染完成,我想在dom渲染完成之后移除骨架,有什么好的思路么

回复

小生 · 2018年10月23日

对node版本又要求么?

回复

酣然入码 · 2018年11月09日

小编,我下载了node skeleton报错~

F:\gitTemp\vue-skeleton\node_modules\vue-server-renderer\build.js:8532
        throw new Error(("Cannot locate bundle file: " + bundle))
        ^

Error: Cannot locate bundle file: F:\gitTemp\vue-skeleton\server-dist\skeleton.json
    at createBundleRenderer (F:\gitTemp\vue-skeleton\node_modules\vue-server-renderer\buil
d.js:8532:15)
    at Object.<anonymous> (F:\gitTemp\vue-skeleton\skeleton.js:7:18)
    at Module._compile (module.js:653:30)
你的文件有bug啊

回复

0

应该是我们npm包版本变更导致的,我也遇到了

Dylan · 5月14日
heath_learning · 5月24日

大佬你好,我根据你的文章做了骨架屏配置,skeleton.json文件生成了,运行到第二步的时候报错了
运行node skeleton.js
报错如下:

D:\work\ukfc\node_modules\lodash.template\index.js:1097
    throw result;
    ^

SyntaxError: Unexpected token =
    at Function (<anonymous>)
    at D:\work\ukfc\node_modules\lodash.template\index.js:1089:12
    at apply (D:\work\ukfc\node_modules\lodash.template\index.js:74:25)
    at D:\work\ukfc\node_modules\lodash.template\index.js:1126:12
    at apply (D:\work\ukfc\node_modules\lodash.template\index.js:76:25)
    at D:\work\ukfc\node_modules\lodash.template\index.js:317:12
    at template (D:\work\ukfc\node_modules\lodash.template\index.js:1088:16)
    at parseTemplate (D:\work\ukfc\node_modules\vue-server-renderer\build.dev.js:8784:11)
    at new TemplateRenderer (D:\work\ukfc\node_modules\vue-server-renderer\build.dev.js:8866:9)
    at createRenderer (D:\work\ukfc\node_modules\vue-server-renderer\build.dev.js:9131:26)

skeleton.js文件如下:

const fs = require('fs')
const path = require('path')
const cheerio  = require('cheerio');

const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

fs.readFile(path.join(__dirname, './public/index-template.html'), 'utf-8', (err, res) => {
 console.log(res)
 // 读取`skeleton.json`,以`index.html`为模板写入内容
 const renderer = createBundleRenderer(path.join(__dirname, './dist/skeleton.json'), {
  template: res
 });
 renderer.renderToString({}, (err, html) => {
  console.log('html', html);
  fs.writeFileSync(path.join(__dirname, './public/index.html'), html, 'utf-8')
 });
});

回复

0

问题找到了,是<link rel="icon" href="<%=BASE_URL%>favicon.ico">中的<%=BASE_URL%>造成的,不知大佬是怎么解决的?

heath_learning · 5月24日
暖男倾城Y9 · 6月9日

项目已经运行了,但只有一个test按钮,无法点击,是怎么回事呢

回复

载入中...