原文: Learn JavaScript Object-Oriented Programming by Building a Timer Application

在这篇文章里,我们将通过创建一个简单的计时器应用来学习 JavaScript 面向对象编程。

面向对象编程是一种重要的编程范式,它将代码组织为对象,以便更好地管理和维护应用程序。

有很多文章详细地阐释面向对象编程的好处,以及如何应用它来构建应用程序。

但是,初学者可能会有疑问:为什么要应用面向对象编程?我应该何时应用它?

本文将会通过面向过程和面向对象两种设计思路来实现同样的计时器功能,帮助初学者理解这些问题。

我们将学习哪些知识

我们会讨论到

  • HTML 基础知识,例如 DOM 方法、DOM 事件
  • CSS 基础知识,例如 flex 布局
  • JavaScript 知识,例如正则表达式、class(类)、constructor() 构造函数、对象、this 关键字、setInterval() 方法、事件机制

目录

用 HTML 和 CSS 编写计时器界面

首先,我们用 HTML 和 CSS 编写一个基本的计时器界面,包含一个显示时间的区域和几个控制按钮。

timer_1

HTML 如下所示:

<!DOCTYPE html>
<html>

<head>
    <title>计时器</title>
</head>

<body>
    <div class="container">
        <h1>计时器</h1>
        <div class="ipt">
            <input id="inputh" type="number" placeholder="时">
            <input id="inputm" type="number" placeholder="分">
            <input id="inputs" type="number" placeholder="秒">
        </div>
        <div class="btn">
            <button id="btn-start" onclick="start_counting()">开始</button>
            <button id="btn-pause" onclick="pause_counting()">暂停</button>
            <button id="btn-stop" onclick="end_counting()">结束</button>
        </div>
        <p id="currentTime">当前时间:</p>
    </div>
</body>

计时器包含三个输入框,id 分别为 inputhinputminputstypenumber,允许用户输入时、分和秒的值。

输入框下方有三个按钮 button,分别控制计时器时间的开始、暂停和结束。每个按钮都有一个 onclick 事件。onclick 事件的属性值是一个函数,我们将在 JavaScript 部分写这个函数的代码。当用户点击一个按钮时,会执行相应的函数。在 JavaScript 中,我们会通过函数名调用函数,所以需要在函数名后加上括号。

你可以在这篇文章中了解 onclick 事件的更多信息。

按钮下方的 p 文本同步显示输入框中的时间。

我们给计时器添加一些简单的 CSS 代码,设置它的样式:

