Приветствую тебя мой дорогой друг, сегодня мы продолжим разговор о JavaScript Ссылка на заголовок

В предыдущем посте я описал как создать простейший переключатель тем с кнопкой, он вполне себе рабочий и выполняет отведённую ему роль на 100%, однако, я отметил что скрипт можно оптимизировать, и сегодня мы немного углубимся в js, а именно - поговрим о том как писать полноценные самодостаточные модули на примере переключателя тем. Основная суть пойдёт о концепции singleton-модуля на базе статического класса в JavaScript.

Дисклеймер Ссылка на заголовок

Главные роли в данном посте исполнят цитаты от так наываемых “профи” которые привыкли думать что JavaScript это толко про инициализировать массив и повесить обработчик, а document.getElementById() используют только динозавры:

  • “грязный хак”
  • “так никто не делает…”
  • “это не для прода…”
  • “у тебя тут ошибка”

Материал рассчитан на любого желающего написать что-нибудь “своё” или “эдакое”, и не требует особой подготовки, лишь хотя-бы представление о синтаксисе. В целях экономии моего времени, и вашего внимания рассмотрим лишь некоторые возможности языка согласно его спецификациям в последнем стандарте ECMAScript 2022. Постараюсь максималь “разжевать” каждую строчку и сделать акцент на основных моментах, которых боятся начинающие разработчики, и обходят, даже, бывалые.

В первую очередь этот пост(и сам модуль) посвящён паре моих друзей которые уже более 3-х лет работают “фронтами” с JS(React/Vue/Vanilla…), но таких элементарных вещей не понимают только лишь потому что привыкли работать с фреймворками и боятся в лес ходить потому что там волки бродят.

ТЗ Ссылка на заголовок

  1. Спректировать класс-модуль ThemeManager
    • Публичный интерфейс:
      • toggle(): переключает тему на следующую.
      • set(themeName): устанавливает указанную тему(если она есть в списке), либо выдать ошибку в консоль об отсутствии данной темы.
      • add(themeName, themeDescription?, themeImage?): добавляет новую тему где:
        • themeName - внутреннее имя темы ('light');
        • themeDescription - описательное название которое отражается в меню ('Светлая');
        • themeImage - текстовое представление иконки ('<svg>...</svg>').
    • Кнопка переключения тем:
      • Фиксирована на странице, стиль/размер настраивается через CSS.
      • при переключении темы иконка должна меняться на активную тему.
      • При наведении более чем на 2 секунды должно появляться меню выбора тем.
    • Меню выбора тем:
      • Должно содержатть все имеющиеся темы.
      • При наведении на элемент окрашиваться в соответствующие цвета.
      • Анимированно появляться и исчезать сразу после выбора темы.
  2. Модуль должен обеспечить максимальную простоту использования:
    • Подключаться в любом месте(head/body/defer).
    • Корректно обрабатывать события при передаче методов в обработчики(addEventListener('click', ThemeManager.toggle))
    • Обеспечить добавление новых тем вызовом ThemeManager.add() после подключения модуля.
      class ThemeManager {...};
      ThemeManager.add('ocean','Океан', null);// SVG по умолчанию(значок контрастности)
      
    • Обеспечить корректную самоинициализацию(RAII).
    • Быть устойчивым к рефакторингу(class ThemeManager -> class Theme или любому другому названию) без каких-либо дополнительных манипуляций.
    • При недоступности localStorage использовать cookie, либо кричать на всю Ивановскую что хранилища недоступны.
  3. Все настройки по умолчанию должны быть вынесены в самое начало класса для упрощения внесения изменений в ключи хранилища или названия идентификаторов/классов
  4. Все динамически конфигурируемые строковые константы должны подхватывать “основные” настройки:
    class {
        static #CONFIG = {
            globalPrefix: 'prefix',     // Должно меняться только здесь
    
            someId: 'prefix-someId'     // Это поле должно подхватывать изменение `prefix` автоматически
            someClassName: 'prefix-someClassName'   // И это поле должно подхватывать изменение автоматически
        };
        static #foo() { console.log(this.#CONFIG.someClassName); }  // должно выводить 'pfx-someClassName' если `globalPrefix` изменён на 'pfx'
    }
    

Реализация Ссылка на заголовок

Чтож, ТЗ, скажем так - на грани реальности JS. Всё же - это вполне выполнимо, но, RAII в JS это относительное понятие, что имеется в виду: подключение скрипта в любое место уже и есть обретение ресурса, никаких ThemeManager.init() и прочего, только добавление пользовательских тем после подключения(это если совсем страшно его потрашить), но можно сделать и иначе - напрямую пихнуть тему в массив внутри класса очень простым способом(об этом немного позже).

