April 20th, 2021

JavaScript: класс VS функция-конструктор

Прочел подраздел 9.1 «Класс: базовый синтаксис» учебника по JavaScript:
https://learn.javascript.ru/class

Для создания множества объектов одного вида в учебнике ранее предлагалось использовать функцию-конструктор, о которой рассказывалось в подразделе 4.5 «Конструкторы, создание объектов через "new"» учебника:
https://learn.javascript.ru/constructor-new

Классы, в принципе, используются для того же.

К подразделу 9.1 имеется только одна задача «Перепишите класс», в которой задан класс Clock, написанный с помощью функции-конструктора. Требуется переписать этот класс с помощью синтаксиса, использующего ключевое слово class.

Сама постановка задачи мне нравится. Если в языке есть два способа сделать одно и то же, то неплохо было бы знать, чем эти способы отличаются друг от друга и чем отличаются друг от друга полученные с помощью этих способов результаты. Однако, как оказалось, изложенного в подразделе недостаточно (по крайней мере, мне) для решения этой задачи с налёта. Думаю, подраздел 9.1 учебника требует дальнейшей доработки.

Формулировка самой задачи тоже вызывает вопросы. В тексте задачи код класса Clock не задан и сразу непонятно, где его искать (в англоязычной версии этой задачи, кстати, сказано, что этот код находится в песочнице). В итоге извлекаем код из песочницы:

0. Исходный код
//function Clock({ template }) {
function Clock( template ) {

    let timer;

    function render() {
        let date = new Date();

        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;

        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;

        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;

        let output = template
            .replace('h', hours)
            .replace('m', mins)
            .replace('s', secs);

        console.log(output);
    }

    this.stop = function() {
        clearInterval(timer);
    };

    this.start = function() {
        render();
        timer = setInterval(render, 1000);
    };

}
  
//let clock = new Clock({template: 'h:m:s'});
let clock = new Clock('h:m:s');
clock.start();

В этом коде объявляется функция-конструктор Clock, в теле которой объявлены переменная timer и три функции render, this.stop и this.start. Далее с помощью этой функции-конструктора создается объект clock, после чего запускается метод этого объекта clock.start. При запуске этого скрипта в браузерном окружении работы этого скрипта не видно, так как на экран ничего не выводится. Однако, если после запуска скрипта открыть консоль разработчика, то в ней можно увидеть как тикают созданные нами часы: это реализовано бесконечным выводом одной за другой строк с текущим временем. Новые строки в консоль выводятся каждую секунду.

Разобраться в работе этого кода несложно, если вы читали предыдущие подразделы учебника.

Единственное, что меня сначала ввело в ступор, это передача параметра template в функцию-конструктор в первой строке кода:
function Clock({ template }) {

Я не смог сразу понять, зачем тут нужны фигурные скобки вокруг параметра template. Но в комментариях к подразделу другие читатели учебника это тоже заметили и подсказали: об этом в учебнике уже рассказывалось в подразделе 5.10 «Деструктурирующее присваивание». Синтаксис с деструктуризацией объекта, как в данном случае, может использоваться для создания умных параметров функции. Однако, это полезно, если у функции много параметров и часть из них имеет значения по умолчанию. В данном же случае параметр только один (строка-шаблон), поэтому синтаксис с деструктуризацией объекта тут не нужен, а для данной задачи он не имеет никакого значения. Поэтому я переписал первую строчку кода и предпоследнюю строчку кода, чтобы избавиться от синтаксиса с деструктуризацией объекта (первоначальные варианты остались в коде, но я их закомментировал).

Итак, начнем переписывать класс. Понятно, что нужно применить ключевое слово class, создать метод constructor, в который нужно передать параметр template, а методы this.stop и this.start легко переделать в методы класса:

1.
class Clock {

    constructor(template) {}

    stop() {
        clearInterval(timer);
    }

    start() {
        render();
        timer = setInterval(render, 1000);
    }

}

Но что делать с переменной timer и вспомогательной функцией render? Почему бы не оставить их, как есть, ведь класс — это разновидность функции в языке JavaScript (так сказано в подразделе 9.1 учебника), а в теле функции можно объявлять локальные переменные и локальные функции. Однако, как выяснилось, в теле класса такие объявления запрещены. Например, попробуем запустить в браузере следующие куски кода:

class Clock {

    let timer;

}
или
class Clock {

    function render() {}

}

Мой браузер даже не может отобразить такой скрипт в консоли разработчика и выдает ошибку «Uncaught SyntaxError: Unexpected identifier».

После этого я решил, что вспомогательную функцию render тоже можно сделать методом класса. А переменную timer можно сделать свойством класса. Тогда получается следующее:

2.
class Clock {

    timer;

    constructor(template) {}

    render() {
        let date = new Date();

        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;

        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;

        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;

        let output = template
            .replace('h', hours)
            .replace('m', mins)
            .replace('s', secs);

        console.log(output);
    }

    stop() {
        clearInterval(timer);
    }

    start() {
        render();
        timer = setInterval(render, 1000);
    }

}

let clock = new Clock('h:m:s');
clock.start();

Синтаксически данный вариант кода класса уже не вызовет ошибки и объект clock будет успешно создан. Но при запуске метода clock.start выясняется, что внутри этого метода не видно метода render, что приводит к ошибке.

На самом деле, как я понял, обращение к методам и свойствам класса из других методов этого же класса должно происходить через переменную this. В подразделе 9.1 об этом прямо не сказано, но показано в примерах. В языке C++, который я изучал ранее, в методах класса можно спокойно обращаться к другим методам и свойствам класса напрямую. Ладно, переписываем класс с учетом этого момента (объявление свойства timer класса теперь можно убрать, так как оно должно автоматически добавиться к объекту при первом к нему обращении из метода clock.start):

3.
class Clock {

    // timer;

    constructor(template) {}

    render() {
        let date = new Date();

        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;

        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;

        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;

        let output = template
            .replace('h', hours)
            .replace('m', mins)
            .replace('s', secs);

        console.log(output);
    }

    stop() {
        clearInterval(this.timer);
    }

    start() {
        this.render();
        this.timer = setInterval(this.render, 1000);
    }

}

let clock = new Clock('h:m:s');
clock.start();

Теперь метод this.render класса запустился из первой строки метода clock.start, но внутри метода this.render не видно параметра template. Очевидно, что значение параметра template должно быть доступно всем методам класса, а методы класса, как я понимаю, видят только свойства класса. Следовательно, для хранения значения параметра template нужно создать свойство класса, назовем его this.template. Так же, как и свойство timer класса, объявлять свойство this.template класса отдельно не будем, а создадим его в объекте при первом к нему обращении. Где же сделать это первое обращение? Очевидно, что в методе constructor. Переписываем код:

4.
class Clock {

    constructor(template) {
        this.template = template;
    }

    render() {
        let date = new Date();

        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;

        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;

        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;

        let output = this.template
            .replace('h', hours)
            .replace('m', mins)
            .replace('s', secs);

        console.log(output);
    }

    stop() {
        clearInterval(this.timer);
    }

    start() {
        this.render();
        this.timer = setInterval(this.render, 1000);
    }

}

let clock = new Clock('h:m:s');
clock.start();

Код всё еще не работает. Почему? Первая строка метода clock.start теперь отрабатывает успешно. Однако, при выполнении второй строки этого метода происходит ошибка (в консоли разработчика показаны уже последствия этой ошибки):
        this.timer = setInterval(this.render, 1000);

При обращении к this.render происходит потеря this. Об этом подробно рассказывалось в подразделе 6.10 «Привязка контекста к функции». Один из способов избавиться от этой ошибки — использование стрелочной функции в качестве обертки. Переписываем код:

5. Окончательное решение
class Clock {

    constructor(template) {
        this.template = template;
    }

    render() {
        let date = new Date();

        let hours = date.getHours();
        if (hours < 10) hours = '0' + hours;

        let mins = date.getMinutes();
        if (mins < 10) mins = '0' + mins;

        let secs = date.getSeconds();
        if (secs < 10) secs = '0' + secs;

        let output = this.template
            .replace('h', hours)
            .replace('m', mins)
            .replace('s', secs);

        console.log(output);
    }

    stop() {
        clearInterval(this.timer);
    }

    start() {
        this.render();
        this.timer = setInterval(() => this.render(), 1000);
    }

}

let clock = new Clock('h:m:s');
clock.start();

Теперь всё работает. Задание выполнено.

Что интересно, использование класса, объявленного с помощью функции-конструктора, и использование класса, объявленного с помощью ключевого слова class, в итоге ничем друг от друга не отличаются. Сравните последние две строки исходного кода и последние две строки окончательного решения. Они идентичны:

let clock = new Clock('h:m:s');
clock.start();