我:偷偷告诉你,我们项目里的进度条,全都是假的!🤣 产品:???😲
扯皮
最近接到了一个需求:前端点击按钮触发某个任务并开启轮询获取任务进度,直至 100% 任务完成后给予用户提示
这个业务场景还挺常见的,但是突然上周后端联系到我说现在的效果有点差,之前都是小任务那进度条展示还挺不错的,现在有了一些大任务且会存在排队阻塞的情况,就导致视图上经常卡 0% 排队,用户体验太差了,问能不能在刚开始的时候做个假进度先让进度条跑起来😮
因此就有了这篇文章,简单做一下技术调研以及在项目中的应用
正文
其实假进度条也不难做,无非是轮询的时候我们自己做一个随机的自增,让它卡到 99% 等待后端真实进度完成后再结束
只不过还是想调研一下看看市面上有没有一些成熟的方案并去扒一下它们的源码🤓
NProgress
首先当我听到这里的需求后第一时间想到的就是它:rstacruz/nprogress: For slim progress bars like on YouTube, Medium, etc
记得大学期间做的一些中后台系统基本都少不了路由跳转时的顶部进度条加载,那时候就有了解到 NProgress,它的使用方式也很简单,完全手控:NProgress: slim progress bars in JavaScript,去文档里玩一下就知道了
视图呈现的效果就是如果你不手动结束那它就会一直缓慢前进卡死 99% ,挺符合我们这里的需求,可以去扒一下它内部进度计算相关的逻辑
NProgress 的内容实际上比较少,源码拉下来可以看到主要都在这一个 JS 文件里了:
需要注意的是我们看的是这个版本:rstacruz/nprogress at v0.2.0,master 分支与 npm 安装的 0.2.0 内部实现还是有些差别的
我们这里不关注它的样式相关计算,主要来看看对进度的控制,直奔 start 方法:
还是比较清晰的,这里的 status
就是内部维护的进度值,默认为 null,所以会执行 NProgress.set
,我们再来看看 set 方法:
set 方法里有一大堆设置动画样式逻辑都被我剪掉了,关于进度相关的只有这些。相当于利用 clamp 来做一个夹层,因为初始进来的 n 为 null,所以经过处理后进度变为 0.08
再回到 start 的逻辑,其中 work
就是内部轮询控制进度自增的方法了,初始配置 trickle
为 true 代表自动开启进度自增,由于进度条在 set 方法中已经设置为 0.08,所以走到后面的 NProgress.trickle
逻辑
看来这里就是进度控制的核心逻辑了, trickle
里主要调用了 inc
,在 trickle
中给 inc
传递了一个参数:Math.random() * Settings.trickleRate
,显然这里范围是:0 <= n < 0.02
而在 inc
中,如果传递的 amount 有值的话那就每次以该值进行自增,同时又使用 clamp 将最大进度卡在 0.994
最后再调用 set
方法,set 里才是更新进度和视图进度条的方法,涉及到进度更新时都需要回到这里
当然 NProgress.inc
也可以手动调用,还对未传参做了兼容处理:
amount = (1 - n) * clamp(Math.random() * n, 0.1, 0.95)
即根据当前进度 n 计算剩余进度,再随机生成自增值
再来看 done
方法,它就比较诡异了:
按理来说直接将进度设置为 1 就行,但它以链式调用 inc
再调用 set
,相当于调用了两次 set
而这里 inc
传参又没什么规律性,推测是为了 set
中的样式处理,感兴趣的可以去看看那部分逻辑,还挺多的...😶
一句话总结一下 NProgress 的进度计算逻辑:随机值自增,最大值限制
但是因为 NProgress 与进度条样式强绑定,我们肯定是没法直接用的
fake-progress
至于 fake-progress 则是我在调研期间直接搜关键词搜出来的😶:piercus/fake-progress: Fake a progress bar using an exponential progress function
很明显看介绍就是干这个事的,而且还十分专业,引入数学函数展示假进度条效果,具有说服力:
所以我们项目中其实就是用的这个包,只不过和 NProgress 类似,两个包都比较老了,瞟一眼源码发现都是老 ES5 了🤐
因为我们项目中用的是 React,这里给出 React 的 demo 吧,为了编写方便使用了几个 ahooks 里的 hook:
其实使用方法上与 NProgress 都类似,不过两者都属于通用的工具库不依赖其他框架,所以像视图渲染都需要自己手动来做
注意实例化中的传参 timeConstant
,某种意义上来讲这个值就相当于“进度增长的速率”,但也不完全等价,我们来看看源码
因为不涉及到样式,fake-progress 源码更简单,核心就在这里:
下方的数学公式就是介绍图中展示的,只能说刚看到这部分内容是真的是死去的数学只是突然又开始攻击我😅,写了那么多函数,数学函数是啥都快忘了
我们来简单分析一下这个函数 1 - Math.exp(-1 * x)
,exp(x)= exe^xex,exe^xex 的图像长这样,高中的时候见的太多了:
那假如这里改成 exp(-x) 呢?有那味了,以前应该是有一个类似的公式 f(x)f(x)f(x) 与 f(−x)f(-x)f(−x) 图像效果是关于 y 轴对称,好像是有些特殊的不符合这个规律?🤔反正大部分都是满足的
OK,那我们继续进行转换,看看 -exp(-x) 的效果
同样有个公式 f(x)f(x)f(x) 与 −f(x)-f(x)−f(x) 图像效果是关于 x 轴对称:
初见端倪,不知道你们有没有注意 -exp(-x) 最终呈现的图像是无限接近于 x 轴的,也就是 0:
那有了🤓,假如我再给它加个 1 呢?它不就无限接近于 1 了,即 -exp(-x) + 1,这其实就是 fake-progress 里公式的由来:
但你会发现如果 x 按 1 递增就很快进度就接近于 1 了,所以有了 timeConstant
配置项来控制 x 的增长,回看这个公式:1 - Math.exp(-1 * this._time / this.timeConstant)
this._time
是一直在增长的,而 this.timeConstant
作为分母如果被设置为一个较大的值,那可想而知进度增长会巨慢
所以 fake-progress 的核心原理是借助数学函数,以函数值无限接近于 1 来实现假进度条,但是这种实现有一个 bug,可以看我提的这个 issues,不过看这个包的更新时间感觉作者也不会管了😅:
bug: progress may reach 100% · Issue #7 · piercus/fake-progress
useFakeProgress
虽然我们现在项目中使用的是 fake-progress,但是个人感觉用起来十分鸡肋,而且上面的 bug 也需要自己手动兼容,因此萌生出自己封装一个 hook 的想法,让它更符合业务场景
首先我们确定一下进度计算方案,这里我毫不犹豫选择的是 NProgress 随机值增长方案,为什么?因为方便用户自定义
而且 NProgress 相比于 fake-progress 有一个巨大优势:手动 set 进度后仍然保持进度正常自动递增
这点在 fake-progress 中实现是比较困难的,因为你无法保证手动 set 的进度是在这个函数曲线上,相当于给出函数 y 值反推 x 值,根据反推的 x 值再进行递增,想想都麻烦
确定好方案后我们来看下入参吧,参考 NProgress 我定义了这几个配置项:
这里我简单解释一下 rerender 和 amount 配置:
实际上在封装这个 hook 的时候我一直在纠结这里的 progress 到底是 state 还是 ref,因为大多数场景下 hook 内部通过轮询定时器更新进度,而真实业务代码中也会开启定时器去轮询监听业务接口的
所以如果写死为 state,那这个场景 hook 内部的每次更新 render 是没必要的,但是假如用户又想只是使用假进度展示,没有后端业务接口呢?
思来想去其实完全可以放权给用户进行配置,因为 state = ref + update,统一使用 ref,用户配置 rerender 时我们在每次更新时 update 即可
至于 amount 我是希望放权给用户进行自定义递增值,你可以配置成一个固定值也可以配置成随机值,更可以像 NProgress master 分支下这样根据当前进度来控制自增,反正以函数参数的形式能够拿到当前的 progress:
至于实现细节就不再讲述了,实际上就是轮询定时器没什么复杂的东西,直接上源码了:
import { useRef, useState } from "react";
interface Options {
minimun?: number;
maximum?: number;
speed?: number;
rerender?: boolean;
amount?: (progress: number) => number;
formatter?: (progress: number) => string;
onProgress?: (progress: number) => void;
onFinish?: () => void;
}
export function useFakeProgress(options?: Options): [
{ current: string },
{
inc: (amount?: number) => void;
set: (progress: number) => void;
start: () => void;
stop: () => void;
done: () => void;
reset: () => void;
get: () => number;
}
] {
const {
minimun = 0.08,
maximum = 0.99,
speed = 800,
rerender = false,
amount = (p: number) => (1 - p) * clamp(Math.random() * p, minimun, maximum),
formatter = (p: number) => `${p}`,
onProgress,
onFinish,
} = options || {};
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const progressDataRef = useRef(""); // formatter 后结果
const [, update] = useState({});
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const setProgress = (p: number) => {
progressRef.current = p;
progressDataRef.current = formatter(p);
onProgress?.(p);
if (rerender) update({});
};
const work = () => {
const p = clamp(progressRef.current + amount(progressRef.current), minimun, maximum);
setProgress(p);
};
const start = () => {
function pollingWork() {
work();
timerRef.current = setTimeout(pollingWork, speed);
}
if (!timerRef.current) pollingWork();
};
const set = (p: number) => {
setProgress(clamp(p, minimun, maximum));
};
const inc = (add?: number) => {
set(progressRef.current + (add || amount(progressRef.current)));
};
const stop = () => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
const reset = () => {
stop();
setProgress(0);
};
const done = () => {
stop();
setProgress(1);
onFinish?.();
};
return [
progressDataRef,
{
start,
stop,
set,
inc,
done,
reset,
get: () => progressRef.current,
},
];
}
这里需要补充一个细节,在返回值里使用的是 progressDataRef 是 formatter 后的结果为 string 类型,如果用户想要获取原 number 的 progress,可以使用最下面提供的 get 方法拿 progressRef 值
一个 demo 看看效果,感觉还可以:
当然由于直接返回了 ref,为了防止用户篡改可以再上一层代理劫持,我们就省略了
这也算一个工具偏业务的 hook,可以根据自己的业务来进行定制,这里很多细节都没有补充只是一个示例罢了🤪
End
以上就是这篇文章的内容,记得上班之前还在想哪有那么多业务场景需要封装自定义 hook,现在发现真的是各种奇葩需求都可以封装,也算是丰富自己武器库了...
来源:juejin.cn/post/7449307011710894080