<style>
    .container {
        margin: 0 auto;
        width: 300px;
        height: 300px;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
    }

    .ipt {
        margin: 0 auto;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    input {
        width: 100px;
        height: 50px;
        font-size: 20px;
        text-align: center;
    }

    .btn {
        margin: 10px;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    button {
        margin: 10px 10px;
        width: 50px;
        height: 30px;
        font-size: 10px;
    }

    #currentTime {
        margin: 10px;
        color: green;
    }
</style>

我们使用 Flexbox 将元素居中。为父元素添加

display: flex;
justify-content: center;
align-items: center;

即可将子元素相对于父元素水平且垂直居中。

你可以在这篇文章里查看 CSS 布局的更多信息,也可以根据自己的喜好进一步设置计时器的样式。

接下来,我们为计时器添加 JavaScript 代码,实现开始、暂停和结束计时的功能。

通过面向过程编程实现计时器

实现基本功能

这段代码展示了一个以事件驱动、面向过程思路设计的计时器程序,主要包括:

  • 2 组全局变量:时/分/秒的存储变量、setInterval() 函数的返回值
  • 4 个关键函数:开始计时按钮按下后的事件处理函数、暂停计时按钮按下后的事件处理函数、结束计时按钮按下后的事件处理函数、计时过程中被循环调用的计时动作执行函数

首先,用 DOM 方法 document.getElementById().disabled 初始化按钮状态。

// 初始化按钮状态
document.getElementById("btn-pause").disabled = true;
document.getElementById("btn-stop").disabled = true;

接着定义全局变量以存储时、分、秒。

// 定义全局变量
var timer = null; // 用于存储计时器的返回值
var h = 0; // 用于存储小时
var m = 0; // 用于存储分钟
var s = 0; // 用于存储秒数

在开始计时的 start_counting() 函数中,我们通过 document.getElementById().value 获取 id 分别为 inputhinputminputs 的元素的值,即用户在计时器输入框中输入的时、分、秒的值,并赋值给 hms

然后通过 if 语句判断用户输入的时、分、秒的值,如果值都等于 0,或者其中一个值小于 0,则弹出提示输入的时间不合法!并返回,程序停止执行。

为计时器变量 timer 赋值 setInterval()。这个方法有两个参数,第一个参数是一个函数,第二个参数是一个单位为”毫秒“的时间。在这个例子里,我们规定计时器每隔 1000 毫秒(即 1 秒)执行一个函数 counting,将在下方介绍。

关于 setInterval() 方法的详细信息,你可以查看 MDN 文档

之后,改变按钮和输入框的状态,禁止用户再次输入。

// 定义函数
// 开始计时
function start_counting() {
    // 获取输入的时间,补充默认值
    h = +document.getElementById("inputh").value || h;
    m = +document.getElementById("inputm").value || m;
    s = +document.getElementById("inputs").value || s;

    // 判断输入的时间是否合法
    if (
        (h == 0 && m == 0 && s == 0) ||
        (h < 0 || m < 0 || s < 0)
    ) {
        alert("输入的时间不合法!");
        return;
    }

    // 开始计时
    timer = setInterval(counting, 1000);

    // 改变按钮和输入框的状态,禁止用户再次输入
    document.getElementById("btn-start").disabled = true;
    document.getElementById("btn-pause").disabled = false;
    document.getElementById("btn-stop").disabled = false;
    document.getElementById("inputh").disabled = true;
    document.getElementById("inputm").disabled = true;
    document.getElementById("inputs").disabled = true;
}

接着,在负责暂停计时的 pause_counting() 函数中,设置按钮和输入框在计时暂停时的状态,并调用 clearInterval() 以移除计时器,停止计时。

// 暂停计时
function pause_counting() {
    // 改变按钮和输入框的状态,允许用户再次输入
    document.getElementById("btn-start").disabled = false;
    document.getElementById("btn-pause").disabled = true;
    document.getElementById("btn-stop").disabled = false;
    document.getElementById("inputh").disabled = false;
    document.getElementById("inputm").disabled = false;
    document.getElementById("inputs").disabled = false;

    // 暂停计时
    clearInterval(timer);
}

end_counting() 函数使计时停止,同样调用 clearInterval(),并将时、分、秒重置为 0,下方的文本“当前时间:”被更新为“计时已结束”。

// 结束计时
function end_counting() {
    // 改变按钮和输入框的状态,允许用户再次输入
    document.getElementById("btn-start").disabled = false;
    document.getElementById("btn-pause").disabled = true;
    document.getElementById("btn-stop").disabled = true;
    document.getElementById("inputh").disabled = false;
    document.getElementById("inputm").disabled = false;
    document.getElementById("inputs").disabled = false;

    // 结束计时
    clearInterval(timer);

    // 重置时间变量
    h = 0;
    m = 0;
    s = 0;
    document.getElementById("currentTime").innerHTML = "计时已结束";
}

接下来是 counting() 函数,它是 setInterval() 中调用的函数,通过 if 语句判断时、分或秒是否为 0,并执行相应的动作。这是对于计时器的常规的实现方式,结合我们在日常生活中的经验,也容易理解,例如:当秒数为 0 时,从分钟数借 1,秒数变成 59。

setInterval() 方法每秒更新一次 hms 的值。我们通过 document.getElementById().innerHTML 把更新后的时间同步显示在”当前时间:“文本中。

最后,又通过 if 语句判断时、分、秒的值,当三个值都为 0 时,执行 end_counting() 函数和 setTimeout() 函数。在 setTimeout() 函数里,执行弹窗提示“时间到!”。

这里有个有趣的知识,你可以试试删除 setTimeout(),直接在 end_counting() 后面执行 alert("时间到!"),会发现弹窗阻塞了 DOM 渲染——即先弹出“时间到!”,再改变按钮和输入框的状态。而当我们使用 setTimeout(),这两个动作是同时发生的。你可以思考一下 setTimeout() 在这里起了什么作用。

如果你想了解更多 setTimeout() 的用法,可以查看 freeCodeCamp 的这篇文章

// 计时
function counting() {
    // 判断秒数是否为 0
    if (s == 0) {
        // 秒数为 0 时,判断分钟是否为 0
        if (m == 0) {
            // 启动计时前已经检查过输入的时间是否合法,所以这里不需要再检查变量 h 的值
            h--;
            m = 59;
            s = 59;
        } else {
            // 分钟不为 0 时,分钟减 1,秒数变为 59
            m--;
            s = 59;
        }
    } else {
        // 秒数不为 0 时,秒数减 1
        s--;
    }

    // 显示当前时间
    document.getElementById("currentTime").innerHTML = "当前时间:" + h + " 时 " + m + " 分 " + s + " 秒";
    document.getElementById("inputh").value = h;
    document.getElementById("inputm").value = m;
    document.getElementById("inputs").value = s;

    // 判断秒数是否为 0
    if (s == 0) {
        // 秒数为 0 时,判断分钟是否为 0
        if (m == 0) {
            // 分钟为 0 时,判断小时是否为 0
            if (h == 0) {
                // 小时为 0 时,结束计时
                // 停止计时
                end_counting();
                // 在下一个事件循环里执行弹窗,防止弹窗阻塞 DOM 渲染
                setTimeout(function () {
                    alert("时间到!");
                }, 0);
                return;
            }
        }
    }
}

有时用户可能会在时、分、秒输入框中输入负数,我们的代码通过弹出“输入的时间不合法!”来提醒用户;有时用户可能输入小时数大于 24、分数和秒数大于 59,这不符合实际情况。

此外,为了让时间显示更美观,我们可能想将时、分、秒显示为两位数字。

我们可以对以上代码做两处优化。

有时用户可能会在时、分、秒输入框中输入负数,我们的代码通过弹出“输入的时间不合法!”来提醒用户;有时用户可能输入小时数大于 24、分数和秒数大于 59,这不符合实际情况。

此外,为了让时间显示更美观,我们可能想将时、分、秒显示为两位数字。

我们可以对以上代码做两处优化。

限制时、分、秒数字的输入范围

当输入的小时数大于 24 时,将它自动修改为 24;当它小于 0 时,修改为 0。

当输入的分数和秒数大于 59 时,将它们自动修改为 59;当它们小于 0 时,修改为 0。

这里用到事件监听方法 addEventListener(),在 input 事件发生时执行函数。还用到 parseInt() 将输入的值转换为数字类型。

var inputh = document.getElementById("inputh");
inputh.addEventListener("input", function() { 
    inputh.value = parseInt(inputh.value||0);
    if (inputh.value > 24) inputh.value = 24;
    if (inputh.value < 0) inputh.value = 0;
});

var inputm = document.getElementById("inputm");
inputm.addEventListener("input", function() {
    inputm.value = parseInt(inputm.value||0);
    if (inputm.value > 59) inputm.value = 59;
    if (inputm.value < 0) inputm.value = 0;
});

var inputs = document.getElementById("inputs");
inputs.addEventListener("input", function() {
    inputs.value = parseInt(inputs.value||0);
    if (inputs.value > 59) inputs.value = 59;
    if (inputs.value < 0) inputs.value = 0;
});

优化时、分、秒数字的格式

当小时数、分数或秒数为一位数字时,通过正则表达式给它们前面加上 0。

h = h.toString();
m = m.toString();
s = s.toString();
if (h.match(/^\d$/)) { //如果时为个位数,则在前面加 0
    h = "0" + h;
}
if (m.match(/^\d$/)) { //如果分为个位数,则在前面加 0
    m = "0" + m;
}
if (s.match(/^\d$/)) { //如果秒为个位数,则在前面加 0
    s = "0" + s;
}
pp

你可以在 CodePen 查看在线 demo

See the Pen timer_procedural programming by miyaliu666 (@miyaliu666-the-styleful) on CodePen.

我们成功实现了一个计时器!

你可能会想,如果要在一个项目中添加多个计时器呢?

似乎需要给各个计时器的 input 输入框设置不同的 id,以便 document.getElementById().value 获取相应的值。

比如,对于 idinputh1 的一号计时器,我们在 start_counting() 赋值 h1 = +document.getElementById("inputh1").value,而对于 idinputh2 的二号计时器,赋值为 h2 = +document.getElementById("inputh2").value,以此类推。

而实际的项目业务通常不会只是创建一堆计时器这么简单。可以想象,代码很容易变得冗长而混乱。

这个时候,就需要引入面向对象编程了。

通过面向对象编程实现计时器

你可能在某些场合听过开发者们说 “new 一个对象”,这背后就是面向对象编程的思想。

在这一节里,我们以面向对象的思想重构上一节的计时器程序,将“计时器业务功能”和“用户界面的交互”分离开来。

HTML 和 CSS 部分和上一节类似,这里不再赘述。你可以在这个 CodePen demo 中看到全部代码。

See the Pen timer_object-oriented-programming by miyaliu666 (@miyaliu666-the-styleful) on CodePen.

如果你学习过 freeCodeCamp 的面向对象编程课程,一定记得:所谓对象,可以映射到我们在现实生活中见到的事物,比如汽车、商场和小鸟,具有属性和方法。

那就让我们从定义对象的属性和方法开始吧:)

