Русский
Русский
English
Статистика
Реклама

Фронтенд

Дайджест свежих материалов из мира фронтенда за последнюю неделю 459 (15 21 марта 2021)

22.03.2021 00:17:09 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Новости 512 от CSSSR: История React API, доклады и воркшопы с Я <3 фронтенд, нюансы this, релизы браузеров и V8, semver
podcast Подкаст Сделайте мне красиво 57 Если всё работает, то и тесты не нужны
podcast Подкаст Веб-стандарты 273. Старый Edge, Safari, React Native и Preact, MDN по-русски, печать, EditorConfig, ховеры, шрифты

Веб-разработка


habr Очередной гайд по HTML-элементам, которые можно использовать в каждом проекте
en Объяснение принципа работы Webpacks Hot Module Replacement Feature
en Как имитировать мобильные устройства в режиме Device Mode в Chrome
en Русская локаль на MDN разморожена и можно снова участвовать в переводе лучших материалов для веб-разработчиков. Желающие присоединиться к проекту могут отписаться в соответствующем issue




CSS


en Официальная вложенность в CSS последний кусочек головоломки
en Знаете ли вы о CSS селекторе :has?
en Три важных вещи, которые вы должны знать о :is() в CSS
en 100 анимаций подчеркивания/наложения | The ultimate CSS collection
en CSS генераторы
en Fluid Space Calculator
en Лучшие техники переноса строки для длинных URL-адресов
en Новости платформы: Prefers Contrast, MathML, :is(), и CSS Background InitialValues
en Что я делаю с размерами шрифтов
en Да, вот лучший фреймворк CSS в 2021 году

JavaScript


Удобный доступ кбуферу обмена сClipboard API
habr JavaScript prototype pollution: практика поиска и эксплуатации
en Обработка пользовательских разрешений в JavaScript
en Совет 1 по ознакомлению с новыми кодовыми базами JavaScript
en Что такое JavaScript Internationalization API (I18n)?







Браузеры


Из Firefox намерены убрать компактный режим отображения панелей
В ночных и бета сборках Firefox включена по умолчанию поддержка HTTP/3
Chrome получил функцию Live Captions и теперь может автоматически создавать субтитры для видео и аудио с речью


Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 460 (22 28 марта 2021)

29.03.2021 00:13:50 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast UnderJS Podcast #30 Billing и бухгалтерия инженерная сторона с Виталием Слободиным.
podcast Новости 512 от CSSSR: Firefox 87, Grid на примерах, смерть Node.js-процессов, проблема JS-библиотек, Compat2021
podcast Подкаст Фронтенд Юность #178: Супер-выпуск. Массивы против объектов. Раунд 1
podcast Подкаст Веб-стандарты 274. Новинки V8, бета Chrome 90 и веб-бандлы, ненастоящие PWA и нативный CSS Nesting

Веб-разработка


habr 14 полезных инструментов, ускоряющих и упрощающих веб-разработку
en Глубокое погружение в Eleventy Static Site Generator
en Под капотом Emoji
en Конец AMP
en Интерактивные веб-компоненты проще, чем вы думаете
en Bootstrap 5 Beta 3, Понимание леайута в Bootstrap 5
en 5 различных инструментов для мониторинга фронтенда
en Headless: сценарии использования и для чего он нужен




CSS


video Цветовые функции в CSS. Мечты сбываются: смешивание, прозрачность, контраст
en Compat2021: устранение пяти основных проблем совместимости в вебе
en Приручение режимов наложения: `difference` и`exclusion`
en 4 лучших альтернативы Tailwind CSS на 2021 год
en Обработка текста, размещенного поверх изображений в CSS
en Нетипичное использование горизонтальных разделителей
en Анимация подчеркивания
en Как улучшить недостаточно проработанные элементы select
en Эффект фрагментации изображения с помощью масок CSS и кастомных свойств

JavaScript


habr Как создатель node.js сам разочаровался в нем
en Понятный человеку JavaScript: история двух экспертов
en Основные тренды JavaScript, за которыми стоит следить в 2021 году
en JavaScript SEO: лучшие практики и инструменты отладки
en Самый проклятый JavaScript
en Новые функции ES2021, которые вы могли пропустить







Браузеры


habr Google удалил расширение ClearURLs из Chrome Web Store
habr IETF официально прекратил поддержку протоколов TLS 1.0 и 1.1
Релиз Firefox 87
Microsoft, Google и другие объединяют усилия для улучшения совместимости браузеров
В Chrome 90 утверждено использование HTTPS по умолчанию в адресной строке

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 461 (29 марта 4 апреля 2021)

05.04.2021 00:18:36 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры|Занимательное


Медиа


podcast Новости 512 от CSSSR: Next.js 10.1, Socket.io 4, апрельские security-релизы Node.js, анонс Deno Company, Sublime Text 4
podcast Новости 512 от CSSSR: SvelteKit beta, Bootstrap 5 beta-3, локализация MDN RU, прототип Container Query, атака на PHP
podcast Подкаст Сделайте мне красиво 59 Чем программист отличется от светофора?
podcast Подкаст Фронтенд Юность #180: Конфетти-токены на самописном React-е
podcast Подкаст Веб-стандарты 275. Firefox 87, Container Queries, Compat 2021, SvelteKit, эмоджи, лонгрид и дорога к доступности
video Хард скиллы верстальщика: интервью с Людмилой Мжачих из PayDay, Mail.ru Group
podcast video Подкаст Да как-так то?. Пилотный выпуск


Веб-разработка


habr 7 полезных HTML-атрибутов, о которых вы, возможно, не знаете
habr Вы можете создавать эти элементы, не используя JavaScript
en Как я проанализировал все лицензии в NPM зависимостях
en Использование Python для фронтенда
en Новости платформы: скругленные outlines, GPU-Accelerated анимации в SVG, как определяются CSS переменные





CSS


habr Крутые трюки с переменными CSS
habr Font size бесполезен, давайте это исправим
video CSS Container Queries, или Адаптация по-новому: контейнер вместо вьюпорта
en Container Queries уже на подходе
en Адаптивный веб-дизайн для нового поколения раскладывающихся устройств
en Темный режим за 5 минут, с инвертированными переменными яркости
en Встречайте Utopia: проектирование и разработка с использованием резиновых размеров и масштабирующихся отступов
en Портреты в виде кляксы: развлекаемся с функцией CSS path()
en Несовершенная разработка: стили CSS с перспективой на будущее
en Давайте создадим эффект всплывающего изображения с помощью SVG Clip Path
en Создание волшебной 3D-кнопки
en Совет: стилизуйте псевдоэлементы с помощью Javascript, используя кастомные свойства CSS
en Инструменты для аудита CSS
en Reseter.css современный CSS Reset/Normalizer

JavaScript


habr Экосистема JavaScript: тренды в 2021 году. Всё ли так однозначно?
habr JavaScript: Стек вызовов и магия его размера
habr Умная квартира на JavaScript. От светодиода до распознавания лица в камере домофона
Лучший JavaScript-фреймворк 2021: React илиVue?
en Использование GPU для повышения производительности JavaScript
en [V8 Deep Dives] Случайные мысли о Math.random()






Браузеры


Яндекс.Браузер научился переводить текст на картинках
Браузер Opera получил нативную поддержку ARM-процессора Apple M1


Занимательное


Фальшивая версия плагина jQuery Migrate заразила множество сайтов
Злоумышленники использовали GitHub для криптомайнинга, но сервис до сих пор не закрыл уязвимость
Эксперимент: как запросы в поиске и просмотры на YouTube влияют на ленту от Google с новостями
Офис остался на первом месте: у Amazon и Google не будет полной удалёнки, а сотрудников постепенно вернут в кампусы
Подборка первоапрельских шуток 2021 года
О чём пишут 1 апреля в ИТ-прессе. Лучшие шутки
en Google vs Baidu: ключевые различия в стратегии SEO

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 462 (5 11 апреля 2021)

12.04.2021 00:19:09 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Новости 512 от CSSSR: TypeScript 4.3 beta, воркшоп по TDD c React, Husky 6, сравнение Hyperapp с React, RGB и HSL
podcast Новости 512 от CSSSR: Prototype pollution, Tailwind CSS 2.1, Ruby 3.0.1, Cypress 7.0, security-обновления Node.js
podcast Подкаст Веб-стандарты 276. Safari, static в V8, Deno, Sublime Text, HSL и LCH, F1, WordPress, SPA, гидрация и Next.js
podcast Подкаст Фронтенд Юность #181 Эйблизм открытого ПО

Веб-разработка


habr Веб-империя правительства UK: все во имя человека, для блага человека
habr DevTools для чайников
PWA для SSR приложения в 5 строк на Workbox 6.
en Руководство по SEO в Jamstack
en HTML атрибут ping для якорных элементов (ссылок)
en Обновление архитектуры DevTools: перенос DevTools на TypeScript
en Создание переключателя с поддержкой доступности
en Эффект перехода на полноразмерную страницу по клику на превью

CSS


habr 5 плохих CSS практик
en Псевдоклассы CSS :where и :is
en Устранение проблем с темным режимом Gmail с помощью CSS Blend Modes
en Современные обновления CSS для улучшения доступности
en Инспектирование элементов как способ удовлетворить свое любопытство
en Возвращение к CSS Pie таймеру

JavaScript


Спецификация ECMAScript 2021 для JavaScript приближается к финишу
en Шпаргалка по переходу с jQuery на ванильный JavaScript
en Сниппеты кода на ванильном JavaScript
en Создание умной панели навигации на ванильном JavaScript
en Лечебная сила JavaScript
en 5 способов предотвратить внедрение кода в JavaScript и Node.js
en Замена let на const
en JS классы это не просто синтаксический сахар







Браузеры


habr Шпион, выйди вон: что делают браузеры после установки?
Google Chrome заблокировал HTTP, HTTPS и FTP-доступы на порте 10080 из-за хакерской угрозы
10 малоизвестных возможностей браузера Google Chrome

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 463 (12 18 апреля 2021)

19.04.2021 00:19:54 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Новости 512 от CSSSR: Chrome 90, Deno 1.9, анализ производительности JS, сборщики, верстка писем, pnpm 6, ESLint 7.24.0
podcast Подкаст proConf #92: GatsbyConf 2021
podcast Подкаст Сделайте мне красиво 60 Единственный фронтендер, который откладывает яйца
podcast Подкаст Фронтенд Юность #183: Путь от идеи до популярного OpenSource проекта
podcast Подкаст Да как так-то?. Выпуск 2: Тимлиды, проектные менеджеры, тестировщики кто все эти люди?

Веб-разработка


habr Малоизвестные, но крутые атрибуты в HTML
habr Микрофронтенды: разделяй и властвуй
en Полное руководство по созданию шаблонов HTML-писем
en Практическая доступность, часть 2: дайте имя (почти) всему
en Новости платформы: Использование :focus-visible, новый шрифт BBC, Declarative Shadow DOMs, A11Y иплейсхолдеры
en Медленно и осторожно: конвертация всего интерфейса Sentry на TypeScript
en Напряжение между Wix и WordPress растет




CSS


habr Нестандартные шрифты: как подключить и оптимизировать
habr Какие CSS-генераторы можно использовать в 2021 году
habr Пользовательские CSS-переменные, инверсия светлоты цветов и создание тёмной темы за 5 минут
habr CSS: работа с текстом на изображениях
Tailwind CSS: to use, или not to use?
en Tailwind UI: теперь с поддержкой React + Vue
en Проблемы с Overflow в CSS
en Как подружить стили с Fullscreen API
en Скажите привет CSS Container Queries
en CSS это строго типизированный язык
en Руководство для новичков по новым утилитам в Bootstrap 5
en Используйте Reseter.css вместо Normalize и Reset.css. Чтобы улучшить кроссбраузерность.


JavaScript


habr Типобезопасность в JavaScript: Flow и TypeScript
habr Работа с датой и часовыми поясами в JavaScript
en Изменение размера изображения в зависимости от контета с помощью JavaScript
en Работа со строками в современном JavaScript
en Генераторы JavaScript: превосходный async/await
en Другой подход к архитектуре фронтенда





Браузеры


habr Вышел Chrome 90
Включение поддержки HTTP/3 в Firefox намечено на конец мая
В Firefox 90 будет удалён код, обеспечивающий поддержку FTP
Разработчики Vivaldi и Brave отказались использовать FLoC от Google, призванный заменить сторонние cookie
В Microsoft Edge появился специальный детский режим
en В Firefox Nightly и Beta появилась поддержка QUIC и HTTP / 3
en WebKit: Представляем CSS Grid Inspector

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 464 (19 25 апреля 2021)

26.04.2021 00:06:32 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры|Занимательное|


Медиа


podcast Callback Hell записи аудио-стримов о технологиях и не только от CSSSR
podcast Подкаст Фронтенд Юность #182: Не нужно платить разработчикам 200-300к
podcast Новости 512 от CSSSR: релиз Node.js 16 и Firefox 88, проектирование приложения с TypeScript и ООП, взгляд на Container Query, доклады с Я.Субботника.
podcast Новости 512 от CSSSR: Микрофронтенды в Delivery Club, JS-классы, состояние JS-фреймворков и стейт-менеджеров, минусы Dart
video IT-дебаты: JavaScript-программист vs фронтенд-разработчик
habr video Frontend Meetup 20/04
video Я.Субботник по разработке интерфейсов 2021


Веб-разработка


habr CORS для чайников: история возникновения, как устроен и оптимальные методы работы
habr Как создают и поддерживают веб-страницы tinkoff.ru
habr Трёхпроходный алгоритм рефакторинга Front End
en Что нового в DevTools (Chrome 91)
en Независимые компоненты: новые строительные блоки для веба
en Как я создал свой блог



CSS


habr Полное руководство по CSS Flex + опыт использования
CSS-нестинг больше, чем сахар
en Руководство по новым современным псевдо-селекторам CSS
en Создание (и потенциальные преимущества) CSS-шрифта
en Как добавить двойную границу к SVGShapes
en Начало работы с CSS Custom Properties
en TailwindCSS: добавляет сложности, ничего не делает.
en Работа на ошибками гибкой типографии, базирующихся на вьюпорте в Safari


JavaScript


habr Основы управления памятью в JavaScript: как это работает и какие проблемы могут возникнуть
Выпуск серверной JavaScript-платформы Node.js 16.0
en Чудесный мир Javascript бандлеров
en Улучшите управление состоянием в вашем фронтенде с помощью view models
en Шаблон для свойства отложенной загрузки в JavaScript
en Полное руководство по инкрементной статической регенерации (ISR) с Next.js
en Топ-5 самых популярных вопросов о JavaScript на Stack Overflow
en Руководство по MobX
en Понимание Array Reduce в JavaScript





Браузеры


В Firefox 88 молча удалён пункт контекстного меню Page Info
Apple, Microsoft, Opera и другие разработчики не горят желанием поддерживать технологию Google FLoC
Релиз Firefox 88
В Microsoft Edge тестируется новый режим производительности с иным принципом работы спящих вкладок


Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 465 (26 2 мая 2021)

02.05.2021 22:08:33 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript


Медиа


podcast Подкаст proConf #94: GraphQL Galaxy
podcast Новости 512 от CSSSR: Chrome 91 Beta, postcss-easy-z, tree-shakeable библиотеки, гайды по кастомным CSS-свойствам и CLS
podcast Подкаст Callback Hell: Падение последнего оплота Dart, визуальные ЯП на примере Enso, Lucy DSL для стейт-машин
podcast Подкаст Фронтенд Юность#184: Матрица для мешка с картошкой


Веб-разработка


История фронтенда: JavaScript как отражение новой эпохи
habr HTMHell адовая разметка
habr HTML-теги и атрибуты, о которых вы, возможно, не знали
habr Адаптивный дизайн как антипаттерн
en Скромный элемент img и Core Web Vitals
en Как реализовать выбор дейстий для выделенного текста с помощью SelectionAPI




CSS


habr Примеры применения переменных CSS на практике
habr Контейнерные запросы в CSS
habr Как обеспечить глассморфизм с помощью HTML и CSS
VDS (value definition syntax)
en fit-content и fit-content()
en Полное руководство по Custom Properties
en Первый взгляд на CQFill, полифилл для CSS Container Queries
en Изучение color-contrast() в первый раз
en GPT-3 и CSS-фреймворки
en Понимание easing-функций для анимации и переходов в CSS
en Новые возможности WebKit в Safari 14.1 (Flexbox Gap, Date & Time Inputs, CSS Individual Transform Properties)

JavaScript


habr Целительная сила JavaScript
habr Человеко-читаемый JavaScript: история о двух экспертах
Принцип мозаики, или Как мы сделали JavaScript по-настоящему модульным
en Fower утилитарная CSS in JS библиотека для быстрой разработки интерфейсов.
en Клиентские шаблоны API, о которых должен знать каждый разработчик фронтенда








Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Модульные front-end блоки пишем свой мини фреймворк

09.05.2021 16:23:04 | Автор: admin

Доброго времени суток уважаемые читатели хабра. С каждым годом в веб разработке появляется все больше разнообразных решений которые используют модульный подход и упрощают разработку и редактирование кода. В данной статье я предлагаю вам свой взгляд на то, какими могут быть переиспользуемые front-end блоки (для проектов с бэкендом на php) и предлагаю пройти все шаги от идеи до реализации вместе со мной. Звучит интересно? Тогда добро пожаловать под кат.

Предисловие

Представлюсь - я молодой веб разработчик с опытом работы 5 лет. Крайний год я работаю на фрилансе и большая часть текущих проектов связана с WordPress. Несмотря на различую критику CMS в общем и WordPress в часности, я считаю сама архитектура WordPress это довольно удачное решение, хотя конечно не без определенных недостатков. И один из них на мой взгляд это шаблоны. В крайних обновлениях сделаны большие шаги чтобы это исправить, и Gutenberg в целом становится мощным инструментом, однако к сожалению в большинстве тем продолжается каша в шаблонах, стилях и скриптах, которая делает редактирование чего-либо крайне болезненным, а переиспользование кода зачастую невозможным. Именно эта проблема и подтолкнуло меня к идее своего мини фреймворка (читай пакета, но поскольку он будет предъявлять требования к структуре, то гордо назовем мини фреймворком), который бы организовывал структуру и позволял переиспользовать блоки.

Реализация будет в виде composer пакета, который можно будет использовать в совершенно различных проектах, без привязки к WordPress.

Мотивом написать данную статью было желание поделится решением для организации модульных блоков, а также желание читателя хабра написать свою статью, что сродни желанию создать свой пакет, которое порой возникает у начинающих использовать готовые пакеты composer или npm.

Как можно заключить из текста выше, это моя первая статья на хабре, по-этому просьба не бросать помидоры не судить строго.

Постановка задачи

Понятие блок ниже будет по сути тем же понятием что и блок в BEM методологии, т.е. это будет группа html/js/css кода которая будет представлять одну сущность.

Генерировать html и управлять зависимостями блоков мы будем через php, что говорит о том, что наш пакет будет подходить для проектов с бекендом на php. Также условимся на берегу что, не вдаваясь в споры, не будем поддаваться влиянию новомодных вещей, таких как css-in-js или bem-json и будем придерживаться эль-классико классического подхода, т.е. предполагать что html, css и js это разные файлы.

Теперь давайте сформулируем наши основные требования к будущему мини-фреймворку:

  • Обеспечить структуру блоков

  • Предоставить поддержку наследования (расширения) блоков

  • Предоставить возможность использовать блок в блоке и соответственно поддержку зависимости ресурсов одного блока от ресурсов других блоков

Структура мини фреймворка

Как условились выше, такие ресурсы как css и js всегда будут в виде обычных файлов, т.е. это будут .js и .css или .min.css и .min.js в случае использования препроцесссоров и сборщиков (как webpack например). Для вставки данных в html код мы будем использовать шаблонизатор Twig (для тех кто не знаком ссылка). Кто-то может заметить, что Php и сам по себе хороший шаблонизатор, не будем вдаваться в споры, кроме доводов указанных на главной странице проекта Twig, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.

Теперь давайте продумаем структуру нашего мини фреймворка более детально.

  1. Блок

    Каждый блок будет состоять из:

    1. Статических ресурсов (css/js/twig)

    2. Класса модели (его поля мы будет предоставлять как данные для twig шаблона)

    3. Класса контролера (он будет отвечать за наши ресурсы, их зависимости друг от друга и связывать модель с twig шаблоном)

  2. Вспомогательные классы : Класс Settings (будет содержать путь к блокам, их пространство имен и т.д.), класс обертка для Twig пакета

  3. Blocks класс

    Связующий класс, который :

    1. будет содержать вспомогательные классы (Settings, Twig)

    2. предоставлять функцию рендера блока

    3. содержать список использованных блоков, чтобы иметь возможность получить их ресурсы (css/js)

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

Требования к блокам

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

  • php 7.4+

  • Все блоки должны иметь одну родительскую директорию

  • Классы моделей и контроллеров должны иметь PSR-4 совместимое пространство имен с автозагрузчиком (PSR-4 де факто стандарт, если вы используете автозагрузчик от composer, т.е. указываете autoload/psr4 директиву в вашем composer.json то ваш проект уже соответствует этому требованию)

  • Соглашение об именах:

    • Имя контроллера должно содержать _C суффикс

    • Класс модели должен иметь то же пространство имен и то же имя (без суффикса) что и соответствующих контроллер

    • Имена ресурсов должны соответствовать имени контроллера, но с данными отличиями:

      • Без суффикса контроллера

      • Верблюжья нотация в имени должны быть заменена на тире (CamelCase = camel-case)

      • Нижнее подчеркивание в имени должно быть заменено на тире (just_block = just-block)

      • Таким образом по правилам выше имя ресурса с контроллером Block_Theme_Main_C будет blocktheme--main

