Генерация HTML: удобнее чем хелперы и чистый HTML

в 8:18, , рубрики: html, php, Веб-разработка, генератор html, нешаблонизатор

Писать чистый HTML часто неудобно, особенно если нужно делать динамические вставки.

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

В некоторых фреймворках есть хелперы, в частности написать эту статью меня вынудила Aura.Html. С хелперами иная история — они изначально задуманы для реального упрощения, поскольку одной командой могут генерировать хороший кусок HTML кода, но они в большинстве заточены под определённое использование, и что-то дальше этого выглядит слишком криво.

Как более универсальное решение было бы не плохо не изобретать причудливый синтаксис, а использовать самый обычный PHP и всем знакомые примитивные CSS-селекторы.

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

Как оно работает?

Идея была в том, чтобы сделать как можно проще:

h::div('Content')

что на выходе даст

<div>
    Content
</div>

Это самый простой пример. Название метода — тэг, внутри передается значение. Если нужно добавить атрибутов — не проблема:

h::div(
    'Content',
    [
        'class' => 'some-content'
    ]
)

<div class="some-content">
    Content
</div>

И можно было бы подумать, что проще уже никак, но тут на помощь приходят CSS-селекторы, и немного уличной магии:

h::{'div.some-content'}('Content')

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

В сравнении с Aura.Html

В начале я упоминал Aura.Html, стоит сравнить как генерируется HTML там, и тут.
Aura.Html (пример из документации):

$helper->input(array(
    'type'    => 'search',
    'name'    => 'foo',
    'value'   => 'bar',
    'attribs' => array()
));

Наш вариант:

h::{'input[type=search][name=foo][value=bar]'}()

Любой из параметров можно было вынести в массив.
На выходе:

<input name="foo" type="search" value="bar"> 

И ещё вариант посерьезней.

Aura.Html (пример из документации):

$helper->input(array(
    'type'    => 'select',
    'name'    => 'foo',
    'value'   => 'bar',
    'attribs' => array(
        'placeholder' => 'Please pick one',
    ),
    'options' => array(
        'baz' => 'Baz Label',
        'dib' => 'Dib Label',
        'bar' => 'Bar Label',
        'zim' => 'Zim Label',
    ),
))

Наш вариант:

h::{'select[name=foo]'}([
    'in'       => [
        'Please pick one',
        'Baz Label',
        'Dib Label',
        'Bar Label',
        'Zim Label'
    ],
    'value'    => [
        '',
        'baz',
        'dib',
        'bar',
        'zim'
    ],
    'selected' => 'bar',
    'disabled' => ''
])

Тут in используется явно, его можно использовать для передачи внутренностей тэга, как Content в примере с div выше. Используются как общие правила, так и некоторые специальные, немного подробнее о которых дальше.
На выходе то же самое:

<select name="foo">	
	<option disabled value="">Please pick one</option>
	<option value="baz">Baz Label</option>
	<option value="dib">Dib Label</option>
	<option selected value="bar">Bar Label</option>
	<option value="zim">Zim Label</option>
</select>

Специальная обработка

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

h::{'input[name=agree][type=checkbox][value=1][checked=1]'}()

<input name="agree" checked type="checkbox" value="1">

Работает похоже с select, в value значение, а checked проставится когда совпадет одноименный элемент передаваемого массива.

Ещё один пример использования in и специальной обработкой input[type=radio]:

h::{'input[type=radio]'}([
    'checked'   => 1,
    'value'     => [0, 1],
    'in'        => ['Off', 'On']
])

<input type="radio" value="0"> Off
<input checked type="radio" value="1"> On

Никаких оберток label не добавляется специально, чтобы сделать код максимально общим и предсказуемым.

Если нужно обработать массив

Это, наверное, самая часто используемая вместе с контролем вложенности возможность, так как данные и правда часто приходят откуда-то в виде массива.
Для обработки массива его можно передать прямо вместо значения:

h::{'tr td'}([
    'First cell',
    'Second cell',
    'Third cell'
])

Либо даже опустить лишние скобки в самом простом случае

h::{'tr td'}(
    'First cell',
    'Second cell',
    'Third cell'
)

На выходе:

<tr>
    <td>
        First cell
    </td>
    <td>
        Second cell
    </td>
    <td>
        Third cell
    </td>
</tr>

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