新建计时器类

class 关键字创建一个类,命名为 Timer,它具有构造函数 constructor()_on_update()start()stop()pause()show() 方法。

<script>
    class Timmer {
        constructor() {
            this.name = '未命名计时器';
            this.timmer = undefined;
            this.h = 0;
            this.m = 0;
            this.s = 10;

            this._on_update_callback = undefined;
            this._on_stop_callback = undefined;
        }

        _on_update() {

        }

        start() {

        }

        stop() {

        }

        pause() {

        }

        show() {

        }
    }

</script>

根据 MDN 文档

constructor() 是一种用于创建和初始化 class 创建的对象的特殊方法。

我们在其中通过 this 关键字初始化几个变量,即对象的属性。在 JavaScript 中,this 的指向是随环境而变的。在这里,它指的是调用函数的对象,即 Timer 的实例。同时用 this 初始化计时器更新时的回调函数 _on_update_callback 和计时器停止时的回调函数 _on_stop_callback

_on_update()start()stop()pause()show() 方法分别用于计时器更新、开始计时、停止计时、暂停计时和显示当前时间。我们将逐步为它们添加代码。

计时器更新

_on_update() 方法里处理计时器更新时的程序。和上文一样,仍然通过 if 语句判断时分秒为 0 时执行相应的动作。

