依赖反转 + 迭代器思想,实现setTimeout面向next编程

4,203 阅读8分钟

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,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);

image.png

定时器

前端常见三大定时器setTimeout, setInterval, requestAnimationFrame

setInterval的坑不是本文讨论的重点,所以剩下的选择是 setTimeout, requestAnimationFrame

有很多时候,我们需要多次调用定时器,比如验证码倒计时,canvas绘制。 基本都是处理完数据后,进入下一个周期, 我们一起看看例子。

定时器应用

setTimeout

我们用原生代码实现一个60秒倒计时,并支持暂停,继续的功能,来看一看代码: 大概是下面这个样子:

image.png

 <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>

有没有,什么问题? 我觉得有,

  1. INTERVAL,ticketsetTimeout满天飞, 不够高雅,我们应该更关心业务的处理;
  2. 有多处类似的逻辑,就得重复的写setTimeout,缺少复用;
  3. 语义不好

当然,大家肯定都有自己的封装,我这里要解决的是定时器的封装,与页面和逻辑无关。

我们不妨再看一段代码:
一样的功能,看起来简洁很多,而且语义很清晰。

  • 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绘制的例子,我们每隔一个绘制周期,就把当前的时间戳画在画布上。 大概是这个样子:

image.png 同样的,可以暂停和继续。

  • 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();
    });

这里大家都注意到了,createTimeoutGeneratorcreateRequestAnimationFrameGenerator 是关键,是魔法关键,我们来揭开面纱。

createTimeoutGenerator 的背后

因标题太长,应该是createTimeoutGeneratorcreateRequestAnimationFrameGenerator的背后。

createTimeoutGenerator的代码:

其内部构造了一个具有 executecancel属性的对象,然后实例化了一个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

看完了createTimeoutGeneratorcreateRequestAnimationFrameGenerator。 你是不是可以大胆的认为,只要我构造一个对象有executecancel方法,就能弄出一个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();  
})

image.png

如你所愿,现在你可以为所欲为,你要你想得到,不管是 setTimeout, requestAnimationFramePromise, async/await等等,你都可以用来创造一个属于你自己节拍的定时器。

宏观思路

分析到这,这里说一下思路

  1. 面向next编程
  2. 依赖反转
  3. 组合优先于继承

面向next编程(迭代器)

这个叫,纯属我个人喜欢。 其属于迭代器模式。

我们调用一次后,需要在一定的时机后调用下一次,是不是 next 呢?

前端原生自带的有:

  1. Iterator
  2. Generator

可能有些人记不得了,我贴个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

至于后台服务的 expresskoa,大家都熟悉,就不提了。

依赖反转

引用 王争 设计模式之美里面的话

高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。

NextGenerator 就是高层模块,我们编写的具有executecancel属性的对象是低层模块。

NextGenerator 和具有executecancel属性的对象并没有直接的依赖关系,两者都依赖同一个“抽象”。

我们用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")

仔细看代码注释:

  1. this 等于 context
  2. 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

状态

我们实现说明一些规则

  1. cancel 之后, next 不会触发下一次, 只能调用continue 恢复;
  2. 执行函数中,多次调用 next 只会生效一次

基于上,我们大致有几种关键状态

  1. 等待中,已经请求计划
  2. 执行中
  3. 取消

缓存参数

通过上面的代码,我们得知,我们是可以传递上下文和参数的,也还可以通过next的参数覆盖的,所以我们要缓存这些参数。

上下文

更改函数的上下文有多种手段:

  1. 绑定到一个对象上
  2. call
  3. apply
  4. 箭头函数
  5. bind
  6. 其他

我们这里采用的是bind,因为其返回的依旧是一个函数,提供了更多的操作空间。

代码全文

源码导读:

  1. 其最核心的代码是就是next方法 其调用了NextFnGenerator实例生成了一个新的对象NextFnInfo的实例,其提供了获取下一次执行计划和取消下一次执行计划的方法。

  2. 其最精彩的是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中间件原理和实现等等。

太大了把握不住,那么分而治之,才有了这篇文章。

  1. 可以自己实现NextFnGenerator,提供了比较高的定制能力
  2. 内置了createRequestAnimationFrameGenerator, createTimeoutGenerator, createStepUpGenerator, 开箱即用
  3. 初始化和next都可以调整上下文和参数,增加调用的灵活性
  4. 仅仅暴露 start, cancel, continue, 符合最少知道原则

存在的问题:

  1. 超时了怎么算
  2. 异常了怎么算
  3. 同步的Generator怎么算

写在最后

欢迎关注专栏 从问题到提问 ,一起交流和学习。

写作不易,您的一赞一评就是我前行的动力。