Скрипт JavaScript для переключения тем(часть 2)
Приветствую тебя мой дорогой друг, сегодня мы продолжим разговор о JavaScript Ссылка на заголовок
В предыдущем посте я описал как создать простейший переключатель тем с кнопкой, он вполне себе рабочий и выполняет отведённую ему роль на 100%, однако, я отметил что скрипт можно оптимизировать, и сегодня мы немного углубимся в js, а именно - поговрим о том как писать полноценные самодостаточные модули на примере переключателя тем. Основная суть пойдёт о концепции singleton-модуля на базе статического класса в JavaScript. В посте будут затронуты темы самоинициализации полей в процессе обработки скрипта(интерпретации), боль многих js-кодеров this, а также посмотрим на зверьков IIFE и Closure.
Дисклеймер Ссылка на заголовок
Главные роли в данном посте исполнят цитаты от так наываемых “профи” которые привыкли думать что JavaScript это толко про “инициализировать массив” и “повесить обработчик”, а document.getElementById() используют только динозавры:
- “грязный хак”
- “так никто не делает…”
- “это не для прода…”
- “у тебя тут ошибка”
Материал рассчитан на любого желающего написать что-нибудь “своё” или “эдакое”, и не требует особой подготовки, лишь хотя-бы представление о синтаксисе. В целях экономии моего времени, и вашего внимания рассмотрим лишь некоторые возможности языка согласно его спецификациям в последнем стандарте ECMAScript 2022. Постараюсь максималь “разжевать” каждую строчку и сделать акцент на основных моментах, которых боятся начинающие разработчики, и обходят, даже, бывалые.
В первую очередь этот пост(и сам модуль) посвящён паре моих друзей которые уже более 3-х лет работают “фронтами” с JS(React/Vue/Vanilla…), но таких элементарных вещей не понимают только лишь потому что привыкли работать с фреймворками и боятся в лес ходить потому что там волки бродят.
ТЗ Ссылка на заголовок
- Спректировать класс-модуль
ThemeManager- Публичный интерфейс:
toggle(): переключает тему на следующую.set(themeName): устанавливает указанную тему(если она есть в списке), либо выдать ошибку в консоль об отсутствии данной темы.add(themeName, themeDescription?, themeImage?): добавляет новую тему где:themeName- внутреннее имя темы ('light');themeDescription- описательное название которое отражается в меню ('Светлая');themeImage- текстовое представление иконки ('<svg>...</svg>').
- Кнопка переключения тем:
- Фиксирована на странице, стиль/размер настраивается через
CSS. - при переключении темы иконка должна меняться на активную тему.
- При наведении более чем на 2 секунды должно появляться меню выбора тем.
- Фиксирована на странице, стиль/размер настраивается через
- Меню выбора тем:
- Должно содержатть все имеющиеся темы.
- При наведении на элемент окрашиваться в соответствующие цвета.
- Анимированно появляться и исчезать сразу после выбора темы.
- Публичный интерфейс:
- Модуль должен обеспечить максимальную простоту использования:
- Подключаться в любом месте(head/body/defer).
- Корректно обрабатывать события при передаче методов в обработчики(
addEventListener('click', ThemeManager.toggle)) - Обеспечить добавление новых тем вызовом
ThemeManager.add()после подключения модуля.class ThemeManager {...}; ThemeManager.add('ocean','Океан', null);// SVG по умолчанию(значок контрастности) - Обеспечить корректную самоинициализацию(RAII).
- Быть устойчивым к рефакторингу(
class ThemeManager->class Themeили любому другому названию) без каких-либо дополнительных манипуляций. - При недоступности
localStorageиспользоватьcookie, либо кричать на всю Ивановскую что хранилища недоступны.
- Все настройки по умолчанию должны быть вынесены в самое начало класса для упрощения внесения изменений в ключи хранилища или названия идентификаторов/классов
- Все динамически конфигурируемые строковые константы должны подхватывать “основные” настройки:
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',
defaultImage:'<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 base = {
/* Настраиваем имена */
globalPrefix: 'theme',
buttonToggleId: 'button-toggle',
colorSchemePrefix: 'color-scheme',
defaultImage:'<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>',
menuId: 'menu',
menuRowClass: 'row',
menuImageClass: 'image',
menuDescriptionClass: 'description',
storageKey: 'id',
};
/* Логика сбора строк */
base.globalPrefix += '-';
/* С этого момента globalPrefix = '${base.globalPrefix}-' */
/* Ключ хранилища/storageKey */
base.storageKey = base.globalPrefix + base.storageKey;
/* Меню выбора темы */
base.menuId = base.globalPrefix + base.menuId;
base.menuRowClass = `${base.menuId}-${base.menuRowClass}`;
base.menuImageClass = `${base.menuId}-${base.menuImageClass}`;
base.menuDescriptionClass = `${base.menuId}-${base.menuDescriptionClass}`;
/* buttonToggleId */
base.buttonToggleId = base.globalPrefix + base.buttonToggleId;
/* Префикс класса темы */
base.colorSchemePrefix = base.globalPrefix + base.colorSchemePrefix + '-';
/* Возвращаем полностью инициализированный объект с динамически скомпонованными строками */
return base;
})();
/* Это - демонстрация специфики инициализации полей(друг за другом,сверху вниз) */
static #_ = (() => { console.log(this.#CONFIG); })();
};
“Хак второй”-среднебородатый-тутутебяошибка-новичкомнечитаемый-тоженедляпрода: Ссылка на заголовок
Обратите внимание на поле ThemeManager.#_ это не просто IIFE, а гибрид с Closure(Заыканием), в чем тут особенность: замыкание захватывает this как указатель на сам класс ThemeManager из внутреннего контекста класса(внутренняя область видимости)!!!
Замыкания(Closure: () => {...}) - особые функции JavaScript, они захватывают контекст на момент своего создания(указатели/ссылки и видимые переменные). У IIFE нет такой способности, они выполняются вне всякого контекста кроме глобального(даже если мы будем создавать IIFE внутри класса, её тело будет считаться внешней относительно этого класса), и this у них имеет значение undefined(неопределён) либо window(но это редкость и рассчитывать на это не стоит).
Для чего это сделано: демонстрирует порядок инициализации статических полей, на момент создания поля #_ поле-объект #CONFIG не просто определён, а полностью проинициализирован как и предписано по ТЗ, по этому, при инициализации любого последующего поля/объекта у нас будет полностью готовый к работе конфиг.
Меню выбора тем Ссылка на заголовок
На данный момент мы уже имеем готовый к работе конфиг, а класс только начал инициализироваться, теперь давайте займёмся меню, ибо оно у нас будет корневым элементом для списка тем и будет очен удобно использовать его полностью инициализированным при инициализации тем.
Есть несколько вариантов исполнения:
-
Унаследоваться от
HTMLDivElement: супер идея. никакой лишней логики, отдельный клас с методами, однако - вспоминаем про рефакторинг и эта идея разбивается в том месте где нам придётся использоватьThemeManager.#set(idx), это достаточно просто можно обойти, к примеру вот так:static #MENU = (() => { let c = class extends HTMLDivElement { #manager = null; constructor() { super(); /* Это очень уязвимое место, этот элемент можно будет "перепривязать" извне. * Разумеется что можно сделать проверку `if (!#this.manager)`, что исключит * возможность перепривязки, однако, это уже самый что ни на есть костыль. * Можно, конечно и удалить метод после добавления ссылки, но это тоже костыль. * */ this.bind = function(manager) { this.#manager = manager; return this; }; this.textContent = 'Пользовательский элемент ThemeMenu'; }; log() { console.log('theme-menu:'); console.log(this.#manager); } }; customElements.define(this.#CONFIG.menuIs.is, c, { extends: 'div' }); // придётся добавить в конфиг объект с полем is - `{ is: 'theme-menu' }` let e = document.createElement('div', this.#menuIs).bind(this); // вот тут мы создаём элемент и сразу привязываем ссылку на класс ThemeManager delete e.bind; // и тут мы удаляем уязвимый метод bind e.id = 'theme-menu'; return e; })();По логике инкапсуляции и единственной ответственности, это, пожалуй, хороший подход, но - не такой уж и простой когда это всё должно управляться менеджером, далее поймёте почему.
-
Использовать литеральный объект с методами, но тут тоже большая беда с
this, он будет указывать на этот самый литеральный объект, а к родительскому классу придётся обращаться черезThemeManager, или, всё-таки - нет?Не придётся если мы сохраним ссылку на этапе создания объекта в одно из его полей. Ибо, согласно спецификации, при инициализации литерального объекта(до закрывающей фигурной скобки
}) это лексический контекст либо близжайшего класса(class), либо глобальная область:static #MENU = (() => { let menu = { /* заодно пробрасываем нужные нам методы и элементы чтоб иметь прямой доступ из самого класса */ add: null, appendChild: null, current: '', div: document.createElement('div'), manager: this // это 100% class ThemeManager remove: null, replace: null, style: null, }; menu.div.id = this.#CONFIG.menuId; menu.add = menu.div.classList.add.bind(menu.div.classList); // Привязываем контекст исполнения к `div.classList` menu.appendChild = menu.div.appendChild.bind(menu.div); // Привязываем контекст исполнения к `div` menu.remove = menu.div.classList.remove.bind(menu.div.classList); // Привязываем контекст исполнения к `div.classList` menu.replace = menu.div.classList.replace.bind(menu.div.classList); // Привязываем контекст исполнения к `div.classList` menu.style = menu.div.style; // сохраняем ссылку на `div.style` return menu; })();
this - Mystic_lvl80
Ссылка на заголовок
С моей точки зрения(колокольни), самым подходящим в данном случае будет использование литерального объекта вот почему: при инициализации литерального объекта this указывает на область видимости конструктора объекта, полем которого он является, т.е.:
class ThemeManager {
self = this; // `this` - это объект класса ThemeManager
static #self = this; // `this` - это сам класс 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()(из других методов класса).
Исходя из вышесказанного можно сделать вывод: Для обеспечения удобства использования в обработчиках или таймерах внешние методы следует объявлять как стрелочные функции или сразу привязывать к методу контекст:
class Example {
/* Статические свойства и методы класса */
static #cname = 'static field: class Example.name';
static #timer = null;
static foo = (function () { console.log(this.#cname); }).bind(this); // привязываем контекст класса к статическому методу
static #counter = 0;
/* Свойства и методы объекта класса */
#oname = null;
#example = this;
constructor(Class = null) {
/* инициаизируем объект */
this.#oname = `object[${Example.#counter++}] constructor(Class):`;
if (Class)
this.#example = Class;
this.timer = setTimeout(this.foo, 1000);
}
foo = (function(){ console.log(this.#oname); console.log(this.#example); }).bind(this); // привязываем контекст объекта к методу объекта, т.е. создаём для каждого объекта отдельный метод
/* Устанавливаем таймер на вызов статического метода */
static #_ = (() => { this.#timer = setTimeout(this.foo, 1000); return function () {}; })();
/* Пример фабрики объектов */
static new = (function() { return new Example(this); }).bind(this);
};
let e = [new Example(), Example.new()];
тогда никакой проблемы с “подменой” this не возникнет. Либо писать методы таким образом чтобы не зависеть от контекста. К примеру если нам нужно анимировать какой-нибудь пользовательский элемент - достаточно написать статический метод который будет исходить из того что он “метод html-элемента”, а не самого класса.
П.С.: Если вам кажется что дочитав до сего момента Вы понимаете что ничего не понимаете - не печальтесь, это нормально, многие “бывалые” кодеры в этих мелочах не до конца разбираются, это как для большинства студентов фармакология в медицине или сопромат в строительстве, до конца понимаешь только когда начинаешь плотно работать, а к экзаменам просто зазубриваешь и после - забываешь.
Ссылка на заголовок
К сожалению - пора отдыхать, продолжение следует…