如果外部回调函数 _on_update_callback 存在,则调用它。

_on_update() { 
    if (0 === this.h && 0 === this.m && 0 === this.s) {
        this.stop();
        return;
    } else if (0 === this.s) {
        this.s = 59;
        if (0 === this.m) {
            this.m = 59;
            this.h = this.h - 1;
        } else {
            this.m = this.m - 1;
        }
    } else {
        this.s = this.s - 1;
    }

    this.show();
    if (0 === this.h && 0 === this.m && 0 === this.s) {
        this.stop();
    }

    // 检查外部回调函数是否存在,如果存在就调用
    if (this._on_update_callback && typeof this._on_update_callback === 'function') {
        this._on_update_callback();
    }
}

开始计时

start() 方法用于开始计时,即用户点击“开始”按钮后执行这个方法。通过 setInterval() 方法,每秒钟执行一次 _on_update()

start() {
    if (this.timmer) {
        console.log(`[${this.name}]已经在计时了`);
        return;
    }
    console.log(`[${this.name}]开始计时`);
    this.timmer = setInterval(() => {
        this._on_update();
    }, 1000);
    this.show();
}

停止计时

stop() 方法用于停止计时。使用 clearInterval() 停止计时器。如果外部回调函数 _on_stop_callback() 存在,则调用它。

stop() {
    console.log(`[${this.name}]停止计时`);
    clearInterval(this.timmer);
    this.timmer = undefined;

    // 和 update 类似,检查 stop 函数
    if (this._on_stop_callback && typeof this._on_stop_callback === 'function') {
        this._on_stop_callback();
    }
}

暂停计时

使用 pause() 方法暂停计时,在其中使用 clearInterval() 停止计时器。

pause() {
    console.log(`[${this.name}]暂停计时`);
    clearInterval(this.timmer);
    this.timmer = undefined;
}

显示当前时间

show() 方法用于在控制台打印当前时间。

show() { // 显示当前时间
    console.log(`[${this.name}]当前时间:${this.h}:${this.m}:${this.s}`);
}

创建对象实例

接下来,用 new 关键字创建两个对象实例,即两个计时器,它们具有 Timer 对象的属性和方法。

根据 MDN 文档

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

把两个对象分别赋值给 t1t2,并把它们放入数组 list_timmer。另外,创建一个声音数组 list_sound,稍后我们会用到里面的声音。

const t1 = new Timmer();
t1.name = '计时器一';
const t2 = new Timmer();
t2.name = '计时器二';
const list_timmer = [t1, t2];
const list_sound = ['miao', 'wang'];

通过函数实现用户界面的交互

然后创建 6 个函数。

第一个函数 play_audio(),具有一个参数。函数在 DOM 中创建一个 audio 元素,把它赋值给 audio。然后设置 src 属性值为一个模板字面量,并调用它的 play() 方法以播放提示音。

function play_audio(sound) {
    // miao.mp3 和 wang.mp3 是两个本地文件,需要放在和 HTML 文件同一目录下
    const audio = document.createElement('audio');
    audio.src = `${sound}.mp3`;
    audio.play();
}

第二个函数 btn_start_onclick(),具有一个参数 i。在两个计时器的“开始”按钮被点击时调用这个函数,传入参数为 1 或 2:

<input id="tmr-1-btn-start" class=" btn" type="button" value="开始" onclick="btn_start_onclick(1)" />
<input id="tmr-2-btn-start" class="btn" type="button" value="开始" onclick="btn_start_onclick(2)" />

函数首先获取输入框的值,并赋值给计时器,然后通过 dom_update_inputs() 函数设置输入框和按钮的状态。稍后我们将设置 dom_update_inputs()

还记得我们在一开始初始化了两个回调函数吗?这里我们将给它们赋值一个箭头函数。

_on_stop_callback 执行回调函数 dom_update_inputs,下方会讲到。

这里也执行回调函数 play_audio(),参数为 list_sound[i - 1],即从前面定义的数组 list_sound 中获取元素 miaowang,传给 audiosrc 属性,当计时器停止计时时播放相应的提示音。例如,当 i 为 1 时,audio.src = miao.mp3;

_on_update_callback 执行回调函数 dom_update_timmer(),我们很快就会讲到这个函数。

通过 const tmr = list_timmer[i - 1]; 从计时器数组中取出对应的计时器,执行 start() 方法以开始计时。

