使用骨架屏减少首屏白屏现象
背景
用户从输入 url
到打开页面,经历的步骤可以参考这里。现代前端应用程序通常使用 React
、Vue
、Angular
、Solid
等框架进行开发,这些框架统一管理工程化内容。
这也导致了一个问题:通过这些框架开发的单页面应用(SPA)通常只包含一个 <div id="app"></div>
,而其余内容都是在后续脚本运行时动态渲染。这使得用户加载的 HTML 页面往往呈现为白屏,只有等到脚本解析执行后,内容才会呈现。因此,服务端渲染(SSR)应运而生,它在服务器端就将内容渲染好并返回给前端,虽然这需要整体改造项目,成本较高。另一种方案是使用首屏骨架图渲染,以减少白屏现象。
原理
骨架屏的原理是直接将骨架图嵌入 HTML 中,实际内容加载完毕后将骨架图替换为真实内容。
生成骨架屏的方式
- 单独编写骨架屏样式并注入:需要手动维护样式。
- 使用骨架屏图片:适合简单场景,但不够灵活。
- 自动生成骨架屏:
page-skeleton-webpack-plugin
:不再维护,不推荐。- 使用 Chrome 插件生成骨架屏,比如
@killblanks/skeleton-ext
,效果不错,但样式需要微调。 - 自定义实现,原理简单,将页面的文字和图片替换为骨架图形式。参考源码。如果你恰巧有
油猴
插件,也可以直接安装脚本使用点击直达
注入代码 1 - 注入进 #app
内
这里我使用的是 vite
打包工具,webpack
可以使用类似的方法。
首先需要编写一个插件,在生成时修改 HTML 代码: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// /plugins/skeletonPlugin.ts
import { PluginOption } from 'vite';
import { join } from 'path';
const filename = join(__dirname, './homeSkeleton.js');
export function SkeletonPlugin(): PluginOption {
return {
name: 'SkeletonPlugin',
async transformIndexHtml(html) {
// 新增
const modifiedHtml = html.replace('<script', '<script defer');
const content = (await import(filename)).default;
const code = `
<script>
var map = ${JSON.stringify(content)}
var pathname = window.location.pathname
var target = map[pathname]
var content = target && target.html || ''
document.write(content)
</script>
`;
return modifiedHtml.replace(/__SKELETON_CONTENT__/, code);
},
};
}
在 HTML 中,<div id="root">
内部增加内容 __SKELETON_CONTENT__
,以便填充骨架屏。1
2
3
4
5
6
7
8
9
10
11
12
13
14<!-- /index.html -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root">__SKELETON_CONTENT__</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>1
2
3
4
5
6
7// /plugins/homeSkeleton.js
export default {
'/home': {
pathname: '/home',
html: `<div>xxx 骨架图内容 xxx</div>`,
},
};
生成结果如下,需要注意的是,页面入口的 <script>
需要设置为 defer
,以确保骨架图代码生效,避免阻塞后续代码执行。
粗糙一点的实现是,在 plugins/skeletonPlugin.ts
中暴力将所有 <script>
标签新增 defer
属性,虽然这种方式不够优雅,但可以解决问题。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// /plugins/skeletonPlugin.ts
import { PluginOption } from 'vite';
import { join } from 'path';
const filename = join(__dirname, './homeSkeleton.js');
export function SkeletonPlugin(): PluginOption {
return {
name: 'SkeletonPlugin',
async transformIndexHtml(html) {
const modifiedHtml = html.replace('<script', '<script defer');
const content = (await import(filename)).default;
const code = `
<script>
var map = ${JSON.stringify(content)}
var pathname = window.location.pathname
var target = map[pathname]
var content = target && target.html || ''
document.write(content)
</script>
`;
return modifiedHtml.replace(/__SKELETON_CONTENT__/, code);
},
};
}
至此,该方案基本完成。然而在实际应用中,仍会出现白屏闪烁现象,这是由于框架加载页面时的异步操作导致的,首先渲染根路由信息,然后才会渲染具体路由的信息,因此该方案有待进一步完善。
注入代码 2 - 优化,显示在页面最上层
可以将骨架屏渲染在一个空的 div
中,并通过 fixed
样式将其固定在页面的最上层。随后,监听页面实际渲染的状态,页面渲染完成后将骨架图隐藏,从而在视觉上达到良好的效果。
新增改动1:script defer修改,经调试 vite
源码发已内置 async
,将 defer
属性改为 async
效果一样。
新增改动2:plugin注入代码调整: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
30import { PluginOption } from 'vite';
import { join } from 'path';
import { readFile } from 'node:fs/promises';
// 屏架图映射数据
const filename = join(__dirname, './homeSkeleton.js');
// 骨架屏逻辑
const code = await readFile(join(__dirname, './script.js'), {
encoding: 'utf-8',
});
export function SkeletonPlugin(): PluginOption {
return {
name: 'SkeletonPlugin',
async transformIndexHtml(html) {
const content = (await import(filename)).default;
// 注入进html中
return {
html,
tags: [
{
tag: 'script',
injectTo: 'body-prepend',
children: `var map=${JSON.stringify(content)};${code}`,
},
],
};
},
};
}
实现效果如下:
最终代码
GitHub 链接
参考
Vue项目骨架屏注入实践
一个前端非侵入式骨架屏自动生成方案