import { onWindowResize, onWindowLoad, onDOMReady } from "@web3r/flowerkit/evt"; import { isFnClass, isFnAsync } from "@web3r/flowerkit/fn"; import { getJSONFromStr } from "@web3r/flowerkit/json"; import { isObjPromise } from "@web3r/flowerkit/obj"; import { isMobileDevice, isTouchDevice, deleteCookie } from "@web3r/flowerkit/user"; import { getWindow, getDocument } from "ssr-window"; import { constants } from "../../constants"; import type { IStyle, ISvgIcon } from "../../types/webpack"; import { setVhVar, setScrollBarWidth, useLazyStyles } from "../../utils"; import { Dispatcher } from "../dispatcher"; import { Forms } from "../forms"; import { Localisation } from "../localisation"; import { Observer } from "../observer"; /** * Входные данные для стилей или иконок. */ type TLazyEntry = Entry | Promise | ((instance?: Core) => Entry) | ((instance?: Core) => Promise); /** * Callback, вызываемый после запуска ядра и плагинов. */ export type TCoreOnInitCallback = (params: { isDebug: boolean; plugins: Map; }) => void; /** * Параметры конструктора плагина `Core`. */ export type TCoreProps = Partial<{ utils: TCoreUtilities; plugins: TPluginMap; styles: TStylesEntry; icons: TIconsEntry; isDebug: boolean | null; clearCache: string[]; clearCacheKeys: string[]; onWindowLoad: () => void; onWindowResize: () => void; onDOMReady: () => void; onInit: TCoreOnInitCallback | null; env: string; appNameSpace: string; DispatcherPlugin: TPluginDefinition; ObserverPlugin: TPluginDefinition; globalAttr: string; }>; /** * Статистика инициализации ядра. */ export type TCoreStats = { plugins: number; styles: number; icons: number; }; /** * Набор пользовательских утилит ядра. */ export type TCoreUtilities = Record; /** * Тип утилиты ядра, выполняемой при инициализации. */ export type TCoreUtility = () => unknown; /** * Описание записи иконок для ленивой загрузки. */ export type TIconsEntry = TLazyEntry; /** * Тип плагина в виде класса. */ export type TPluginClass = { new (...args: never[]): unknown; isApp?: boolean; }; /** * Допустимый формат определения плагина. */ export type TPluginDefinition = TPluginClass | TPluginFn | null; /** * Тип плагина в виде функции. */ export type TPluginFn = { (...args: never[]): unknown | Promise; isApp?: boolean; }; /** * Карта подключаемых плагинов. */ export type TPluginMap = Record; /** * Описание записи стилей для ленивой загрузки. */ export type TStylesEntry = TLazyEntry; /** * Singleton. Главный плагин, отвечающий за выполнение JS-кода, подключение стилей (в режиме локальной разработки) и добавление иконок. Вызывается непосредственно во входной точке приложения. Плагин самостоятельно ждёт наступления события `DOMContentLoaded` и выполняет другие переданные ему плагины. * * В передаваемых плагинах (классах) могут присутствовать следующие статические поля: * * - Если `isApp` с булевым значением равен true, то плагин привязывается к глобальной зоне видимости и становится доступен через `window[appNameSpace]` — `window.App` по умолчанию. * * Ядро позволяет привязать плагины, утилиты и переменные к глобальному объекту window, создав в нем соответствующий ключ, указанный через appNameSpace. В качестве значения ключа выступает объект с глобальными параметрами, среди которых присутствуют: * * - `isDebug` — состояние режима отладки; * - `svgSpriteRevision` — временная метка актуальности изменения файла спрайта с SVG-иконками приложения, по умолчанию равная текущему времени. * * Чтобы добавить или перезаписать переменные, их следует добавить в формате JSON в атрибут тела страницы, указанный в ключе globalAttr. * * @module Core * @param utils{Object=} объект с выполняемыми утилитами * @param plugins{Object=} объект с выполняемыми плагинами * @param styles{TStylesEntry=} массив/промис/функция стилей, применяемых для страницы (только для dev-версии) * @param icons{TIconsEntry=} массив/промис/функция иконок, используемых на сайте * @param isDebug{Boolean=} включение debug-режима * @param clearCache{Array=} = очистка `localStorage` и `cookies` по указанным в массиве ключам, `*` очищает все значения * @param onWindowLoad{Function=} callback для загрузки окна * @param onWindowResize{Function=} callback для изменения размера окна * @param onDOMReady{Function=} callback для загрузки DOM * @param env{String=} значение переменной окружения (`development`, `production`, `test`) * @param appNameSpace{String=} название глобальной переменной, куда могут быть записаны плагины (по умолчанию `App`) * @param DispatcherPlugin{Function} собственный экземпляр Dispatcher * @param ObserverPlugin{Function} собственный экземпляр Observer * @param globalAttr{String=} глобальный атрибут приложения на теле документа * @param onInit{Function=} функция, вызываемая после запуска всех плагинов и инициализации фреймворка * @example * import { Core, IconSprite } from "@delement/ui/plugins" * * new Core({ * styles: async () => [], // массив стилей * icons: async () => [], // массив иконок * plugins: { * IconSprite: () => new IconSprite("/frontend/build/"), // пример вызова IconSprite с другими аргументами, * Localisation: null // пример отключения стандартного плагина * }, * onInit: ({ plugins }) => { * // plugins - экземпляры вызовов всех плагинов * } * }) */ export class Core { today!: number; utils!: TCoreUtilities; plugins!: TPluginMap; styles!: IStyle[]; icons!: ISvgIcon[]; isDebug!: boolean; onWindowLoad!: (() => void) | null; onWindowResize!: (() => void) | null; onDOMReady!: (() => void) | null; appNameSpace!: string; globalAttr!: string; clearCacheKeys!: string[]; onInit!: TCoreOnInitCallback | null; stats!: TCoreStats; env!: string; #runningPlugins = new Map(); #crashedPlugins = new Set(); #stylesEntry: TStylesEntry = []; #iconsEntry: TIconsEntry = []; /** * Singleton-экземпляр ядра приложения. * @type {Core|null} */ static instance: Core | null = null; constructor(props: TCoreProps = {}) { if (Core.instance) { return Core.instance; } const { utils = {}, plugins = {}, styles = [], icons = [], isDebug = null, clearCache = [], clearCacheKeys = [], onWindowLoad = null, onWindowResize = null, onDOMReady = null, onInit = null, env = process.env.NODE_ENV ?? "development", appNameSpace = "App", DispatcherPlugin = Dispatcher, ObserverPlugin = Observer, globalAttr = "data-js-app", } = props; this.today = new Date().getTime(); this.#stylesEntry = styles; this.#iconsEntry = icons; this.utils = { setVhVar, setScrollBarWidth, checkMobile: () => getDocument().documentElement.classList.toggle(constants.stateClasses.mobileDevice, isMobileDevice()), checkTouch: () => getDocument().documentElement.classList.toggle(constants.stateClasses.touchscreen, isTouchDevice()), ...utils, }; this.plugins = env === "test" ? { ...plugins, DispatcherPlugin, ObserverPlugin, } : { Localisation: () => Localisation.create(), Forms, ...plugins, DispatcherPlugin, ObserverPlugin, }; this.isDebug = this.#isDebugEnabled(isDebug); this.onWindowLoad = onWindowLoad; this.onWindowResize = onWindowResize; this.onDOMReady = onDOMReady; this.appNameSpace = appNameSpace; this.globalAttr = globalAttr; this.clearCacheKeys = clearCacheKeys.length ? clearCacheKeys : clearCache; this.onInit = onInit; this.stats = { plugins: 0, styles: 0, icons: 0, }; this.env = env; this.clearCache(); this.#bindEvents(); Core.instance = this; } #isDebugEnabled(isDebug: TCoreProps["isDebug"]) { return typeof isDebug === "boolean" ? isDebug : (getWindow().location.port.length > 0 || getWindow().location.host === "localhost" || (getWindow().location.search.indexOf("debug") > -1) || getWindow().location.pathname.indexOf("/build/") > -1); } /** * Получает переданный ресурс иконок или стилей * @private */ async #getLazyEntry< Entry = TLazyEntry, Result = IStyle[] | ISvgIcon[] >(entry: Entry): Promise { try { let result: unknown; switch (true) { case Array.isArray(entry): // valid array result = entry; break; case typeof entry === "function": { // fn result = await entry(this); break; } case isObjPromise(entry): { // promise result = await entry; break; } default: // unknown type throw new Error(`unknown entry type`); } if (!Array.isArray(result)) { throw new Error(`invalid entry result, must be an Array`); } return result as Result; } catch (err: unknown) { console.error(`[Core] Error while extract entry`, err); return [] as Result; } } /** * Очищает кеш браузера (cookies и localStorage) по указанным ключам * @param keys{String[]=} массив ключей для очистки, "*" очищает всё */ clearCache(keys: string[] = this.clearCacheKeys) { if (Array.isArray(keys)) { const cookies = getDocument().cookie.split(";"); const getCookieName = (cookie: string) => { const eqPos = cookie.indexOf("="); return eqPos > -1 ? cookie.substring(0, eqPos) : cookie; }; if (keys.find((key) => key === "*")) { // clear all cookies cookies.forEach((cookie: string) => { const name = getCookieName(cookie); if (name) { deleteCookie(name); } }); // clear localStorage getWindow().localStorage.clear(); } else { keys .filter((key) => !!key) .forEach((key) => { // clear specific cookies cookies.forEach((cookie: string) => { const name = getCookieName(cookie); if (!!name && name.startsWith(key)) { deleteCookie(name); } }); // clear specific localStorage items getWindow().localStorage.removeItem(key); }); } if (keys.length) { console.debug(`[Core] Cache cleared`); } } } /** * Запускает утилиты — функции, выполняемые перед загрузкой приложения. * По умолчанию это `setVhVar`, `setScrollBarWidth`, `checkMobile`, `checkTouch`. * Более подробную информацию по утилитам можно найти в документации по Common utils. * @private */ #runUtils() { Object.values(this.utils) .filter((fn): fn is TCoreUtility => typeof fn === "function") .forEach((fn) => fn()); } /** * Применяет стили после формирования документа. * Используется только в локальной разработке. * @private */ async #resolveStyles(entry: TStylesEntry) { const styles = await this.#getLazyEntry(entry); this.styles = styles; this.stats.styles = styles.length; this.#stylesEntry = []; } /** * Применяет иконки после формирования документа. * @private */ async #resolveIcons(entry: TIconsEntry) { const icons = await this.#getLazyEntry(entry); this.icons = icons; this.stats.icons = icons.length; this.#iconsEntry = []; } /** * Добавляет плагин в глобальную область видимости * @param name{String} * @param instance{Object} * @param isApp{Boolean=} * @private */ #appendPlugin(name: string, instance: unknown, isApp = false) { if (instance) { if (isApp) { const win = getWindow() as Window & Record; const app = (win[this.appNameSpace] ?? {}) as Record; app[name] = instance; win[this.appNameSpace] = app; } this.#runningPlugins.set(name, instance); } else { this.#crashedPlugins.add(name); } } #onPluginError(name: string, err: unknown) { console.error(`[Core] Plugin "${name}" was crashed with error:`, err); this.#appendPlugin(name, null, false); } /** * Получает экземпляр вызова запущенного плагина по его имени * @param name{String} имя плагина * @returns {Object|null} экземпляр плагина или null */ getPluginInstance(name: string) { return this.#runningPlugins.has(name) ? this.#runningPlugins.get(name) : null; } /** * Получает результат вызова функции плагина * @param fn{*} * @returns {*} * @private */ #runPlugin(fn: Exclude) { switch (true) { case isFnClass(fn): { return new (fn as TPluginClass)(); } case isFnAsync(fn): { return (fn as TPluginFn)() as Promise; } default: { return (fn as TPluginFn)(); } } } /** * Получает список корректно включенных плагинов * @returns {Array} */ #getFilteredPlugins() { return (Object.entries(this.plugins) as Array<[string, TPluginDefinition]>) .filter((entry): entry is [string, Exclude] => { const [ name, fn ] = entry; const isCorrect = typeof fn === "function" || isFnClass(fn); if (fn === null) { // console.debug(`[Core] "${name}" passed to plugins is disabled`); } else if (!isCorrect) { console.error(`[Core] "${name}" passed to plugins is not a function, async function or instance of ES6 class`); } return !!fn && isCorrect; }); } /** * Подключает плагины синхронно * @private */ async #appendPlugins() { this.stats.plugins = Object.entries(this.plugins).length; const correctPlugins = this.#getFilteredPlugins(); const tasks = correctPlugins.map(async ([ name, fn ]) => { const isAppFromFn = (fn as { isApp?: boolean; })?.isApp ?? false; try { const plugin = await Promise.resolve(this.#runPlugin(fn)); const isApp = isAppFromFn || (plugin as { constructor?: { isApp?: boolean; }; })?.constructor?.isApp || false; this.#appendPlugin(name, plugin, isApp); } catch (err: unknown) { this.#onPluginError(name, err); } }); await Promise.all(tasks); } /** * Создаёт глобальный объект */ #setGlobalObject() { const globalParamsRaw = getDocument().body.hasAttribute(this.globalAttr) ? getJSONFromStr(getDocument().body.getAttribute(this.globalAttr) || "") : {}; const globalParams = (typeof globalParamsRaw === "object" && globalParamsRaw !== null ? globalParamsRaw : {}) as Record; const win = getWindow() as Window & Record; win._APP_NAMESPACE = this.appNameSpace; win[this.appNameSpace] = { isDebug: this.isDebug, svgSpriteRevision: this.today, ...globalParams, }; } async #appendResources() { // prepare styles if (this.env === "development") { await this.#resolveStyles(this.#stylesEntry); this.styles.forEach((style) => { useLazyStyles(style); }); } // prepare icons await this.#resolveIcons(this.#iconsEntry); } /** * Фасад. Запускает фреймворк * @private */ async #init() { await this.#appendResources(); this.#runUtils(); this.#setGlobalObject(); await this.#appendPlugins(); } /** * Callback для загрузки DOM-дерева * @private */ async #handleDOMReady() { // adds UI main class document.documentElement.classList.add(constants.dsMainClass); // adds dom ready class requestAnimationFrame(() => { requestAnimationFrame(() => { getDocument().documentElement.classList.add( constants.stateClasses.domReady ); }); }); // user callback if (typeof this.onDOMReady === "function") { this.onDOMReady(); } // run frameworkП this.#init() .then(() => { // user callback if (typeof this.onInit === "function") { this.onInit({ isDebug: this.isDebug, plugins: this.#runningPlugins, }); } const getPluginNames = (from: Map | Set) => Array.from(from.keys()).join(", "); console.debug(`[Core] Ready in "${this.env}" env with ${this.#runningPlugins.size}/${this.stats.plugins} plugins (${getPluginNames(this.#runningPlugins)}), ${this.stats.styles} lazy styles & ${this.stats.icons} icons.`); if (this.#crashedPlugins.size) { console.debug(`[Core] Crashed plugins:`, getPluginNames(this.#crashedPlugins)); } return Promise.resolve(); }) .catch((err: unknown) => { console.error(`[Core] Init failed with error:`, err); }); } /** * Callback для загрузки всего документа * @private */ #handleWindowLoad() { getDocument().documentElement.classList.add(constants.stateClasses.pageLoaded); if (typeof this.onWindowLoad === "function") { this.onWindowLoad(); } } /** * Callback для изменения размера окна * @private */ #handleWindowResize() { this.#runUtils(); if (typeof this.onWindowResize === "function") { this.onWindowResize(); } } /** * Установка событий на документ * @private */ #bindEvents() { onDOMReady(() => this.#handleDOMReady()); onWindowLoad(() => this.#handleWindowLoad()); onWindowResize(() => this.#handleWindowResize()); } }