使用骨架屏减少首屏白屏现象

背景

用户从输入 url 到打开页面,经历的步骤可以参考这里。现代前端应用程序通常使用 ReactVueAngularSolid 等框架进行开发,这些框架统一管理工程化内容。

这也导致了一个问题:通过这些框架开发的单页面应用(SPA)通常只包含一个 <div id="app"></div>,而其余内容都是在后续脚本运行时动态渲染。这使得用户加载的 HTML 页面往往呈现为白屏,只有等到脚本解析执行后,内容才会呈现。因此,服务端渲染(SSR)应运而生,它在服务器端就将内容渲染好并返回给前端,虽然这需要整体改造项目,成本较高。另一种方案是使用首屏骨架图渲染,以减少白屏现象。

原理

骨架屏的原理是直接将骨架图嵌入 HTML 中,实际内容加载完毕后将骨架图替换为真实内容。

生成骨架屏的方式

  1. 单独编写骨架屏样式并注入:需要手动维护样式。
  2. 使用骨架屏图片:适合简单场景,但不够灵活。
  3. 自动生成骨架屏
    • 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 -->
<!doctype 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
30
import { 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项目骨架屏注入实践

一个前端非侵入式骨架屏自动生成方案