Let's write a small program timer management library

Posted by edspace on Tue, 23 Jun 2020 09:17:17 +0200

Author: attapulgite - Anthology

background

Attaman is a small program developer, he wants to implement the countdown of seconds in small programs. Without thinking about it, he wrote the following code:

Page({
  init: function () {
    clearInterval(this.timer)
    this.timer = setInterval(() => {
      // Countdown calculation logic
      console.log('setInterval')
    })
  },
})

However, bump man found the page hidden in the background, the timer is still running. So attapulgite man optimizes it. It runs when the page is displayed and pauses when it is hidden.

Page({
  onShow: function () {
    if (this.timer) {
      this.timer = setInterval(() => {
        // Countdown calculation logic
        console.log('setInterval')
      })
    }
  },
  onHide: function () {
    clearInterval(this.timer)
  },
  init: function () {
    clearInterval(this.timer)
    this.timer = setInterval(() => {
      // Countdown calculation logic
      console.log('setInterval')
    })
  },
})

The problem seems to have been solved. When bump man happily rubbed his little hands, he suddenly found that the onHide function might not be called when the applet page was destroyed, so the timer could not be cleaned up? That would cause a memory leak. Attapulgite man thought, in fact, the problem is not difficult to solve, in the page onUnload time also clean the timer once.

Page({
  ...
  onUnload: function () {
    clearInterval(this.timer)
  },
})

All of these problems have been solved, but we can find that we need to be very careful when using timers in small programs. If we are not careful, it will cause memory leaks.
The more timers in the background accumulate, the more small programs get stuck, the more power they consume, and eventually the programs get stuck or even crash. Especially for projects developed by teams, it's hard to make sure that every member cleans up the timers correctly. Therefore, it will be helpful to write a timer management library to manage the timer life cycle.

Train of thought arrangement

First of all, let's design the API specification of timer first. The closer to the native API, the better, so that developers can replace it painlessly.

function $setTimeout(fn, timeout, ...arg) {}
function $setInterval(fn, timeout, ...arg) {}
function $clearTimeout(id) {}
function $clearInterval(id) {}

Next, we mainly solve the following two problems

  1. How to pause and resume timer
  2. How to make developers not have to deal with timers in life cycle functions

How to pause and resume timer

The ideas are as follows:

  1. Save timer function parameters and recreate when timer is restored
  2. Since the timer ID will be different due to the re creation of the timer, you need to customize the global unique ID to identify the timer
  3. Record the remaining countdown time of timer when it is hidden, and use the remaining time to recreate timer when it is recovered

First, we need to define a Timer class. The Timer object will store Timer function parameters. The code is as follows

class Timer {
    static count = 0
    /**
     * Constructor
     * @param {Boolean} isInterval setInterval or not
     * @param {Function} fn Callback function
     * @param {Number} timeout Timer execution interval
     * @param  {...any} arg Timer other parameters
     */
    constructor (isInterval = false, fn = () => {}, timeout = 0, ...arg) {
        this.id = ++Timer.count // Timer increment id
        this.fn = fn
        this.timeout = timeout
        this.restTime = timeout // Timer remaining time
        this.isInterval = isInterval
        this.arg = arg
    }
  }
  
  // Create timer
  function $setTimeout(fn, timeout, ...arg) {
    const timer = new Timer(false, fn, timeout, arg)
    return timer.id
  }

Next, we will pause and resume the timer. The implementation idea is as follows:

  1. Start the timer, call the native API to create the timer and record the start time stamp.
  2. Pause the timer, clear the timer and calculate the remaining time of the cycle.
  3. Resume the timer, re record the start time stamp, and create the timer with the remaining time.

The code is as follows:

class Timer {
    constructor (isInterval = false, fn = () => {}, timeout = 0, ...arg) {
        this.id = ++Timer.count // Timer increment id
        this.fn = fn
        this.timeout = timeout
        this.restTime = timeout // Timer remaining time
        this.isInterval = isInterval
        this.arg = arg
    }

    /**
     * Start or resume timer
     */
    start() {
        this.startTime = +new Date()

        if (this.isInterval) {
            /* setInterval */
            const cb = (...arg) => {
                this.fn(...arg)
                /* timerId Empty means clearInterval */
                if (this.timerId) this.timerId = setTimeout(cb, this.timeout, ...this.arg)
            }
            this.timerId = setTimeout(cb, this.restTime, ...this.arg)
            return
        }
        /* setTimeout  */
        const cb = (...arg) => {
            this.fn(...arg)
        }
        this.timerId = setTimeout(cb, this.restTime, ...this.arg)
    }
    