Итак, приступим:

“Хак первый”-бородатый-трудночитаемый-недляпрода-такнеделаемый: устойчивый к рефакторингу конфиг с “преинициализацией”(сборкой строк при инициализации) Ссылка на заголовок

На первый взгляд “без фреймов - нереально”, однако - реально более чем, и очень даже легко делается используя специфику языка(React, Vue и им подобные написаны на JS). Из ТЗ мы имеем представление как это примерно должно выглядеть. Если опереться на пример из ТЗ объект создасться, однако динамически скомпоновать строки не получится, что делать? Ответ прост: IIFE((...)()), почему именно так - JavaScript позволяет при инициализации статического поля использовать любое выражение/функцию. Для чего вообще может понадобиться такое “изощрение”, а оно очень хорошо экономит ресурсы ьраузера потенциального посетителя: как минимум - не придётся при каждом обращении к какой-то строке динамически собирать её в геттере, что одновременно убивает двух зайцев - устойчивасть к рефакторингу и, собственно, упрощение того самого рефакторинга.

Итак, называем наш класс как в ТЗ и пробуем написать конфигурационную функцию итогом выполнения которой будет вот такой объект:

class ThemeManager {
    static #CONFIG = {
        buttonToggleId: 'theme-button-toggle',
        colorSchemePrefix: 'theme-color-scheme',
        defautlImage:'<svg width="100%"height="100%"viewBox="0 0 135.46666 135.46667"version="1.1"xmlns="http://www.w3.org/2000/svg"xmlns:svg="http://www.w3.org/2000/svg"><path id="path1"style="opacity:100%;fill:currentColor;fill-opacity:1;stroke:#171717;stroke-width:0"d="M 67.777258,12.777515 A 55,55 0 0 0 12.777515,67.777258 55,55 0 0 0 67.777258,122.77752 55,55 0 0 0 122.77752,67.777258 55,55 0 0 0 67.777258,12.777515 Z m -0.128157,4.95577 V 117.73338 A 50,50 0 0 1 17.733285,67.733333 50,50 0 0 1 67.649101,17.733285 Z"/></svg>',
        globalPrefix: 'theme',
        menuId: 'theme-menu',
        menuRowClass: 'theme-menu-row',
        menuImageClass: 'theme-menu-image',
        menuDescriptionClass: 'theme-menu-description',
        storageKey: 'theme-id',
    };
};

Чтож, приступим, определяем класс ThemeManager, и пишем IIFE-функцию которая сразу инициализирует объект именно так как нам нужно:

class ThemeManager {
    static #CONFIG = (function() { let c = {
            /* Настраиваем имена */
            buttonToggleId: 'button-toggle',
            colorSchemePrefix: 'color-scheme',
            defautlImage:'<svg width="100%"height="100%"viewBox="0 0 135.46666 135.46667"version="1.1"xmlns="http://www.w3.org/2000/svg"xmlns:svg="http://www.w3.org/2000/svg"><path id="path1"style="opacity:100%;fill:currentColor;fill-opacity:1;stroke:#171717;stroke-width:0"d="M 67.777258,12.777515 A 55,55 0 0 0 12.777515,67.777258 55,55 0 0 0 67.777258,122.77752 55,55 0 0 0 122.77752,67.777258 55,55 0 0 0 67.777258,12.777515 Z m -0.128157,4.95577 V 117.73338 A 50,50 0 0 1 17.733285,67.733333 50,50 0 0 1 67.649101,17.733285 Z"/></svg>',
            globalPrefix: 'theme',
            menuId: 'menu',
            menuRowClass: 'row',
            menuImageClass: 'image',
            menuDescriptionClass: 'description',
            storageKey: 'id',
        };
        /* Собираем строки */
        c.globalPrefix += '-';
        /* С этого момента globalPrefix = '${c.globalPrefix}-' */
        c.storageKey = c.globalPrefix + c.storageKey;
        c.menuId = c.globalPrefix + c.menuId;
        c.menuRowClass = `${c.menuId}-${c.menuRowClass}`;
        c.menuImageClass = `${c.menuId}-${c.menuImageClass}`;
        c.menuDescriptionClass = `${c.menuId}-${c.menuDescriptionClass}`;
        c.buttonToggleId = c.globalPrefix + c.buttonToggleId;
        c.colorSchemePrefix = c.globalPrefix + c.colorSchemePrefix + '-';
        /* Возвращаем полностью инициализированный объект с динамически скомпонованными строками */
        return c;
    })();
    /* Это - демонстрация специфики инициализации полей(друг за другом,сверху вниз) */
    static #_ = (() => { console.log(this.#CONFIG); })();
};

