本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!
前言
本着遇到问题,解决问题,记录方案,思考问题的原则,写一个专栏
从问题到提问, 欢迎大家关注。
上一篇专栏的文章是 两个数组数据的高效合并方案。
先举个定时器的例子
每1秒执行一次,3次后,停止调用。
const nextFactory = createTimeoutGenerator();
let context = {
counts: 0
};
nextFactory.start(function (this: any, next: Function) {
context.counts ++;
console.log("counts", context.counts);
if(context.counts > 3){
nextFactory.cancel();
}
next();
}, context);
复制代码
定时器
前端常见三大定时器setTimeout
, setInterval
, requestAnimationFrame
setInterval
的坑不是本文讨论的重点,所以剩下的选择是 setTimeout
, requestAnimationFrame
。
有很多时候,我们需要多次调用定时器,比如验证码倒计时,canvas绘制。
基本都是处理完数据后,进入下一个周期, 我们一起看看例子。
定时器应用
setTimeout
我们用原生代码实现一个60秒倒计时,并支持暂停,继续的功能,来看一看代码:
大概是下面这个样子:
<div class="wrapper">
<span id="seconds">60</span>
<div>
<button id="btnPause">暂停</button>
<button id="btnContinue">继续</button>
</div>
</div>
<script>
const secondsEl = document.getElementById("seconds");
const INTERVAL = 1000;
let ticket;
let seconds = 60;
function setSeconds(val) {
secondsEl.innerText = val;
}
function onTimeout() {
seconds--;
setSeconds(seconds);
ticket = setTimeout(onTimeout, INTERVAL);
}
ticket = setTimeout(onTimeout, INTERVAL);
document.getElementById("btnPause").addEventListener("click", () => {
clearTimeout(ticket);
});
document.getElementById("btnContinue").addEventListener("click", () => {
ticket = setTimeout(onTimeout, INTERVAL);
});
</script>
复制代码
有没有,什么问题?
我觉得有,
INTERVAL
,ticket
和setTimeout
满天飞, 不够高雅,我们应该更关心业务的处理;- 有多处类似的逻辑,就得重复的写
setTimeout
,缺少复用; - 语义不好
当然,大家肯定都有自己的封装,我这里要解决的是定时器的封装,与页面和逻辑无关。
我们不妨再看一段代码:
一样的功能,看起来简洁很多,而且语义很清晰。
- start: 开始
- cancel: 取消
- continue: 继续
<div class="wrapper">
<span id="seconds">60</span>
<div>
<button id="btnPause">暂停</button>
<button id="btnContinue">继续</button>
</div>
</div>
<script src="../dist/index.js"></script>
<script>
const nextFactory = createTimeoutGenerator();
const secondsEl = document.getElementById("seconds");
let seconds = 60;
function setSeconds(val) {
secondsEl.innerText = val;
};
nextFactory.start(function(next){
seconds--;
setSeconds(seconds);
next();
});
document.getElementById("btnPause").addEventListener("click", () => {
nextFactory.cancel();
});
document.getElementById("btnContinue").addEventListener("click", () => {
nextFactory.continue();
});
</script>
复制代码
requestAnimationFrame
再一起来看一个canvas绘制的例子,我们每隔一个绘制周期,就把当前的时间戳画在画布上。
大概是这个样子:
同样的,可以暂停和继续。
- drawTime 绘制时间
- requestAnimationFrame 启动定时器
- 两个按钮的点击事件,分别处理暂停和继续
先一起来看看原生JS的基础版本:
<div style="margin: 50px;">
<canvas id="canvas" height="300" width="300"></canvas>
</div>
<div>
<div>
<button id="btnPause">暂停</button>
<button id="btnContinue">继续</button>
</div>
</div>
<script>
let ticket;
const canvasEl = document.getElementById("canvas");
const ctx = canvasEl.getContext("2d");
ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 300, 300);
function drawTime() {
ctx.clearRect(0, 0, 300, 300);
ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 300, 300);
ctx.fillStyle = "#000";
ctx.font = "bold 20px Arial";
ctx.fillText(Date.now(), 100, 100);
}
function onRequestAnimationFrame() {
drawTime();
ticket = requestAnimationFrame(onRequestAnimationFrame);
}
ticket = requestAnimationFrame(onRequestAnimationFrame);
document.getElementById("btnPause").addEventListener("click", () => {
cancelAnimationFrame(ticket);
});
document.getElementById("btnContinue").addEventListener("click", () => {
requestAnimationFrame(onRequestAnimationFrame);
});
</script>
复制代码
问题依旧,我们看看另外一个版本:
const nextFactory = createRequestAnimationFrameGenerator();
const canvasEl = document.getElementById("canvas");
const ctx = canvasEl.getContext("2d");
ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 300, 300);
function drawTime() {
ctx.clearRect(0, 0, 300, 300);
ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, 300, 300);
ctx.fillStyle = "#000";
ctx.font = "bold 20px Arial";
ctx.fillText(Date.now(), 100, 100);
}
nextFactory.start((next)=>{
drawTime();
next();
});
document.getElementById("btnPause").addEventListener("click", () => {
nextFactory.cancel();
});
document.getElementById("btnContinue").addEventListener("click", () => {
nextFactory.continue();
});
复制代码
这里大家都注意到了,createTimeoutGenerator
与createRequestAnimationFrameGenerator
是关键,是魔法关键,我们来揭开面纱。
createTimeoutGenerator 的背后
因标题太长,应该是createTimeoutGenerator
与createRequestAnimationFrameGenerator
的背后。
createTimeoutGenerator
的代码:
其内部构造了一个具有 execute
与cancel
属性的对象,然后实例化了一个NextGenerator
,
也就是说,NextGenerator
才是核心。
export function createTimeoutGenerator(interval: number = 1000) {
const timeoutGenerator = function (cb: Function) {
let ticket: number;
function execute() {
ticket = setTimeout(cb, interval);
}
return {
execute,
cancel: function () {
clearTimeout(ticket);
}
}
}
const factory = new NextGenerator(timeoutGenerator);
return factory;
}
复制代码
迫不及待打开createRequestAnimationFrameGenerator
:
顿然醒悟,妙啊,秒啊。
export function createRequestAnimationFrameGenerator() {
const requestAnimationFrameGenerator = function (cb: FrameRequestCallback) {
let ticket: any;
function execute() {
ticket = window.requestAnimationFrame(cb);
}
return {
execute,
cancel: function () {
cancelAnimationFrame(ticket);
}
}
}
const factory = new NextGenerator(requestAnimationFrameGenerator);
return factory
}
复制代码
随心所欲的next
看完了createTimeoutGenerator
与createRequestAnimationFrameGenerator
。
你是不是可以大胆的认为,只要我构造一个对象有execute
与cancel
方法,就能弄出一个NextGenerator
, 然后嚣张的调用
- start
- cancel
- continue
答案,是的。
我们不妨,现在造一个,时间翻倍的计时器, 第一次 100ms, 第二次200ms, 第二次 400ms, 依着葫芦画瓢:
export function createStepUpGenerator(interval: number = 1000) {
const stepUpGenerator = function (cb: Function) {
let ticket: any;
function execute() {
interval = interval * 2;
ticket = setTimeout(cb, interval);
}
return {
execute,
cancel: function () {
clearTimeout(ticket);
}
}
}
const factory = new NextGenerator(stepUpGenerator);
return factory;
}
复制代码
interval
参数为第一次默认的初始值,之后翻倍。 一次执行一下看看结果。
测试代码:
const nextFactory = createStepUpGenerator(100);
let lastTime = Date.now();
nextFactory.start(function (this: any, next, ...args: any[]) {
const now = Date.now();
console.log("time:", Date.now());
console.log("costt time", now - lastTime);
lastTime = now;
console.log(" ");
next();
})
复制代码
如你所愿,现在你可以为所欲为,你要你想得到,不管是 setTimeout
, requestAnimationFrame
, Promise
, async/await
等等,你都可以用来创造一个属于你自己节拍的定时器。
宏观思路
分析到这,这里说一下思路
- 面向next编程
- 依赖反转
- 组合优先于继承
面向next编程(迭代器)
这个叫,纯属我个人喜欢。 其属于迭代器模式。
我们调用一次后,需要在一定的时机后调用下一次,是不是 next
呢?
前端原生自带的有:
可能有些人记不得了,我贴个Iterator
的代码吧:
class RangeIterator {
constructor(start, stop) {
this.value = start;
this.stop = stop;
}
[Symbol.iterator]() { return this; }
next() {
var value = this.value;
if (value < this.stop) {
this.value++;
return {done: false, value: value};
}
return {done: true, value: undefined};
}
}
function range(start, stop) {
return new RangeIterator(start, stop);
}
for (var value of range(0, 3)) {
console.log(value); // 0, 1, 2
}
复制代码
前端框架 redux
的中间件,是不是也有那个next
。
至于后台服务的 express
与koa
,大家都熟悉,就不提了。
依赖反转
引用 王争 设计模式之美里面的话
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。
NextGenerator 就是高层模块,我们编写的具有execute
和cancel
属性的对象是低层模块。
NextGenerator 和具有execute
和cancel
属性的对象并没有直接的依赖关系,两者都依赖同一个“抽象”。
我们用TS来描述一下这个抽象:
NextFnInfo
就这个抽象
interface Unsubscribe {
(): void
}
interface CallbackFunction<T = any> {
(context: T, ...args: any[]): void
}
interface NextFnInfo<T = any> {
cancel: Unsubscribe
execute: (next: CallbackFunction<T>) => any
}
复制代码
细心的肯定发现了,其实next
函数是还有context
和其他参数的,没错。
前面为了简化代码,都去掉了, context就是 start传入的回调函数的this
上下文。
const nextFactory = createTimeoutGenerator();
let context = {
val: 0
};
nextFactory.start(function (this: any, next, ...args: any[]) {
console.log("this", this); // this { val: 0 }
console.log("args", ...args); // args param1 param2
nextFactory.cancel();
}, context, "param1", "param2")
复制代码
仔细看代码注释:
- this 等于 context
- param1与 param2被原封不动传递
其实,还有更进一层的信息, next
函数是可以重新传递 context与其他参数的。
再秀一把:
我们执行完毕后,next传递{ a: 10 }
作为上下文,下次调用检查a是不是等于10, 如果等于,停止调用。
const nextFactory = createTimeoutGenerator();
let context = {
val: 0
};
nextFactory.start(function (this: any, next, ...args: any[]) {
console.log("this", this); // this { val: 0 }
console.log("args", ...args); // args param1 param2
next({ a: 10 }, "param-1", "param-2");
if (this.a === 10) {
nextFactory.cancel();
}
}, context, "param1", "param2")
复制代码
输出结果:
this { val: 0 }
args param1 param2
this { a: 10 }
args param-1 param-2
复制代码
组合优先于继承
实际上,完全可以写一个类,留有一些抽象的方法,然后重写。
但是我个人也是喜欢组合优先于继承的思路。
核心之NextGenerator
状态
我们实现说明一些规则
cancel
之后,next
不会触发下一次, 只能调用continue
恢复;- 执行函数中,多次调用
next
只会生效一次
基于上,我们大致有几种关键状态
- 等待中,已经请求计划
- 执行中
- 取消
缓存参数
通过上面的代码,我们得知,我们是可以传递上下文和参数的,也还可以通过next
的参数覆盖的,所以我们要缓存这些参数。
上下文
更改函数的上下文有多种手段:
- 绑定到一个对象上
- call
- apply
- 箭头函数
- bind
- 其他
我们这里采用的是bind,因为其返回的依旧是一个函数,提供了更多的操作空间。
代码全文
源码导读:
- 其最核心的代码是就是
next
方法
其调用了NextFnGenerator
实例生成了一个新的对象NextFnInfo
的实例,其提供了获取下一次执行计划和取消下一次执行计划的方法。
- 其最精彩的是
execute
方法
其被next
方法绑定了上下文,以及传入的所有参数。
这决定了它既能够和NextGenerator
实例交互,又能拿到所有的参数,执行回调函数。
一些TS申明:
interface Unsubscribe {
(): void
}
interface CallbackFunction<T = any> {
(context: T, ...args: any[]): void
}
interface NextFnInfo<T = any> {
cancel: Unsubscribe
execute: (next: CallbackFunction<T>) => any
}
interface NextFnGenerator {
(...args: any[]): NextFnInfo;
}
enum EnumStatus {
uninitialized = 0,
initialized,
waiting,
working,
canceled,
unkown
}
复制代码
核心类NextGenerator:
export default class NextGenerator<T = any> {
private status: EnumStatus = EnumStatus.uninitialized;
private nextInfo!: NextFnInfo;
// 传入的回调函数
private cb!: CallbackFunction;
// 下次回调函数的参数
private args: any[] = [];
constructor(private generator: NextFnGenerator) {
this.status = EnumStatus.initialized;
}
private next(...args: any[]) {
if (this.status === EnumStatus.canceled) {
return console.warn("current status is canceled, please call continute method to continue");
}
if (this.status === EnumStatus.waiting) {
return console.warn("current status is waiting, please don't multiple call next method");
}
if (args.length > 0) {
this.args = args;
}
// this.args[0] context
const boundFn = this.execute.bind(this, this.cb, ...this.args);
this.nextInfo = this.generator(boundFn);
this.status = EnumStatus.waiting;
this.nextInfo.execute(undefined as any);
}
private execute(this: NextGenerator<T>, cb: Function, context: T, ...args: any[]) {
this.status = EnumStatus.working;
cb.apply(context, [this.next.bind(this), ...args]);
}
cancel() {
this.status = EnumStatus.canceled;
if (this.nextInfo && typeof this.nextInfo.cancel === "function") {
this.nextInfo.cancel();
}
}
start(cb: CallbackFunction, ...args: any[]) {
if (typeof cb === "function") {
this.cb = cb;
}
if (typeof this.cb !== "function") {
throw new SyntaxError("param cb must be a function");
}
if (args.length > 0) {
this.args = args;
}
this.next();
}
continue() {
this.status = EnumStatus.initialized;
this.next();
}
}
复制代码
总结
我们总是写代码,当写了两次或者多次同样的代码,那么就应该停下来思考思考,我们是不是哪里存在问题,有没有优化的空间。
曾今就写过一个简化setTimeout调用的库timeout, 那个时候的眼界和抽象还不够。 解决的问题也很局限。
最开始是想写 面向next编程以及实战的,涉及到太多的东西,比如 redux中间件,koa中间件, express中间件原理和实现等等。
太大了把握不住,那么分而治之,才有了这篇文章。
- 可以自己实现
NextFnGenerator
,提供了比较高的定制能力 - 内置了
createRequestAnimationFrameGenerator
,createTimeoutGenerator
,createStepUpGenerator
, 开箱即用 - 初始化和next都可以调整上下文和参数,增加调用的灵活性
- 仅仅暴露
start
,cancel
,continue
, 符合最少知道原则
存在的问题:
- 超时了怎么算
- 异常了怎么算
- 同步的
Generator
怎么算
写在最后
欢迎关注专栏专栏
从问题到提问 ,一起交流和学习。
写作不易,您的一赞一评就是我前行的动力。