function btn_start_onclick(i) {
    // 获取输入框的值
    const ipt_h = document.getElementById(`ipt-${i}-h`);
    const ipt_m = document.getElementById(`ipt-${i}-m`);
    const ipt_s = document.getElementById(`ipt-${i}-s`);

    // 设置输入框和按钮状态
    dom_update_inputs(i, "COUNTING");
    // 从计时器数组中取出对应的计时器
    const tmr = list_timmer[i - 1];
    // 将输入框的值赋值给计时器
    tmr.h = Number(ipt_h.value);
    tmr.m = Number(ipt_m.value);
    tmr.s = Number(ipt_s.value);

    // 设置回调函数
    tmr._on_stop_callback = () => {
        // 播放提示音
        play_audio(list_sound[i - 1]);
        // 设置输入框和按钮状态
        dom_update_inputs(i, "STOPPED");
    }
    tmr._on_update_callback = () => {
        dom_update_timmer(i);
    }
    // 开始计时
    tmr.start();
}

第三个函数 btn_pause_onclick() 具有一个参数,在两个计时器的“暂停”按钮被点击时调用这个函数,传入参数为 1 或 2。同样执行回调函数 dom_update_inputs 以设置计时暂停时输入框和按钮的状态,并执行 pause() 方法暂停计时。

function btn_pause_onclick(i) {
    dom_update_inputs(i, "PAUSED");

    // 从计时器数组中取出对应的计时器
    const tmr = list_timmer[i - 1];

    // 暂停计时
    tmr.pause();
}

第四个函数 btn_stop_onclick() 和第三个函数类似,在两个计时器的“停止”按钮被点击时调用,设置计时停止时输入框和按钮的状态,然后执行 stop() 方法以停止计时。

function btn_stop_onclick(i) {
    dom_update_inputs(i, "STOPED");

    // 从计时器数组中取出对应的计时器
    const tmr = list_timmer[i - 1];


    // 停止计时
    tmr.stop();
}

第五个函数 dom_update_inputs() 具有两个参数,istatus,通过 if...else if... 语句设置当 status 为不同关键字时输入框和按钮的状态。当这个函数在上述第二、三、四个函数中被调用时,即定义计时器在开始、暂停和停止时输入框和按钮的状态。

function dom_update_inputs(i, status) {
    if ('COUNTING' === status) {
        // 设置输入框状态
        document.getElementById(`ipt-${i}-h`).disabled = true;
        document.getElementById(`ipt-${i}-m`).disabled = true;
        document.getElementById(`ipt-${i}-s`).disabled = true;

        // 设置按钮的状态
        document.getElementById(`tmr-${i}-btn-start`).disabled = true;
        document.getElementById(`tmr-${i}-btn-pause`).disabled = false;
        document.getElementById(`tmr-${i}-btn-stop`).disabled = false;
    } else if ('PAUSED' === status) {
        // 设置输入框状态
        document.getElementById(`ipt-${i}-h`).disabled = false;
        document.getElementById(`ipt-${i}-m`).disabled = false;
        document.getElementById(`ipt-${i}-s`).disabled = false;

        // 设置按钮的状态
        document.getElementById(`tmr-${i}-btn-start`).disabled = false;
        document.getElementById(`tmr-${i}-btn-pause`).disabled = true;
        document.getElementById(`tmr-${i}-btn-stop`).disabled = false;
    } else if ('STOPPED' === status) {
        // 设置输入框状态
        document.getElementById(`ipt-${i}-h`).disabled = false;
        document.getElementById(`ipt-${i}-m`).disabled = false;
        document.getElementById(`ipt-${i}-s`).disabled = false;

        // 设置按钮的状态
        document.getElementById(`tmr-${i}-btn-start`).disabled = false;
        document.getElementById(`tmr-${i}-btn-pause`).disabled = true;
        document.getElementById(`tmr-${i}-btn-stop`).disabled = true;
    }
}

第六个函数 dom_update_timmer() 的作用是将计时器的时间同步到页面。

function dom_update_timmer(i) {
    // 从计时器数组中取出对应的计时器
    const tmr = list_timmer[i - 1];

    // 将计时器的时间同步到页面
    document.getElementById(`ipt-${i}-h`).value = tmr.h;
    document.getElementById(`ipt-${i}-m`).value = tmr.m;
    document.getElementById(`ipt-${i}-s`).value = tmr.s;
} 

以上,我们将“计时器业务功能”封装到了 Timmer 类中,将“用户界面的交互”保留在全局作用域中,借助于 Timmer 类的实例,实现多个计时器同时运行。

oop

在这一节里,我没有设置限制时、分、秒数字的输入范围和优化时、分、秒数字的格式,如果你感兴趣,可以参考上一节的代码,在 CodePen demo 中自己动手实现:)

进一步思考,如果我们的项目除了这一组两个计时器,还有别的功能模块,例如,两组计时器,是同一个类型 Timer 的对象实例,在为回调函数 _on_stop_callback 赋值的时候,一组实例需要通过 play_audio() 函数播放提示音,而另一组则需要通过另一个函数设置计时器的颜色,这时候,第二次赋的值就会覆盖第一次赋的值。

我们引入事件机制来解决这个问题。

在面向对象编程中添加事件机制

这一节代码在上一节的基础上,添加事件机制代替原来的回调函数。事件机制好处是,当计时器的状态发生变化时,可以通知多个其他对象。对于我们的例子,当计时器的状态发生变化时,会通知页面上的按钮,让按钮的状态同步发生变化。

同样,HTML 和 CSS 部分和第一节类似,这里不再赘述。

你可以在这个 CodePen demo 中看到全部代码。

See the Pen timer_oop-with-events by miyaliu666 (@miyaliu666-the-styleful) on CodePen.

创建事件发生器

首先,我们新建一个类 EventEmitter,这是一个事件发射器,用于实现事件机制,在本例中用于实现计时器的状态变化通知。

其中,on 用于监听(订阅)事件,当事件发生时,执行回调函数,回调函数的参数是事件的参数,回调函数的 this 指向事件的触发者。

emit 用于发射(抛出)事件。

removeListener 用于移除某个事件的某个监听器。

class EventEmitter {
    constructor() {
        this._events = {};
    }

    on(type, listener) {
        if (this._events[type]) {
            this._events[type].push(listener);
        } else {
            this._events[type] = [listener];
        }
    }

    emit(type, ...args) {
        if (this._events[type]) {
            this._events[type].forEach(listener => {
                listener(...args);
            });
        }
    }

    removeListener(type, listener) {
        if (this._events[type] && listener) {
            this._events[type] = this._events[type].filter(l => l !== listener);
        } else if (this._events[type] && !listener) {
            this._events[type] = [];
        }
    }
}

新建计时器类

这里,我们新建一个类 Timmerextends 关键字表示 TimmerEventEmitter 类的子类。子类继承父类的所有属性和方法。

关于 extends 关键字的更多信息,你可以在这里查看。

class Timmer extends EventEmitter {
    constructor() {
        super();
        this.name = '未命名计时器';
        this.timmer = undefined;
        this.h = 0;
        this.m = 0;
        this.s = 10;
    }

    _on_update() {
        if (0 === this.h && 0 === this.m && 0 === this.s) {
            this.stop();
            return;
        } else if (0 === this.s) {
            this.s = 59;
            if (0 === this.m) {
                this.m = 59;
                this.h = this.h - 1;
            } else {
                this.m = this.m - 1;
            }
        } else {
            this.s = this.s - 1;
        }

        this.show()
        // 抛出事件
        this.emit('update', {
            h: this.h,
            m: this.m,
            s: this.s
        });
        if (0 === this.h && 0 === this.m && 0 === this.s) {
            this.stop();
        }
    }

    start() {
        if (this.timmer) {
            console.log(`[${this.name}]已经在计时了`);
            return;
        }
        console.log(`[${this.name}]开始计时`);
        this.timmer = setInterval(() => {
            this._on_update();
        }, 1000);
        this.show();

        // 抛出事件
        this.emit('start', {
            h: this.h,
            m: this.m,
            s: this.s
        });
    }

