前言
本着遇到问题,解决问题,记录方案,思考问题的原则,写一个专栏 从问题到提问, 欢迎大家关注。
上一篇专栏的文章是 两个数组数据的高效合并方案。
先举个定时器的例子
每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
怎么算
写在最后
欢迎关注专栏 从问题到提问 ,一起交流和学习。
写作不易,您的一赞一评就是我前行的动力。