Карго-культ HTML в современном фронтенде

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


    Разрыв шаблона


    Далее мы разберём что такое шаблоны. Их ключевые достоинства и фатальные недостатки. Зачем они нужны и почему не нужны. Сформируем представление о правильном решении и проедемся катком по популярным. Так что полная гамма чувств нам обеспечена.


    Прошу к столу..


    А что такое шаблон?


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


    Это шаблон:


    "Hello, ${name}!"

    А это уже нет:


    "Hello" + name + "!"

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


    Яркий пример синтаксически согласованных управляющих конструкций можно наблюдать в XSLT:


    <xsl:template name="page">
        <acticle>
            <h1>
                <xsl:copy-of select="./head" />
            </h1>
            <xsl:copy-of select="./body" />
        </article>
    </xsl:template>

    А вот такой код, не смотря на использование шаблонов в 1 и 3 строке, в целом шаблоном всё же не является, так как чтобы понять, каков будет результат, нужно мысленно корректно исполнить JSX-код:


    const head = <h1>{ headContent }</h1>
    const body = 'Hello, World'
    const article = <article>{ head }{ body }</article>

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


    Как видно в последнем примере, код на JSX может быть шаблоном, а может им и не быть. И как правило шаблоном он всё же не является, не смотря на синтаксическое подражание HTML.


    Зловещая долина


    А необходим ли HTML?


    Во фронтенде целевым языком для шаблонов как правило является HTML. А HTML является не более, чем сериализованным представлением DOM дерева. И в прошлом именно HTML был языком коммуникации между клиентом и сервером. Поэтому серверу нужно было генерировать именно его.


    Однако, в современном вебе клиент и сервер больше не обмениваются HTML, предпочитая JSON, ProtoBuf и другие более эффективные форматы. Более того, теперь клиент уже сам формирует DOM напрямую, через JS-API, минуя HTML представление. А это значит, что в качестве целевого языка описания DOM может быть использован не только HTML, но и иные форматы сериализации DOM.


    Например, HAML:


    !!!
    %html{ :lang => "ru" }
        %head
            %title= title
            %meta{ 'http-equiv' => 'Content-Type', :content => 'text/html' }/
        %body
            %h1= title
            %p= description

    Или xml.tree:


    ! DOCTYPE html
    html
        @ lang \ru
        head
            title ? title
            meta
                @ content \text/html; charset=utf-8
                @ http-equiv \Content-Type
        body
            h1 ? title
            p ? description

    Или даже JSON. Без примера, ибо слишком уж он развесистый получается.


    В этом свете использование HTML-шаблонизации является скорее данью традиции, чем реальной необходимостью:


    <!DOCTYPE html>
    <html lang='ru'>
        <head>
            <title>{title}</title>
            <meta
                content='text/html; charset=utf-8'
                http-equiv='Content-Type'
            />
        </head>
        <body>
            <h1>{title}</h1>
            <p>{description}</p>
        </body>
    </html>

    Эффект штурмовика


    А достаточно ли HTML?


    Мощности HTML хватает лишь для описания DOM. Но современная разработка предполагает компонентную декомпозицию. А где декомпозиция — там и композиция. То есть нам необходим инструмент для создания экземпляров компонент, их настройки и соединения друг с другом реактивными связями разных направлений.


    Тут не то что HTML, а даже DOM уже катастрофически не хватает, что неизбежно порождает чудовищ. Например, вам нужно вставить несколько компонент и провязать их состояния друг с другом.


    Возьмём ангуляровский "шаблон":


    <bi-panel class="example">
    
        <check-box
            class="editable"
            side="left"
            [(checked)]="editable"
            i18n
            >
            Editable
        </check-box>
    
        <text-area
            #input
            class="input"
            side="left"
            [(value)]="text"
            [enabled]="editable"
            placeholer="Markdown content.."
            i18n-placeholder="Showed when input is empty"
        />
    
        <div
            *ngIf="text"
            class="output-label"
            side="right"
            i18n
            >
            Result
        </div>
    
        <mark-down
            *ngIf="text"
            class="output"
            side="right"
            text="{{text}}"
        />
    
    </bi-panel>

    Весьма похоже на HTML, но только это не HTML, чтобы там ни говорили Angular-евангелисты. DOM (и как следствие HTML) поддерживают лишь задание строк в качестве атрибутов. А для компонент нужны не только строки, но и другие типы данных: числа, объекты и даже другие компоненты. И их надо не только хардкодить в шаблоне, но и брать из свойств, класть в свойства, а то и вообще обеспечивать двустороннее связывание.


    И тут начинаются кастомные расширения HTML. Каждый атрибут в примере выше имеет свою семантику, но синтаксически выглядят они все одинаково:


    • #input — это локальный идентификатор, для доступа через TS.
    • class="editable" — это имя класса для привязки стилей через CSS.
    • side="left" — это имя слота, куда этот элемент будет помещён.
    • [(checked)]="editable" — это двустороннее связывание свойств вложенного и внешнего компонентов.
    • [enabled]="editable" — это уже одностороннее.
    • text="{{text}}" — а это тоже самое.
    • placeholer="Markdown content.." — это какой-то захардкоженный текст.
    • i18n-placeholder="Showed when input is empty" — а это, внезапно, указание, что атрибут placeholder подлежит переводу, и пояснение переводчику.
    • *ngIf="text" — это же вообще к компоненту не относится, а регулирует будет ли компонент рендериться в родителе.

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


    А внутре у ней неонка


    Но чем же HTML хорош?


    Ключевое достоинство HTML — его декларативность. Вопреки расхожему мнению, декларативные языки описывают на самом деле не "результат", а некоторую семантическую структуру. И эта структура может быть использована для программного анализа с получением множества разных "результатов" в зависимости от потребностей.


    Мы можем взять HTML и нарисовать на экране красивый плоский интерфейс. Можем в VR показать объёмный интерфейс, который можно потрогать. Можем реализовать голосовой интерфейс для не зрячих. Можем распечатать в виде книги. Можем собрать все заголовки для формирования оглавления и все термины для тезауруса. Можем собрать все ссылки и уведомить сайты, куда они ведут, о том, откуда на них ссылаются. Можем отправить уведомление всем упомянутым пользователям. И много чего ещё.


    Но всё это многообразие возможностей крайне затруднено, а то и попросту не возможно при написании императивного кода, который описывает конкретные действия по получению одного конкретного результата. Что бы там ни говорили адепты функционального программирования, но оно ни в коем разе не является декларативным. ФП — это просто императивное программирование на иммутабельных структурах, описывающее конкретные действия, по трансформации заданных входных параметров в заданные выходные. Всё, что можно сделать с чистой функцией — это её исполнить. А всё, что может дать нам анализ её кода — это "ну, там вызываются какие-то функции, вот их сигнатуры".


    Простой пример императивного функционального кода:


    приготовить_яичницу = ()=> последовательность(
        ()=> яйцо ,
        яйцо => разбей( яйцо ) ,
        разбитое_яйцо => уберать_скорлупу( разбитое_яйцо ),
        яйцо_без_скорлупы => пожарить( сковорода )( яйцо_без_скорлупы ),
        жаренное_яйцо => добавить_приправы( жаренное_яйцо )
    )

    А вот пример настоящего декларативного кода в модели RDF:


    яичница
        включает
            жареное_яйцо
            приправы
    жареное_яйцо
        создаётся_посредством
            горячая_поверхность
    скворода
        является
            горячая_поверхность
    жареное_яйцо
        создаётся_из
            яйцо_без_скорлупы
    яйцо
        включает
            яйцо_без_скорлупы
            скорлупа

    Это логические триплеты. Благодаря нормализованному представлению их очень просто парсить и анализировать.


    Но вернёмся к нашим шаблонам. Возьмём популярный сейчас JSX, который мимикрирует не только под HTML, но и под JS, и даже под ФП, при этом ничем из упомянутого не являясь:


    const Example = ( props: {
        className?: string
        text?: string
        onTextChanged?: ( next: string )=> void
        editable?: boolean
        onEditableToggle?: ( next: boolean )=> void
    } )=> {
    
        const [ stateText, setStateText ] = useState( props.text ?? '' )
        const [ stateEditable, setStateEditable ] = useState( props.editable ?? true )
        const [ inputElement, setInputElement ] = useState< HTMLTextAreaElement >( null )
    
        const className = ( props.className ?? '' ) + ' example'
        const text = props.text ?? stateText
        const editable = props.editable ?? stateEditable
    
        const setText = useCallback( ( next: string )=> {
            setStateText( next )
            props.onTextChanged?.( next )
        }, [ props.onTextChanged ] )
    
        const setEditable = useCallback( ( next: boolean )=> {
            setStateEditable( next )
            props.onEditableToggle?.( next )
        }, [ props.onEditableToggle ] )
    
        return (
            <BiPanel
    
                className={ className }
    
                left={
                    <>
    
                        <CheckBox
                            className="editable"
                            checked={ editable }
                            onToggle={ setEditable }
                            >
                            { l10n( 'Editable' ) }
                        </CheckBox>
    
                        <TextArea
                            ref={ setInputElement }
                            className="input"
                            value={ text }
                            onChange={ setText }
                            enabled={ editable }
                            placeholder={ l10n( 'Markdown content..' ) }
                        />
    
                    </>
                }
    
                right={
                    text
                        ? <>
    
                            <div
                                className="output-label"
                                >
                                { l10n( 'Result' ) }
                            </div>
    
                            <MarkDown
                                className="output"
                                text={ text }
                            />
    
                        </>
                        : <></>
                }
    
            />
        )
    
    }

    Закроем пока глаза на объёмность и сложность кода. Давайте подумаем, что мы можем получить из него без исполнения..


    Можем ли мы при сборке вытащить все локализуемые тексты и заменить их на персистентные ключи?

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


    Можем ли мы в визуальном конфигураторе понять, что свойства CheckBox.checked, TextArea.enabled и props.editable связаны друг с другом двусторонней связью?

    Нет. И не верьте адептам Реакта, утверждающим, что двустороннего связывания там нет, и что оно вообще не нужно. Оно и нужно, и есть, хоть и реализуется через костыли с парными пропсами вида checked={ editable } onToggle={ setEditable }.


    Можем ли мы там же понять, что если не задать свойство editable, то текстария будет изначально редактируемой?

    Нет. Разве что очень сильно заморочиться и реализовать data-flow анализ. И то он будет справляться далеко не со всем многообразием возможного кода.


    Можем ли мы при сборке проверить, что CSS-селектор .example .output .link действительно на что-то матчится?

    Нет. Так как имена классов собираются из строк в прикладном коде.


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


    А король-то голый!


    А возможна ли декларативность?


    Чем меньше язык позволяет вольностей, тем проще его программно анализировать. Но обратная сторона этого — снижение гибкости. Чтобы достичь максимальной гибкости, нужен язык общего назначения. Во фронтенде таким языком обычно является JS и другие языки, в него компилирующиеся. Поэтому очень велик соблазн либо собирать компоненты сразу в нём, либо засовывать в "шаблоны" вкрапления на JS. Разумеется ни о какой декларативности в этом случае уже говорить не приходится.


    Чтобы добиться гибкости, но не потерять декларативность, нужно разбивать код компонента на 2 части:


    • Декларативная, где происходит компоновка компонент друг с другом.
    • Императивная, где описывается логика работы.

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


    Для примера возьмём язык view.tree, используемый в $mol:


    $my_example $mol_view
        sub /
            <= Panel $my_bipanel
                left <= input /
                    <= Editable $mol_check_box
                        checked?val <=> editable?val true
                        title @ \Editable
                    <= Input $mol_textarea
                        hint @ \Markdown content..
                        value?val <=> text?val \
                        enabled <= editable
                right <= output /
                    <= Output_label $mol_paragraph
                        sub / <= output_label @ \Result
                    <= Output $mol_text
                        text <= text

    Он мало того, что в несколько раз меньше эквивалентного JSX кода, так из него ещё и легко вычленять локализацию, собирать статистику использования, проверять переопределения стилей и много чего ещё. Можно даже построить конфигуратор позволяющий взять произвольное существующее приложение и, не написав ни строчки кода, собрать из него что-то новое. Ну а когда декларативных возможностей не хватает — всегда можно написать комплексную логику в отдельном скрипте, используя всю мощь языка общего назначения:


    export class $my_example extends $.$my_example {
    
        output() {
            return this.text() ? super.output() : []
        }
    
    }

    К сожалению, с подачи Фейсбука вместо чего-то такого мы имеем сейчас повсеместный императивный JSX и кучу костыльных проектов, пытающихся его программно анализировать. А в тех фреймворках, где есть отделение скриптов от шаблонов, вместо шаблонов мы видим императивный недо-DSL мимикрирующий под HTML, что приверженцы Реакта справедливо считают бессмысленным.


    Dart, да не Дарт


    Что опять за наезды на JSX?


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


    Push семантика


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


    return (
        <Dialog visible={ opened } >
            { ()=> <>Heavy content</> }
        </Dialog>
    )

    Но, как всегда, есть "но":


    • Заворачивать всё подряд в замыкания банально не удобно.
    • Замыкания нужно мемоизировать через useCallback, чтобы избежать лишних рендеров.
    • Без автоматического трекинга зависимостей это просто не будет работать.
    • Изменение получения VDOM на замыкание меняет API компонента.

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


    const dialogContent = useCallback( ()=> (
        <>Heavy content</>
    ) )
    
    return userObserver( ()=> (
        <Dialog visible={ opened } >
            { dialogContent }
        </Dialog>
    ) )

    Сравнение push и pull семантики — это отдельная большая тема. Поэтому вкратце обрисую преимущества pull: она позволяет просто и эффективно реализовать ленивые вычисления, рендеринг, загрузку и вообще экономить ресурсы. У push семантики же с этим всем серьёзные проблемы.


    Неэффективность


    JSX компилируется в крайне не удачный JS код, который из-за своей мегаморфности крайне сложно поддаётся оптимизации JIT-компилятором:



    Сверху — то, каким он мог бы быть быстрым при мономорфности. А снизу — суровая реальность в FireFox.


    Слабые возможности связывания


    JSX заточен под проталкивание значений. Но любые другие связывания — это боль. Хочешь передать замыкание — изволь завернуть его в useCallback и описать отдельным массивом всё, от чего оно зависит (и счастливой отладки, если что-то забудешь):


    const setName = useCallBack( ( name: string )=> {
        setInfo({ ... info, name })
    }, [ info, setInfo ] )
    
    return <Input value={ info.name } onChange={ setName }>

    Самое забавное, что useCallback тут должен спасать от лишних рендеров, но так как замыкание зависит от info, то его приходится указывать в зависимостях, что приводит к обновлению замыкания при каждом изменении данных, даже если info.name фактически не поменялся. А следовательно рендер Input будет происходить при каждом изменении любого поля info.


    Про двустороннее связывание я ранее уже упомянул, что оно делается через костыли с прокидыванием пары свойств. Это мало того, что многословно, так ещё и легко разъезжается, приводя ко крепким обнимашкам с дебаггером.


    Неконсистентность


    Из-за подражания HTML константные строки прокидываются одним синтаксисом, а все остальные типы и неконстантные строки — другим:


    <input type="password" minLength={ 5 } className={ 'password ' + className  } />

    Дочерние компоненты могут быть переданы двумя совсем разными способами:


    <Dialog>
        <Hello />
        <World />
    </Dialog>

    <Dialog
        children={[
            <Hello />,
            <World />,
        ]}
    />

    А уж сколько есть вариантов условного рендеринга — один хуже другого.


    Всё это — следствие попытки усидеть сразу на двух стульях: HTML и JS.


    Костыли для комментариев


    Набирать и читать их просто неудобно:


    <Dialog>
        <Hello />
        {/* World */}
    </Dialog>

    Волшебные атрибуты


    JSX никак не форсирует простановку уникальных идентификаторов вложенным компонентам. А потребность получать ссылку на конкретный DOM элемент есть. Поэтому в вёрстке появляются волшебные атрибуты:


    <Dialog>
        <Hello ref={ setHelloRef } />
        <World ref={ setWorldRef } />
    </Dialog>

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


    <Dialog>
        <Message key="hello">Hello</Message>
        <Message key="world">World</Message>
    </Dialog>

    Правда, при переносе в другого родителя, не спасает и он.


    И беда даже не в том, что эти атрибуты вообще существуют, а в том, что синтаксически они неотличимы от любых других.


    Много мусора в вёрстке


    Мало нам закрывающих тегов из HTML. Давайте добавим ещё и лесенку из контекстов:


    <ThemeContext.Provider value={theme} >
        <UserContext.Provider value={signedInUser} >
            <Layout />
        </UserContext.Provider>
    </ThemeContext.Provider>

    <ThemeContext.Consumer>
        { theme => (
            <UserContext.Consumer>
                { user => (
                    <ProfilePage user={user} theme={theme} />
                ) }
            </UserContext.Consumer>
        ) }
    </ThemeContext.Consumer>

    Отсутствие ограничений


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


    <div className="tag-list">
        {tags.map((tag) => (
            <button
                key={tag}
                className="tag-pill tag-default"
                onClick={() =>
                    dispatch({
                        type: 'SET_TAB',
                        tab: { type: 'TAG', label: tag },
                    })
                }
            >
                {tag}
            </button>
        ))}
    </div>

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


    А view.tree прям такой идеальный?


    Нет, конечно, педаль в пол, давим и его..


    Слабая интеграция с IDE


    Microsoft добавила поддержку JSX прямо в компилятор TypeScript, что дало не только хороший тайпчек, но и интеграцию в тайпскриптовый Language Server. А это значит отличную интеграцию не только с их же VSCode, но и с другими IDE.


    К сожалению, Microsoft не озаботилась простотой интеграции сторонних языков с TS. view.tree, конечно, компилируется в TS, что даёт тайпчек при сборке, но IDE этого всего не видит. Соответственно, не работают подсказки, рефакторинги и тп. Хорошо хоть подсветка синтаксиса есть.


    Неявная типизация


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


    Например, значение null имеет тип any:


    /**
     * Placeholder null
     */
    Placeholder() {
        return null as any
    }

    Как и аргументы методов:


    /**
     * name!id?next \Unknown
     */
    @ $mol_mem_key
    name(id: any, next?: any) {
        if ( next !== undefined ) return next
        return "Unknown"
    }

    Но это всё компромиссы конкретного языка, а не декларативного подхода в целом. Ведь можно же было сделать, например, так:


    /**
     * Placeholder null $mol_view
     */
    Placeholder() {
        return null as null | $mol_view
    }
    
    /**
     * name!number?string \Unknown
     */
    @ $mol_mem_key
    name(id: number, next?: string) {
        if ( next !== undefined ) return next
        return "Unknown"
    }

    Что тут ещё сказать?


    Фух, покатались на славу. Пришло время остановиться и перевести дух, поразмыслить над смыслом бытия, и двинуться дальше..


    В выступлении "Tree — единый AST чтобы править всеми" можно познакомиться с форматом tree. В выступлении "Свой язык с поддержкой sourcemaps за полчаса" с его пайплайном. А в выступлении "$mol — лучшее средство от геморроя" можно найти краткое введение конкретно в язык view.tree.


    Заглядывайте в чат "Разработка языков программирования" всё это обсудить.


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


    То, что тебя не убивает, делает тебя… страннее!

    Комментарии 0

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

    Самое читаемое