用加载画面改善用户体验
毋庸置疑,单页面应用(Single Page Application,以下简称 SPA)正在变得越来越流行。SPA 具有前后端分离、接口重用度高、操作响应快速等优点,在用户体验上相比传统的网站也更接近原生应用。但与此同时,SPA 的体积却在不断增大,导致加载所需的时间越来越长,而这个问题在微信里面特别突出。
微信中的加载问题
首先,微信通常安装在移动设备上,网络的速度并不像固定宽带那么有保障,虽然 4G 已经普及,但总有些地方或某些时候网络没那么理想。在网络状况不佳时,加载较大的 SPA 耗时较长,屏幕表现为长时间白屏。
然后,微信对网页的缓存机制跟一般的浏览器不同。即使你没在服务器上进行特别的配置,它默认就会缓存 html、css、javascript 等它认为是“静态”的内容,而且在缓存有效期间“拒绝”去服务器上看看是否有更新,给不少新接触微信开发的程序员造成困扰。反过来,当你希望它将某个内容长期缓存,并通过响应头部的信息明确告知时,它也不能很好的遵守。说实话,你没法知道缓存什么时候会过期。不过也不能全怪它,微信毕竟不是浏览器。
结果,我们必须面对的事实就是经常会遇到这样的情况:用户打开你的公众号,点击某个按钮进入了你开发的 SPA,要加载的东西不少、网络又不太好、而缓存又都过期了,用户等待了 5 秒钟,页面还是空白的,用户关闭了页面并觉得你的网站很糟糕。
在对比 SPA 和原生应用时,常常会把“不需要下载和安装”作为一项优势,但我们都清楚,其实每次使用前都要“下载”一次。
加载画面
加载画面本身不能加快网站的加载速度,甚至会稍微拖慢一点,但它是一个信号,告诉用户这个网站并没有挂掉,他已经打开了这个网站,只是还要等待一会才能使用。这会让用户更有耐心继续等下去,尤其是在你能提供一个进度条的时候。
既然决定要在应用里加入加载画面了,那么一定要第一时间让它出现,并且随着网页内容的加载随时汇报加载进度。
显示加载画面
先来看一个没有加载画面的网页简化之后的例子:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>
在上面这个例子中,样式文件放在 head 中,脚本文件放在 body 的尾部,是一种推荐写法,能让网页在第一时间进行显示,然后再加载 javascript 来对网页进行渐进式的增强。
但是这个做法对于 SPA 的帮助不是很大,现在大多数 SPA 都是基于 Angular、React、Vue 等前端框架,其内容都是通过 javascript 动态生成后再显示出来的。
这里我们假设网站已经使用 require.js 实现了按需加载和多线并行加载,但用户对加载速度仍不满意(这就是我遇到的问题和写这篇文章的原因)。
为了让加载画面能第一时间显示,我必须放弃利用其他框架,写成一个零依赖的库。它的位置应该同样位于 body 的尾部,但在所有其他 javascript 文件之前。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>
<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>
写完后,通过浏览器进行观察,方法是调出 Chrome 的开发者工具,打开 Network 标签,勾选 Disable Cache 禁用缓存,在 Throttling 处选择 GPRS,这样浏览器就会模拟 GRPS 网络的速度来加载文件。
可以观察到,加载画面在加载 require.js 之前就显示了,但是暴露出另一个问题,在样式文件加载完之前,网页一直是空白的,这是因为浏览器在加载完样式文件之前,是不会进行渲染的。
这让我想起了早期的 Opera 浏览器(换核之前),加载样式文件是不会阻止渲染的,它会直接显示出一个没有样式的网页,在加载完样式文件后再渲染成有样式的样子。
在新的标准中,link 标签将会有一个可选的 preload 属性,允许浏览器在不加载完这个样式文件的时候就进行渲染,但目前很多浏览器尚未支持,微信在 Android 中使用的 X5 内核,以及 iOS 的 Safari 也都不支持。
我目前开发的大多数的项目都需要加载 bootstrap、font-awesome 这两个体积稍大的样式库,还有一些根据项目需要另外引入的零碎的库。加在一起要加载的量也是不小的。
为了让加载画面出现的更快,样式文件必须下移到显示加载画面的 script 标签之后。但是很不幸,微信内嵌浏览器使用的内核,无论是自己的 X5 还是 iOS 的 Safari 内核,还有一个特性,就是只要遇到加载样式文件的 link 标签,就会停止渲染,直到样式文件加载完毕。
也就是说,即使把 link 放在 script 之后,也无法先显示加载画面。
现在就剩下一条路了,动态加载样式文件,听起来高大上,实现起来很简单,用 javascript 创建一个 link 标签,指定好要加载的样式文件,再挂到 DOM 树中。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>
<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);
var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>
经过这一次的修改,加载画面就可以在只加载了 html 文件的情况下显示啦。接下来我们要实现显示加载进度的功能。
显示加载进度
首先,我们要明确在第一个主要内容页面显示之前,总共要加载多少东西,或者说有几个节点,这样我们就有了一个计数的目标;然后创建一个空的计数器,每完成一个节点,计数器加一,并更新进度;最终,当计数器到达计数目标的时候,加载完成,隐藏掉加载画面,显示出内容。
var total = 节点数;
var count = 0;
var loaded = function () {
count++;
if (count < total) {
// 更新加载进度
} else {
// 隐藏加载画面
}
}
total 是计数目标,count 是计数器,每次完成一个节点,调用 loaded 函数就可以更新计数。
现在,我们暂不考虑应用本身的逻辑,只把文件的加载纳入进度显示中,那么在上面的例子中有两个需要加载的文件,一个是 bootstrap.css ,另一个是 require.js 。对于 JavaScript 文件,由于是顺序加载和执行的,实现起来非常简单:
<script src="/javascript/require.js"></script>
<script>
loaded();
</script>
如上所示,require.js 加载和执行完毕后,就会执行 loaded() 使计数器加一。
对于样式文件,我们通过监听 load 事件来实现:
var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);
回到现实的情况中,通过 require.js 加载的、运行 SPA 必备的库也可以作为节点,通过在 require 函数的回调中加入 loaded 来监控进度。
require(['jquery'], function () {
loaded();
// 继续处理
});
如果需要,还可以进一步将进度监控到获取渲染第一个页面所需的数据的步骤,但是别忘了将计数目标做出相应改变。
$.get('/api/some-data', function (data) {
loaded();
// 渲染第一个主要内容页面
});
完整的示例代码:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>
<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);
var total = 4;
var count = 0;
var loaded = function () {
count++;
if (count < total) {
progress.textContent = count + '/' + total;
} else {
splash.remove();
}
}
var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
loaded();
require(['/javascript/jquery.js'], function ($) {
loaded();
$.get('/api/some-data', function (data) {
loaded();
$('app').append(data);
});
});
</script>
</body>
</html>
至此,一个简单的,可显示进度的加载画面的原型已经完成。本文的主要内容也就到此为止了。
尾声
如果要应用到生产环境中,还有几个问题必须解决:动态添加样式的几个语句应该定义成一个函数以使加载多个样式文件更方便;暴露在全局中的变量太多,不利于维护;样式固定,无法适应不同风格的应用。