Причины и проблемы, которые нужно было решить
В этой статье я вам расскажу о том как я писал кодогенератор на php. Расскажу о пути, который он прошел от генерации простых таблиц, до довольно полноценного генератора html и css кода. Приведу примеры его использования и покажу уже сделанные проекты.
В этом семестре на одном из предметов можно было использовать только PHP.
После бесконеного ренейма проекта Проект получил имя MelonPHP. Чтобы люди думали о еде когда произносили его имя? Но у нас тут статья не о генерации бреда, поэтому давайте я вам расскажу о причине его создания.
Написать надо было много, но это не проблема. Основная проблема заключалась в выводе HTML кода через PHP. Я постараюсь объяснить проблему ниже.
Например вот вывод текста через всем знакомое echo:
$text = "out text";
echo "<p>$text</p>";
Выглядит просто и понятно. Давайте возьмем отрезок кода моего друга, где он генерирует таблицу:
...
$sql = "SELECT * FROM table";$result = $conn->query($sql);
if($result->num_rows > 0) {
echo "<b>Table table</b><br><br>";
echo "<table border=2>";
echo "<tr><td> name </td>"."<td> name </td>"."<td> name </td></tr>";
while($row = $result->fetch_assoc()) {
echo "<tr><td>".$row["name"]."</td><td>".$row["name"]."</td><td>".$row["name"]."</td></tr>";
}
echo "</table>";
} else {
echo "0 results";}
...
Это страшный код демонстрирует проблемы, которые я хотел решить:
- Присутствие html в php коде, что делает его по моему мнению менее читаемым. Все-таки файл для одного яп должен содержать код только одного яп(а), по моему мнению
- Нет разделения логики, все в каше. Хотелось более приятный "фронтенд" на PHP
Стоит отметить, что я относительно давно пишу на Flutter и мне очень нравится идея заложенная в его основе, связанная с написанием интерфейса с помощью постройки дерева из виджетов. Я решил позаимствовать оттуда идею с нодами (виджетами).
Я был уверен, что быстрее будет написать небольшой кодогенератор, чем терпеть это.
Изначально генератор занимался генерацией таблиц через функции. Но потом перерос в нечто более масштабное.
Основными идеями были следующими:
- UI пишется из элементов/компонентов (привет React)
- Удобные макеты (Избавиться от div, div, div, div...)
- Чтобы весь UI писался на PHP (без JS, без HTML, без CSS).
Rebuild через callback события, через AJAX + JQueryне сужденоУдобная система роутовне суждено- Поддержка CSS (и не просто строку писать, на уровне "width: 100px", а полноценная поддержка прямо в PHP коде)
- ООП
Особенности MelonPHP
- Почти все элементы (кроме текста, кнопки и еще нескольких) по умолчанию имеют ширину и высоту в 100%, в том числе и документ.
- Кроме того, если элементы будут выходить за пределы страницы, то скролла по умолчанию не будет. Для этого нужно использовать ScrollView.
- Так же по умолчанию нельзя выделать никакие элементы.
Архитектура
Очень печально, что в PHP отсутствует передачу параметров по имени, и по этому я решил использовать вместо них функции. Я уверен что это не менее удобный аналог (Какого было мое удивление, когда Microsoft показали MAUI, который использует ту же идею с функциями).
Все классы в MelonPHP наследуется от Node. Это простой класс, который имеет только 2 функции: Generate(), static Create().
- Generate() возвращает string — сгенерированный код.
- Create() — это статическая функция. Она нужна чтобы было проще создавать ноды в дереве.
abstract class Node
{
abstract function Generate() : string;
static function Create() ...
}
Element
Element — это более высокоуровневый класс, который нужен для более комфортного написания своих элементов.
Элемент в основном занимается генерацией чистого html кода.
Элементами в Фреймворк являются такие сущности как контейнер, кнопка, таблица и тд.
Component
Основная идея компонента в том, что этот класс управляет, и состоит из дерева элементов в нем. Компонент наследуется от элемента (бредовая идея).
Компонентами могут быть например дисплеи (аналог страниц в MelonPHP), списки, карточки, навигационные меню и тд.
abstract class Component extends Element
{
function Initialize() ...
abstract function Build() : Element;
function Detach() ...
}
Компонент удобен тем, что вам не надо генерировать строки; чем занимается Element. Так же в нем есть удобные функции для использования логики.
Попробуем написать простой компонент. Создадим класс ListItem, наследуемый от Component.
Перезапишем функции Initialize() и Build().
Initialize() вызывается при создание компонента. В ней например можно инициализировать переменные или обработать логику.
Build() вызывается при генерации элемента. В ней обязательно должен возвращаться элемент. Обязательная для перезаписи.
Detach() вызывается при удалении компонента.
В Build() возвратим контейнер, а в качестве его ребенка элемент текста и присвоим ему текст из переменной класса $Text.
В Initialize() пропишем значение $Text по умолчанию.
Добавим функцию Text(string) в которой будет записываться значение пользователя в переменную $Text.
Обязательно надо возвращать $this в функциях, которые будут вызываться в дереве.
class ListItem extends Component
{
private $Text;
function Initialize() {
$this->Text = "Name";
}
function Build() : Element {
return Container::Create()
->Child(
Text::Create()
->Text($this->Text)
);
}
function Text(string $string) {
$this->Text = $string;
return $this;
}
}
DisplayComponent
DisplayComponent — это компонент, который может выводит сгенерированный код на страницу. Для генерации нужно вызвать функцию Display.
Попробуем написать пример простого дисплея.
В функции Build() возвратим Document и присваиваем ему Title(string).
В DisplayComponent, в функции Build() всегда должен возвращаться Document. Document — это класс, который генерирует стандартную разметку HTML5.
Создадим функцию BuildList(), в которой через цикл заполним колонку созданными выше ListItem.
В качестве ребенка документа вызовем BuildList() функцию. Разделение дерева из нод на функции не дает ему превратиться в макаронного монстра.
Если будет ситуация что надо выполнить какую-то логику прямо в дереве, то для того есть класс Builder. Но так лучше не делать...
После тела класса вызовем функцию Diplay(), которая при переходе на данный файл, на сайте cгенерирует его и выведит.
class ListDisplay extends DisplayComponent
{
function Build() : Document {
return Document::Create()
// название страницы
->Title("test page")
->Child($this->BuildList());
}
function BuildList() {
$column = new Column;
for($i = 0; $i < 10; $i++)
$column->Children(
ListItem::Create()
->Text("number: $i")
);
return $column;
}
} ListDisplay::Display();
Макеты
Одна из целей преследуемых при создании фреймворка — удобство при создание макетов сайта.
Container
Container — это макет, который используется для декоративных целей.
Может содержать только одного ребенка.
Column и Row
У большинства элементов есть дети. Если у элемента доступен метод Child то он может иметь только одного ребенка, а если Children то у него может быть больше одного ребенка.
Так же Child перезаписывает переменную ребенка в то время как Children добавляет аргументы в верх стека.
Если в Children один элемент в аргументе, то не обязательно его заносить в массив.
Тоесть вместо Children([Text::Create()]) можно написать Children(Text::Create())
Column — это макет, который выравнивает его детей вертикально.
Обращаясь к функциям CrossAlign и MainAlign можно выравнивать детей внутри колонки.
Row идентичен Column, но выравнивает детей горизонтально.
Stack
Stack — это макет, дети которого не позиционированы. Это полезно если вы хотите чтобы ноды пересекались, для создания более красивого дизайна.
ScrollView, HorizontalScrollView, VerticalScrollView
Эти контейнеры который является областью для скороллинга.
HorizontalScrollView — в этом контейнере можно скроллить только по горизонтальной оси.
VerticalScrollView — в этом контейнере можно скроллить только по вертикальной оси.
ScrollView — в этом контейнере можно скроллить по всем осям.
Стилизация
Долга думая как лучше встроить css в фреймворк я пришел к идее с константами.
Например у нас в css есть "background-color". Я строку записываю в константу и в php коде можно будет использовать без "". Это намного удобнее.
...
const BackgroundBlendMode = "background-blend-mode";
const BackgroundAttachment = "background-attachment";
const Border = "border";
const BorderSpacing = "border-spacing";
const BorderRadius = "border-radius";
const BorderImage = "border-image";
...
Что касается например такой конструкции "34".Px. Тут идея с константами выглядит не читабельно. По этому я решил в таких ситуациях использовать функции для css — например Px(34). Выглядит понятно и вписывается в пхп код.
Простая стилизация
Для простой стилизации в элементе функция ThemeParameter(...). Первый аргумент — это название параметра, а второй аргумент — это или массив из значений/значение.
Рассмотрим пример.
В первом параметре мы изменим цвет фона на #f0f0f0.
Во втором параметре мы добавим отступы. Сверху и снизу 20px, справа и слева 15px.
Значения в массиве генерируются через пробел. Если вы хотите чтобы генерация происходила через запятую, то для этого есть функция CommaLine().
...
Container::Create()
->ThemeParameter(BackgroundColor, Hex("f0f0f0"))
->ThemeParameter(Padding, [Px(20), Px(15)]);
...
Как видно все очень просто и удобно, но если нам понадобятся модификаторы (hover например)? Для этого сделаны темы.
Темы
Темы в этом Фреймворк — это более продвинутый css, с media, keyframes, и модификаторами.
Напишем тему для контейнера с модификатором hover и active.
Для того надо создать класс темы и добавить в него ThemeBlock через метод ThemeBlocks.
Блоку темы нужно присвоить ключ / ключи. Я назову ключ my_container.
Дальше в блок темы можно добавить модификаторы. Я добавил: StandartModifier, HoverModifier, ActiveModifier. И задал для них параметры через метод Parameter(...). Parameter работает так же как ThemeParameter.
function GetMyTheme() : Theme {
return Theme::Create()
->ThemeBlocks([
ThemeBlock::Create()
->Keys("my_container")
->Modifiers([
StandartModifier::Create()
->Parameter(BackgroundColor, Red)
->Parameter(Padding, [Px(10), Px(12)]),
HoverModifier::Create()
->Parameter(BackgroundColor, Green),
ActiveModifier::Create()
->Parameter(BackgroundColor, Blue)
])
]);
}
Дальше контейнеру я присвоил ключ (имя класса в css) темы через метод ThemeKeys. Но для того чтобы тему можно было использовать ее надо добавить в документ через метод Themes.
class TestThemeDisplay extends DisplayComponent
{
function Build() : Document {
return Document::Create()
->Themes(GetMyTheme())
->Child(
Container::Create()
->ThemeKeys("my_container")
);
}
} TestThemeDisplay::Display();
После можно запустить дисплей и увидеть что тема по ключу применилась.
Продвинутые анимации
Для продвинутой анимации есть keyframes.
Для того чтобы добавить в тему keyframe, используйте метод FrameBlocks.
Добавим уже в существующую тему FrameBlock.
В FrameBlock есть метод Frames. Вызовем его и добавим несколько фреймов, так же для каждого фрейма надо указывать Value. Оно может быть в процентах (используйте функцию Pr(value)) или может быть константой From или To.
Каждому кадру можно добавлять параметры, которые будут уникальны и плавно изменятся между другими кадрами.
Так же добавим блок темы, к которому будет применяться эта анимация и назовем его shake_text.
Создадим текст в предыдущем дисплее, в контейнере и присвоим ему ключ темы.
Так же на примере видно, что в параметры можно использовать и обычные строки.
function GetMyTheme() : Theme {
return Theme::Create()
->ThemeBlocks([
ThemeBlock::Create()
->Keys("my_container")
->Modifiers([
StandartModifier::Create()
->Parameter(Padding, [Px(10), Px(12)]),
HoverModifier::Create()
->Parameter(BackgroundColor, Green),
ActiveModifier::Create()
->Parameter(BackgroundColor, Blue)
]),
ThemeBlock::Create()
->Keys("shake_text")
->Modifiers([
StandartModifier::Create()
->Parameter(Color, Red)
->Parameter(Animation, ["shake_text_anim", ".2s", "ease-in-out", "5", "alternate-reverse"])
])
])
->FrameBlocks(
FrameBlock::Create()
->Key("shake_text_anim")
->Frames([
Frame::Create()
->Value(Pr(0))
->Parameter(Transform, Translate(0, 0)),
Frame::Create()
->Value(Pr(25))
->Parameter(Color, Hex("ff4040"))
->Parameter(Filter, Blur(Px(0.5))),
Frame::Create()
->Value(Pr(50))
->Parameter(Filter, Blur(Px(1.2))),
Frame::Create()
->Value(Pr(75))
->Parameter(Color, Hex("ff4040"))
->Parameter(Filter, Blur(Px(0.5))),
Frame::Create()
->Value(Pr(100))
->Parameter(Transform, Translate(Px(10), 0)),
])
);
}
class TestThemeDisplay extends DisplayComponent
{
function Build() : Document {
return Document::Create()
->Themes(GetMyTheme())
->Child(
Container::Create()
->ThemeKeys("my_container")
->Child(
Text::Create()
->ThemeKeys("shake_text")
->Text("Error text")
)
);
}
} TestThemeDisplay::Display();
Адаптивность
Создадим еще 2 темы. Одна будет для мобильных девайсов, а вторая для пк. У темы есть функции: MinWidth, MaxWidth, MinHeight, MaxHeight, объявив которые вы можете указать на каком размере будет работать тема.
Теме для телефонов зададим MinWidth 800px.
Теме для пк зададим MaxWidth 800px.
Создадим блок темы где в стандартном модификаторе для мобильной версии будет присваиваться цвет фона зелёный, а на пк версии — желтый. Назовём блок adaptive_color.
Добавим обе темы в документ дисплея.
Добавим ключи темы к контейнеру.
function GetMobileTheme() : Theme {
return Theme::Create()
->MinWidth(Px(800))
->ThemeBlocks(
ThemeBlock::Create()
->Keys("adaptive_color")
->Modifiers(
StandartModifier::Create()
->Parameter(BackgroundColor, Green)
)
);
}
function GetDesktopTheme() : Theme {
return Theme::Create()
->MaxWidth(Px(800))
->ThemeBlocks(
ThemeBlock::Create()
->Keys("adaptive_color")
->Modifiers(
StandartModifier::Create()
->Parameter(BackgroundColor, Red)
)
);
}
class TestThemeDisplay extends DisplayComponent
{
function Build() : Document {
return Document::Create()
->Themes([
GetMyTheme(),
GetDesktopTheme(),
GetMobileTheme()
])
->Child(
Container::Create()
->ThemeKeys(["my_container", "adaptive_color"])
->Child(
Text::Create()
->ThemeKeys("shake_text")
->Text("Error text")
)
);
}
} TestThemeDisplay::Display();
Логика
Попробуем написать простой кликкер.
Для начала нам надо создать класс и наследовать его от DisplayComponent.
Создадим функцию Build() и возвратим в ней Document.
class ClickerDisplay extends DisplayComponent
{
function Build() : Element {
return Document::Create()
->Title("Clicker");
}
} ClickerDisplay::Display();
Добавим колонку в качестве ребенка документа.
Так же в качестве детей колонки добавим текст и кнопку.
class ClickerDisplay extends DisplayComponent
{
function Build() : Element {
return Document::Create()
->Title("Clicker")
->Child(
Column::Create()
->Children([
Text::Create()
->Text("Pressed 0 times"),
Button::Create()
->Text("Press")
])
);
}
} ClickerDisplay::Display();
Результат будет следующим.
Далее добавим простые ThemeParameter, чтобы сделать наш пример красивее.
class ClickerDisplay extends DisplayComponent
{
function Build() : Element {
return Document::Create()
->Title("Clicker")
->Child(
Column::Create()
->ThemeParameter(Padding, Px(15))
->Children([
Text::Create()
->ThemeParameter(PaddingBottom, Px(15))
->Text("Pressed 0 times"),
Button::Create()
->ThemeParameter(Width, Auto)
->ThemeParameter(Padding, [Px(4), Px(10)])
->ThemeParameter(BackgroundColor, Blue)
->ThemeParameter(Color, White)
->ThemeParameter(BorderRadius, Px(4))
->Text("Press")
])
);
}
} ClickerDisplay::Display();
Выглядит куда лучше в несколько простых строчек.
Теперь можно добавить логику.
Для начала нужно инициализировать функцию Initialize() и создать приватную переменную TapCount.
Аналог form в фреймворке — это Action.
Добавим Action в наше дерево элементов. Action тип пусть будет Post. В качестве детей укажем нашу колонку, где находится наша кнопка.
Далее добавим click_count переменную в Action. А в качестве её значение присвоим TapCount.
В Initialize() через Action::GetValue(name, standart_value, action_type) получим наше переменную. В качестве значения по умолчанию укажем 0, а в качестве типа укажем Post.
Добавим инкремент для нашей переменной.
В тексте выведим "Press $this->TapCount times".
Все, простой кликкер готов.
class ClickerDisplay extends DisplayComponent
{
private $TapCount;
function Initialize() {
$this->TapCount = Action::GetValue("click_count", 0 /* standart value */, ActionTypes::Post);
$this->TapCount++;
}
function Build() : Document {
return Document::Create()
->Title("Test page")
->Child(
Action::Create()
->Type(ActionTypes::Post)
->Variable("click_count", $this->TapCount)
->Child(
Column::Create()
->ThemeParameter(Padding, Px(15))
->Children([
Text::Create()
->ThemeParameter(PaddingBottom, Px(15))
->Text("Press $this->TapCount times"),
Button::Create()
->ThemeParameter(Width, Auto)
->ThemeParameter(Padding, [Px(4), Px(10)])
->ThemeParameter(BackgroundColor, Blue)
->ThemeParameter(Color, White)
->ThemeParameter(BorderRadius, Px(4))
->Text("Press")
])
)
);
}
} ClickerDisplay::Display();
Итог
Мне удалось написать простой, но достаточно мощный кодогенератор.
Он прошел путь от генерации простых таблиц до полноценного генератора html и css, на котором можно удобно верстать проекты и совмещять верстку с логикой.
На данном фреймворке я написал курсовой проект (скриншоты ниже), использовал его на экзамене и делал на нём учебные задания.