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

背景

用户从输入 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
// /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 content = (await import(filename)).default;
const code = `
<script id="skeleton-script">
var map = ${JSON.stringify(content)}
var pathname = window.location.pathname
var target = map[pathname]
var content = target && target.html || ''
content && (document.querySelector('#skeleton-script').parentElement.innerHTML += content)
</script>
`;
return html.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
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 id="skeleton-script">
var map = ${JSON.stringify(content)}
var pathname = window.location.pathname
var target = map[pathname]
var content = target && target.html || ''
content && (document.querySelector('#skeleton-script').parentElement.innerHTML += content)
</script>
`;
return modifiedHtml.replace(/__SKELETON_CONTENT__/, code);
},
};
}

源码:https://github.com/popring/vite-skeleton/tree/v1

至此,该方案基本完成。然而在实际应用中,仍会出现白屏闪烁现象,这是由于框架加载页面时的异步加载导致的,首先渲染根路由信息,然后才会渲染具体路由的信息,因此该方案有待进一步完善。

想要还原这种场景只需要在路由位置加一个 Suspense 标签就可以

改动位置:https://github.com/popring/vite-skeleton/commit/7c0eccb4106be99f28c7106cb7fd1584203a31e3

如图,可以很明显看出白屏情况

注入代码 2 - 优化,显示在页面最上层

可以将骨架屏渲染在一个空的 div 中,并通过 fixed 样式将其固定在页面的最上层。随后,监听页面实际渲染的状态,页面渲染完成后将骨架图隐藏,从而在视觉上达到良好的效果。

新增改动1:script defer修改,经调试 vite 源码 发现已内置 async,另外,由于 type=”module” 情况默认就是 defer , 所以其实不需要加都可以。(尴尬)

新增改动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
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;
return {
html,
tags: [
{
tag: 'script',
injectTo: 'body',
children: `var map=${JSON.stringify(content)};${code}`,
},
],
};
},
};
}

其中 script.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
63
64
65
66
67
68
69
70
71
const wrapId = 'skeleton-overlay';

function showSkeleton(content) {
let sEl = document.getElementById(wrapId)
if (sEl) {
sEl.style.display = 'block';
return
}
document.body.innerHTML += content
}

function removeSkeleton() {
const skeletonDom = document.getElementById(wrapId);
if (skeletonDom) {
skeletonDom.style.display = 'none';
}
}

function matchPathname() {
var pathname = window.location.pathname;
var target = map[pathname];
var content = (target && target.html) || '';
return content;
}

// 辅助函数,用于检查节点或其子节点是否包含指定类名
function hasClassName(node, className) {
if (node.classList.contains(className)) {
return true;
}
for (const child of node.children) {
if (hasClassName(child, className)) {
return true;
}
}
return false;
}

function observeDOMChangesForClassName(targetNode, targetClassName, callback) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === 1) {
// 检查当前节点及其子节点是否包含指定类名
if (hasClassName(addedNode, targetClassName)) {
callback(addedNode);
observer.disconnect();
return;
}
}
}
}
});

observer.observe(targetNode, { childList: true, subtree: true });
}

function startCheck() {
const content = matchPathname();
if (content) {
// 展示骨架图
showSkeleton(content);
// DOM中出现指定元素,隐藏骨架图
observeDOMChangesForClassName(document.body, 'product-list', () => {
console.log('hidden showSkeleton');
removeSkeleton();
});
}
}

startCheck();

实现效果如下:

最终代码
GitHub 链接

最后

本文主要是根据实践提出骨架图实现思路,其中代码有挺多可以优化的地方,切勿直接搬运到项目中落地,有问题欢迎指出。

参考

Vue项目骨架屏注入实践

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