    /* Pause timer */
    suspend () {
        if (this.timeout > 0) {
            const now = +new Date()
            const nextRestTime = this.restTime - (now - this.startTime)
            const intervalRestTime = nextRestTime >=0 ? nextRestTime : this.timeout - (Math.abs(nextRestTime) % this.timeout)
            this.restTime = this.isInterval ? intervalRestTime : nextRestTime
        }
        clearTimeout(this.timerId)
    }
}

There are several key points to prompt:

  1. When restoring the timer, we actually re create a timer. If we directly use the ID returned by setTimeout to return to the developer, the developer wants clearTimeout, which cannot be cleared at this time. Therefore, you need to define a globally unique ID internally when you create a timer object this.id = ++ Timer.count , return the ID to the developer. When developer clearTimeout, we will find the real timer ID according to the ID( this.timerId ).
  2. When timeout = 0, there is no need to calculate the remaining time; when timeout > 0, you need to distinguish between setInterval and setTimeout. setInterval needs to take the remainder of the time interval because there is a cycle.
  3. setInterval is implemented by calling setTimeout at the end of the callback function. When the timer is cleared, a flag bit should be added to the timer( this.timeId ="") means it is cleared to prevent the dead cycle.

We complete the Timer pause and resume function by implementing Timer class. Next, we need to combine the Timer pause and resume function with the life cycle of components or pages. It's better to extract it into public reusable code, so that developers don't need to process timers in the life cycle function. Looking through the official documents of the applet, I found that Behavior is a good choice.

Behavior

behaviors are features for code sharing between components, similar to "mixins" or "traits" in some programming languages.
Each behavior can contain a set of properties, data, life cycle functions and methods. When a component references it, its properties, data and methods will be incorporated into the component, and the life cycle functions will be called at the corresponding time. Each component can refer to multiple behaviors, and behaviors can refer to other behaviors.

// behavior.js  Define behavior
const TimerBehavior = Behavior({
  pageLifetimes: {
    show () { console.log('show') },
    hide () { console.log('hide') }
  },
  created: function () { console.log('created')},
  detached: function() { console.log('detached') }
})

export { TimerBehavior }

// component.js  Using behavior
import { TimerBehavior } from '../behavior.js'

Component({
  behaviors: [TimerBehavior],
  created: function () {
    console.log('[my-component] created')
  },
  attached: function () { 
    console.log('[my-component] attached')
  }
})

As shown in the above example, after a component uses timerbehavior, it will call in turn during component initialization TimerBehavior.created () => Component.created () => TimerBehavior.show ().
Therefore, we only need to call the method corresponding to Timer in the life cycle of TimerBehavior, and open the creation and destruction API of Timer to developers.
The ideas are as follows:

  1. When a component or page is created, a new Map object is created to store the timer of the component or page.
  2. When you create a Timer, save the Timer object in the Map.
  3. When the Timer is finished or cleared, the Timer object is removed from the Map to avoid memory leakage.
  4. Pause the timer in the Map when the page is hidden, and resume the timer in the Map when the page is redisplayed.
const TimerBehavior = Behavior({
  created: function () {
    this.$store = new Map()
    this.$isActive = true
  },
  detached: function() {
    this.$store.forEach(timer => timer.suspend())
    this.$isActive = false
  },
  pageLifetimes: {
    show () { 
      if (this.$isActive) return

      this.$isActive = true
      this.$store.forEach(timer => timer.start(this.$store))
    },
    hide () { 
      this.$store.forEach(timer => timer.suspend())
      this.$isActive = false
    }
  },
  methods: {
    $setTimeout (fn = () => {}, timeout = 0, ...arg) {
      const timer = new Timer(false, fn, timeout, ...arg)
      
      this.$store.set(timer.id, timer)
      this.$isActive && timer.start(this.$store)
      
      return timer.id
    },
    $setInterval (fn = () => {}, timeout = 0, ...arg) {
      const timer = new Timer(true, fn, timeout, ...arg)
      
      this.$store.set(timer.id, timer)
      this.$isActive && timer.start(this.$store)
      
      return timer.id
    },
    $clearInterval (id) {
      const timer = this.$store.get(id)
      if (!timer) return

      clearTimeout(timer.timerId)
      timer.timerId = ''
      this.$store.delete(id)
    },
    $clearTimeout (id) {
      const timer = this.$store.get(id)
      if (!timer) return

      clearTimeout(timer.timerId)
      timer.timerId = ''
      this.$store.delete(id)
    },
  }
})

There are many redundant places in the above code. We can optimize it again. Define a TimerStore class separately to manage the add, delete, resume and pause functions of components or page timers.

class TimerStore {
    constructor() {
        this.store = new Map()
        this.isActive = true
    }

    addTimer(timer) {
        this.store.set(timer.id, timer)
        this.isActive && timer.start(this.store)

        return timer.id
    }

    show() {
        /* No hiding, no need to restore timer */
        if (this.isActive) return

        this.isActive = true
        this.store.forEach(timer => timer.start(this.store))
    }

    hide() {
        this.store.forEach(timer => timer.suspend())
        this.isActive = false
    }

    clear(id) {
        const timer = this.store.get(id)
        if (!timer) return

        clearTimeout(timer.timerId)
        timer.timerId = ''
        this.store.delete(id)
    }
}

Then simplify TimerBehavior again

const TimerBehavior = Behavior({
  created: function () { this.$timerStore = new TimerStore() },
  detached: function() { this.$timerStore.hide() },
  pageLifetimes: {
    show () { this.$timerStore.show() },
    hide () { this.$timerStore.hide() }
  },
  methods: {
    $setTimeout (fn = () => {}, timeout = 0, ...arg) {
      const timer = new Timer(false, fn, timeout, ...arg)
      
      return this.$timerStore.addTimer(timer)
    },
    $setInterval (fn = () => {}, timeout = 0, ...arg) {
      const timer = new Timer(true, fn, timeout, ...arg)
      
      return this.$timerStore.addTimer(timer)
    },
    $clearInterval (id) {
      this.$timerStore.clear(id)
    },
    $clearTimeout (id) {
      this.$timerStore.clear(id)
    },
  }
})

In addition, after the Timer created by setTimeout runs, in order to avoid memory leakage, we need to remove the Timer from the Map. Slightly modify the start function of Timer as follows:

class Timer {
    // Omit some codes
    start(timerStore) {
        this.startTime = +new Date()

        if (this.isInterval) {
            /* setInterval */
            const cb = (...arg) => {
                this.fn(...arg)
                /* timerId Empty means clearInterval */
                if (this.timerId) this.timerId = setTimeout(cb, this.timeout, ...this.arg)
            }
            this.timerId = setTimeout(cb, this.restTime, ...this.arg)
            return
        }
        /* setTimeout  */
        const cb = (...arg) => {
            this.fn(...arg)
            /* At the end of operation, remove the timer to avoid memory leakage */
            timerStore.delete(this.id)
        }
        this.timerId = setTimeout(cb, this.restTime, ...this.arg)
    }
}

Use happily

From then on, the task of clearing timers is left to timer behavior management, and there is no need to worry about the small programs getting stuck.

import { TimerBehavior } from '../behavior.js'

// Use in page
Page({
  behaviors: [TimerBehavior],
  onReady() {
    this.$setTimeout(() => {
      console.log('setTimeout')
    })
    this.$setInterval(() => {
      console.log('setTimeout')
    })
  }
})

// Use in components
Components({
  behaviors: [TimerBehavior],
  ready() {
    this.$setTimeout(() => {
      console.log('setTimeout')
    })
    this.$setInterval(() => {
      console.log('setTimeout')
    })
  }
})

npm package support

In order to make developers better use of the timer management library of applets, we sorted out the code and released the npm package for developers to use. Developers can install the timer management library of applets through npm install -- save timer miniprogram. See the documentation and complete code for details https://github.com/o2team/tim...

eslint configuration

In order to make the team better comply with the timer usage specification, we can also configure eslint to add code prompts as follows:

// .eslintrc.js
module.exports = {
    'rules': {
        'no-restricted-globals': ['error', {
            'name': 'setTimeout',
            'message': 'Please use TimerBehavior and this.$setTimeout instead. see the link: https://github.com/o2team/timer-miniprogram'
        }, {
            'name': 'setInterval',
            'message': 'Please use TimerBehavior and this.$setInterval instead. see the link: https://github.com/o2team/timer-miniprogram'
        }, {
            'name': 'clearInterval',
            'message': 'Please use TimerBehavior and this.$clearInterval instead. see the link: https://github.com/o2team/timer-miniprogram'
        }, {
            'name': 'clearTimout',
            'message': 'Please use TimerBehavior and this.$clearTimout  instead. see the link: https://github.com/o2team/timer-miniprogram'
        }]
    }
}

summary

Thousands of miles of dike, collapse in the ant nest.

Mismanaged timers will drain the memory and performance of the applet a little bit and eventually crash the program.

Pay attention to timer management and keep away from timer leakage.

reference resources

Applet developer documentation

Welcome to attapulgite lab blog: aotu.io

Or focus on the official account of bump Laboratory (AOTULabs).

Welcome to attapulgite lab blog: aotu.io

Or focus on the official account of bump Laboratory (AOTULabs).

Topics: Javascript github npm Programming