В первой части я поделился своим взглядом на то, какими могут быть переиспользуемые 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, отмечу важный для меня пункт, то что он дисциплинирует, т.е. заставляет отделять обработку от вывода и подготавливать переменные заранее, и в данном случае мы будем использовать его.
-
Блок
Каждый блок будет состоять из:
-
Статических ресурсов (css/js/twig)
-
Класса блока, который будет предоставлять данные для twig шаблона и управлять зависимостями.
-
-
Вспомогательные классы: Settings (пути к блокам и их пространства имен), TwigWrapper (обертка для Twig пакета), BlocksLoader (автозагрузка всех блоков, опционально), Helper (набор статических доп. функций)
-
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; }}
<?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); } }}
<?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); }}
<?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,]); }}
<?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; }}
<?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; }}
<?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'; }}
<div class="header"> {{ name }}</div>
.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'; }}
<div class="button"> {{ name }}</div>
.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(); }}
<div class="article"> <p class="article__name">{{ name }}</p> {{ _include(button) }}</div>
.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 за конструктивные комментарии к первой части.