“Хак второй”-среднебородатый-тутутебяошибка-новичкомнечитаемый-тоженедляпрода: Ссылка на заголовок

Обратите внимание на поле ThemeManager.#_ это не просто IIFE, а гибрид с Closure(Заыканием), в чем тут особенность: замыкание захватывает this как указатель на сам класс ThemeManager из внутреннего контекста класса(внутренняя область видимости)!!!

Замыкания(Closure: () => {...}) - особые функции JavaScript, они захватывают контекст на момент своего создания(указатели/ссылки и видимые переменные). У IIFE нет такой способности, они выполняются вне всякого контекста кроме глобального(даже если мы будем создавать IIFE внутри класса, её тело будет считаться внешней относительно этого класса), и this у них имеет значение undefined(неопределён) либо window(но это редкость и рассчитывать на это не стоит).

Для чего это сделано: демонстрирует порядок инициализации статических полей, на момент создания поля #_ поле-объект #CONFIG не просто определён, а полностью проинициализирован как и предписано по ТЗ, по этому, при инициализации любого последующего поля/объекта у нас будет полностью готовый к работе конфиг.

Меню выбора тем Ссылка на заголовок

На данный момент мы уже имеем готовый к работе конфиг, а класс только начал инициализироваться, теперь давайте займёмся меню, ибо оно у нас будет корневым элементом для списка тем и будет очен удобно использовать его полностью инициализированным при инициализации тем.

Есть несколько вариантов исполнения:

  1. Унаследоваться от HTMLDivElement: супер идея. никакой лишней логики, отдельный клас с методами, однако - вспоминаем про рефакторинг и эта идея разбивается в том месте где нам придётся использовать ThemeManager.#set(idx), конечно можно это обойти дублированием кода, но, это не про нас.
  2. Использовать литеральный объект с методами, но тут тоже большая беда с this, он будет указывать на этот самый литеральный объект, а к родительскому классу придётся обращаться через ThemeManager, или, всё таки, нет?

Магия this Ссылка на заголовок

С моей точки зрения(колокольни), самым подходящим в данном случае будет использование литерального объекта вот почему: при инициализации литерального объекта this указывает на область видимости конструктора объекта, полем которого он является, т.е.:

class ThemeManager {
    static #Menu = {
        tm: this,                                   // здесь `this` указывает на класс `ThemeManager`
        div: document.createElement('div'),
        show() {
            this.div.style.visibility = 'visible';  // а тут `this` указывает на `ThemeManager.#Menu`
        },
        hide() {
            this.div.style.visibility = 'hidden';   // и тут `this` указывает на `ThemeManager.#Menu`
        },
        foo: (
            function() {
                console.log(this);                          // здесь `this` указывает в чёрнцю дыру('undefined')
                return function() { console.log(this); };   // а этот становится методом и указывает на `ThemeManager.#Menu`
            })(),
        bar: () => { console.log(this); },          // а вот тут `this` снова указывает на `ThemeManager`
        obj: {
            tm: this,                           // `this == ThemeManager`
            parent: this.#Menu,                 // `parent == 'undefined'` т.к. #Menu ещё не присвоен создаваемый объект
            foo: () => { console.log(this); }       // Правильно, угадали, `this == ThemeManager`
        },
    };
    static OtherMenu = class  {
        /* указывает на объект вызова(может даже и не родительского класса вовсе), к примеру,
         * если повесить на обработчик - `this` будт указывать на window или 'undefined' */
        static foo() { console.log(this); };
        static bar = () => { console.log(this); };    // само-собой на `ThemeManager`? А вот и нет - `ThemeManager.OtherMenu`
    };
};

Однако все эти методы и функции(кроме замыканий) имеют один большущий недостаток - при указании их в качестве обработчика события их контекст меняется, то есть this принимает значение вызывающего объекта. На сам объект или класс во всех приведённых случаях this указывает тогда и только тогда когда они вызываются оператором разыменовывания ., а именно ThemeManager.OtherMenu.foo() или this.#Menu.show()(из других методов класса).

К сожалению - пора отдыхать, продолжение следует…