    stop() {
        console.log(`[${this.name}]停止计时`);
        clearInterval(this.timmer);
        this.timmer = undefined;

        // 抛出事件
        this.emit('stop', {
            h: this.h,
            m: this.m,
            s: this.s
        });
    }

    pause() {
        console.log(`[${this.name}]暂停计时`);
        clearInterval(this.timmer);
        this.timmer = undefined;

        // 抛出事件
        this.emit('pause', {
            h: this.h,
            m: this.m,
            s: this.s
        });
    }

    show() {
        console.log(`[${this.name}]当前时间:${this.h}:${this.m}:${this.s}`);
    }
}

你可以看到这段代码的注释中有四处“抛出事件”。emit 方法分别抛出 updatestartstoppause 四个事件,将计时器内部的变化发射出去。所有订阅这些事件的对象都会执行相应的回调函数。

创建对象实例

同样,我们新建两个计时器对象实例 t1t2,以及储存计时器和声音的数组,赋值给 list_timmerlist_sound

const t1 = new Timmer();
t1.name = '计时器一';
const t2 = new Timmer();
t2.name = '计时器二';
const list_timmer = [t1, t2];
const list_sound = ['miao', 'wang'];
const list_sound_str = ['🐱喵~~~', '🐶汪~汪~汪~'];

通过函数实现用户界面的交互

和上一节一样,这里需要创建六个函数。

其中,play_audio()btn_pause_onclickbtn_stop_onclickdom_update_inputs()dom_update_timmer() 五个函数和上一节相同。

我们以 btn_start_onclick() 函数为例说明事件订阅的机制。

function btn_start_onclick(i) {
    // 获取输入框的值
    const ipt_h = document.getElementById(`ipt-${i}-h`);
    const ipt_m = document.getElementById(`ipt-${i}-m`);
    const ipt_s = document.getElementById(`ipt-${i}-s`);

    // 设置输入框和按钮状态
    dom_update_inputs(i, "COUNTING");

    // 从计时器数组中取出对应的计时器
    const tmr = list_timmer[i - 1];
    // 将输入框的值赋值给计时器
    tmr.h = Number(ipt_h.value);
    tmr.m = Number(ipt_m.value);
    tmr.s = Number(ipt_s.value);

    // 订阅计时器的 update 事件,同步显示时间到页面
    tmr.removeListener('update');
    tmr.removeListener('stop');
    tmr.on('update', () => dom_update_timmer(i));
    tmr.on('stop', () => {
        console.log(list_sound_str[i - 1]);
    });
    tmr.on('stop', () => {
        // 播放提示音
        play_audio(list_sound[i - 1]);
        // 设置输入框和按钮状态
        dom_update_inputs(i, "STOPPED");
    });

    // 开始计时
    tmr.start();
}

这个函数是在用户点击“开始”按钮时被调用的,它通过 on() 方法订阅了 update 事件,执行 dom_update_timmer() 回调函数。

并且,它订阅了两次 stop 事件,执行不同的功能模块,一次是在控制台打印声音文字,一次是播放提示音与设置输入框和按钮状态,互不干扰。这就是事件机制相对于上一节中 _on_stop_callback 回调函数的优势。

注意开头的 tmr.removeListener('update');tmr.removeListener('stop');,这是为了在每次执行 this.start() 时移除事件监听器(如有)。

总结

在这篇文章中,我们对比了基于面向过程和面向对象两种编程思想创建计时器,并在面向对象编程中添加事件机制,逐步探索编程范式的最佳实践。如果你觉得某些代码块不容易理解,建议你多花点时间理解它,并且尝试自己写几遍。

如果你想就本文的内容和我讨论或者给我提出建议,欢迎你在 freeCodeCamp 论坛给我发送消息,我的 id 是 miyaliu。

谢谢你阅读本文,happy coding!

封面图 by Yogendra Singh on Unsplash