h::{'tr.row td.cs-left[style=text-align:left;][colspan=2]'}(
    'First cell',
    [
        'Second cell',
        [
            'class'     => 'middle-cell',
            'style'     => 'color:red;',
            'colspan'   => 1
        ]
    ],
    [
        'Third cell',
        [
            'colspan'   => false
        ]
    ]
)

Если в вызове тоже были указаны атрибуты — class и style будут расширены, остальные перезаписаны, атрибуты с логическим значением false будут удалены.

<tr class="row">
    <td class="cs-left" colspan="2" style="text-align:left;">
        First cell
    </td>
    <td class="cs-left middle-cell" colspan="1" style="text-align:left;color:red;">
        Second cell
    </td>
    <td class="cs-left" style="text-align:left;">
        Third cell
    </td>
</tr>

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

h::{'tr| td'}([
    [
        'First row, first column',
        'First row, second column'
    ],
    [
        'Second row, first column',
        'Second row, second column'
    ]
])

<tr>
    <td>
        First row, first column
    </td>
    <td>
        First row, second column
    </td>
<tr>
<tr>
    <td>
        Second row, first column
    </td>
    <td>
        Second row, second column
    </td>
<tr>

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

$array = [
    [
        'text'  => 'Text1',
        'id'    => 10
    ],
    [
        'text'  => 'Text2',
        'id'    => 20
    ]
];
h::a(
    '$i[text]',
    [
        'href'      => 'Page/$i[id]',
        'insert'    => $array
    ]
)

<a href="Page/10">
    Text1
</a>
<a href="Page/20">
    Text2
</a>

Можно и в одну строчку все атрибуты написать:

$array = [
    [
        'id'    => 'first_checkbox',
        'value' => 1
    ],
    [
        'id'    => 'second_checkbox',
        'value' => 0
    ],
    [
        'id'    => 'third_checkbox',
        'value' => 1
    ]
];
h::{'input[id=$i[id]][type=checkbox][checked=$i[value]][value=1]'}([
    'insert'    => $array
])

<input id="first_checkbox" checked type="checkbox" value="1"> 
<input id="second_checkbox" type="checkbox" value="1"> 
<input id="third_checkbox" checked type="checkbox" value="1">

А ещё всё это можно расширять

Этот класс представляет только общие, ни к чему не привязанные правила генерации HTML, которые могут быть использованы независимо от окружения.
Но иногда хочется упростить выполнение более сложных рутинных операций.
Например, я использую многие элементы UIkit на фронтенде, и, например, для переключателя нужна особым образом подготовленный HTML.
Скопировав оригинальный код обработки input и слегка отредактировав можно получить такой результат:

h::radio([
    'checked'   => 1,
    'value'     => [0, 1],
    'in'        => ['Off', 'On']
])

<span class="uk-button-group" data-uk-button-radio="">	
	<label class="uk-button uk-active" for="input_544f4ae475f58">	
		<input checked="" id="input_544f4ae475f58" type="radio" value="1"> On
	</label>
	<label class="uk-button" for="input_544f4ae475feb">	
		<input id="input_544f4ae475feb" type="radio" value="0"> Off
	</label>
</span>

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

Преимущество использования

Генерируется HTML без шанса оставить тэг незакрытым, или что-то в этом роде.
Везде используются общие правила обработки, которые логичны, весьма быстро запоминаются, и являются намного чаще удобными, чем наоборот.
Можно использовать с абсолютно любыми тэгами, даже с веб-компонентами (пример писать не буду, и так много примеров).
Нет никаких зависимостей, есть возможность унаследовать и переопределить/расширить по желанию всё что угодно, так как это всего лишь один статический класс, и больше ничего.
На выходе обычная строка, которую можно легко использовать вместе с абсолютно любым кодом, использовать на входе следующего вызова класса.

Где взять и почитать

На этом, пожалуй, хватит примеров.
Исходный код на GitHub
Там же есть документация с подробным объяснением всех нюансов использования и всех поддерживаемых конструкций.
Поставить можно через composer, либо просто подключив файл с классом.
Пример наследования с добавлением функциональности

Планы

Нужно всё-таки отрефакторить __callStatic(), не сломав при этом ничего)
Было бы круто переписать на Zephir, и сделать расширение для PHP (это скорее мечта, но, возможно, когда-то возьмусь и за нее).

Автор: nazarpc

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js