Реализация

Пришло время перейти к реализации нашей идеи, т.е. к коду.

Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов. Согласен с людьми, которые говорят что тесты есть лучшая документация, однако к своему стыду я начал использовать их в своих проектах недавно, по-этому не смотря на мои старания их имена или структура могут повергнуть в шок сбивать с толку, просьба не принимать близко к сердцу.

FIELDS_READER

Все наша магия при работе с моделями и контроллерами будет строится на функции get_class_vars которая предоставит нам имена полей класса и на ReflectionProperty классе, который предоставит нам информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.

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

FIELDS_READER.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;use Exception;use ReflectionProperty;abstract class FIELDS_READER {private array $_fieldsInfo;public function __construct() {$this->_fieldsInfo = [];$this->_readFieldsInfo();$this->_autoInitFields();}final protected function _getFieldsInfo(): array {return $this->_fieldsInfo;}protected function _getFieldType( string $fieldName ): ?string {$fieldType = null;try {// used static for child support$property = new ReflectionProperty( static::class, $fieldName );} catch ( Exception $ex ) {return $fieldType;}if ( ! $property->isProtected() ) {return $fieldType;}return $property->getType() ?$property->getType()->getName() :'';}private function _readFieldsInfo(): void {// get protected fields without the '__' prefix$fieldNames = array_keys( get_class_vars( static::class ) );$fieldNames = array_filter( $fieldNames, function ( $fieldName ) {$prefix = substr( $fieldName, 0, 2 );return '__' !== $prefix;} );foreach ( $fieldNames as $fieldName ) {$fieldType = $this->_getFieldType( $fieldName );// only protected fieldsif ( is_null( $fieldType ) ) {continue;}$this->_fieldsInfo[ $fieldName ] = $fieldType;}}private function _autoInitFields(): void {foreach ( $this->_fieldsInfo as $fieldName => $fieldType ) {// ignore fields without a typeif ( ! $fieldType ) {continue;}$defaultValue = null;switch ( $fieldType ) {case 'int':case 'float':$defaultValue = 0;break;case 'bool':$defaultValue = false;break;case 'string':$defaultValue = '';break;case 'array':$defaultValue = [];break;}try {if ( is_subclass_of( $fieldType, MODEL::class ) ||     is_subclass_of( $fieldType, CONTROLLER::class ) ) {$defaultValue = new $fieldType();}} catch ( Exception $ex ) {$defaultValue = null;}// ignore fields with a custom type (null by default)if ( is_null( $defaultValue ) ) {continue;}$this->{$fieldName} = $defaultValue;}}}
FIELDS_READERTest.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocksFramework\CONTROLLER;use LightSource\FrontBlocksFramework\FIELDS_READER;use LightSource\FrontBlocksFramework\MODEL;class FIELDS_READERTest extends Unit {public function testReadProtectedField() {$fieldsReader = new class extends FIELDS_READER {protected $_loadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( ['_loadedField' => '',], $fieldsReader->getFields() );}public function testIgnoreReadProtectedPrefixedField() {$fieldsReader = new class extends FIELDS_READER {protected $__unloadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( [], $fieldsReader->getFields() );}public function testIgnoreReadPublicField() {$fieldsReader = new class extends FIELDS_READER {public $unloadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( [], $fieldsReader->getFields() );}public function testIgnoreReadPrivateField() {$fieldsReader = new class extends FIELDS_READER {private $unloadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( [], $fieldsReader->getFields() );}public function testReadFieldWithType() {$fieldsReader = new class extends FIELDS_READER {protected string $_loadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( ['_loadedField' => 'string',], $fieldsReader->getFields() );}public function testReadFieldWithoutType() {$fieldsReader = new class extends FIELDS_READER {protected $_loadedField;public function __construct() {parent::__construct();}public function getFields() {return $this->_getFieldsInfo();}};$this->assertEquals( ['_loadedField' => '',], $fieldsReader->getFields() );}////public function testAutoInitIntField() {$fieldsReader = new class extends FIELDS_READER {protected int $_int;public function __construct() {parent::__construct();}public function getInt() {return $this->_int;}};$this->assertTrue( 0 === $fieldsReader->getInt() );}public function testAutoInitFloatField() {$fieldsReader = new class extends FIELDS_READER {protected float $_float;public function __construct() {parent::__construct();}public function getFloat() {return $this->_float;}};$this->assertTrue( 0.0 === $fieldsReader->getFloat() );}public function testAutoInitStringField() {$fieldsReader = new class extends FIELDS_READER {protected string $_string;public function __construct() {parent::__construct();}public function getString() {return $this->_string;}};$this->assertTrue( '' === $fieldsReader->getString() );}public function testAutoInitBoolField() {$fieldsReader = new class extends FIELDS_READER {protected bool $_bool;public function __construct() {parent::__construct();}public function getBool() {return $this->_bool;}};$this->assertTrue( false === $fieldsReader->getBool() );}public function testAutoInitArrayField() {$fieldsReader = new class extends FIELDS_READER {protected array $_array;public function __construct() {parent::__construct();}public function getArray() {return $this->_array;}};$this->assertTrue( [] === $fieldsReader->getArray() );}public function testAutoInitModelField() {$testModel        = new class extends MODEL {};$testModelClass   = get_class( $testModel );$fieldsReader     = new class ( $testModelClass ) extends FIELDS_READER {protected $_model;private $_testClass;public function __construct( $testClass ) {$this->_testClass = $testClass;parent::__construct();}public function _getFieldType( string $fieldName ): ?string {return ( '_model' === $fieldName ?$this->_testClass :parent::_getFieldType( $fieldName ) );}public function getModel() {return $this->_model;}};$actualModelClass = $fieldsReader->getModel() ?get_class( $fieldsReader->getModel() ) :'';$this->assertEquals( $actualModelClass, $testModelClass );}public function testAutoInitControllerField() {$testController      = new class extends CONTROLLER {};$testControllerClass = get_class( $testController );$fieldsReader        = new class ( $testControllerClass ) extends FIELDS_READER {protected $_controller;private $_testClass;public function __construct( $testControllerClass ) {$this->_testClass = $testControllerClass;parent::__construct();}public function _getFieldType( string $fieldName ): ?string {return ( '_controller' === $fieldName ?$this->_testClass :parent::_getFieldType( $fieldName ) );}public function getController() {return $this->_controller;}};$actualModelClass    = $fieldsReader->getController() ?get_class( $fieldsReader->getController() ) :'';$this->assertEquals( $actualModelClass, $testControllerClass );}public function testIgnoreInitFieldWithoutType() {$fieldsReader = new class extends FIELDS_READER {protected $_default;public function __construct() {parent::__construct();}public function getDefault() {return $this->_default;}};$this->assertTrue( null === $fieldsReader->getDefault() );}}

MODEL

Данный класс по сути лишь небольшая обертка для класса FIELDS_READER, который содержит поле _isLoaded, что отвечает за состояние модели, оно пригодится нам когда мы будем работать с twig, и функции getFields, которая возвращает массив со значениями protected полей, в котором ключи это их имена.

MODEL.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;abstract class MODEL extends FIELDS_READER {private bool $_isLoaded;public function __construct() {parent::__construct();$this->_isLoaded = false;}final public function isLoaded(): bool {return $this->_isLoaded;}public function getFields(): array {$args = [];$fieldsInfo = $this->_getFieldsInfo();foreach ( $fieldsInfo as $fieldName => $fieldType ) {$args[ $fieldName ] = $this->{$fieldName};}return $args;}final protected function _load(): void {$this->_isLoaded = true;}}
MODELTest.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocksFramework\MODEL;class MODELTest extends Unit {public function testGetFields() {$model = new class extends MODEL {protected string $_field1;public function __construct() {parent::__construct();}public function update() {$this->_field1 = 'just string';}};$model->update();$this->assertEquals( ['_field1'   => 'just string',], $model->getFields() );}}

CONTROLLER

Данный класс также как и MODEL наследует класс FIELDS_READER, однако имеет и другие важные задачи. Содержит два поля модель и массив __external, который пригодится нам далее при работе с twig шаблоном.

Статический метод GetResourceInfo позволяет получить информацию о статических ресурсах данного блока (twig,css,js) , такую как имя ресурса или относительный путь к нему (мы можем получить это из имени и пространства имен контроллера благодаря соблюдению требований выше).

Метод getTemplateArgs будет возвращать данные для twig шаблона, это все protected поля соответствующей модели (без префикса _ если есть) и два дополнительных поля, _template и _isLoaded, первое будет содержать путь к шаблону, а второе отображать состояние модели. Также в этом методе мы реализуем возможность использовать блок в блоке (т.е. иметь класс Model в другом классе Model как поле) - мы соединяем поля контроллера и поля соответствующей модели по имени : т.е. если каждому полю с типом контроллер мы находим соответствующее поле в модели (с типом модель), то мы инициализируем поле контроллер моделью и вызываем метод getTemplateArgs у этого контроллера, получая таким образом все необходимую информацию для отображения этого вложенного блока.

Метод getDependencies на основании наших полей с типом контроллер рекурсивно (с обходом всех подзависимостей) возвращает нам уникальный (т.е. без повторений) список используемых классов-контроллеров, что помогает нам реализовать возможность зависимости ресурсов одного блока от другого.

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

CONTROLLER.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;use Exception;abstract class CONTROLLER extends FIELDS_READER {const TEMPLATE_KEY__TEMPLATE = '_template';const TEMPLATE_KEY__IS_LOADED = '_isLoaded';private ?MODEL $_model;// using the prefix to prevent load this fieldprotected array $__external;public function __construct( ?MODEL $model = null ) {parent::__construct();$this->_model     = $model;$this->__external = [];$this->_autoInitModel();}final public static function GetResourceInfo( Settings $settings, string $controllerClass = '' ): array {// using static for children support$controllerClass = ! $controllerClass ?static::class :$controllerClass;// e.g. $controllerClass = Example/Theme/Main/Example_Theme_Main_C$resourceInfo = ['resourceName'         => '',// e.g. example--theme--main'relativePath'         => '',// e.g. Example/Theme/Main'relativeResourcePath' => '', // e.g. Example/Theme/Main/example--theme--main];$controllerSuffix = Settings::$ControllerSuffix;//  e.g. Example/Theme/Main/Example_Theme_Main$relativeControllerNamespace = $settings->getBlocksDirNamespace() ?str_replace( $settings->getBlocksDirNamespace() . '\\', '', $controllerClass ) :$controllerClass;$relativeControllerNamespace = substr( $relativeControllerNamespace, 0, mb_strlen( $relativeControllerNamespace ) - mb_strlen( $controllerSuffix ) );// e.g. Example_Theme_Main$phpBlockName = explode( '\\', $relativeControllerNamespace );$phpBlockName = $phpBlockName[ count( $phpBlockName ) - 1 ];// e.g. example--theme--main (from Example_Theme_Main)$blockNameParts    = preg_split( '/(?=[A-Z])/', $phpBlockName, - 1, PREG_SPLIT_NO_EMPTY );$blockResourceName = [];foreach ( $blockNameParts as $blockNamePart ) {$blockResourceName[] = strtolower( $blockNamePart );}$blockResourceName = implode( '-', $blockResourceName );$blockResourceName = str_replace( '_', '-', $blockResourceName );// e.g. Example/Theme/Main$relativePath = explode( '\\', $relativeControllerNamespace );$relativePath = array_slice( $relativePath, 0, count( $relativePath ) - 1 );$relativePath = implode( DIRECTORY_SEPARATOR, $relativePath );$resourceInfo['resourceName']         = $blockResourceName;$resourceInfo['relativePath']         = $relativePath;$resourceInfo['relativeResourcePath'] = $relativePath . DIRECTORY_SEPARATOR . $blockResourceName;return $resourceInfo;}// can be overridden if Controller doesn't have own twig (uses parents)public static function GetPathToTwigTemplate( Settings $settings, string $controllerClass = '' ): string {return self::GetResourceInfo( $settings, $controllerClass )['relativeResourcePath'] . $settings->getTwigExtension();}// can be overridden if Controller doesn't have own model (uses parents)public static function GetModelClass(): string {$controllerClass = static::class;$modelClass      = rtrim( $controllerClass, Settings::$ControllerSuffix );return ( $modelClass !== $controllerClass &&         class_exists( $modelClass, true ) &&         is_subclass_of( $modelClass, MODEL::class ) ?$modelClass :'' );}public static function OnLoad() {}final public function setModel( MODEL $model ): void {$this->_model = $model;}private function _getControllerField( string $fieldName ): ?CONTROLLER {$controller = null;$fieldsInfo = $this->_getFieldsInfo();if ( key_exists( $fieldName, $fieldsInfo ) ) {$controller = $this->{$fieldName};// prevent possible recursion by a mistake (if someone will create a field with self)// using static for children support$controller = ( $controller &&                $controller instanceof CONTROLLER ||                get_class( $controller ) !== static::class ) ?$controller :null;}return $controller;}public function getTemplateArgs( Settings $settings ): array {$modelFields  = $this->_model ?$this->_model->getFields() :[];$templateArgs = [];foreach ( $modelFields as $modelFieldName => $modelFieldValue ) {$templateFieldName = ltrim( $modelFieldName, '_' );if ( ! $modelFieldValue instanceof MODEL ) {$templateArgs[ $templateFieldName ] = $modelFieldValue;continue;}$modelFieldController = $this->_getControllerField( $modelFieldName );$modelFieldArgs       = [];$externalFieldArgs    = $this->__external[ $modelFieldName ] ?? [];if ( $modelFieldController ) {$modelFieldController->setModel( $modelFieldValue );$modelFieldArgs = $modelFieldController->getTemplateArgs( $settings );}$templateArgs[ $templateFieldName ] = HELPER::ArrayMergeRecursive( $modelFieldArgs, $externalFieldArgs );}// using static for children supportreturn array_merge( $templateArgs, [self::TEMPLATE_KEY__TEMPLATE  => static::GetPathToTwigTemplate( $settings ),self::TEMPLATE_KEY__IS_LOADED => ( $this->_model && $this->_model->isLoaded() ),] );}public function getDependencies( string $sourceClass = '' ): array {$dependencyClasses = [];$controllerFields  = $this->_getFieldsInfo();foreach ( $controllerFields as $fieldName => $fieldType ) {$dependencyController = $this->_getControllerField( $fieldName );if ( ! $dependencyController ) {continue;}$dependencyClass = get_class( $dependencyController );// 1. prevent the possible permanent recursion// 2. add only unique elements, because several fields can have the same typeif ( ( $sourceClass && $dependencyClass === $sourceClass ) ||     in_array( $dependencyClass, $dependencyClasses, true ) ) {continue;}// used static for child support$subDependencies = $dependencyController->getDependencies( static::class );// only unique elements$subDependencies = array_diff( $subDependencies, $dependencyClasses );// sub dependencies are before the main dependency$dependencyClasses = array_merge( $dependencyClasses, $subDependencies, [ $dependencyClass, ] );}return $dependencyClasses;}// Can be overridden for declare a target model class and provide an IDE supportpublic function getModel(): ?MODEL {return $this->_model;}private function _autoInitModel() {if ( $this->_model ) {return;}$modelClass = static::GetModelClass();try {$this->_model = $modelClass ?new $modelClass() :$this->_model;} catch ( Exception $ex ) {$this->_model = null;}}}
CONTROLLERTest.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocksFramework\{CONTROLLER,MODEL,Settings};class CONTROLLERTest extends Unit {private function _getModel( array $fields, bool $isLoaded = false ): MODEL {return new class ( $fields, $isLoaded ) extends MODEL {private array $_fields;public function __construct( array $fields, bool $isLoaded ) {parent::__construct();$this->_fields = $fields;if ( $isLoaded ) {$this->_load();}}public function getFields(): array {return $this->_fields;}};}private function _getController( ?MODEL $model ): CONTROLLER {return new class ( $model ) extends CONTROLLER {public function __construct( ?MODEL $model = null ) {parent::__construct( $model );}};}private function _getTemplateArgsWithoutAdditional( array $templateArgs ) {$templateArgs = array_diff_key( $templateArgs, [CONTROLLER::TEMPLATE_KEY__TEMPLATE  => '',CONTROLLER::TEMPLATE_KEY__IS_LOADED => '',] );foreach ( $templateArgs as $templateKey => $templateValue ) {if ( ! is_array( $templateValue ) ) {continue;}$templateArgs[ $templateKey ] = $this->_getTemplateArgsWithoutAdditional( $templateValue );}return $templateArgs;}////public function testGetResourceInfoWithoutCamelCaseInBlockName() {$settings = new Settings();$settings->setControllerSuffix( '_C' );$settings->setBlocksDirNamespace( 'Namespace' );$this->assertEquals( ['resourceName'         => 'block','relativePath'         => 'Block','relativeResourcePath' => 'Block/block',], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Block_C' ) );}public function testGetResourceInfoWithCamelCaseInBlockName() {$settings = new Settings();$settings->setControllerSuffix( '_C' );$settings->setBlocksDirNamespace( 'Namespace' );$this->assertEquals( ['resourceName'         => 'block-name','relativePath'         => 'BlockName','relativeResourcePath' => 'BlockName/block-name',], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\BlockName\\BlockName_C' ) );}public function testGetResourceInfoWithoutCamelCaseInTheme() {$settings = new Settings();$settings->setControllerSuffix( '_C' );$settings->setBlocksDirNamespace( 'Namespace' );$this->assertEquals( ['resourceName'         => 'block--theme--main','relativePath'         => 'Block/Theme/Main','relativeResourcePath' => 'Block/Theme/Main/block--theme--main',], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Theme\\Main\\Block_Theme_Main_C' ) );}public function testGetResourceInfoWithCamelCaseInTheme() {$settings = new Settings();$settings->setControllerSuffix( '_C' );$settings->setBlocksDirNamespace( 'Namespace' );$this->assertEquals( ['resourceName'         => 'block--theme--just-main','relativePath'         => 'Block/Theme/JustMain','relativeResourcePath' => 'Block/Theme/JustMain/block--theme--just-main',], CONTROLLER::GetResourceInfo( $settings, 'Namespace\\Block\\Theme\\JustMain\\Block_Theme_JustMain_C' ) );}////public function testGetTemplateArgsWhenModelContainsBuiltInTypes() {$settings   = new Settings();$model      = $this->_getModel( ['stringVariable' => 'just string',] );$controller = $this->_getController( $model );$this->assertEquals( ['stringVariable' => 'just string',], $this->_getTemplateArgsWithoutAdditional( $controller->getTemplateArgs( $settings ) ) );}public function testGetTemplateArgsWhenModelContainsAnotherModel() {$settings = new Settings();$modelA              = $this->_getModel( ['_modelA' => 'just string from model a',] );$modelB              = $this->_getModel( ['_modelA' => $modelA,'_modelB' => 'just string from model b',] );$controllerForModelA = $this->_getController( null );$controllerForModelB = new class ( $modelB, $controllerForModelA ) extends CONTROLLER {protected $_modelA;public function __construct( ?MODEL $model = null, $controllerForModelA ) {parent::__construct( $model );$this->_modelA = $controllerForModelA;}};$this->assertEquals( ['modelA' => ['modelA' => 'just string from model a',],'modelB' => 'just string from model b',], $this->_getTemplateArgsWithoutAdditional( $controllerForModelB->getTemplateArgs( $settings ) ) );}public function testGetTemplateArgsWhenControllerContainsExternalArgs() {$settings = new Settings();$modelA              = $this->_getModel( ['_additionalField' => '','_modelA'          => 'just string from model a',] );$modelB              = $this->_getModel( ['_modelA' => $modelA,'_modelB' => 'just string from model b',] );$controllerForModelA = $this->_getController( null );$controllerForModelB = new class ( $modelB, $controllerForModelA ) extends CONTROLLER {protected $_modelA;public function __construct( ?MODEL $model = null, $controllerForModelA ) {parent::__construct( $model );$this->_modelA               = $controllerForModelA;$this->__external['_modelA'] = ['additionalField' => 'additionalValue',];}};$this->assertEquals( ['modelA' => ['additionalField' => 'additionalValue','modelA'          => 'just string from model a',],'modelB' => 'just string from model b',], $this->_getTemplateArgsWithoutAdditional( $controllerForModelB->getTemplateArgs( $settings ) ) );}public function testGetTemplateArgsContainsAdditionalFields() {$settings   = new Settings();$model      = $this->_getModel( [] );$controller = $this->_getController( $model );$this->assertEquals( [CONTROLLER::TEMPLATE_KEY__TEMPLATE,CONTROLLER::TEMPLATE_KEY__IS_LOADED,], array_keys( $controller->getTemplateArgs( $settings ) ) );}public function testGetTemplateArgsWhenAdditionalIsLoadedIsFalse() {$settings   = new Settings();$model      = $this->_getModel( [] );$controller = $this->_getController( $model );$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => '', ] );$this->assertEquals( [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => false, ], $actual );}public function testGetTemplateArgsWhenAdditionalIsLoadedIsTrue() {$settings   = new Settings();$model      = $this->_getModel( [], true );$controller = $this->_getController( $model );$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => '', ] );$this->assertEquals( [ CONTROLLER::TEMPLATE_KEY__IS_LOADED => true, ], $actual );}public function testGetTemplateArgsAdditionalTemplateIsRight() {$settings   = new Settings();$model      = $this->_getModel( [] );$controller = $this->_getController( $model );$actual = array_intersect_key( $controller->getTemplateArgs( $settings ), [ CONTROLLER::TEMPLATE_KEY__TEMPLATE => '', ] );$this->assertEquals( [CONTROLLER::TEMPLATE_KEY__TEMPLATE => $controller::GetPathToTwigTemplate( $settings ),], $actual );}////public function testGetDependencies() {$controllerA = $this->_getController( null );$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA = $controllerA;}};$this->assertEquals( [get_class( $controllerA ),], $controllerB->getDependencies() );}public function testGetDependenciesWithSubDependencies() {$controllerA = new class extends CONTROLLER {public function getDependencies( string $sourceClass = '' ): array {return ['A',];}};$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA = $controllerA;}};$this->assertEquals( ['A',get_class( $controllerA ),], $controllerB->getDependencies() );}public function testGetDependenciesWithSubDependenciesRecursively() {$controllerA = new class extends CONTROLLER {public function getDependencies( string $sourceClass = '' ): array {return ['A',];}};$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA = $controllerA;}};$controllerC = new class ( null, $controllerB ) extends CONTROLLER {protected $_controllerB;public function __construct( ?MODEL $model = null, $controllerB ) {parent::__construct( $model );$this->_controllerB = $controllerB;}};$this->assertEquals( ['A',get_class( $controllerA ),get_class( $controllerB ),], $controllerC->getDependencies() );}public function testGetDependenciesWithSubDependenciesInOrderWhenSubBeforeMainDependency() {$controllerA = new class extends CONTROLLER {public function getDependencies( string $sourceClass = '' ): array {return ['A',];}};$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA = $controllerA;}};$this->assertEquals( ['A',get_class( $controllerA ),], $controllerB->getDependencies() );}public function testGetDependenciesWithSubDependenciesWhenBlocksAreDependentFromEachOther() {$controllerA = new class extends CONTROLLER {protected $_controllerB;public function setControllerB( $controllerB ) {$this->_controllerB = $controllerB;}};$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA = $controllerA;}};$controllerA->setControllerB( $controllerB );$this->assertEquals( [get_class( $controllerA ),], $controllerB->getDependencies() );}public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType() {$controllerA = $this->_getController( null );$controllerB = new class ( null, $controllerA ) extends CONTROLLER {protected $_controllerA;protected $_controllerAA;protected $_controllerAAA;public function __construct( ?MODEL $model = null, $controllerA ) {parent::__construct( $model );$this->_controllerA   = $controllerA;$this->_controllerAA  = $controllerA;$this->_controllerAAA = $controllerA;}};$this->assertEquals( [get_class( $controllerA ),], $controllerB->getDependencies() );}////public function testAutoInitModel() {$modelClass      = str_replace( [ '::', '\\' ], '_', __METHOD__ );$controllerClass = $modelClass . Settings::$ControllerSuffix;eval( 'class ' . $modelClass . ' extends ' . MODEL::class . ' {}' );eval( 'class ' . $controllerClass . ' extends ' . CONTROLLER::class . ' {}' );$controller = new $controllerClass();$actualModelClass = $controller->getModel() ?get_class( $controller->getModel() ) :'';$this->assertEquals( $modelClass, $actualModelClass );}public function testAutoInitModelWhenModelHasWrongClass() {$modelClass      = str_replace( [ '::', '\\' ], '_', __METHOD__ );$controllerClass = $modelClass . Settings::$ControllerSuffix;eval( 'class ' . $modelClass . ' {}' );eval( 'class ' . $controllerClass . ' extends ' . CONTROLLER::class . ' {}' );$controller = new $controllerClass();$this->assertEquals( null, $controller->getModel() );}}

Settings

Вспомогательный класс, думаю что не нуждается в комментариях

Settings.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;class Settings {public static string $ControllerSuffix = '_C';private string $_blocksDirPath;private string $_blocksDirNamespace;private array $_twigArgs;private string $_twigExtension;private $_errorCallback;public function __construct() {$this->_blocksDirPath      = '';$this->_blocksDirNamespace = '';$this->_twigArgs           = [// will generate exception if a var doesn't exist instead of replace to NULL'strict_variables' => true,// disable autoescape to prevent break data'autoescape'       => false,];$this->_twigExtension      = '.twig';$this->_errorCallback      = null;}public function setBlocksDirPath( string $blocksDirPath ): void {$this->_blocksDirPath = $blocksDirPath;}public function setBlocksDirNamespace( string $blocksDirNamespace ): void {$this->_blocksDirNamespace = $blocksDirNamespace;}public function setTwigArgs( array $twigArgs ): void {$this->_twigArgs = array_merge( $this->_twigArgs, $twigArgs );}public function setErrorCallback( ?callable $errorCallback ): void {$this->_errorCallback = $errorCallback;}public function setTwigExtension( string $twigExtension ): void {$this->_twigExtension = $twigExtension;}public function setControllerSuffix( string $controllerSuffix ): void {$this->_controllerSuffix = $controllerSuffix;}public function getBlocksDirPath(): string {return $this->_blocksDirPath;}public function getBlocksDirNamespace(): string {return $this->_blocksDirNamespace;}public function getTwigArgs(): array {return $this->_twigArgs;}public function getTwigExtension(): string {return $this->_twigExtension;}public function callErrorCallback( array $errors ): void {if ( ! is_callable( $this->_errorCallback ) ) {return;}call_user_func_array( $this->_errorCallback, [ $errors, ] );}}

Twig

Также вспомогательный класс, лишь уточню что мы расширили twig своей функцией _include (которая является оберткой для встроенного и использует наши поля _isLoaded и _template из метода CONROLLER->getTemplateArgs выше) и фильтр _merge (который отличается тем, что рекурсивно сливает массивы).

Twig.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;use Exception;use Twig\Environment;use Twig\Loader\FilesystemLoader;use Twig\Loader\LoaderInterface;use Twig\TwigFilter;use Twig\TwigFunction;class Twig {private ?LoaderInterface $_twigLoader;private ?Environment $_twigEnvironment;private Settings $_settings;public function __construct( Settings $settings, ?LoaderInterface $twigLoader = null ) {$this->_twigEnvironment = null;$this->_settings        = $settings;$this->_twigLoader      = $twigLoader;$this->_init();}// e.g for extend a twig with adding a new filterpublic function getEnvironment(): ?Environment {return $this->_twigEnvironment;}private function _extendTwig(): void {$this->_twigEnvironment->addFilter( new TwigFilter( '_merge', function ( $source, $additional ) {return HELPER::ArrayMergeRecursive( $source, $additional );} ) );$this->_twigEnvironment->addFunction( new TwigFunction( '_include', function ( $block, $args = [] ) {$block = HELPER::ArrayMergeRecursive( $block, $args );return $block[ CONTROLLER::TEMPLATE_KEY__IS_LOADED ] ?$this->render( $block[ CONTROLLER::TEMPLATE_KEY__TEMPLATE ], $block ) :'';} ) );}private function _init(): void {try {$this->_twigLoader      = ! $this->_twigLoader ?new FilesystemLoader( $this->_settings->getBlocksDirPath() ) :$this->_twigLoader;$this->_twigEnvironment = new Environment( $this->_twigLoader, $this->_settings->getTwigArgs() );} catch ( Exception $ex ) {$this->_twigEnvironment = null;$this->_settings->callErrorCallback( ['message' => $ex->getMessage(),'file'    => $ex->getFile(),'line'    => $ex->getLine(),'trace'   => $ex->getTraceAsString(),] );return;}$this->_extendTwig();}public function render( string $template, array $args = [], bool $isPrint = false ): string {$html = '';// twig isn't loadedif ( is_null( $this->_twigEnvironment ) ) {return $html;}try {// will generate ean exception if a template doesn't exist OR broken// also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct)$html .= $this->_twigEnvironment->render( $template, $args );} catch ( Exception $ex ) {$html = '';$this->_settings->callErrorCallback( ['message'  => $ex->getMessage(),'file'     => $ex->getFile(),'line'     => $ex->getLine(),'trace'    => $ex->getTraceAsString(),'template' => $template,] );}if ( $isPrint ) {echo $html;}return $html;}}
TwigTest.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework\Tests\unit;use Codeception\Test\Unit;use Exception;use LightSource\FrontBlocksFramework\CONTROLLER;use LightSource\FrontBlocksFramework\Settings;use LightSource\FrontBlocksFramework\Twig;use Twig\Loader\ArrayLoader;class TwigTest extends Unit {private function _renderBlock( array $blocks, string $renderBlock, array $renderArgs = [] ): string {$twigLoader = new ArrayLoader( $blocks );$settings   = new Settings();$twig    = new Twig( $settings, $twigLoader );$content = '';try {$content = $twig->render( $renderBlock, $renderArgs );} catch ( Exception $ex ) {$this->fail( 'Twig render exception, ' . $ex->getMessage() );}return $content;}public function testExtendTwigIncludeFunctionWhenBlockIsLoaded() {$blocks      = ['block-a.twig' => '{{ _include(blockB) }}','block-b.twig' => 'block-b content',];$renderBlock = 'block-a.twig';$renderArgs  = ['blockB' => [CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',CONTROLLER::TEMPLATE_KEY__IS_LOADED => true,],];$this->assertEquals( 'block-b content', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );}public function testExtendTwigIncludeFunctionWhenBlockNotLoaded() {$blocks      = ['block-a.twig' => '{{ _include(blockB) }}','block-b.twig' => 'block-b content',];$renderBlock = 'block-a.twig';$renderArgs  = ['blockB' => [CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',CONTROLLER::TEMPLATE_KEY__IS_LOADED => false,],];$this->assertEquals( '', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );}public function testExtendTwigIncludeFunctionWhenArgsPassed() {$blocks      = ['block-a.twig' => '{{ _include(blockB, {classes:["test-class",],}) }}','block-b.twig' => '{{ classes|join(" ") }}',];$renderBlock = 'block-a.twig';$renderArgs  = ['blockB' => [CONTROLLER::TEMPLATE_KEY__TEMPLATE  => 'block-b.twig',CONTROLLER::TEMPLATE_KEY__IS_LOADED => true,'classes'                           => [ 'own-class', ],],];$this->assertEquals( 'own-class test-class', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );}public function testExtendTwigMergeFilter() {$blocks      = ['block-a.twig' => '{{ {"array":["a",],}|_merge({"array":["b",],}).array|join(" ") }}',];$renderBlock = 'block-a.twig';$renderArgs  = [];$this->assertEquals( 'a b', $this->_renderBlock( $blocks, $renderBlock, $renderArgs ) );}}

Blocks

Это наш объединяющий класс.

Статический метод LoadAll загружает все контроллеры и вызывает статический метод OnLoad у каждого контроллера (в нашем случае это не используется, как было указано выше это необходимо для расширения и выходит за рамки статьи).

Метод renderBlock принимает объект контроллера и производит рендер блока, передавая в twig шаблон аргументы из метода CONROLLER->getTemplateArgs выше. Также добавляет класс используемого контроллера и классы всех его зависимостей в список использованных блоков, что позволит нам далее получить используемый css и js.

Ну и наконец метод getUsedResources используя список выше и статический метод CONTROLLER::GetResourceInfo позволяет нам после рендера блоков получить используемый css и js код, объединенный в правильной последовательности, т.е. с учетом всех зависимостей./

Blocks.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework;class Blocks {private array $_loadedControllerClasses;private array $_usedControllerClasses;private Settings $_settings;private Twig $_twig;public function __construct( Settings $settings ) {$this->_loadedControllerClasses = [];$this->_usedControllerClasses   = [];$this->_settings                = $settings;$this->_twig                    = new Twig( $settings );}final public function getLoadedControllerClasses(): array {return $this->_loadedControllerClasses;}final public function getUsedControllerClasses(): array {return $this->_usedControllerClasses;}final public function getSettings(): Settings {return $this->_settings;}final public function getTwig(): Twig {return $this->_twig;}final public function getUsedResources( string $extension, bool $isIncludeSource = false ): string {$resourcesContent = '';foreach ( $this->_usedControllerClasses as $usedControllerClass ) {$getResourcesInfoCallback = [ $usedControllerClass, 'GetResourceInfo' ];if ( ! is_callable( $getResourcesInfoCallback ) ) {$this->_settings->callErrorCallback( ['message' => "Controller class doesn't exist",'class'   => $usedControllerClass,] );continue;}$resourceInfo = call_user_func_array( $getResourcesInfoCallback, [$this->_settings,] );$pathToResourceFile = $this->_settings->getBlocksDirPath() . DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension;if ( ! is_file( $pathToResourceFile ) ) {continue;}$resourcesContent .= $isIncludeSource ?"\n/* " . $resourceInfo['resourceName'] . " */\n" :'';$resourcesContent .= file_get_contents( $pathToResourceFile );}return $resourcesContent;}private function _loadController( string $phpClass, array $debugArgs ): bool {$isLoaded = false;if ( ! class_exists( $phpClass, true ) ||     ! is_subclass_of( $phpClass, CONTROLLER::class ) ) {$this->_settings->callErrorCallback( ['message' => "Class doesn't exist or doesn't child",'args'    => $debugArgs,] );return $isLoaded;}call_user_func( [ $phpClass, 'OnLoad' ] );return true;}private function _loadControllers( string $directory, string $namespace, array $controllerFileNames ): void {foreach ( $controllerFileNames as $controllerFileName ) {$phpFile   = implode( DIRECTORY_SEPARATOR, [ $directory, $controllerFileName ] );$phpClass  = implode( '\\', [ $namespace, str_replace( '.php', '', $controllerFileName ), ] );$debugArgs = ['directory' => $directory,'namespace' => $namespace,'phpFile'   => $phpFile,'phpClass'  => $phpClass,];if ( ! $this->_loadController( $phpClass, $debugArgs ) ) {continue;}$this->_loadedControllerClasses[] = $phpClass;}}private function _loadDirectory( string $directory, string $namespace ): void {// exclude ., ..$fs = array_diff( scandir( $directory ), [ '.', '..' ] );$controllerFilePreg = '/' . Settings::$ControllerSuffix . '.php$/';$controllerFileNames = HELPER::ArrayFilter( $fs, function ( $f ) use ( $controllerFilePreg ) {return ( 1 === preg_match( $controllerFilePreg, $f ) );}, false );$subDirectoryNames   = HELPER::ArrayFilter( $fs, function ( $f ) {return false === strpos( $f, '.' );}, false );foreach ( $subDirectoryNames as $subDirectoryName ) {$subDirectory = implode( DIRECTORY_SEPARATOR, [ $directory, $subDirectoryName ] );$subNamespace = implode( '\\', [ $namespace, $subDirectoryName ] );$this->_loadDirectory( $subDirectory, $subNamespace );}$this->_loadControllers( $directory, $namespace, $controllerFileNames );}final public function loadAll(): void {$directory = $this->_settings->getBlocksDirPath();$namespace = $this->_settings->getBlocksDirNamespace();$this->_loadDirectory( $directory, $namespace );}final public function renderBlock( CONTROLLER $controller, array $args = [], bool $isPrint = false ): string {$dependencies                 = array_merge( $controller->getDependencies(), [ get_class( $controller ), ] );$newDependencies              = array_diff( $dependencies, $this->_usedControllerClasses );$this->_usedControllerClasses = array_merge( $this->_usedControllerClasses, $newDependencies );$templateArgs = $controller->getTemplateArgs( $this->_settings );$templateArgs = HELPER::ArrayMergeRecursive( $templateArgs, $args );return $this->_twig->render( $templateArgs[ CONTROLLER::TEMPLATE_KEY__TEMPLATE ], $templateArgs, $isPrint );}}
BlocksTest.php
<?phpdeclare( strict_types=1 );namespace LightSource\FrontBlocksFramework\Tests\unit;use Codeception\Test\Unit;use Exception;use LightSource\FrontBlocksFramework\Blocks;use LightSource\FrontBlocksFramework\CONTROLLER;use LightSource\FrontBlocksFramework\MODEL;use LightSource\FrontBlocksFramework\Settings;use LightSource\FrontBlocksFramework\Twig;use org\bovigo\vfs\vfsStream;use org\bovigo\vfs\vfsStreamDirectory;class BlocksTest extends Unit {private function _getBlocks( string $namespace, vfsStreamDirectory $rootDirectory, array $structure, array $usedControllerClasses = [] ): ?Blocks {vfsStream::create( $structure, $rootDirectory );$settings = new Settings();$settings->setBlocksDirNamespace( $namespace );$settings->setBlocksDirPath( $rootDirectory->url() );$twig = $this->make( Twig::class, ['render' => function ( string $template, array $args = [], bool $isPrint = false ): string {return '';},] );try {$blocks = $this->make( Blocks::class, ['_loadedControllerClasses' => [],'_usedControllerClasses'   => $usedControllerClasses,'_twig'                    => $twig,'_settings'                => $settings,] );} catch ( Exception $ex ) {$this->fail( "Can't make Blocks stub, " . $ex->getMessage() );}$blocks->loadAll();return $blocks;}// get a unique namespace depending on a test method to prevent affect other testsprivate function _getUniqueControllerNamespaceWithAutoloader( string $methodConstant, vfsStreamDirectory $rootDirectory ): string {$namespace = str_replace( '::', '_', $methodConstant );spl_autoload_register( function ( $class ) use ( $rootDirectory, $namespace ) {$targetNamespace = $namespace . '\\';if ( 0 !== strpos( $class, $targetNamespace ) ) {return;}$relativePathToFile = str_replace( $targetNamespace, '', $class );$relativePathToFile = str_replace( '\\', '/', $relativePathToFile );$absPathToFile = $rootDirectory->url() . DIRECTORY_SEPARATOR . $relativePathToFile . '.php';include_once $absPathToFile;} );return $namespace;}// get a unique directory name depending on a test method to prevent affect other testsprivate function _getUniqueDirectory( string $methodConstant ): vfsStreamDirectory {$dirName = str_replace( [ ':', '\\' ], '_', $methodConstant );return vfsStream::setup( $dirName );}private function _getControllerClassFile( string $namespace, string $class ): string {$vendorControllerClass = '\LightSource\FrontBlocksFramework\CONTROLLER';return '<?php namespace ' . $namespace . '; class ' . $class . ' extends ' . $vendorControllerClass . ' {}';}private function _getController( array $dependencies = [] ) {return new class ( null, $dependencies ) extends CONTROLLER {private array $_dependencies;public function __construct( ?MODEL $model = null, array $dependencies ) {parent::__construct( $model );$this->_dependencies = $dependencies;}function getDependencies( string $sourceClass = '' ): array {return $this->_dependencies;}function getTemplateArgs( Settings $settings ): array {return [CONTROLLER::TEMPLATE_KEY__TEMPLATE => '',];}};}////public function testLoadAllControllersWithPrefix() {// fixme$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['Block' => ['Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ),],] );$this->assertEquals( ["{$namespace}\Block\Block_C",], $blocks->getLoadedControllerClasses() );}public function testLoadAllIgnoreControllersWithoutPrefix() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['Block' => ['Block.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block' ),],] );$this->assertEquals( [], $blocks->getLoadedControllerClasses() );}public function testLoadAllIgnoreWrongControllers() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['Block' => ['Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'WrongBlock_C' ),],] );$this->assertEquals( [], $blocks->getLoadedControllerClasses() );}////public function testRenderBlockAddsControllerToUsedList() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] );$controller    = $this->_getController();$blocks->renderBlock( $controller );$this->assertEquals( [get_class( $controller ),], $blocks->getUsedControllerClasses() );}public function testRenderBlockAddsControllerDependenciesToUsedList() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] );$controller    = $this->_getController( [ 'A', ] );$blocks->renderBlock( $controller );$this->assertEquals( ['A',get_class( $controller ),], $blocks->getUsedControllerClasses() );}public function testRenderBlockAddsDependenciesBeforeControllerToUsedList() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] );$controller    = $this->_getController( [ 'A', ] );$blocks->renderBlock( $controller );$this->assertEquals( ['A',get_class( $controller ),], $blocks->getUsedControllerClasses() );}public function testRenderBlockIgnoreDuplicateControllerWhenAddsToUsedList() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] );$controllerA   = $this->_getController();$blocks->renderBlock( $controllerA );$blocks->renderBlock( $controllerA );$this->assertEquals( [get_class( $controllerA ),], $blocks->getUsedControllerClasses() );}public function testRenderBlockIgnoreDuplicateControllerDependenciesWhenAddsToUsedList() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, [] );$controllerA   = $this->_getController( [ 'A', ] );$controllerB   = $this->_getController( [ 'A', ] );$blocks->renderBlock( $controllerA );$blocks->renderBlock( $controllerB );$this->assertEquals( ['A',get_class( $controllerA ),// $controllerB has the same class], $blocks->getUsedControllerClasses() );}////public function testGetUsedResourcesWhenBlockWithResources() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['Block' => ['Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ),'block.css'   => 'just css code',],], ["{$namespace}\Block\Block_C",] );$this->assertEquals( 'just css code',$blocks->getUsedResources( '.css', false ) );}public function testGetUsedResourcesWhenBlockWithoutResources() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['Block' => ['Block_C.php' => $this->_getControllerClassFile( "{$namespace}\Block", 'Block_C' ),],], ["{$namespace}\Block\Block_C",] );$this->assertEquals( '',$blocks->getUsedResources( '.css', false ) );}public function testGetUsedResourcesWhenSeveralBlocks() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['BlockA' => ['BlockA_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockA", 'BlockA_C' ),'block-a.css'  => 'css code for a',],'BlockB' => ['BlockB_C.php' => $this->_getControllerClassFile( "{$namespace}\BlockB", 'BlockB_C' ),'block-b.css'  => 'css code for b',],], ["{$namespace}\BlockA\BlockA_C","{$namespace}\BlockB\BlockB_C",] );$this->assertEquals( 'css code for acss code for b',$blocks->getUsedResources( '.css', false ) );}public function testGetUsedResourcesWithIncludedSource() {$rootDirectory = $this->_getUniqueDirectory( __METHOD__ );$namespace     = $this->_getUniqueControllerNamespaceWithAutoloader( __METHOD__, $rootDirectory );$blocks        = $this->_getBlocks( $namespace, $rootDirectory, ['SimpleBlock' => ['SimpleBlock_C.php' => $this->_getControllerClassFile( "{$namespace}\SimpleBlock", 'SimpleBlock_C' ),'simple-block.css'  => 'css code',],], ["{$namespace}\SimpleBlock\SimpleBlock_C",] );$this->assertEquals( "\n/* simple-block */\ncss code",$blocks->getUsedResources( '.css', true ) );}}

Вот и все, теперь осталось объединить эти части и наш мини-фреймворк готов. Момент публикации composer пакета я опущу, т.к. это довольно простая задача и если вам будет интересно то вы без труда найдете информацию по этому поводу.

Демонстрационный пример

Ниже приведу демонстрационный пример использования пакета, с одним чистым css для наглядности, если кому-то интересен пример с scss/webpack смотрите ссылки в конце статьи.

Создаем блоки для теста, BlockA и BlockC будут независимыми блоками, BlockB будет содержкать BlockC.

BlockA

BlockA.php
<?phpnamespace LightSource\FrontBlocksExample\BlockA;use LightSource\FrontBlocksFramework\MODEL;class BlockA extends MODEL {protected string $_name;public function load() {parent::_load();$this->_name = 'I\'m BlockA';}}
BlockA_C.php

/sp

<?phpnamespace LightSource\FrontBlocksExample\BlockA;use LightSource\FrontBlocksFramework\Blocks;use LightSource\FrontBlocksFramework\CONTROLLER;class BlockA_C extends CONTROLLER {public function getModel(): ?BlockA {/** @noinspection PhpIncompatibleReturnTypeInspection */return parent::getModel();}}
block-a.twig

/

<div class="block-a">    {{ name }}</div>
block-a.css

Bl

.block-a {    color: green;    border:1px solid green;    padding: 10px;}

BlockB

BlockB.php
<?phpnamespace LightSource\FrontBlocksExample\BlockB;use LightSource\FrontBlocksExample\BlockC\BlockC;use LightSource\FrontBlocksFramework\MODEL;class BlockB extends MODEL {protected string $_name;protected BlockC $_blockC;public function __construct() {parent::__construct();$this->_blockC = new BlockC();}public function load() {parent::_load();$this->_name = 'I\'m BlockB, I contain another block';$this->_blockC->load();}}
BlockB_C.php
<?phpnamespace LightSource\FrontBlocksExample\BlockB;use LightSource\FrontBlocksExample\BlockC\BlockC_C;use LightSource\FrontBlocksFramework\CONTROLLER;class BlockB_C extends CONTROLLER {protected BlockC_C $_blockC;public function getModel(): ?BlockB {/** @noinspection PhpIncompatibleReturnTypeInspection */return parent::getModel();}}
block-b.twig
<div class="block-b">    <p class="block-b__name">{{ name }}</p>    {{ _include(blockC) }}</div>
block-b.css

Blo

.block-b {    color: orange;    border: 1px solid orange;    padding: 10px;}.block-b__name {    margin: 0 0 10px;    line-height: 1.5;}

BlocksC

BlockC.php
<?phpnamespace LightSource\FrontBlocksExample\BlockC;use LightSource\FrontBlocksFramework\MODEL;class BlockC extends MODEL {protected string $_name;public function load() {parent::_load();$this->_name = 'I\'m BlockC';}}
BlockC_C.php

/

<?phpnamespace LightSource\FrontBlocksExample\BlockC;use LightSource\FrontBlocksFramework\CONTROLLER;class BlockC_C extends CONTROLLER {public function getModel(): ?BlockC {/** @noinspection PhpIncompatibleReturnTypeInspection */return parent::getModel();}}

Подключаем наш пакет и рендерим блоки

block-c.twig
<div class="block-c">    {{ name }}</div>
block-c.css
.block-c {    color: black;    border: 1px solid black;    padding: 10px;}

Подключаем наш пакет и рендерим блоки, результат вставляем в html код страницы, в шапке выводим использованный css код

example.php
<?phpuse LightSource\FrontBlocksExample\{BlockA\BlockA_C,BlockB\BlockB_C,};use LightSource\FrontBlocksFramework\{Blocks,Settings};require_once __DIR__ . '/vendors/vendor/autoload.php';//// settings$settings = new Settings();$settings->setBlocksDirNamespace( 'LightSource\FrontBlocksExample' );$settings->setBlocksDirPath( __DIR__ . '/Blocks' );$settings->setErrorCallback( function ( array $errors ) {// todo log or any other actionsecho '<pre>' . print_r( $errors, true ) . '</pre>';});$blocks = new Blocks( $settings );//// usage$blockA_Controller = new BlockA_C();$blockA_Controller->getModel()->load();$blockB_Controller = new BlockB_C();$blockB_Controller->getModel()->load();$content = $blocks->renderBlock( $blockA_Controller );$content .= $blocks->renderBlock( $blockB_Controller );$css     = $blocks->getUsedResources( '.css', true );//// html?><html><head>    <title>Example</title>    <style>        <?= $css ?>    </style>    <style>        .block-b {            margin-top: 10px;        }    </style></head><body><?= $content ?></body></html>

в результате вывод будет примерно таким

example.png

Послесловие

Данный пакет был создан для личных целей, я использую его в своих проектах и он облегчает мне разработку, и я буду рад если он пригодится кому-то еще. Не стесняйтесь задавать вопросы и комментировать я буду рад ответить на ваши вопросы и услышать ваше мнение.

Вот и все, спасибо за внимание.

Ссылки:

репозиторий с мини фреймворком

репозиторий с демонстрационным примером

репозиторий с примером использования scss и js в блоках (webpack сборщик)

репозиторий с примером использования в WordPress теме (здесь вы также можете увидеть пример расширения класса контроллера и использования автозагрузки всех контоллеров, что добавляет поддержку ajax запросов для блоков).

Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 466 (3 9 мая 2021)

10.05.2021 00:07:48 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript


Медиа


podcast Подкаст 'Callback Hell': Убийцы вебпака, Microsoft + Bytecode Alliance, удалёнка
podcast Новости 512 от CSSSR: Bootstrap 5, V8 9.1, дженерики в TypeScript, RxJS в Angular, e2e-тесты с Cypress, баг в Safari 14.1
podcast Новости 512 от CSSSR: История фронтенда ч.2, Safari 14.1, CORS, Cookie Store API, Next.js 10.2, RxJS 7, Google I/O 2021
podcast Подкаст Веб-стандарты 280. Safari 14.1, гэпы во флексах, история JS, мозаичный JS, кому нужны алгоритмы
video Видеокаст Front-end. Вопросы на собеседовании #1
podcast video Подкаст Pro Conf #95: HollyJS Moscow 2020

Веб-разработка


habr Вышел Bootstrap 5: оцениваем 7 главных нововведений
habr Почему стоит использовать тег <picture> вместо <img>
habr Базовая структура HTML-документа с объяснением каждой строчки
habr HTML трюки
en Эволюция Jamstack
en Как мы используем веб-компоненты на GitHub
en Аудит дизайн-систем на предмет доступности
en Ускорение процесса разработки с помощью Bootstrap 5
en Как мы ускорили трассировку стека Chrome DevTools в 10 раз




CSS


en Состояние кроссбраузерной разработки CSS
en Container Queries: разъяснения и предложения
en Два варианта использования кастомных свойств
en Полное руководство по веб-шрифтам в шаблонах писем
en Является ли CSS языком программирования?
en CSS Hell Сборник распространенных ошибок в CSS и способы их исправления
en Текст размером 16 пикселей или больше предотвращает масштабирование формы в iOS
en Fluid typography Создавайте текст, масштабируемый в соответствии с размером окна, чтобы заголовки отлично смотрелись на любом экране.
en Вендорные префиксы мертвы?
en Компиляция CSS по запросу с помощью последней версии компилятора Tailwind


JavaScript


habr Как я написал браузерный 3D FPS шутер на Three.js, Vue и Blender
Кастомные типы данных в TypeScript: валидация на этапе компиляции
en Возможны ли 0kb JavaScript в вашем будущем?
en Vue Composition API против React Hooks основная разница
en Создайте трекер спутников с нуля 30-ю строками JavaScript кода







Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 467 (10 16 мая 2021)

17.05.2021 00:12:32 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Подкаст Веб-стандарты 281. SpiderMonkey 25 лет, Safari TP, Bootstrap 5, Гитхаб, префиксы, монорепы и свой git в Яндексе
podcast Подкаст Фронтенд Юность #186. Утюжить веб. В гостях создатель и главный редактор Smashing Magazine Виталий Фридман.
video Видеокаст Front-end. Вопросы на собеседовании #2
video Нужен ли джуну идеальный код: интервью с Вадимом Макеевым
podcast video Подкаст Да как так-то?. Выпуск 4: из филолога-япониста во фронтенд на фрилансе

Веб-разработка


Солидные фронтенды: мониторинг
en Регистрация обработчика протокола URL для PWA
en Различия между WebSockets и Socket.IO
habr Переход к Meta GSAP: поиски идеальной бесконечной прокрутки




CSS


habr Выявление устройств с сенсорными экранами на чистом CSS
habr Венец эволюции CSS-in-JS уже здесь: полностью типизированные стили без рантайма в vanilla-extract
habr Сравнение производительности CSS и CSS-in-JS в реальном мире
habr Инструменты для аудита CSS
Родительский селектор :has() в реальность!


en Дизайн для чтения: советы по оптимизации контента для режимов чтения и приложений-читалок
en Продвинутая CSS-анимация с использованием cubic-bezier()
en aspect-ratio и grid
en Создание Stylesheet Feature Flags с помощью Sass!default
en Плавная прокрутка Sticky ScrollSpy Navigation с фиксированным фоном на CSS
en Взгляд на CSS Tailwind

JavaScript


habr Отслеживание и визуализация положения МКС с помощью 30 строк JavaScript-кода
habr Шпаргалка по JS-методам для работы с DOM
habr Паттерны отложенной инициализации свойств объектов в JavaScript
habr Я выпустил Grafar JS-библиотеку для визуализации
en 7 шагов для безопасного JavaScript в 2021 году
en Современный Javascript: все, что вы пропустили за последние 10 лет (ECMAScript 2020)
en Создайте тетрис с помощью современного JavaScript








Браузеры


en Использование обработчиков пользовательских протоколов для кросс-браузерного отслеживания в Tor, Safari, Chrome и Firefox
Идентификация через анализ внешних обработчиков протоколов в браузере

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Модульные frond-end блоки пишем свой пакет. Часть 2

20.05.2021 14:22:33 | Автор: admin

В первой части я поделился своим взглядом на то, какими могут быть переиспользуемые front-end блоки, получил конструктивную критику, доработал пакет и теперь хотел бы поделиться с вами новой версией. Она позволит легко организовать использование модульных блоков для любого проекта с бекендом на php.

Для тех кто не знаком с первой частью я буду оставлять спойлеры из нее, которые введут в курс дела. Тем кому интересен конечный результат - демонстрационный пример и ссылки на репозитории в конце статьи.

Предисловие

Представлюсь - я молодой веб разработчик с опытом работы 5 лет. Крайний год я работаю на фрилансе и большая часть текущих проектов связана с WordPress. Несмотря на различую критику CMS в общем и WordPress в часности, я считаю сама архитектура WordPress это довольно удачное решение, хотя конечно не без определенных недостатков. И один из них на мой взгляд это шаблоны. В крайних обновлениях сделаны большие шаги чтобы это исправить, и Gutenberg в целом становится мощным инструментом, однако к сожалению в большинстве тем продолжается каша в шаблонах, стилях и скриптах, которая делает редактирование чего-либо крайне болезненным, а переиспользование кода зачастую невозможным. Именно эта проблема и подтолкнуло меня к идее своего пакета, который бы организовывал структуру и позволял переиспользовать блоки.

Реализация будет в виде composer пакета, который можно будет использовать в совершенно различных проектах, без привязки к WordPress.

Постановка задачи

Понятие блок ниже будет по сути тем же понятием что и блок в BEM методологии, т.е. это будет группа html/js/css кода которая будет представлять одну сущность.

Генерировать html и управлять зависимостями блоков мы будем через php, что говорит о том, что наш пакет будет подходить для проектов с бекендом на php. Также условимся на берегу что, не вдаваясь в споры, не будем поддаваться влиянию новомодных вещей, таких как css-in-js или bem-json и будем придерживаться эль-классико классического подхода, т.е. предполагать что html, css и js это разные файлы.

Теперь давайте сформулируем наши основные требования к пакету:

  • Обеспечить структуру блоков

  • Предоставить поддержку наследования (расширения) блоков

  • Предоставить возможность использовать блок в блоке и соответственно поддержку зависимости ресурсов одного блока от ресурсов других блоков

Структура пакета

О ресурах блока и twig шаблонах

Как условились выше, такие ресурсы как css и js всегда будут в виде обычных файлов, т.е. это будут .js и .css или .min.css и .min.js в случае использования препроцесссоров и сборщиков (как webpack например). Для вставки данных в html код мы будем использовать шаблонизатор Twig (для тех кто не знаком ссылка). Кто-то может заметить, что php и сам по себе хороший шаблонизатор, не будем вдаваться в споры, кроме доводов указанных на главной странице проекта Twig, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.

  1. Блок

    Каждый блок будет состоять из:

    1. Статических ресурсов (css/js/twig)

    2. Класса блока, который будет предоставлять данные для twig шаблона и управлять зависимостями.

  2. Вспомогательные классы: Settings (пути к блокам и их пространства имен), TwigWrapper (обертка для Twig пакета), BlocksLoader (автозагрузка всех блоков, опционально), Helper (набор статических доп. функций)

  3. Renderer класс - связующий класс, который будет объединять вспомогательные классы, предоставлять функцию рендера блока, содержать список использованных блоков и их ресурсы (css, js)

Требования к блокам

В отличии от первого пакета количество требований сократилось, теперь это:

  • php 7.4

  • Классы блоков должны иметь PSR-4 совместимое пространство имен с автозагрузчиком (PSR-4 де факто стандарт, если вы используете автозагрузчик от composer, т.е. указываете autoload/psr4 директиву в вашем composer.json то ваш проект уже соответствует этому требованию)

  • Имена ресурсов должны совпадать с именем блока (например для Button.php будут Button.css и Button.twig)

Реализация

Ниже части реализации (классы) будут в формате : текстовое описание, код реализации и код тестов.

Block

Основной действующий класс, его потомки будут содержать данные для twig шаблона (в protected полях) и предоставлять список зависимостей, а также мы сможем получить путь к ресурсам (шаблону, стилям). Все наша магия при работе с полями будет строится на функции get_class_vars которая предоставит имена полей класса и на ReflectionProperty классе, который предоставит информацию об этих полях, такую как видимость поля (protected/public) и его тип. Мы будем собирать информацию только о protected полях.

Также упростим дальнейшую разработку, добавив автоинициализацию полей значением по умолчанию, это избавит нас от необходимости инициализировать их в конструкторе вручную, что при большом количестве блоков сэкономит наше время.

Block.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;use Exception;use ReflectionProperty;abstract class Block{    public const TEMPLATE_KEY_NAMESPACE = '_namespace';    public const TEMPLATE_KEY_TEMPLATE = '_template';    public const TEMPLATE_KEY_IS_LOADED = '_isLoaded';    public const RESOURCE_KEY_NAMESPACE = 'namespace';    public const RESOURCE_KEY_FOLDER = 'folder';    public const RESOURCE_KEY_RELATIVE_RESOURCE_PATH = 'relativeResourcePath';    public const RESOURCE_KEY_RELATIVE_BLOCK_PATH = 'relativeBlockPath';    public const RESOURCE_KEY_RESOURCE_NAME = 'resourceName';    private array $fieldsInfo;    private bool $isLoaded;    public function __construct()    {        $this->fieldsInfo = [];        $this->isLoaded   = false;        $this->readFieldsInfo();        $this->autoInitFields();    }    public static function onLoad()    {    }    public static function getResourceInfo(Settings $settings, string $blockClass = ''): ?array    {        // using static for child support        $blockClass = ! $blockClass ?            static::class :            $blockClass;        // e.g. $blockClass = Namespace/Example/Theme/Main/ExampleThemeMain        $resourceInfo = [            self::RESOURCE_KEY_NAMESPACE              => '',            self::RESOURCE_KEY_FOLDER                 => '',            self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH => '',// e.g. Example/Theme/Main/ExampleThemeMain            self::RESOURCE_KEY_RELATIVE_BLOCK_PATH    => '',// e.g. Example/Theme/Main            self::RESOURCE_KEY_RESOURCE_NAME          => '',// e.g. ExampleThemeMain        ];        $blockFolderInfo = $settings->getBlockFolderInfoByBlockClass($blockClass);        if (! $blockFolderInfo) {            $settings->callErrorCallback(                [                    'error'      => 'Block has the non registered namespace',                    'blockClass' => $blockClass,                ]            );            return null;        }        $resourceInfo[self::RESOURCE_KEY_NAMESPACE] = $blockFolderInfo['namespace'];        $resourceInfo[self::RESOURCE_KEY_FOLDER]    = $blockFolderInfo['folder'];        //  e.g. Example/Theme/Main/ExampleThemeMain        $relativeBlockNamespace = str_replace($resourceInfo[self::RESOURCE_KEY_NAMESPACE] . '\\', '', $blockClass);        // e.g. ExampleThemeMain        $blockName = explode('\\', $relativeBlockNamespace);        $blockName = $blockName[count($blockName) - 1];        // e.g. Example/Theme/Main        $relativePath = explode('\\', $relativeBlockNamespace);        $relativePath = array_slice($relativePath, 0, count($relativePath) - 1);        $relativePath = implode(DIRECTORY_SEPARATOR, $relativePath);        $resourceInfo[self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH] = $relativePath . DIRECTORY_SEPARATOR . $blockName;        $resourceInfo[self::RESOURCE_KEY_RELATIVE_BLOCK_PATH]    = $relativePath;        $resourceInfo[self::RESOURCE_KEY_RESOURCE_NAME]          = $blockName;        return $resourceInfo;    }    private static function getResourceInfoForTwigTemplate(Settings $settings, string $blockClass): ?array    {        $resourceInfo = self::getResourceInfo($settings, $blockClass);        if (! $resourceInfo) {            return null;        }        $absTwigPath = implode(            '',            [                $resourceInfo['folder'],                DIRECTORY_SEPARATOR,                $resourceInfo['relativeResourcePath'],                $settings->getTwigExtension(),            ]        );        if (! is_file($absTwigPath)) {            $parentClass = get_parent_class($blockClass);            if ($parentClass &&                is_subclass_of($parentClass, self::class) &&                self::class !== $parentClass) {                return self::getResourceInfoForTwigTemplate($settings, $parentClass);            } else {                return null;            }        }        return $resourceInfo;    }    final public function getFieldsInfo(): array    {        return $this->fieldsInfo;    }    final public function isLoaded(): bool    {        return $this->isLoaded;    }    private function getBlockField(string $fieldName): ?Block    {        $block      = null;        $fieldsInfo = $this->fieldsInfo;        if (key_exists($fieldName, $fieldsInfo)) {            $block = $this->{$fieldName};            // prevent possible recursion by a mistake (if someone will create a field with self)            // using static for children support            $block = ($block &&                      $block instanceof Block &&                      get_class($block) !== static::class) ?                $block :                null;        }        return $block;    }    public function getDependencies(string $sourceClass = ''): array    {        $dependencyClasses = [];        $fieldsInfo        = $this->fieldsInfo;        foreach ($fieldsInfo as $fieldName => $fieldType) {            $dependencyBlock = $this->getBlockField($fieldName);            if (! $dependencyBlock) {                continue;            }            $dependencyClass = get_class($dependencyBlock);            // 1. prevent the possible permanent recursion            // 2. add only unique elements, because several fields can have the same type            if (                ($sourceClass && $dependencyClass === $sourceClass) ||                in_array($dependencyClass, $dependencyClasses, true)            ) {                continue;            }            // used static for child support            $subDependencies = $dependencyBlock->getDependencies(static::class);            // only unique elements            $subDependencies = array_diff($subDependencies, $dependencyClasses);            // sub dependencies are before the main dependency            $dependencyClasses = array_merge($dependencyClasses, $subDependencies, [$dependencyClass,]);        }        return $dependencyClasses;    }    // can be overridden for add external arguments    public function getTemplateArgs(Settings $settings): array    {        // using static for child support        $resourceInfo = self::getResourceInfoForTwigTemplate($settings, static::class);        $pathToTemplate = $resourceInfo ?            $resourceInfo[self::RESOURCE_KEY_RELATIVE_RESOURCE_PATH] . $settings->getTwigExtension() :            '';        $namespace      = $resourceInfo[self::RESOURCE_KEY_NAMESPACE] ?? '';        $templateArgs = [            self::TEMPLATE_KEY_NAMESPACE => $namespace,            self::TEMPLATE_KEY_TEMPLATE  => $pathToTemplate,            self::TEMPLATE_KEY_IS_LOADED => $this->isLoaded,        ];        if (! $pathToTemplate) {            $settings->callErrorCallback(                [                    'error' => 'Twig template is missing for the block',                    // using static for child support                    'class' => static::class,                ]            );        }        foreach ($this->fieldsInfo as $fieldName => $fieldType) {            $value = $this->{$fieldName};            if ($value instanceof self) {                $value = $value->getTemplateArgs($settings);            }            $templateArgs[$fieldName] = $value;        }        return $templateArgs;    }    protected function getFieldType(string $fieldName): ?string    {        $fieldType = null;        try {            // used static for child support            $property = new ReflectionProperty(static::class, $fieldName);        } catch (Exception $ex) {            return $fieldType;        }        if (! $property->isProtected()) {            return $fieldType;        }        return $property->getType() ?            $property->getType()->getName() :            '';    }    private function readFieldsInfo(): void    {        $fieldNames = array_keys(get_class_vars(static::class));        foreach ($fieldNames as $fieldName) {            $fieldType = $this->getFieldType($fieldName);            // only protected fields            if (is_null($fieldType)) {                continue;            }            $this->fieldsInfo[$fieldName] = $fieldType;        }    }    private function autoInitFields(): void    {        foreach ($this->fieldsInfo as $fieldName => $fieldType) {            // ignore fields without a type            if (! $fieldType) {                continue;            }            $defaultValue = null;            switch ($fieldType) {                case 'int':                case 'float':                    $defaultValue = 0;                    break;                case 'bool':                    $defaultValue = false;                    break;                case 'string':                    $defaultValue = '';                    break;                case 'array':                    $defaultValue = [];                    break;            }            try {                if (is_subclass_of($fieldType, Block::class)) {                    $defaultValue = new $fieldType();                }            } catch (Exception $ex) {                $defaultValue = null;            }            // ignore fields with a custom type (null by default)            if (is_null($defaultValue)) {                continue;            }            $this->{$fieldName} = $defaultValue;        }    }    final protected function load(): void    {        $this->isLoaded = true;    }}
BlockTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class BlockTest extends Unit{    protected UnitTester $tester;    public function testReadProtectedFields()    {        $block = new class extends Block {            protected $loadedField;        };        $this->assertEquals(            ['loadedField',],            array_keys($block->getFieldsInfo())        );    }    public function testIgnoreReadPublicFields()    {        $block = new class extends Block {            public $ignoredField;        };        $this->assertEquals(            [],            array_keys($block->getFieldsInfo())        );    }    public function testReadFieldWithType()    {        $block = new class extends Block {            protected string $loadedField;        };        $this->assertEquals(            [                'loadedField' => 'string',            ],            $block->getFieldsInfo()        );    }    public function testReadFieldWithoutType()    {        $block = new class extends Block {            protected $loadedField;        };        $this->assertEquals(            [                'loadedField' => '',            ],            $block->getFieldsInfo()        );    }    public function testAutoInitIntField()    {        $block = new class extends Block {            protected int $int;            public function getInt()            {                return $this->int;            }        };        $this->assertTrue(0 === $block->getInt());    }    public function testAutoInitFloatField()    {        $block = new class extends Block {            protected float $float;            public function getFloat()            {                return $this->float;            }        };        $this->assertTrue(0.0 === $block->getFloat());    }    public function testAutoInitStringField()    {        $block = new class extends Block {            protected string $string;            public function getString()            {                return $this->string;            }        };        $this->assertTrue('' === $block->getString());    }    public function testAutoInitBoolField()    {        $block = new class extends Block {            protected bool $bool;            public function getBool()            {                return $this->bool;            }        };        $this->assertTrue(false === $block->getBool());    }    public function testAutoInitArrayField()    {        $block = new class extends Block {            protected array $array;            public function getArray()            {                return $this->array;            }        };        $this->assertTrue([] === $block->getArray());    }    public function testAutoInitBlockField()    {        $testBlock        = new class extends Block {        };        $testBlockClass   = get_class($testBlock);        $block            = new class ($testBlockClass) extends Block {            protected $block;            private $testClass;            public function __construct($testClass)            {                $this->testClass = $testClass;                parent::__construct();            }            public function getFieldType(string $fieldName): ?string            {                return ('block' === $fieldName ?                    $this->testClass :                    parent::getFieldType($fieldName));            }            public function getBlock()            {                return $this->block;            }        };        $actualBlockClass = $block->getBlock() ?            get_class($block->getBlock()) :            '';        $this->assertEquals($actualBlockClass, $testBlockClass);    }    public function testIgnoreAutoInitFieldWithoutType()    {        $block = new class extends Block {            protected $default;            public function getDefault()            {                return $this->default;            }        };        $this->assertTrue(null === $block->getDefault());    }    public function testGetResourceInfo()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            [                Block::RESOURCE_KEY_NAMESPACE              => 'TestNamespace',                Block::RESOURCE_KEY_FOLDER                 => 'test-folder',                Block::RESOURCE_KEY_RELATIVE_RESOURCE_PATH => 'Button/Theme/Red/ButtonThemeRed',                Block::RESOURCE_KEY_RELATIVE_BLOCK_PATH    => 'Button/Theme/Red',                Block::RESOURCE_KEY_RESOURCE_NAME          => 'ButtonThemeRed',            ],            Block::getResourceInfo($settings, 'TestNamespace\\Button\\Theme\\Red\\ButtonThemeRed')        );    }    public function testGetDependenciesWithSubDependenciesRecursively()    {        $spanBlock   = new class extends Block {        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                get_class($spanBlock),                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesInRightOrder()    {        $spanBlock   = new class extends Block {        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                get_class($spanBlock),                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesWhenBlocksAreDependentFromEachOther()    {        $buttonBlock = new class extends Block {            protected $formBlock;            public function __construct()            {                parent::__construct();            }            public function setFormBlock($formBlock)            {                $this->formBlock = $formBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $buttonBlock->setFormBlock($formBlock);        $this->assertEquals(            [                get_class($buttonBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetDependenciesWithoutDuplicatesWhenSeveralWithOneType()    {        function getButtonBlock()        {            return new class extends Block {            };        }        $inputBlock = new class (getButtonBlock()) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $formBlock = new class ($inputBlock) extends Block {            protected $inputBlock;            protected $firstButtonBlock;            protected $secondButtonBlock;            public function __construct($inputBlock)            {                parent::__construct();                $this->inputBlock        = $inputBlock;                $this->firstButtonBlock  = getButtonBlock();                $this->secondButtonBlock = getButtonBlock();            }        };        $this->assertEquals(            [                get_class(getButtonBlock()),                get_class($inputBlock),            ],            $formBlock->getDependencies()        );    }    public function testGetTemplateArgsWhenBlockContainsBuiltInTypes()    {        $settings    = new Settings();        $buttonBlock = new class extends Block {            protected string $name;            public function __construct()            {                parent::__construct();                $this->name = 'button';            }        };        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => '',                Block::TEMPLATE_KEY_IS_LOADED => false,                'name'                        => 'button',            ],            $buttonBlock->getTemplateArgs($settings)        );    }    public function testGetTemplateArgsWhenBlockContainsAnotherBlockRecursively()    {        $settings    = new Settings();        $spanBlock   = new class extends Block {            protected string $name;            public function __construct()            {                parent::__construct();                $this->name = 'span';            }        };        $buttonBlock = new class ($spanBlock) extends Block {            protected $spanBlock;            public function __construct($spanBlock)            {                parent::__construct();                $this->spanBlock = $spanBlock;            }        };        $formBlock   = new class ($buttonBlock) extends Block {            protected $buttonBlock;            public function __construct($buttonBlock)            {                parent::__construct();                $this->buttonBlock = $buttonBlock;            }        };        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => '',                Block::TEMPLATE_KEY_IS_LOADED => false,                'buttonBlock'                 => [                    Block::TEMPLATE_KEY_NAMESPACE => '',                    Block::TEMPLATE_KEY_TEMPLATE  => '',                    Block::TEMPLATE_KEY_IS_LOADED => false,                    'spanBlock'                   => [                        Block::TEMPLATE_KEY_NAMESPACE => '',                        Block::TEMPLATE_KEY_TEMPLATE  => '',                        Block::TEMPLATE_KEY_IS_LOADED => false,                        'name'                        => 'span',                    ],                ],            ],            $formBlock->getTemplateArgs($settings)        );    }    public function testGetTemplateArgsWhenTemplateIsInParent()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase'  => [                    'ButtonBase.php'  => $this->tester->getBlockClassFile(                        $namespace . '\ButtonBase',                        'ButtonBase',                        '\\' . Block::class                    ),                    'ButtonBase.twig' => '',                ],                'ButtonChild' => [                    'ButtonChild.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonChild',                        'ButtonChild',                        '\\' . $namespace . '\ButtonBase\ButtonBase'                    ),                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $buttonChildClass = $namespace . '\ButtonChild\ButtonChild';        $buttonChild      = new $buttonChildClass();        if (! $buttonChild instanceof Block) {            $this->fail("Class doesn't child to Block");        }        $this->assertEquals(            [                Block::TEMPLATE_KEY_NAMESPACE => $namespace,                Block::TEMPLATE_KEY_TEMPLATE  => 'ButtonBase/ButtonBase.twig',                Block::TEMPLATE_KEY_IS_LOADED => false,            ],            $buttonChild->getTemplateArgs($settings)        );    }}

BlocksLoader

Предоставляет опциональную возможность автозагрузки всех блоков, при этом у каждого блока будет вызван статический метод ::onLoad, что может быть использовано для расширения, например поддержки ajax запросов и т.д.

BlocksLoader.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class BlocksLoader{    private array $loadedBlockClasses;    private Settings $settings;    public function __construct(Settings $settings)    {        $this->loadedBlockClasses = [];        $this->settings           = $settings;    }    final public function getLoadedBlockClasses(): array    {        return $this->loadedBlockClasses;    }    private function tryToLoadBlock(string $phpClass): bool    {        $isLoaded = false;        if (            ! class_exists($phpClass, true) ||            ! is_subclass_of($phpClass, Block::class)        ) {            // without any error, because php files can contain other things            return $isLoaded;        }        call_user_func([$phpClass, 'onLoad']);        return true;    }    private function loadBlocks(string $namespace, array $phpFileNames): void    {        foreach ($phpFileNames as $phpFileName) {            $phpClass = implode('\\', [$namespace, str_replace('.php', '', $phpFileName),]);            if (! $this->tryToLoadBlock($phpClass)) {                continue;            }            $this->loadedBlockClasses[] = $phpClass;        }    }    private function loadDirectory(string $directory, string $namespace): void    {        // exclude ., ..        $fs = array_diff(scandir($directory), ['.', '..']);        $phpFilePreg = '/.php$/';        $phpFileNames      = Helper::arrayFilter(            $fs,            function ($f) use ($phpFilePreg) {                return (1 === preg_match($phpFilePreg, $f));            },            false        );        $subDirectoryNames = Helper::arrayFilter(            $fs,            function ($f) {                return false === strpos($f, '.');            },            false        );        foreach ($subDirectoryNames as $subDirectoryName) {            $subDirectory = implode(DIRECTORY_SEPARATOR, [$directory, $subDirectoryName]);            $subNamespace = implode('\\', [$namespace, $subDirectoryName]);            $this->loadDirectory($subDirectory, $subNamespace);        }        $this->loadBlocks($namespace, $phpFileNames);    }    final public function loadAllBlocks(): void    {        $blockFoldersInfo = $this->settings->getBlockFoldersInfo();        foreach ($blockFoldersInfo as $namespace => $folder) {            $this->loadDirectory($folder, $namespace);        }    }}
BlocksLoaderTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\BlocksLoader;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class BlocksLoaderTest extends Unit{    protected UnitTester $tester;    public function testLoadAllBlocksWhichChildToBlock()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase'  => [                    'ButtonBase.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonBase',                        'ButtonBase',                        '\\' . Block::class                    ),                ],                'ButtonChild' => [                    'ButtonChild.php' => $this->tester->getBlockClassFile(                        $namespace . '\ButtonChild',                        'ButtonChild',                        '\\' . $namespace . '\ButtonBase\ButtonBase'                    ),                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEquals(            [                $namespace . '\ButtonBase\ButtonBase',                $namespace . '\ButtonChild\ButtonChild',            ],            $blocksLoader->getLoadedBlockClasses()        );    }    public function testLoadAllBlocksIgnoreNonChild()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'ButtonBase' => [                    'ButtonBase.php' => '<?php use ' . $namespace . '; class ButtonBase{}',                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEmpty($blocksLoader->getLoadedBlockClasses());    }    public function testLoadAllBlocksInSeveralFolders()    {        $rootDirectory   = $this->tester->getUniqueDirectory(__METHOD__);        $firstFolderUrl  = $rootDirectory->url() . '/First';        $secondFolderUrl = $rootDirectory->url() . '/Second';        $firstNamespace  = $this->tester->getUniqueControllerNamespaceWithAutoloader(            __METHOD__ . '_first',            $firstFolderUrl,        );        $secondNamespace = $this->tester->getUniqueControllerNamespaceWithAutoloader(            __METHOD__ . '_second',            $secondFolderUrl,        );        vfsStream::create(            [                'First'  => [                    'ButtonBase' => [                        'ButtonBase.php' => $this->tester->getBlockClassFile(                            $firstNamespace . '\ButtonBase',                            'ButtonBase',                            '\\' . Block::class                        ),                    ],                ],                'Second' => [                    'ButtonBase' => [                        'ButtonBase.php' => $this->tester->getBlockClassFile(                            $secondNamespace . '\ButtonBase',                            'ButtonBase',                            '\\' . Block::class                        ),                    ],                ],            ],            $rootDirectory        );        $settings = new Settings();        $settings->addBlocksFolder($firstNamespace, $firstFolderUrl);        $settings->addBlocksFolder($secondNamespace, $secondFolderUrl);        $blocksLoader = new BlocksLoader($settings);        $blocksLoader->loadAllBlocks();        $this->assertEquals(            [                $firstNamespace . '\ButtonBase\ButtonBase',                $secondNamespace . '\ButtonBase\ButtonBase',            ],            $blocksLoader->getLoadedBlockClasses()        );    }}

Renderer

Связующий класс, объединяет вспомогательные классы, предоставляет функцию рендера блока, содержит список использованных блоков и их ресурсы (css, js)

Renderer.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class Renderer{    private Settings $settings;    private TwigWrapper $twigWrapper;    private BlocksLoader $blocksLoader;    private array $usedBlockClasses;    public function __construct(Settings $settings)    {        $this->settings         = $settings;        $this->twigWrapper             = new TwigWrapper($settings);        $this->blocksLoader     = new BlocksLoader($settings);        $this->usedBlockClasses = [];    }    final public function getSettings(): Settings    {        return $this->settings;    }    final public function getTwigWrapper(): TwigWrapper    {        return $this->twigWrapper;    }    final public function getBlocksLoader(): BlocksLoader    {        return $this->blocksLoader;    }    final public function getUsedBlockClasses(): array    {        return $this->usedBlockClasses;    }    final public function getUsedResources(string $extension, bool $isIncludeSource = false): string    {        $resourcesContent = '';        foreach ($this->usedBlockClasses as $usedBlockClass) {            $getResourcesInfoCallback = [$usedBlockClass, 'getResourceInfo'];            if (! is_callable($getResourcesInfoCallback)) {                $this->settings->callErrorCallback(                    [                        'message' => "Block class doesn't exist",                        'class'   => $usedBlockClass,                    ]                );                continue;            }            $resourceInfo = call_user_func_array(                $getResourcesInfoCallback,                [                    $this->settings,                ]            );            $pathToResourceFile = $resourceInfo['folder'] .                                  DIRECTORY_SEPARATOR . $resourceInfo['relativeResourcePath'] . $extension;            if (! is_file($pathToResourceFile)) {                continue;            }            $resourcesContent .= $isIncludeSource ?                "\n/* " . $resourceInfo['resourceName'] . " */\n" :                '';            $resourcesContent .= file_get_contents($pathToResourceFile);        }        return $resourcesContent;    }    final public function render(Block $block, array $args = [], bool $isPrint = false): string    {        $dependencies           = array_merge($block->getDependencies(), [get_class($block),]);        $newDependencies        = array_diff($dependencies, $this->usedBlockClasses);        $this->usedBlockClasses = array_merge($this->usedBlockClasses, $newDependencies);        $templateArgs           = $block->getTemplateArgs($this->settings);        $templateArgs           = Helper::arrayMergeRecursive($templateArgs, $args);        $namespace              = $templateArgs[Block::TEMPLATE_KEY_NAMESPACE];        $relativePathToTemplate = $templateArgs[Block::TEMPLATE_KEY_TEMPLATE];        // log already exists        if (! $relativePathToTemplate) {            return '';        }        return $this->twigWrapper->render($namespace, $relativePathToTemplate, $templateArgs, $isPrint);    }}
RendererTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Renderer;use LightSource\FrontBlocks\Settings;use org\bovigo\vfs\vfsStream;use UnitTester;class RendererTest extends Unit{    protected UnitTester $tester;    public function testRenderAddsBlockToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $renderer->render($button);        $this->assertEquals(            [                get_class($button),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockDependenciesToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $this->assertEquals(            [                get_class($button),                get_class($form),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsDependenciesBeforeBlockToUsedList()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $this->assertEquals(            [                get_class($button),                get_class($form),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockToUsedListOnce()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $renderer->render($button);        $renderer->render($button);        $this->assertEquals(            [                get_class($button),            ],            $renderer->getUsedBlockClasses()        );    }    public function testRenderAddsBlockDependenciesToUsedListOnce()    {        $settings = new Settings();        $renderer = new Renderer($settings);        $button = new class extends Block {        };        $form   = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $footer = new class ($button) extends Block {            protected $button;            public function __construct($button)            {                parent::__construct();                $this->button = $button;            }        };        $renderer->render($form);        $renderer->render($footer);        $this->assertEquals(            [                get_class($button),                get_class($form),                get_class($footer),            ],            $renderer->getUsedBlockClasses()        );    }    public function testGetUsedResources()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'Button' => [                    'Button.php' => $this->tester->getBlockClassFile(                        $namespace . '\Button',                        'Button',                        '\\' . Block::class                    ),                    'Button.css' => '.button{}',                ],                'Form'   => [                    'Form.php' => $this->tester->getBlockClassFile(                        $namespace . '\Form',                        'Form',                        '\\' . Block::class                    ),                    'Form.css' => '.form{}',                ],            ],            $rootDirectory        );        $formClass   = $namespace . '\Form\Form';        $form        = new $formClass();        $buttonClass = $namespace . '\Button\Button';        $button      = new $buttonClass();        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $renderer = new Renderer($settings);        $renderer->render($button);        $renderer->render($form);        $this->assertEquals('.button{}.form{}', $renderer->getUsedResources('.css'));    }    public function testGetUsedResourcesWithIncludedSource()    {        $rootDirectory = $this->tester->getUniqueDirectory(__METHOD__);        $namespace     = $this->tester->getUniqueControllerNamespaceWithAutoloader(__METHOD__, $rootDirectory->url());        $blocksFolder  = vfsStream::create(            [                'Button' => [                    'Button.php' => $this->tester->getBlockClassFile(                        $namespace . '\Button',                        'Button',                        '\\' . Block::class                    ),                    'Button.css' => '.button{}',                ],                'Form'   => [                    'Form.php' => $this->tester->getBlockClassFile(                        $namespace . '\Form',                        'Form',                        '\\' . Block::class                    ),                    'Form.css' => '.form{}',                ],            ],            $rootDirectory        );        $formClass   = $namespace . '\Form\Form';        $form        = new $formClass();        $buttonClass = $namespace . '\Button\Button';        $button      = new $buttonClass();        $settings = new Settings();        $settings->addBlocksFolder($namespace, $blocksFolder->url());        $renderer = new Renderer($settings);        $renderer->render($button);        $renderer->render($form);        $this->assertEquals(            "\n/* Button */\n.button{}\n/* Form */\n.form{}",            $renderer->getUsedResources('.css', true)        );    }}

Settings

Вспомогательный класс, основные данные это пути к блокам и их пространства имен

Settings.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;class Settings{    private array $blockFoldersInfo;    private array $twigArgs;    private string $twigExtension;    private $errorCallback;    public function __construct()    {        $this->blockFoldersInfo = [];        $this->twigArgs         = [            // will generate exception if a var doesn't exist instead of replace to NULL            'strict_variables' => true,            // disable autoescape to prevent break data            'autoescape'       => false,        ];        $this->twigExtension    = '.twig';        $this->errorCallback    = null;    }    public function addBlocksFolder(string $namespace, string $folder): void    {        $this->blockFoldersInfo[$namespace] = $folder;    }    public function setTwigArgs(array $twigArgs): void    {        $this->twigArgs = array_merge($this->twigArgs, $twigArgs);    }    public function setErrorCallback(?callable $errorCallback): void    {        $this->errorCallback = $errorCallback;    }    public function setTwigExtension(string $twigExtension): void    {        $this->twigExtension = $twigExtension;    }    public function getBlockFoldersInfo(): array    {        return $this->blockFoldersInfo;    }    public function getBlockFolderInfoByBlockClass(string $blockClass): ?array    {        foreach ($this->blockFoldersInfo as $blockNamespace => $blockFolder) {            if (0 !== strpos($blockClass, $blockNamespace)) {                continue;            }            return [                'namespace' => $blockNamespace,                'folder'    => $blockFolder,            ];        }        return null;    }    public function getTwigArgs(): array    {        return $this->twigArgs;    }    public function getTwigExtension(): string    {        return $this->twigExtension;    }    public function callErrorCallback(array $errors): void    {        if (! is_callable($this->errorCallback)) {            return;        }        call_user_func_array($this->errorCallback, [$errors,]);    }}
SettingsTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Settings;class SettingsTest extends Unit{    public function testGetBlockFolderInfoByBlockClass()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            [                'namespace' => 'TestNamespace',                'folder'    => 'test-folder',            ],            $settings->getBlockFolderInfoByBlockClass('TestNamespace\Class')        );    }    public function testGetBlockFolderInfoByBlockClassWhenSeveral()    {        $settings = new Settings();        $settings->addBlocksFolder('FirstNamespace', 'first-namespace');        $settings->addBlocksFolder('SecondNamespace', 'second-namespace');        $this->assertEquals(            [                'namespace' => 'FirstNamespace',                'folder'    => 'first-namespace',            ],            $settings->getBlockFolderInfoByBlockClass('FirstNamespace\Class')        );    }    public function testGetBlockFolderInfoByBlockClassIgnoreWrong()    {        $settings = new Settings();        $settings->addBlocksFolder('TestNamespace', 'test-folder');        $this->assertEquals(            null,            $settings->getBlockFolderInfoByBlockClass('WrongNamespace\Class')        );    }}

TwigWrapper

Класс обертка для Twig пакета, обеспечиват работу с шаблонами. Также расширили twig своей функцией _include (которая является оберткой для встроенного include и использует наши поля _isLoaded и _template из метода Block->getTemplateArgs выше) и фильтром _merge (который отличается тем, что рекурсивно сливает массивы).

TwigWrapper.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;use Exception;use Twig\Environment;use Twig\Loader\FilesystemLoader;use Twig\Loader\LoaderInterface;use Twig\TwigFilter;use Twig\TwigFunction;class TwigWrapper{    private ?LoaderInterface $twigLoader;    private ?Environment $twigEnvironment;    private Settings $settings;    public function __construct(Settings $settings, ?LoaderInterface $twigLoader = null)    {        $this->twigEnvironment = null;        $this->settings        = $settings;        $this->twigLoader      = $twigLoader;        $this->init();    }    private static function GetTwigNamespace(string $namespace)    {        return str_replace('\\', '_', $namespace);    }    // e.g for extend a twig with adding a new filter    public function getEnvironment(): ?Environment    {        return $this->twigEnvironment;    }    private function extendTwig(): void    {        $this->twigEnvironment->addFilter(            new TwigFilter(                '_merge',                function ($source, $additional) {                    return Helper::arrayMergeRecursive($source, $additional);                }            )        );        $this->twigEnvironment->addFunction(            new TwigFunction(                '_include',                function ($block, $args = []) {                    $block = Helper::arrayMergeRecursive($block, $args);                    return $block[Block::TEMPLATE_KEY_IS_LOADED] ?                        $this->render(                            $block[Block::TEMPLATE_KEY_NAMESPACE],                            $block[Block::TEMPLATE_KEY_TEMPLATE],                            $block                        ) :                        '';                }            )        );    }    private function init(): void    {        $blockFoldersInfo = $this->settings->getBlockFoldersInfo();        try {            // can be already init (in tests)            if (! $this->twigLoader) {                $this->twigLoader = new FilesystemLoader();                foreach ($blockFoldersInfo as $namespace => $folder) {                    $this->twigLoader->addPath($folder, self::GetTwigNamespace($namespace));                }            }            $this->twigEnvironment = new Environment($this->twigLoader, $this->settings->getTwigArgs());        } catch (Exception $ex) {            $this->twigEnvironment = null;            $this->settings->callErrorCallback(                [                    'message' => $ex->getMessage(),                    'file'    => $ex->getFile(),                    'line'    => $ex->getLine(),                    'trace'   => $ex->getTraceAsString(),                ]            );            return;        }        $this->extendTwig();    }    public function render(string $namespace, string $template, array $args = [], bool $isPrint = false): string    {        $html = '';        // twig isn't loaded        if (is_null($this->twigEnvironment)) {            return $html;        }        // can be empty, e.g. for tests        $twigNamespace = $namespace ?            '@' . self::GetTwigNamespace($namespace) . '/' :            '';        try {            // will generate ean exception if a template doesn't exist OR broken            // also if a var doesn't exist (if using a 'strict_variables' flag, see Twig_Environment->__construct)            $html .= $this->twigEnvironment->render($twigNamespace . $template, $args);        } catch (Exception $ex) {            $html = '';            $this->settings->callErrorCallback(                [                    'message'  => $ex->getMessage(),                    'file'     => $ex->getFile(),                    'line'     => $ex->getLine(),                    'trace'    => $ex->getTraceAsString(),                    'template' => $template,                ]            );        }        if ($isPrint) {            echo $html;        }        return $html;    }}
TwigWrapperTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocks\Settings;use LightSource\FrontBlocks\TwigWrapper;use Twig\Loader\ArrayLoader;class TwigWrapperTest extends Unit{    private function renderBlock(array $blocks, string $template, array $renderArgs = []): string    {        $twigLoader = new ArrayLoader($blocks);        $settings   = new Settings();        $twig       = new TwigWrapper($settings, $twigLoader);        return $twig->render('', $template, $renderArgs);    }    public function testExtendTwigIncludeFunctionWhenBlockIsLoaded()    {        $blocks     = [            'form.twig'   => '{{ _include(button) }}',            'button.twig' => 'button content',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => true,            ],        ];        $this->assertEquals('button content', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigIncludeFunctionWhenBlockNotLoaded()    {        $blocks     = [            'form.twig'   => '{{ _include(button) }}',            'button.twig' => 'button content',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => false,            ],        ];        $this->assertEquals('', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigIncludeFunctionWhenArgsPassed()    {        $blocks     = [            'form.twig'   => '{{ _include(button,{classes:["test-class",],}) }}',            'button.twig' => '{{ classes|join(" ") }}',        ];        $template   = 'form.twig';        $renderArgs = [            'button' => [                Block::TEMPLATE_KEY_NAMESPACE => '',                Block::TEMPLATE_KEY_TEMPLATE  => 'button.twig',                Block::TEMPLATE_KEY_IS_LOADED => true,                'classes'                     => ['own-class',],            ],        ];        $this->assertEquals('own-class test-class', $this->renderBlock($blocks, $template, $renderArgs));    }    public function testExtendTwigMergeFilter()    {        $blocks     = [            'button.twig' => '{{ {"array":["first",],}|_merge({"array":["second",],}).array|join(" ") }}',        ];        $template   = 'button.twig';        $renderArgs = [];        $this->assertEquals('first second', $this->renderBlock($blocks, $template, $renderArgs));    }}

Helper

Вспомогательный класс, содержит лишь пару функций, оставлю без комментариев.

Helper.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks;abstract class Helper{    final public static function arrayFilter(array $array, callable $callback, bool $isSaveKeys): array    {        $arrayResult = array_filter($array, $callback);        return $isSaveKeys ?            $arrayResult :            array_values($arrayResult);    }    final public static function arrayMergeRecursive(array $args1, array $args2): array    {        foreach ($args2 as $key => $value) {            if (intval($key) === $key) {                $args1[] = $value;                continue;            }            // recursive sub-merge for internal arrays            if (                is_array($value) &&                key_exists($key, $args1) &&                is_array($args1[$key])            ) {                $value = self::arrayMergeRecursive($args1[$key], $value);            }            $args1[$key] = $value;        }        return $args1;    }}
HelperTest.php
<?phpdeclare(strict_types=1);namespace LightSource\FrontBlocks\Tests\unit;use Codeception\Test\Unit;use LightSource\FrontBlocks\Helper;class HelperTest extends Unit{    public function testArrayFilterWithoutSaveKeys()    {        $this->assertEquals(            [                0 => '2',            ],            Helper::ArrayFilter(                ['1', '2'],                function ($value) {                    return '1' !== $value;                },                false            )        );    }    public function testArrayFilterWithSaveKeys()    {        $this->assertEquals(            [                1 => '2',            ],            Helper::ArrayFilter(                ['1', '2'],                function ($value) {                    return '1' !== $value;                },                true            )        );    }    public function testArrayMergeRecursive()    {        $this->assertEquals(            [                'classes' => [                    'first',                    'second',                ],                'value'   => 2,            ],            Helper::arrayMergeRecursive(                [                    'classes' => [                        'first',                    ],                    'value'   => 1,                ],                [                    'classes' => [                        'second',                    ],                    'value'   => 2,                ]            )        );    }}

Это был последний класс, теперь можно переходить к демонстрационному примеру.

Демонстрационный пример

Ниже приведу демонстрационный пример использования пакета, с одним чистым css для наглядности, если кому-то интересен пример с scss/webpack смотрите ссылки в конце статьи.

Создаем блоки для теста, пусть это будут Header, Article и Button. Header и Button будут независимыми блоками, Article будет содержкать Button.

Header

Header.php
<?phpnamespace LightSource\FrontBlocksSample\Header;use LightSource\FrontBlocks\Block;class Header extends Block{    protected string $name;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Header';    }}
Header.twig
<div class="header">    {{ name }}</div>
Header.css
.header {    color: green;    border:1px solid green;    padding: 10px;}

Button

Button.php
<?phpnamespace LightSource\FrontBlocksSample\Button;use LightSource\FrontBlocks\Block;class Button extends Block{    protected string $name;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Button';    }}
Button.twig
<div class="button">    {{ name }}</div>
Button.css
.button {    color: black;    border: 1px solid black;    padding: 10px;}

Article

Article.php
<?phpnamespace LightSource\FrontBlocksSample\Article;use LightSource\FrontBlocks\Block;use LightSource\FrontBlocksSample\Button\Button;class Article extends Block{    protected string $name;    protected Button $button;    public function loadByTest()    {        parent::load();        $this->name = 'I\'m Article, I contain another block';        $this->button->loadByTest();    }}
Article.twig
<div class="article">    <p class="article__name">{{ name }}</p>    {{ _include(button) }}</div>
Article.css
.article {    color: orange;    border: 1px solid orange;    padding: 10px;}.article__name {    margin: 0 0 10px;    line-height: 1.5;}

Далее подключаем пакет и рендерим блоки, результат вставляем в html код страницы, в шапке выводим использованный css код

example.php
<?phpuse LightSource\FrontBlocks\{    Renderer,    Settings};use LightSource\FrontBlocksSample\{    Article\Article,    Header\Header};require_once __DIR__ . '/vendors/vendor/autoload.php';//// settingsini_set('display_errors', 1);$settings = new Settings();$settings->addBlocksFolder('LightSource\FrontBlocksSample', __DIR__ . '/Blocks');$settings->setErrorCallback(    function (array $errors) {        // todo log or any other actions        echo '<pre>' . print_r($errors, true) . '</pre>';    });$renderer = new Renderer($settings);//// usage$header = new Header();$header->loadByTest();$article = new Article();$article->loadByTest();$content = $renderer->render($header);$content .= $renderer->render($article);$css     = $renderer->getUsedResources('.css', true);//// html?><html><head>    <title>Example</title>    <style>        <?= $css ?>    </style>    <style>        .article {            margin-top: 10px;        }    </style></head><body><?= $content ?></body></html>

в результате вывод будет таким

example.png

Послесловие

Данный пакет был создан для личных целей, он облегчает мне разработку, и я буду рад если он пригодится кому-то еще. Не стесняйтесь задавать вопросы и комментировать я буду рад ответить на ваши вопросы и услышать ваше мнение. Спасибо за внимание.

Понравилась статья? Не забудь проголосовать.

Ссылки:

репозиторий с данным пакетом

репозиторий с демонстрационным примером

репозиторий с примером использования scss и js в блоках (webpack сборщик)

репозиторий с примером использования в WordPress теме (здесь вы также можете увидеть пример расширения класса блока и использования автозагрузки, что добавляет поддержку ajax запросов для блоков)

P.S. Благодарю @alexmixaylov, @bombe и @rpsv за конструктивные комментарии к первой части.

Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 468 (17 23 мая 2021)

24.05.2021 00:09:13 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Подкаст Веб-стандарты #282: Rome, CloudFront Functions, кроссбраузерность, has() и другой современный CSS, мониторинг, GDE
podcast Подкаст Фронтенд Юность #187: Bootstrap круче чем все сраные фреймворки
podcast Подкаст Callback Hell: Производительность CSS-in-JS, языки логического программирования, ООП в современном фронтенде
podcast Новости 512 от CSSSR: Angular 12, Deno 1.10, мониторинг, тестирование UI, :has(), курс по git, Rome + $, TypeScript 4.3 RC
podcast Подкаст Callback Hell Поддержка нескольких мажорных версий, венчурный капитал в Open Source и возвращение тонкого клиента
podcast Подкаст proConf #96: DeveloperWeek 2020
podcast video Подкаст Цинковый Прод #113: Сайт сына маминой подруги

Веб-разработка


W3C представил черновой вариант стандарта WebGPU
en Google AMP мертв! AMP-страницы больше не пользуются приоритетом в поиске Google
en Incremental Static Regeneration: создавайте статические сайты понемногу
en Тестирование фронтенд-приложений что, где, как?





CSS


habr Трюки CSS, которые сделают из вас ниндзя верстки
habr Взгляд на Tailwind CSS
en Новая отзывчивость: веб-дизайн в мире компонентов
en Нет, утилитарные классы это не то же самое, что инлайн стили
en Как создать неоновый текст с помощью CSS
en Как стилизовать любое поле ввода советы и методы
en 82% разработчиков неправильно проходят этот трехстрочный тест по CSS
en Learn CSS Постоянно обновляемый курс CSS и справочник для повышения вашего уровня знаний в области стилизации веба
en aspect-ratio

JavaScript


habr Швейцарский нож отладки JavaScript
habr Трасси что? Доклад Яндекса
en DOM Events изучение системы событий DOM с помощью визуального исследования
en ES12 сделает вашу жизнь проще
en Справочник по массивам JavaScript методы работы с JS-массивами с примерами
en Двухмерные оптические демки в Javascript
en JavaScript API для распознавания людей и ботов в Chrome







Браузеры


habr Microsoft прекратит поддержку приложения Internet Explorer 11 в Windows 10 с июня 2022 года
habr Кросс-браузерный трекинг на основе перебора обработчика внешних протоколов
В Chrome экспериментируют с поддержкой RSS, чисткой User-Agent и автосменой паролей
Компания Mozilla представила режим строгой изоляции сайтов для Firefox
Выпуск перенастраиваемого web-браузера Nyxt 2.0.0

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 469 (24 30 мая 2021)

31.05.2021 00:23:47 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Подкаст Веб-стандарты 83. Sublime Text 4, Sass, Svelte после React, Container Queries, Learn CSS, Google I/O, новые GDE
podcast Подкаст proConf #97: JavaScript for WordPress 2020
podcast Подкаст Callback Hell: Sublime Text 4 и другие редакторы, проблемы написания читаемого кода, завершение эпохи IE
podcast Новости 512 от CSSSR: Chrome 91, TypeScript 4.3, Server-Sent Events API, logux и logux/state, postTask, Parcel 2 beta 3
podcast Новости 512 от CSSSR: Sublime Text 4, PostCSS 8.3, ненадежность TypeScript, Angular DevTools, WebContainers, Google I/O 21
podcast Пилотный выпуск подкаста Goose & Duck: Babel, деньги, два гуся

Веб-разработка


habr Самая серьёзная проблема HTML? Разработчики, разработчики, разработчики
habr Использование веб-компонентов при работе над GitHub
habr Наиболее полное руководство по практическому использованию Web Speech API
en Эволюция и новое определение Jamstack
en 10 вариантов клиентских хранилищ и когда их использовать
en Нарушаете ли вы патент, публикуя PWA?
en Создайте эффект плавного наведения с помощью GSAP и SVG



CSS


habr HTML и CSS ошибки, ухудшающие UX
en Тщательный анализ CSS-in-JS
en CSS Container Queries для дизайнеров
en 25 лет CSS
en CSS Container Queries: примеры использования и стратегии миграции
en Новый способ уменьшить влияние загрузки шрифтов: дескрипторы шрифтов в CSS

JavaScript


habr Карманная книга по TypeScript. Часть 1. Основы, Часть 2. Типы на каждый день
habr 3 способа визуального извлечения данных с помощью JavaScript
en Sparkplug неоптимизирующий компилятор JavaScript
en Новые стандарты доступа к оборудованию устройств с использованием JavaScript
en 7 инструментов, трансформирующих JavaScript-разработку
en Введение в Clio lang: несложная реализация производительного critical js






Браузеры


habr Mozilla примет Manifest v3 для дополнений Firefox, но без мер против блокировщиков рекламы
Релиз Chrome 91
en Призрак Google Reader находит свой путь в новой сборке Chrome Canary

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 470 (1 6 июня 2021)

07.06.2021 00:10:32 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast video Подкаст Goose&Duck #1 Ржавеющий JavaScript
podcast CSSSR Callback Hell: Rescript, мысли пьяного Senior-разработчика, слежка за сотрудниками
podcast Новости 512 от CSSSR: Server-Sent Events: ограничения, поддержка Node.js-проектов, плагины для VSCode, 12 лет Node.js
podcast Подкаст Фронтенд Юность #189 Рон-дом-дом

Веб-разработка


habr С помощью перехода на микросервис мы ускорили бизнес-процесс в 60 раз
en Создание нескольких прогрессивных веб-приложений в одном домене
en Тестирование фронтенда для всех
en Разрушение мифов: Jamstack не может обрабатывать динамический контент
en История веба: часть 1
en Некоторые из лучших пасхальных яиц, спрятанных на сайтах в Интернете





CSS


habr 25 лет CSS
Нативная валидация ввода в CSS
en CSS in SVG in CSS: добавление конфетти в дизайн-систему Stack Overflow
en Новые функциональные селекторы псевдоклассов CSS: is() и: where()
en Тригонометрия в CSS и JavaScript: Введение в тригонометрию
en Тригонометрия в CSS и JavaScript: творческий подход с помощью тригонометрических функций
en The CSS Layout Generator визуальный инструмент для создания компонентов лейаута на CSS Grid
en Inherit, initial, unset, revert
en Шестиугольники и не только: гибкие, отзывчивые сеточные шаблоны, без медиа-запросов

JavaScript


habr Управление зависимостями в Node.js
habr Как мы потерпели неудачу, а затем преуспели в переходе на TypeScript
habr Создание нейронной сети Хопфилда на JavaScript
ES12 сделает вашу жизнь проще!
en Обеспечение быстрой работы JavaScript в WebAssembly
en Еще одна альтернатива Javascript: ReScript
en Взгляд на компиляцию в JavaScript-фреймворках






Браузеры


habr Firefox 89 обновил интерфейс браузера
Релиз Firefox 89 с переработанным интерфейсом
Mozilla, Google, Apple и Microsoft объединили усилия в стандартизации платформы для браузерных дополнений
en Что нового в DevTools (Chrome 92)

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 472 (7 13 июня 2021)

14.06.2021 00:15:08 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript|Браузеры


Медиа


podcast Новости 512 от CSSSR: Firefox 89, Safari 15 Beta, Jest 27, цикл статей о работе браузера, разработка базовых компонентов, обзорная статья о тестировании фронтенда и анонс WebExtensions Community Group.
podcast Подкаст Веб-стандарты #285: Бета Chrome92, Firefox89, якоря ирасширения, TeamCity, JSвнутри WASM, TypeScript4.3
podcast Подкаст Фронтенд Юность #190: Как подступиться к старому проекту и не сесть на кулак
podcast Новости 512 от CSSSR: React 18, Vue 3.1, анонс ESLint 8, курсы от CSSSR, :is(), where() и :has(), как прилёг Интернет
podcast Подкаст Callback Hell: Сервисы Google с плохими Web Vitals, шеринг логики между фронтом и бэком, документация на проектах


Веб-разработка


habr Будущее веба: станет ли рендеринг в <canvas> заменой DOM?
en Правильный тег для работы: почему следует использовать семантический HTML
en 5 проблем фронтенда, которые нельзя игнорировать





CSS


habr Выкладка нетрадиционной ориентации
en Полное руководство по CSS Grid с шпаргалкой
en Системные цвета CSS
en CSS определяет значения цвета, соответствующие системным настройкам.
en Media Queries во времена @container
en Давайте узнаем об Aspect Ratio в CSS
en CSS size-adjust для @font-face
en Равные столбцы с Flexbox: это сложнее, чем вы думаете
en Эксперимент с сортируемыми мультиколоночными таблицами
en Знакомьтесь с :has: нативный CSS селектор
en Рог изобилия ContainerQueries
en Создание правил для font-size CSS и создание Fluid Type Scale

JavaScript


habr Как я ускорил движок на 13%
habr Прогнозирование временных рядов на JS: анализ данных для самых маленьких фронтендеров
habr Sparkplug неоптимизирующий компилятор JavaScript в подробностях
en Как создать фулстек-приложение с помощью Supabase и Next.js
en Реализация приватных полей в JavaScript
en Forever Functional: Мемоизация промисов
en Как реализовать принципы SOLID в JavaScript
en Автоматизируйте форматирование и исправление JavaScript кода с помощью Prettier и ESLint
en Современный JavaScript
en Выходя за рамки ESLint: обзор статического анализа в JavaScript
en Доберенные типы API для безопасности JavaScript DOM
en Как создать NFT с помощью JavaScript
en Rust с точки зрения JavaScript





Браузеры


habr Vivaldi 4.0 Первое приближение
Google признал неудачным эксперимент с показом только домена в адресной строке Chrome
en Возможности WebKit в Safari, продемонстрированные на WWDC21


Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Дайджест свежих материалов из мира фронтенда за последнюю неделю 473 (14 20 июня 2021)

21.06.2021 00:15:47 | Автор: admin
Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.


Медиа|Веб-разработка|CSS|JavaScript


Медиа


podcast Подкаст Веб-стандарты 286: Высокопроизводительное хранилище для вашего приложения: Storage Foundation API
podcast Подкаст Callback Hell: Микрофронтенды и Module Federation, почему компании боятся открывать свой код, игровая выставка E3
podcast Новости 512 от CSSSR: Canvas-рендеринг, Lighthouse 8, пропорции в CSS, PHP 8.1 alpha, Next.js 11, Линус и антипрививочник
podcast video Подкаст Ленивый фронтендер #2 Kaiwa Show | Как сохранить любовь к веб-разработке
podcast Подкаст Фронтенд Юность #191: HR'ы немножко осатанели


Веб-разработка


habr <img>. Доклад Яндекса
habr Темизация. История, причины, реализация
habr DIV должен уйти: улучшаем HTML
en Изучение Eleventy с нуля. Бесплатный курс, состоящий из 31 урока
en Как я использовал WAAPI для создания библиотеки анимации
en Десять лет веб-компонентам



CSS


video :has в CSS псевдокласс из будущего на примере карточки новости
en Использование свойства `outline` в качестве схлопывающейся границы
en Идеальные всплывающие подсказки с обрезкой и маскированием CSS
en Оптический размер, скрытая сверхспособность вариативных шрифтов
en Краткое руководство по логическим свойствам CSS
en Застенчивая кнопка стоимостью 8 миллионов долларов
en Создание таблиц с липким верхним и нижним колонтитулами стало немного проще

JavaScript


habr Скрываем номера курьеров и клиентов с помощью key-value хранилища
habr Юмористичный обзор Rust с перспективы JavaScript
en Управление состоянием: двусторонние биндинги и расширенные средства форматирования биндингов
en Что такое букмарклеты? Как использовать JavaScript для создания букмарклета в Chromium и Firefox
en Тестирование использования памяти в JavaScript
en Двойные кавычки против одинарных кавычек против обратных кавычек в JavaScript
en sorting-algos-visualizer Визуализация популярных алгоритмов сортировки: QuickSort, MergeSort, HeapSort, BubbleSort, InsertionSort







Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.
Подробнее..

Фрактальная шизофрения. Whats up?

01.04.2021 04:04:20 | Автор: admin


По некоторым источникам еще в IV до нашей эры Аристотель задался одним простым вопросом Что было раньше? Курица или яйцо? Сам он в итоге пришел к выводу, что и то, и другое появилось одновременно вот это поворот! Не правда ли?


Ладно, шутки в сторону, тут есть кое-что интересное. Данная простая дилемма хорошо описывает ситуацию, в которой не ясно, какое из двух явлений считать причиной, а какое следствием, но в тоже время позволяет сделать вывод о том, что причина может являться следствием и наоборот следствие может являться причиной, смотря с какой стороны посмотреть (относительность, позиция наблюдателя и т.д.).


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


Сегодня я не буду терзать ваш разум водопадом отборного бреда. Выпуск будет чуть суровее ибо лирики меньше, а кода больше. Тем не менее местами он будет вкуснее ибо вишенки я вам приготовил отменные. Поехали?


What's up guys?


Кажется моя лаборатория по производству генераторов-мутантов слепила что-то действительно годное.


npm i whatsup

Знакомьтесь фронтенд фреймворк вдохновленный идеями фракталов и потоков энергии. С реактивной душой. С минимальным api. С максимальным использованием нативных конструкций языка.


Построен он на генераторах, из коробки даёт функционал аналогичный react + mobx, не уступает по производительности, при этом весит менее 5kb gzip.


Архитектурная идея заключается в том, что всё наше приложение это древовидная структура, по ветвям которой в направлении корня организовано течение данных, отражающих внутреннее состояние. В процессе разработки мы описываем узлы этой структуры. Каждый узел простая самоподобная сущность, полноценное законченное приложение, вся работа которого сводится к тому, чтобы принять данные от других узлов, переработать и отправить следующим.


Cause & Conse


Причина и следствие. Два базовых потока для организации реактивного состояния данных. Для простоты понимания их можно ассоциировать с привычными computed и observable, они, конечно же, отличаются, но для начала сойдёт.


Начнём со следствия. По сути это одноимённая причина, которой из вне можно задавать значение возвращаемого следствия (маслянистое определение получилось, но чисто технически всё так и есть).


const name = conse('John')// И мы ему такие - What`s up name?whatsUp(name, (v) => console.log(v))// а он нам://> "John"name.set('Barry')//> "Barry"

Пример на CodeSandbox


Ничего особенного, правда? conse создает поток с начальным значением, whatsUp "вешает" наблюдателя. С помощью .set(...) меняем значение наблюдатель реагирует в консоли появляется новая запись.


На самом деле Conse это частный случай потока Cause. Последний создается из генератора, внутри которого выражение yield* это "подключение" стороннего потока к текущему, иными словами обстановку внутри генератора можно рассмотреть так, как будто бы мы находимся внутри изолированной комнаты, в которую есть несколько входов yield* и всего один выход return (конечно же yield ещё, но об этом позже)


const name = conse('John')const user = cause(function* () {    return {        name: yield* name,        //    ^^^^^^ подключаем поток name        //           пускаем его данные в комнату    }})// И мы ему такие - What`s up user? :)whatsUp(user, (v) => console.log(v))// а он нам://> {name: "John"}name.set('Barry')//> {name: "Barry"}

Пример на CodeSandbox


Помимо извлечения данных yield* name устанавливает зависимость потока user от потока name, что в свою очередь также приводит к вполне ожидаемым результатам, а именно меняем name меняется user реагирует наблюдатель консоль показывает новую запись.


И в чем тут соль генераторов?


Действительно, пока что всё это выглядит как-то необоснованно "стероидно". Давайте немного усложним наш пример. Представим, что в данных потока user мы хотим видеть некоторый дополнительный параметр revision, отражающий текущую ревизию.


Сделать это просто мы объявляем переменную revision, значение которой включаем в набор данных потока user и каждый раз в процессе перерасчета увеличиваем на единицу.


const name = conse('John')let revision = 0const user = cause(function* () {    return {        name: yield* name,        revision: revision++,    }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}

Пример на CodeSandbox


Что-то подсказывает мне, что так не красиво revision выглядит оторваной от контекста и незащищенной от воздействия из вне. Этому есть решение мы можем поместить определение этой переменной в тело генератора, а для отправки нового значения в поток (выхода из комнаты) использовать yield вместо return, что позволит нам не завершать выполнение генератора, а приостанавливать и возобновлять с места последней остановки при следующем обновлении.


const name = conse('John')const user = cause(function* () {    let revision = 0    while (true) {        yield {            name: yield* name,            revision: revision++,        }    }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}

Пример на CodeSandbox


Как вам? Не завершая генератор, мы получаем дополнительную изолированную область видимости, которая создается и уничтожается вместе с генератором. В ней мы можем определить переменную revision, доступную от вычисления к вычислению, но при этом не доступную из вне. При завершении генератора revision уйдет в мусор, при создании будет создана вместе с ним.


Расширенный пример


Функции cause и conse это шорты для создания потоков. Существуют одноименные базовые классы, доступные для расширения.


import { Cause, Conse, whatsUp } from 'whatsup'type UserData = { name: string }class Name extends Conse<string> {}class User extends Cause<UserData> {    readonly name: Name    constructor(name: string) {        super()        this.name = new Name(name)    }    *whatsUp() {        while (true) {            yield {                name: yield* this.name,            }        }    }}const user = new User('John')whatsUp(user, (v) => console.log(v))//> {name: "John"}user.name.set('Barry')//> {name: "Barry"}

Пример на CodeSandbox


При расширении нам необходимо реализовать метод whatsUp, возвращающий генератор.


Контекст и диспозинг


Единственный агрумент принимаемый методом whatsUp является текущий контекст. В нём есть несколько полезных методов, один из которых update позволяет принудительно инициировать процедуру обновления.


Для избежания ненужных и повторных вычислений все зависимости между потоками отслеживаются динамически. Когда наступает момент, при котором у потока отсутствуют наблюдатели, происходит автоматическое уничтожение генератора. Наступление этого события можно обработать, используя стандартную языковую конструкцию try {} finally {}.


Рассмотрим пример потока-таймера, который с задержкой в 1 секунду, используя setTimeout, генерирует новое значение, а при уничтожении вызывает clearTimeout для очистки таймаута.


const timer = cause(function* (ctx: Context) {    let timeoutId: number    let i = 0    try {        while (true) {            timeoutId = setTimeout(() => ctx.update(), 1000)            // устанавливаем таймер перезапуска с задержкой 1 сек            yield i++            // отправляем в поток текущее значение счетчика            // заодно инкрементим его        }    } finally {        clearTimeout(timeoutId)        // удаляем таймаут        console.log('Timer disposed')    }})const dispose = whatsUp(timer, (v) => console.log(v))//> 0//> 1//> 2dispose()//> 'Timer disposed'

Пример на CodeSandbox


Мутаторы всё из ничего


Простой механизм, позволяющий генерировать новое значение на основе предыдущего. Рассмотрим тот же пример с таймером на основе мутатора.


const increment = mutator((i = -1) => i + 1)const timer = cause(function* (ctx: Context) {    // ...    while (true) {        // ...        // отправляем мутатор в поток        yield increment    }    // ...})

Пример на CodeSandbox


Мутатор устроен очень просто это метод, который принимает предыдущее значение и возвращает новое. Чтобы он заработал нужно всего лишь вернуть его в качестве результата вычислений, вся остальная магия произойдет под капотом. Поскольку при первом запуске предыдущего значения не существует, мутатор получит undefined, параметр i по умолчанию примет значение -1, а результатом вычислений будет 0. В следующий раз ноль мутирует в единицу и т.д. Как вы уже заметили increment позволил нам отказаться от хранения локальной переменной i в теле генератора.


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


class EqualArr<T> extends Mutator<T[]> {    constructor(readonly next: T[]) {}    mutate(prev?: T[]) {        const { next } = this        if (            prev &&             prev.length === next.length &&             prev.every((item, i) => item === next[i])        ) {            /*            Возвращаем старый массив, если он эквивалентен новому,             планировщик сравнит значения, увидит,             что они равны и остановит бессмысленные пересчеты            */            return prev        }        return next    }}const some = cause(function* () {    while (true) {        yield new EqualArr([            /*...*/        ])    }})

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


Также как cause и conse функция mutator это шорт для краткого определения простого мутатора. Более сложные мутаторы можно описать, расширяя базовый класс Mutator, в котором необходимо реализовать метод mutate.


Смотрите вот так можно создать мутатор dom-элемента. И поверьте элемент будет создан и вставлен в body однократно, всё остальное сведётся к обновлению его свойств.


class Div extends Mutator<HTMLDivElement> {    constructor(readonly text: string) {        super()    }    mutate(node = document.createElement('div')) {        node.textContent = this.text        return node    }}const name = conse('John')const nameElement = cause(function* () {    while (true) {        yield new Div(yield* name)    }})whatsUp(nameElement, (div) => document.body.append(div))/*<body>    <div>John</div></body>*/name.set('Barry')/*<body>    <div>Barry</div></body>*/

Пример на CodeSandbox


Так это ж стейт менеджер на генераторах


Да с одной стороны WhatsUp это стейт менеджер на генераторах, в нём есть аналоги привычных observable, computed, reaction. Есть и action, позволяющий внести несколько изменений и провести обновление за один проход. Пока что ничего необычного, но то что вы увидите дальше, выгодно отличает его от других систем управления состоянием.


Фракталы


Я же говорил, что сохранил идею :) Особенность фрактала заключается в том, что для каждого потребителя он создает персональный генератор и контекст. Он как по лекалу создает новую, параллельную вселенную, в которой своя жизнь, но те же правила. Контексты соединяются друг с другом в отношения parent-child получается дерево контекстов, по которому организуется спуск данных вниз к листьям и всплытие событий вверх к корню. Контекст и система событий, Карл! Пример ниже длинный, но наглядно демонстрирует и то, и другое.


import { Fractal, Conse, Event, Context } from 'whatsup'import { render } from '@whatsup/jsx'class Theme extends Conse<string> {}class ChangeThemeEvent extends Event {    constructor(readonly name: string) {        super()    }}class App extends Fractal<JSX.Element> {    readonly theme = new Theme('light');    readonly settings = new Settings()    *whatsUp(ctx: Context) {        // расшариваем поток this.theme для всех нижележащих фракталов        // т.е. "спускаем его" вниз по контексту        ctx.share(this.theme)        // создаем обработчик события ChangeThemeEvent, которое можно        // инициировать в любом нижележащем фрактале и перехватить тут        ctx.on(ChangeThemeEvent, (e) => this.theme.set(e.name))        while (true) {            yield (<div>{yield* this.settings}</div>)        }    }}class Settings extends Fractal<JSX.Element> {    *whatsUp(ctx: Context) {        // берем поток Theme, расшаренный где-то в верхних фракталах        const theme = ctx.get(Theme)        // инициируем всплытие события, используя ctx.dispath        const change = (name: string) =>             ctx.dispath(new ChangeThemeEvent(name))        while (true) {            yield (                <div>                    <h1>Current</h1>                    <span>{yield* theme}</span>                    <h1>Choose</h1>                    <button onClick={() => change('light')}>light</button>                    <button onClick={() => change('dark')}>dark</button>                </div>            )        }    }}const app = new App()render(app)

Пример на CodeSandbox


Метод ctx.share используется для того, чтобы сделать экземпляр какого-то класса доступным для фракталов стоящих ниже по контексту, а вызов ctx.get с конструктором этого экземпляра позволяет нам найти его.


Метод ctx.on принимает конструктор события и его обработчик, ctx.dispatch в свою очередь принимает инстанс события и инициирует его всплытие вверх по контексту. Для отмены обработки события существует ctx.off, но в большинстве случаев им не приходится пользоваться, поскольку все обработчики уничтожаются автоматически при уничтожении генератора.


Я настолько заморочился, что написал свой jsx-рендер и babel-плагин для трансформации jsx-кода. Уже догадываетесь что под капотом? Да мутаторы. Принцип тот же, что и в примере с мутатором dom-элемента, только тут создается и в дальнейшем мутируется определенный фрагмент html-разметки. Создания и сравнения всего виртуального dom (как в react, например) не происходит. Всё сводится к локальным пересчетам, что даёт хороший прирост в производительности. Иными словами в примере выше, при изменении темы оформления, перерасчеты и обновление dom произойдут только во фрактале Settings (потому что yield* theme поток подключен только там).


Механизм реконсиляции получил свой уникальный алгоритм, побочным эффектом которого оказалась возможность рендерить напрямую в элемент <body>. Вторым аргументом функция render конечно же принимает контейнер, но, как видите в примере выше он не обязательный.


Фрагменты и их синтаксис также поддерживаются, а компоненты определяются только как чистые функции, не имеют внутреннего состояния и отвечают исключительно за разметку.


Обработка ошибок


Возникновение исключения на уровне фреймворка является стандартной ситуацией. Ошибка распространяется по потокам, как и любые другие данные, а обрабатывается стандартной конструкцией try {} catch {}. При этом система реактивности сохраняет состояние зависимостей таким образом, что при исправлении ситуации и исчезновении ошибки, потоки пересчитывают свои данные и возвращаются в нормальное рабочее состояние.


import { conse, Fractal } from 'whatsup'import { render } from '@whatsup/jsx'class CounterMoreThan10Error extends Error {}class App extends Fractal<JSX.Element> {    *whatsUp() {        const clicker = new Clicker()        const reset = () => clicker.reset()        while (true) {            try {                yield (<div>{yield* clicker}</div>)            } catch (e) {                // ловим ошибку, если "наша" - обрабатываем,                // иначе отправляем дальше в поток и даем возможность                 // перехватить её где-то в вышестоящих фракталах                if (e instanceof CounterMoreThan10Error) {                    yield (                        <div>                            <div>Counter more than 10, need reset</div>                            <button onClick={reset}>Reset</button>                        </div>                    )                } else {                    throw e                }            }        }    }}class Clicker extends Fractal<JSX.Element> {    readonly count = conse(0)    reset() {        this.count.set(0)    }    increment() {        const value = this.count.get() + 1        this.count.set(value)    }    *whatsUp() {        while (true) {            const count = yield* this.count            if (count > 10) {                throw new CounterMoreThan10Error()            }            yield (                <div>                    <div>Count: {count}</div>                    <button onClick={() => this.increment()}>increment</button>                </div>            )        }    }}const app = new App()render(app)

Пример на CodeSandbox


Мне банально непонятен весь этот звездочный код


Согласен, с непривычки это действительно может тяжело восприниматься, поэтому я решил сгруппировать правила в отдельный блок, тем более что их не так много:


  • yield* подключить поток и извлечь из него данные
  • yield отправить данные в поток
  • return отправить данные в поток и пересоздать генератор
  • throw отправить ошибку в поток и пересоздать генератор

Производительность


Естественно этот вопрос нельзя обойти стороной, поэтому я добавил whatsup в проект js-framework-benchmark. Думаю кому-то он известен, но вкратце поясню суть этого проекта заключается в сравнении производительности фреймворков при решении различных задач, как то: создание тысячи строк, их замена, частичное обновление, выбор отдельной строки, обмен двух строк местами, удаление и прочее. По итогам тестирования собирается подробная таблица результатов. Ниже приведена выдержка из этой таблицы, в которой видно положение whatsup на фоне наиболее популярных библиотек и фреймворков таких, как inferno, preact, vue, react и angular



На мой взгляд, учитывая "зародышевое" состояние, позиция уже вполне достойная. Поле оптимизаций ещё не пахано, поэтому нет никаких сомнений, что "выжать ещё" можно.


Прочие тактико-технические характеристики


Размер


Менее 3 kb gzip. Да это размер самого whatsup. Рендер добавит еще пару кило, что в сумме даст не более 5-ти.


Glitch free


Распространением обновлений занимается специальный планировщик, который производит оценку зависимостей и перерасчет данных в потоках в топологическом порядке. Это позволяет избежать двойных вычислений и "глюков". Не буду дублировать сюда информацию и примеры из википедии, просто оставлю ссылку на статью (Раздел Glitches).


Глубина связей


В комментариях к прошлой статье JustDont справедливо высказался по этому поводу:


Глубина связей данных не может превышать глубину стека вызовов. Да, для современных браузеров это не то, чтоб прямо очень страшно, поскольку счёт идёт минимум на десятки тысяч. Но, например, в хроме некоторой степени лежалости глубина стека вызовов всего лишь в районе 20К. Наивная попытка запилить на этом объемный граф может легко обрушиться в maximum call stack size exceeded.

Я поработал над этим моментом и теперь глубина стека не играет никакой роли. Для сравнения я реализовал один и тот же пример на mobx и whatsup (названия кликабельны). Суть примера заключается в следующем: создаётся "сеть", состоящая из нескольких слоёв. Каждый слой состоит из четырёх ячеек a, b, c, d. Значение каждой ячейки рассчитывается на основе значений ячеек предыдущего слоя по формуле a2 = b1, b2 = a1-c1, c2 = b1+d1, d2 = c1. После создания "сети" происходит вычисление значений ячеек последнего слоя. Затем значения ячеек первого слоя изменяются, что приводит к лавинообразному пересчету во всех ячейках "сети".


Так вот в Chrome 88.0.4324.104 (64-бит) mobx вывозит 1653 слоя, а дальше падает в Maximum call stack size exceeded. В своей практике я однажды столкнулся с этим в одном огромном приложении это был долгий и мучительный дебаг.


Whatsup осилит и 5, и 10 и даже 100 000 слоёв тут уже зависит от размера оперативной памяти компьютера ибо out of memory всё же наступит. Считаю, что такого запаса более чем достаточно. Поиграйтесь в примерах со значением layersCount.


Основу для данного теста я взял из репозитория реактивной библиотеки cellx (Riim спасибо).


О чем я ещё не рассказал


Делегирование


Простой и полезный механизм, благодаря которому один поток может поручить свою работу другому потоку. Всё, что для этого нужно это вызвать yield delegate(otherStream).


Асинхронные задачи


В контексте имеется метод defer, который принимает асинхронный коллбек. Данный коллбек автоматически запускается, возвращает промис, по завершении которого инициируется процедура обновления данных потока.


Роутинг


Вынесен в отдельный пакет @whatsup/route и пока что содержит в себе всего пару методов route и redirect. Для описания шаблона маршрута используются регулярные выражения, не знаю как вам, но в react-router третьей версии мне порой этого очень не хватало. Поддерживаются вложеные роуты, совпадения типа ([0-9]+) и их передача в виде потоков. Там действительно есть прикольные фишки, но рассказывать о них в рамках этой статьи мне кажется уже слишком.


CLI


Не так давно к разработке проекта подключился парень из Бразилии Andr Lins. Наличие интерфейса командной строки для быстрого старта whatsup-приложения целиком и полностью его заслуга.


npm i -g @whatsup/cli# thenwhatsup project

Попробовать


WhatsUp легко испытать где-то на задворках react-приложения. Для этого существует небольшой пакет @whatsup/react, который позволяет сделать это максимально легко и просто.


Примеры


Todos всем известный пример с TodoMVC


Loadable пример, показывающий работу метода ctx.defer, здесь с его помощью организуется показ лоадеров в то время, как в фоне производится загрузка, я специально добавил там небольшие задержки для того, чтоб немного замедлить процессы


Antistress просто игрушка, щёлкаем шарики, красим их в разные цвета и получаем прикольные картинки. По факту это фрактал, который показывает внутри себя кружок, либо три таких же фрактала вписанных в периметр круга. Клик покрасить, долгий клик раздавить, долгий клик в центре раздавленного кружка возврат в исходное состояние. Если раздавить кружки до достаточно глубокого уровня, можно разглядеть треугольник Серпинского


Sierpinski перфоманс тест, который команда реакта показывала презентуя файберы


Напоследок


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


Спасибо за потраченное время, надеюсь я отнял его у вас не зря. Буду рад вашим комментариям, конструктивной критике и помощи в развитии проекта. Да что уж тут даже звездочка на github уже награда.


Кроме того хочу выразить слова благодарности всем тем, кто меня поддерживал, писал в личку, на e-mail, в vk, telegram. Я не ожидал такой реакции после публикации первой статьи, это стало для меня приятной неожиданностью и дополнительным стимулом к развитию проекта. Спасибо!



С уважением, Денис Ч.


"Большая часть моих трудов это муки рождения новой научной дисциплины" Бенуа Мандельброт

Подробнее..

Конец вечного противостоянияsnake_keysVScamelKeys наводим порядок встилях написания переменных

31.05.2021 20:20:52 | Автор: admin

Привет,Хабр!Меня зовут Владимир, работаю в Ozon, занимаюсьфронтендом.

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

Представим ситуацию:начинаетсяработа надсайтом, разработчикитёмной и светлой сторон встречаются обсудить насущные вопросы. Один из таких вопросовсвязан с передачей данных.

Бекенд отдает и принимает данные в виде:

{ user_name: "user1", main_title: "Title", } 

Фронтенд:

{ userName: "user1", mainTitle: "Title", } 

В итоге выбор стиля написания переменных может привести к горячему спору, а иногда и к небольшой потасовке.

В этой статье мы попробуемрешитьэтупроблемупреобразовать все данныебэкендав данныефронтендаи наоборот.Воспользуемся для этогоJavaScript.

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

Шаг 1. Преобразование строки

Нам поможет встроенная функцияreplace. Онаумеетзаменятькаждое вхождение заданного регулярного выраженияс помощью функциимаппера, которую мы передаём вторым аргументом.

# Преобразованиеsnake_keysстроки вcamelKeys:

const snakeToCamel = str => {     return str.replace(/([_][a-z])/g, letter => {         return letter                 .toUpperCase()                 .replace('_', '')     }) } 

# ПреобразованиеcamelKeysстроки вsnake_keys:

const camelToSnake = str => {     return str.replace(/[A-Z]/g, letter => {         return '_' + letter.toLowerCase()     }) } 

Шаг 2. Работа с объектами

# Возьмем пример с начала статьи { user_name: "user1", main_title: "Title", } 

Пройдёмся по ключам объекта и заменим их с помощью уже реализованной функцииsnakeToCamel.

constsimpleKeysTransform=value=>{returnObject.entries(value).reduce((acc, [key,value]) => {constnewKey=snakeToCamel(key)        return{...acc, [newKey]:value}}, {})}

Давайте теперь сделаем универсальную функцию преобразования и будем принимать на вход ещёпеременную, отвечающую за изначальный вид стиля ключей объекта.

const keysTransform1 = (value, isInitialSnake = true) => {     const chooseStyle = isInitialSnake ? snakeToCamel : camelToSnake     return Object.entries(value).reduce((acc, [key, value]) => {         const newKey = chooseStyle(key)         return {...acc, [newKey]: value}     }, {}) } 

Шаг 3. Что делать с вложенными объектами

# Например {   user_info: {     first_name: "User",     last_name: "Userin   } } 

Применимрекурсию.Обернём основную логику в функцию и в ней будем проверять:является ли наше значение объектом. Если да, то будем вызывать нашу функцию снова и снова.

const keysTransform2 = (input, isInitialSnake = true) => {     const chooseStyle = isInitialSnake ? snakeToCamel : camelToSnake     const recursiveTransform = value => {         if (value && typeof value === 'object') {             return Object.entries(value).reduce((acc, [key, value]) => {                 const newKey = chooseStyle(key)                 const newValue = recursiveTransform(value)                 return {...acc, [newKey]: newValue}             }, {})         }         return value     }     return recursiveTransform(input) } 

Шаг 4. Что делать с массивами

# Например {   users: [     {       first_name: "user1",       phone_number: 8996923     },     {       first_name: "user2",       phone_number: 12312312     }   ]   } 

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

const keysTransform = (input, isInitialSnake = true) => {     const chooseStyle = isInitialSnake ? snakeToCamel : camelToSnake     const recursiveTransform = value => {         if (Array.isArray(value)) {             return value.map(recursiveTransform)         }         if (value && typeof value === 'object') {             return Object.entries(value).reduce((acc, [key, value]) => {                 const newKey = chooseStyle(key)                 const newValue = recursiveTransform(value)                 return {...acc, [newKey]: newValue}             }, {})         }         return value     }     return recursiveTransform(input) } 

Перемирие

Давайте посмотрим, что получилось: мы реализовали алгоритм преобразования ключей объектов изsnake_keysвcamelKeysи наоборот.Чуть-чуть меньше раздора междуфронтендоми бэкендом неплохо же!

Существуютидругиестили написания составных слов(PascalKeys,kebab-keys, UPPER_SNAKE_KEYS).При надобности, вы уже сами сможете с ними справиться.

Подробнее..

История одного личного кабинета, который помог нам сделать 15 000 курьеров и сборщиков немного счастливее

13.04.2021 14:10:30 | Автор: admin
Когда мы видим какой-то сервис, мы сразу же спешим оценить, насколько он удобен и полезен. В случае со СберМаркетом клиенту нужны понятный интерфейс, обширный каталог товаров, все подробности по доставке, разные системы оплаты и так далее. Так сервис выполняет свою функцию, а пользователь остается доволен услугой.

Проще всего смотреть на продукт именно так, то есть глазами клиента. Но у продукта всегда есть и обратная сторона: задачи бизнеса, сотрудников, партнеров.

В сентябре 2020-го мы собрали кроссфункциональную команду из разработчиков, аналитиков, дизайнеров, бухгалтеров и операционного отдела и сосредоточились на невидимой для пользователя стороне СберМаркета: личном кабинете для курьеров и сборщиков заказов. Мы сделали его с нуля за 3 месяца.

Рассказываю, какие продуктовые подходы использовали в разработке, чем нам помог личный кабинет и как хорошее приложение может сделать 15 тысяч операционных работников счастливее (даже без повышения вознаграждений).





ИТ-решение вместо амбарной книги



СберМаркет доставляет продукты с полок магазинов. Сборщики собирают заказ, а курьеры везут его домой в удобный двухчасовой интервал. Вся команда полей это самозанятые партнеры сервиса.

В каждом магазине, который подключен к СберМаркету, дежурят сразу несколько таких партнеров во главе со старшим магазина супервайзером. До личного кабинета именно супервайзер служил входящим окном для всех запросов и проблем, которые возникали у сборщиков или курьеров по ходу работы.

Что хотят знать партнеры?

  • Сколько заработали вчера и сколько получат в ближайшее время.
  • Что об их работе думает клиент.
  • Количество своих заказов.
  • Свою скорость сборки.
  • Личный рейтинг, который формируется из пользовательских оценок. На основе рейтинга рассчитываются премия и бонусы.


Раньше перед выплатой вознаграждений супервайзер сверял данные в системе с мнением сборщиков и курьеров. Все ли сходится? В системе у тебя столько-то заказов никакой не забыли? Партнеры смотрели в свои тетрадки или телефонные заметки, подтверждали статистику и получали выплаты.

Это средневековье. Современный онлайн-сервис не должен работать с амбарными книгами. Кроме того, старшему магазина трудно отвечать на одни и те же вопросы, параллельно онбордить новичков и составлять графики смен.

В помощь супервайзеру работала наша собственная линия 911 внутренняя почта для поддержки партнеров. Туда писали с вопросами, которые супервайзер вдруг решить не мог. Но эффективность этого канала была крайне низкой: ежедневно туда падали тысячи писем, разгребать которые у менеджеров не хватало времени.

Когда мы стали изучать проблему, оказалось, что партнеры уже пытаются решить ее разными способами, пусть и очень костыльно. Партнеры сами организовывали группы в мессенджерах, где супервайзеры регулярно делились результатами смен.

Мы поняли, что так продолжать нельзя. Нужно дать людям удобный личный кабинет для работы.

Как он выглядит



Это раздел в уже существующем приложении, куда партнерам падали заказы от клиентов. Основных экранов три: главная страница, список заказов и список позиций в заказе.

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

Что на экране списка заказов: список заказов за выбранный период, карточка заказа с номером, оценка и комментарий от клиента, кол-во заработанных средств, возможность провалиться в заказ и пуши о полезных комментариях от клиента.

Что на экране позиций в заказе: карточки продуктов и товаров в сборке или доставке плюс кнопка информации о заказе, инфо о клиенте, вес заказа, временной слот и так далее.



Личный кабинет помог нам на 15% снизить нагрузку на супервайзеров. Теперь все ответы есть в ЛК. С выплатами вознаграждений тоже стало проще: от потоковой системы сверки мы пришли к претензионной системе.

Фактически мы автоматизировали все выплаты. Теперь к супервайзеру обращаются только в спорных ситуациях как раз тогда, когда у партнеров есть какие-либо претензии.

Как личный кабинет помогает бизнесу



Мы пилили ЛК, ориентируясь не только на потребности партнеров, но и на задачи операционного отдела. В это время СберМаркет готовился перейти на еженедельную систему выплат. Без ИТ-решения нагрузка на супервайзера бы кратно выросла. К тому же, нам надо было грамотно оповестить всех партнеров о том, что теперь их вознаграждение будет приходить на карту чаще.

Теперь личный кабинет решает несколько задач сразу:

  • Легализация существующих костылей. Весь нужный функционал теперь в одном месте.
  • Повышение доверия к СберМаркет у партнеров. Когда сервис дает прозрачную аналитику по заказам, люди видят и знают, что никакая часть их работы не потеряется. Им спокойнее они лучше и охотнее работают.
  • Укрепление связи клиент-партнер. В ЛК сборщики видят комментарии от клиентов: Молодцы, все хорошо. Или наоборот: ну вот, привезли не ту шоколадку. Раньше эта информация могла дойти до партнеров только через супервайзера. Теперь сборщики и курьеры видят свои точки роста, могут работать над ошибками или радоваться положительной обратной связи.
  • Прозрачность и наглядность результатов работы. Сколько собрал, сколько заработал, как быстро довез теперь вся детализация доступна в любое время.
  • Повышение мотивации. Партнеры увидели, что их труд приносит кому-то радость, и даже узнавать постоянных клиентов. Так могут дать более хороший и персонализированный сервис.
  • Платформа для экспериментов. Что если мы захотим попробовать выплачивать вознаграждение не каждую неделю, а каждый день? В личном кабинете мы сможем быстро рассказать об изменениях. Раньше любые новости объявлял супервайзер.
  • Нужный функционал для найма гибких партнеров. Сейчас у нас около 15 тысяч сборщиков и курьеров. Большинство работает по фиксированному графику. Но есть и те, кто хотят работать нерегулярно и по паре часов в день. Личный кабинет помогает держать связь с такими партнерами и не терять их из виду, так как они гораздо реже других общаются с супервайзером.
  • Одно окно для любых вопросов. Мы постоянно увеличиваем количество партнеров. Общаться с тысячами людей сложно, поэтому ЛК призван стать удобным единым каналом коммуникации. Там можно узнать, спросить и понять любой вопрос, не нагружая супервайзера.


Как мы нашли все боли



В работе мы использовали несколько продуктовых подходов: UX-тесты, небольшие опросы на лояльной группе партнеров и большой опрос на 6 тысяч курьеров и сборщиков в конце. А еще собирали требования и обратную связь от бухгалтерии, юристов и отдела информационной безопасности, изучали конкурентов и проводили огромное исследование внутренних процессов. Личный кабинет оказался кроссдоменной задачей, поэтому нужно было учесть мнение всех участников.

Совет менеджерам по продукту: закладывайте больше времени на UX-тесты, опросы и исследования. При качественной работе они занимают столько же времени, сколько полноценная фича. Зато дают крутой результат. Неизвестные переменные часто всплывают в процессе разговора с респондентами, а изначально самые приоритетные задачи могут оказаться менее востребованными у конечных пользователей.

Мы делали два UX-теста перед стартом работы и после выкатки первой версии. Получили несколько инсайтов:

  1. Партнеры хотят видеть оценки от клиентов больше, чем сумму вознаграждений.

    Здесь у нас сильно порвался шаблон. Мы думали, что делаем ЛК ради показа личной статистики (средний балл оценки, вознаграждения, количество заказов), но UX-тесты и опросы показали, что мы ошибались. Поэтому сперва мы начали работать над отображением оценки и комментариев пользователей по каждому заказу. Потом добавили статистику.
  2. Партнеры хотят видеть чаевые и того, кто их оставил.

    После первого MVP мы собрали второй UX-тест, сделали большой опрос и узнали, чего еще не хватает курьерам и сборщикам. Оказалось, что им хочется видеть в личном кабинете в том числе и чаевые: сумму за заказ и адресата. Механика чаевых была реализована на стороннем сервисе. Чтобы посмотреть свой чай, партнеру нужно было каждый раз логиниться в другом месте. Мы быстро добавили эту функцию в ЛК тут даже разработка не потребовалась.
  3. Номер заказа ни о чем не говорит партнерам: нужен список позиций или адреса.

    Окей, все видят отзывы от клиентов, чаевые и свою оценку. А как понять, за какой именно заказ тебя отблагодарили? Оказалось, что сборщики ассоциируют заказ с конкретными позициями: а, это тот, кто купил 100 киндер-сюрпризов!. Для курьеров фактор узнавания адреса. Поэтому мы добавили возможность провалиться в карточку любого заказа, увидеть позиции и другую информацию.


А что теперь



Мы быстро увидели, что 80% партнеров заходят в ЛК несколько раз за смену. Это превзошло наши ожидания. Кабинет работает на геймификацию труда: людям интересно видеть свой прогресс, он их мотивирует.

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

Дальше мы планируем масштабировать ЛК и на другие роли. Например, на партнеров-универсалов: это сборщики, которые сами доставляют заказ.

Супервайзерам тоже пригодится своя платформа, чтобы следить за операционным состоянием магазина и вовремя замечать проблемы. Удобные ИТ-решения помогут минимизировать рутину и оставить людям только те задачи, где без человека не обойтись.

Когда пользуетесь каким-либо сервисом услуг (от такси, клининга, доставки и чего угодно еще) думаете о том, из чего оно состоит внутри?
Подробнее..

Современный фронтенд без ошибок и костылей. 8 полезных докладов конференции DUMP

20.04.2021 22:19:53 | Автор: admin

Привет, Хабр!

На связи IT-конференция DUMP и программный комитет секции Фронтенд: Полина Гуртовая (frontend-разработчик в Evil Martians) и Егор Ходырев (тимлид, full stack-разработчик в Кнопке)

Кто согласен, что современный фронтенд это сложно? Ради чего мы мучаемся с настройкой Webpack? Почему реализация SSR требует писать столько кода, и нужен ли он нам вообще такой ценой? Кто виноват и что мы, как разработчики, можем сделать?

В этом году вместе с нашими спикерами постараемся максимально чётко ответить на эти и сотни других вопросов в секции Frontend.

Со своими идеями и решениями выступят:







Алексей Охрименко из Яндекс.Музыки выступит с докладом ''Трасси... что?''

Отладка приложения занимает 99% нашего времени. Кто-то пользуется Chrome DevTools, кто-то обходится обычным console.log, кто-то использует профайлеры. Зачастую этих инструментов более чем достаточно. Но есть еще один, не особо известный и популярный в JavaScript мире.
Трассировка процесс пошагового выполнения программы. В режиме трассировки программист видит последовательность выполнения команд и значения переменных на данном шаге выполнения программы, что позволяет легче обнаруживать ошибки.

В этом докладе: о плюсах и минусах трассировки, существующих инструментах, и поделюсь советами, которые мне помогали справляться даже с самой сложной кодовой базой.







Григорий Петров из Evrone представит доклад ''Нужен ли нам N(e/u)xt.js?''

Современный фронтенд это сложно. Если легаси проекты ограничены, то для новых приложений, кроме настройки Webpack и Babel, у нас есть HMR, SSR, code splitting, routing, кеширование, stream rendering и это, не считая фронтенд фреймворка и бэкенда, CI/CD и деплоя.

HMR "ломается" на приложениях сложнее hello world, настройку SSR в интернетах хором называют "адски сложной", ну, а роутинг, в уважающей себя связке фронт+бэк, можно неправильно организовать десятью конкурирующими способами.

Вся эта сложность породила новое направление jamstack, и такие решения, как Next.js и Nuxt.js "opinionated фреймворки", где все настроено за нас.

Григорий использует в докладе эти два фреймворка, чтобы рассказать об основных сложностях современной фронтенд-разработки и то, как мы можем с ними бороться: готовыми шаблонами, собственным кодом или новыми архитектурами приложений. Сложности увидим с позиции "почему так получилось?"








Роман Омельницкий из Яндекса в докладе ''Стейт мененджмент на конечных автоматах'' расскажет, что такое конечные автоматы и стейтчарты и как они могут помочь нам писать более чистую и прозрачную логику. Покажет как их применять, какие готовые решения существуют и что они позволяют сделать.

А возможности впечатляют! Это прототипирование и синхронизация с дизайном, управление сложными блоками интерфейса, помощь в сборе аналитики и тестировании, документирование поведения и анимации.







Андрей Гончаров из Hazelcast и тема его доклада, которая звучит так: Lifting state up is killing your app.

Слышали ли вы про lifting state up? Может ли одна из двенадцати ключевых концепций в официальной документации React приводить к плохой производительности? В рамках доклада мы сделаем простейший grid на React. Поэтапно разберем возникающие проблемы производительности. Увидим, что иногда и O(1) - это недостаточно быстро. Будем профилировать и рефакторить до тех пор, пока приложение не станет работать быстрее, чем вы успеете сказать React.






Леонид Семенов из InvestEngine выступит с докладом про Е2Е тесты в браузеры. Когда Cypress, а когда не очень.

Что делать если ошибки в проде смертельны, а релизы идут каждый день?
Как спастись от бессонных ночей, панической боязни сломать прод, и не страдать при этом от тестирования?

Ответов может быть много, но, кажется, что очевиден один автотесты!
Как внедрить автоматизированное сквозное тестирование?
О двух внедрениях, двух инструментах, одном путешествии, проблемах по пути и о самом пути вы тут и узнаете.






Роман Лысов из Semrush расскажет, ''Как создавать React компоненты, которыми будет приятно пользоваться''.

Проблема кастомизации компонентов и их использовании друг с другом всегда болезненна, и в компании Semrush решили эту проблему.
Доклад Романа основан на 4-х летнем опыте создания дизайн-системы для большого числа разработчиков. Он расскажет про практические приемы и паттерны для написания общих компонентов, которые помогут сделать ваш API компонентов более гибким, понятным и предсказуемым, что в итоге сэкономит многие человеко-часы на обслуживании в будущем.







Людмила Мжачих из Mail.Ru Group в докладе ''Как тестировать фронтенд без тестировщиков и спать спокойно'' объяснит, почему процесс разработки не может обойтись без багов и как сводить их к минимуму. Это становится возможным только когда разработка и тестирование начинают жить вместе.

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









Полина Гуртовая из Evil Martians выступит с докладом ''RTC и Франкенштейн', в котором расскажет об особенностях использования WebRTC для боевых задач, опишет проблемы, которые поджидают разработчиков, и покажет способы их преодолевать.

Услышим и обсудим опыт наших спикеров 14 мая в форматах онлайн и офлайн. Полная программа конференции DUMP и билеты на сайте.

А пока спикеры готовятся к выступлениям, посмотри ТОП-3 выступлений секции фронтендеров с нашей прошлой конференции >>>

1. Виталий Дмитриев и его "Реактивное программирование. Как мыслить реактивно, а не проактивно"

2. Александра Шинкевич поделилась болью разработчика в докладе "Как внедрить стандарты разработки, чтобы никто не пострадал"

3. Вадим Макеев и 15 лет опыта: от создания и экспорта графики до оптимизации и вставки в его выступлении "Делайте из слона муху"

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru