@teqfw/vue

от автора

Комментарии коллег к моей последней статье «Почему я ‘мучаюсь’ с JS» навели меня на мысль, что публикации, касающиеся Tequila Framework, нужно помещать в хаб «Ненормальное программирование«.

Почему-то идеи:

  • создание больших web-приложений на «ванильном» JS;
  • отказ от упаковщиков и транспиляторов;
  • логическое пространство имён для прямого доступа к es6-модулям зависимостей, вместо импорта-экспорта на уровне npm-пакетов;
  • автозагрузчик кода и инъекция зависимостей, использующие пространство имен;
  • es6-модули, работающие без изменений одинаково как в браузере, так на стороне nodejs и в тестах;
  • отладка в браузере точно того же кода как тот, что создаётся в редакторе;

вот это всё относится к разряду «ненормального» в современной web-разработке.

В этой публикации — пример построения «нормального» PWA-приложения с использованием «нормальных» Vue 3 и Quasar UI на базе «ненормальной» платформы Tequila Framework.

В качестве базового функционала, реализуемого приложением, выступает классический «Список задач» (ToDo List). В качестве дополнительного — интернационализация (переключение языка) и очистка кэша приложения (актуально для прогрессивных приложений).

Демо

Репозиторий демо-пакета — @flancer64/habr_teqfw_vue.
Само приложение — todo.habr.demo.teqfw.com.

Структура приложения

Основной пакет, в котором реализовано приложение: @flancer64/habr_teqfw_vue. Основные зависимости пакета:

Всего в приложении 32 зависимости (commander, vue, vue-router, i18n, …).

Пакеты-обёртки

Для teq-плагинов (npm-пакетов, совместимых с платформой TeqFW) каждый es6-модуль плагина доступен для автозагрузки посредством совмещения логического пространства имён плагина с файловой структурой es6-модулей. Так для демо-плагина в дескрипторе ./teqfw.json декларируется пространство имён Fl64_Habr_Vue:

{   "di": {     "autoload": {       "ns": "Fl64_Habr_Vue",       "path": "./src"     }   } }

После чего es6-модуль ./src/Front/Mod.mjs становится доступным для автозагрузки DI-контейнером по идентификатору Fl64_Habr_Vue_Front_Mod как для nodejs-приложения, так и в браузере.

Чтобы в браузере иметь доступ к библиотекам, написанным на JS и не совместимым с платформой, нужно:

  • загрузить соответствующую библиотеку на страницу обычным способом (например, через HTML тэг script);
  • добавить объект-обёртку, который при инстанциализации находит в globals нужную библиотеку и предоставляет к ней доступ остальным объектам teq-приложения через DI-контейнер;

Подключение i18next на стартовую страницу приложения:

<script type="application/javascript" src="./src/i18n/i18next.min.js"></script>

Пакет-обёртка в своих зависимостях содержит npm-пакет с соответствующей библиотекой и прокидывает (в своём ./teqfw.json) на её рабочий код (например, каталог ./dist/umd/) линк для обработчика статических файлов из web-плагина:

{   "web": {     "statics": {       "/i18n/": "/i18next/dist/umd/"     }   } }

Таким образом дистро-код пакета i18next становится доступным для загрузки в браузер.

Из браузера дистро-код оригинального пакета извлекается объектом-обёрткой (в конструкторе или init-функции):

if (window.i18next) {     this.#i18n = window.i18next; }

после чего он становится доступным в DI-контейнере через обёртку:

export default class TeqFw_I18n_Front_Lib {     // ...     getI18n() {         return this.#i18n;     } }

Разумеется, объект-обёртка должен создаваться уже после того, как необходимые библиотеки были загружены на страницу.

Пакеты-обёртки позволяют использовать в teq-приложениях любые браузерные JS-библиотеки, доступные через npm.

Оболочка приложения

Web-приложение обычно начинается с HTML-файла (демо — ./web/index.html). В нём можно выделить три секции:

<head>   <!-- Bootstrap JS --> </head> <body>   <!-- Launchpad -->   <!-- Resource Loading --> </body>

Launchpad — это фрагмент страницы, в который будет помещено js-приложение после его инициализации и запуска:

<div>     <app-root>         <div class="launchpad">TeqFW App is loading...</div>     </app-root> </div>

Resource Loading — это часть, в которой на страницу загружаются ресурсы, несовместимые с TeqFW:

<script type="application/javascript" src="./src/vue/vue.global.prod.js"></script> <link rel="stylesheet" href="./src/quasar/quasar.prod.css">

Bootstrap JS — это код, который отслеживает окончание загрузки страницы и стартует загрузку и запуск teq-приложения:

<script>     async function bootstrap() {}      if ("serviceWorker" in navigator) {         self.addEventListener(             "load",             async () => { /* check service worker then bootstrap */ }         );     } </script> 

Service Worker

В демо-приложении в задачи service worker’а (./sw.mjs) входит:

  • кэширование минимального набора файлов для того, чтобы приложение могло стартовать offline;
  • сохранение в кэше всех статических ресурсов, к которым обращается приложение, для ускорения работы при повторных запросах;
  • очистка кэша по команде из приложения;

Код проверки наличия service worker’а и, при необходимости, его установки на странице-оболочке:

app shell code for SW

if ("serviceWorker" in navigator) {     self.addEventListener("load", async () => {         const worker = navigator.serviceWorker;         if (worker.controller === null) {             try {                 const reg = await worker.register("sw.js");                 if (reg.active) {                     await bootstrap();                 } else {                     worker.addEventListener("controllerchange", async () => {                         await bootstrap();                     });                 }             } catch (e) {/* ... */}         } else {             await bootstrap();         }     }); }

Подробнее о service worker’е, манифесте и оболочке — «Минимальное PWA«

Bootstrap

В задачи bootstrap-функции входит:

  • импорт DI-контейнера «нормальным» способом;
  • получение с сервера карты сопоставления пространств имён адресам на сервере для загрузки es6-модулей teq-плагинов и карты замещения модулей («интерфейсы» и «имплементации«);
  • настройка DI-контейнера;
  • загрузка через DI-контейнер основного модуля приложения, его инициализация и монтирование корневого vue-компонента на страницу-оболочку;

bootstrap code

async function bootstrap() {      async function initDiContainer() {         const baseUrl = `${location.origin}${location.pathname}`;         const modContainer = await import('./src/@teqfw/di/Shared/Container.mjs');         /** @type {TeqFw_Di_Shared_Container} */         const container = new modContainer.default();         const res = await fetch('./api/@teqfw/web/load/namespaces');         const json = await res.json();         if (json?.data?.items && Array.isArray(json.data.items))             for (const item of json.data.items)                 container.addSourceMapping(item.ns, (new URL(item.path, baseUrl)).toString(), true, item.ext);         if (json?.data?.replaces && Array.isArray(json.data.replaces))             for (const item of json.data.replaces)                 container.addModuleReplacement(item.orig, item.alter);         return container;     }      try {         const container = await initDiContainer();         /** @type {Fl64_Habr_Vue_Front_App} */         const app = await container.get('Fl64_Habr_Vue_Front_App$');         await app.init();         app.mount('BODY > DIV');     } catch (e) {...} }

Приложение

Так как у некоторых коллег были вопросы по моему стилю оформления кода (раз, два, три), то в этом демо-проекте я придерживался более «нормального» стиля (благо, что в Apple наконец-то подсуетились и в апреле этого года добавили поддержку private-атрибутов в Safari):

export default class Fl64_Habr_Vue_Front_App {     /** @type {TeqFw_Web_Front_Model_Config} */     #config;     /** @type {TeqFw_I18n_Front_Lib} */     #I18nLib;     ...      constructor(spec) {         this.#config = spec['TeqFw_Web_Front_Model_Config$'];         this.#I18nLib = spec['TeqFw_I18n_Front_Lib$'];         ...     }

Модуль, содержащий приложение — Fl64_Habr_Vue_Front_App. Задачи приложения:

  • получение из контейнера всех требуемых зависимостей;
  • инициализация подсистем приложения (конфигурация, i18next, vue, quasar);
  • создание корневого vue-компонента;
  • монтирование корневого vue-компонента на страницу-оболочку;

Фабрики для vue-компонентов

В TeqFW es6-модуль, создающий объекты, можно оформить в виде фабричной функции или класса. Так как vue-компоненты по сути являются объектами-шаблонами, которые Vue использует в качестве базовых при превращении соответствующих тэгов в фрагменты страницы, то они используются в приложении в единственном числе (один шаблон на всё приложение). Для создания таких объектов-шаблонов достаточно фабричной функции. Вот типовая структура такой функции:

const NS = 'Fl64_Habr_Vue_Front_Layout_Base'; export default function Factory(spec) {     // EXTRACT DEPS     /** @type {TeqFw_Vue_Front_Lib} */     const VueLib = spec['TeqFw_Vue_Front_Lib$'];      // DEFINE WORKING VARS     const {ref} = VueLib.getVue();     const template = `...`;      // COMPOSE RESULT     return {         name: NS,         template,         // ...     }; } // to get namespace on debug Object.defineProperty(Factory, 'name', {value: `${NS}.${Factory.name}`});

Связывание vue-компонентов

Используя DI-контейнер и фабричную функцию для создания шаблона vue-компонента, можно инжектить результирующие singleton-шаблоны в другие фабричные функции для создания других vue-компонентов:

function Factory(spec) {     // EXTRACT DEPS     /** @type {Fl64_Habr_Vue_Front_Layout_Navigator.vueCompTmpl} */     const navigator = spec['Fl64_Habr_Vue_Front_Layout_Navigator$'];      return {         components: {navigator}     } }

Символ $ в конце идентификатора зависимости означает, что DI-контейнер загружает соответствующий es6-модуль, берёт из него default-экспорт и создаёт при помощи этого экспорта (функции или класса) объект, который затем сохраняет у себя внутри и использует каждый раз, когда встречает аналогичную зависимость (шаблон singleton).

Таким образом, все фабричные функции для создания шаблонов vue-компонентов отрабатывают только по одному разу, а в дальнейшем контейнер переиспользует результат первого запуска (один и тот же объект). Если бы в конце стояло два символа — $$, то контейнер использовал бы фабричную функцию всякий раз, когда встречал соответствующий идентификатор зависимости, и в конструкторы бы инжектились разные объекты.

Маршрутизация

Vue Router позволяет сделать ленивую загрузку vue-компонентов через динамический импорт:

const UserDetails = () => import('./views/UserDetails')  const router = createRouter({   // ...   routes: [{ path: '/users/:id', component: UserDetails }], })

Что очень хорошо совмещается с работой DI-контейнера:

router.addRoute({     path: '/',     component: () => container.get('Fl64_Habr_Vue_Front_Route_Home$') });

В результате соответствующие es6-модули подгружаются в браузер по мере использования (перехода на соответствующий маршрут PWA/SPA).

I18n

Для локализации текстовых вставок применяется npm-пакет i18next и teq-обёртка для него — @teqfw/i18n. JSON-ресурсы с переводами находятся в каталогах

  • ./i18n/
    • ./back/
    • ./front/
    • ./share/

в файлах ru.json, en.json, …

На серверной стороне реализован реестр TeqFw_I18n_Back_Model_Registry, который при старте backend-приложения сканирует teq-плагины в каталоге ./node_modules/ и формирует массив с переводами, совместимый с i18next. Имя npm-пакета при этом используется в качестве namespace’а для i18n-ресурсов соответствующего плагина:

{   "lng": {     "@vnd/plugin": {       "resource": "translation"     }   } }

Для доставки translation-ресурсов на фронт используется объект-обёртка TeqFw_I18n_Front_Lib и сервис TeqFw_I18n_Back_Service_Load.

Функция Fl64_Habr_Vue_Front_App.init.initI18n инициализирует обёртку, загружая соответствующие ресурсы с сервера, и добавляет пропатченную функцию-транслятор во Vue:

async function initI18n(app, I18nLib) {     await I18nLib.init(['en', 'ru'], 'en');     const appProps = app.config.globalProperties;     const i18n = I18nLib.getI18n();     // add translation function to Vue     appProps.$t = function (key, options) {         // add package name if namespace is omitted in the key         const ns = this.$options.teq?.package;         if (ns && key.indexOf(':') <= 0) key = `${ns}:${key}`;         return i18n.t(key, options);     } }

С пропатченной функцией-траслятором, во vue-компонентах, у которых присутствует атрибут teq.package можно не использовать i18next namespaces, как в html-шаблонах:

<div>{{$t('widget.cfg.lang.title')}}:</div>

так и в JS-коде:

computed: {     optsLang() {         return [             {label: this.$t('widget.cfg.lang.lang.en'), value: 'en'}         ];     }, }

Если в функции-трансляторе i18next namespace используется в явном виде:

<div>{{$t('@vnd/plugin:myKey')}}:</div>

то можно напрямую использовать ресурсы из любого teq-плагина приложения.

Функционал

Список дел

Основной компонент — Fl64_Habr_Vue_Front_Route_Home. Виджеты:

Конфигурация

Основной компонент — Fl64_Habr_Vue_Front_Route_Cfg:

Переключение языка

Виджеты:

С переключением языка интересно. Нужно не только изменить состояние i18next-объекта, который находится в globals, но и перерисовать весь UI с новыми переводами. Для этого во Vue предлагается использовать :key атрибут в самом верхнем элементе иерархии vue-компонентов (у меня — в Fl64_Habr_Vue_Front_Layout_Base):

<q-layout view="..." :key="langChange">     ... </q-layout>

И инкрементировать его каждый раз, когда необходима перерисовка UI’я. Проблема в том, что перерисовку нужно начинать с корня иерархии компонентов, а сигнал на перерисовку — подаваться из глубины иерархии (из Fl64_Habr_Vue_Front_Widget_Cfg_Lang). Во Vue 3 для этого предлагается использовать пару provide / inject. В теории можно создать в Layout_Base реактивный объект и предоставить его дочерним компонентам через provide, а во вложенном Widget_Cfg_Lang получить реактивный объект и изменить его состояние, чтобы перерисовать UI, начиная с самого верха. Вот только состояние объекта изменяется (это видно под отладчиком), а перерисовки не происходит.

Поэтому «грязный хак» — реактивный объект вешается на функцию-конструктор:

function Factory(spec) {     // ...     return {         // ...         setup() {             const langChange = ref(0);             Factory.langChangeCounter = langChange;              return {langChange};         }     }; }

а затем реактивный объект извлекается в Widget_Cfg_Lang:

/** @type {Fl64_Habr_Vue_Front_Layout_Base.Factory} */ const BaseLayoutFactory = spec['Fl64_Habr_Vue_Front_Layout_Base#']; // ... watch: {     fldLang(current, old) {         // ...         BaseLayoutFactory.langChangeCounter.value++;     } }

Вот так — работает, а через Vue 3 provide / inject — нет. Возможно, я что-то криво делаю с «родным DI» (хотя порядок создания и доступа к реактивному объекту правильный и сам объект langChangeCounter изменяется при смене языка).

Очистка кэша

Поскольку это всё-таки PWA, то кэширование статики на уровне service worker’а добавляет некоторой специфики. Во-первых, приложение получает возможность работать offline, а во-вторых — приложению нужен какой-то способ получать с сервера изменения для файлов, сохранённых в кэше. В демо-приложении применяется радикальный способ — пользователь может полностью удалить кэш service-worker’а прямо из приложения:

В Chrome есть инструменты, облегчающие жизнь PWA-разработчику:

но в смартфонах очистку кэша лучше вынести на уровень пользователя.

Резюме

Демо-проект показывает, как можно интегрировать в «ненормальное» teq-приложение код «нормальных» браузерных npm-пакетов, написанных на JS (транспилированных в JS из других языков). Я использовал Vue, потому что мне он больше по душе, но уверен, что точно так же можно оборачивать React (насчёт Angular’а не уверен, т.к. он сам по себе — платформа):

  <script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>   <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>

В общем, всё, что может загрузиться на страницу — можно обернуть и сделать доступным через DI-контейнер (в nodejs таких проблем нет, там и так всё доступно через связки export-import пакетного уровня). Мне понравился Vue 3 и Quasar UI, поэтому мой ToDo List выглядит так (за выбор цветовой гаммы прошу сильно не пинать — я начинал с монохромных дисплеев).

Несмотря на то, что иногда мой код считают «диалектом«, каждый отдельный mjs-файл — это валидный JS. Его можно импортировать как обычный es6-модуль без всяких дополнительных ухищрений и использовать в своём коде. Вот только зависимости в spec-объект конструкторов и фабричных функций придётся добавлять вручную.

Самым ценным для себя я считаю знакомую структуру es6-модулей в браузере:

Сравните со структурой в IDE:

Разница в файлах объясняется тем, что в браузер подгрузились не все модули — «loading on demand» (хотя в кэше service-worker’а находятся все модули).

Также обратите внимание на удобство использования namespace’ов при документировании приложения:

  • Fl64_Habr_Vue_Front_Widget_ToDo_Item
  • Fl64_Habr_Vue_Front_App.init.initI18n

Некоторые считают, длинные идентификаторы для объектов кода не слишком удобными, но чем больше в приложении объектов кода (классов, функций, констант), тем длиннее становятся идентификаторы. Просто мы их прячем в имена npm-пакетов и пути к соответствующему файлу внутри пакета. Так что длинные идентификаторы — это просто следствие больших проектов.

И вообще, прямой import es6-модуля через DI-контейнер из любого пакета с использованием логического пространства имён позволяет строить «модульные монолиты» — когда монолитное приложение собирается из модулей (пакетов), но запускается как единое целое. Более того, ничего не мешает одним и тем же пакетам быть как частями большого «монолита«, так и частями «микросервисов«, обслуживающих этот «монолит«. Код из shared-секций пакетов может, например, работать в back-секциях «микросервисов» и во front-секциях «монолита» (или «монолитов«).

Да, я в курсе, что ровно то же самое можно сделать и без всякого DI, на основе «нормальных» export’ов и import’ов. Вот поэтому я и поместил публикацию в хаб «Ненормальное программирование«. А что касается хаба «JavaScript» — ну так это самый, что ни на есть JS и есть.


ссылка на оригинал статьи https://habr.com/ru/articles/569998/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *