Builders и композиция функциональности

Builders в экосистеме SvelteKit UI-библиотек представляют собой функциональные конструкции, позволяющие декларативно и модульно расширять поведение компонентов без усложнения их внутренней реализации. Основная идея заключается в композиции поведения через функции, которые возвращают набор атрибутов, событий и реактивных состояний, предназначенных для привязки к DOM-элементам.

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


Архитектура Builder-функции

Типичная builder-функция возвращает объект со следующими элементами:

  • props / attributes — набор HTML-атрибутов
  • events — обработчики событий
  • state — реактивные значения (store или derived)
  • actions — Svelte actions (use:)

Пример упрощённой структуры:

function createButtonBuilder(options) {
  let disabled = options.disabled ?? false;

  function onClick(event) {
    if (disabled) return;
    options.onClick?.(event);
  }

  return {
    props: {
      role: 'button',
      tabindex: disabled ? -1 : 0,
      'aria-disabled': disabled
    },
    events: {
      click: onClick
    },
    state: {
      disabled
    }
  };
}

Использование в компоненте:

<script>
  const button = createButtonBuilder({
    onClick: () => console.log('clicked')
  });
</script>

<button {...button.props} on:click={button.events.click}>
  Click
</button>

Композиция Builders

Ключевое преимущество builders — возможность комбинировать несколько источников поведения в одном элементе.

Пример: объединение hover и focus поведения

function hoverBuilder() {
  let isHovered = false;

  return {
    props: {},
    events: {
      mouseenter: () => isHovered = true,
      mouseleave: () => isHovered = false
    },
    state: { isHovered }
  };
}

function focusBuilder() {
  let isFocused = false;

  return {
    props: {},
    events: {
      focus: () => isFocused = true,
      blur: () => isFocused = false
    },
    state: { isFocused }
  };
}

Композиция:

function composeBuilders(...builders) {
  return builders.reduce((acc, builder) => {
    return {
      props: { ...acc.props, ...builder.props },
      events: { ...acc.events, ...builder.events },
      state: { ...acc.state, ...builder.state }
    };
  }, { props: {}, events: {}, state: {} });
}

Использование:

const combined = composeBuilders(
  hoverBuilder(),
  focusBuilder()
);

Конфликты и приоритеты

При композиции возможны конфликты:

  • одинаковые события (click, keydown)
  • одинаковые атрибуты (tabindex, role)

Решения:

1. Чейн событий

function chainHandlers(...handlers) {
  return (event) => {
    for (const handler of handlers) {
      handler?.(event);
    }
  };
}

Применение:

events: {
  click: chainHandlers(builderA.click, builderB.click)
}

2. Приоритеты

Позволяет явно задавать порядок:

composeBuilders(highPriorityBuilder, lowPriorityBuilder)

3. Namespacing

Разделение state:

state: {
  hover: { isHovered },
  focus: { isFocused }
}

Builders и Svelte actions

Builders часто возвращают actions, которые инкапсулируют работу с DOM:

function tooltipBuilder(content) {
  function action(node) {
    function show() {
      // логика отображения tooltip
    }

    node.addEventListener('mouseenter', show);

    return {
      destroy() {
        node.removeEventListener('mouseenter', show);
      }
    };
  }

  return {
    actions: {
      tooltip: action
    }
  };
}

Использование:

<div use:tooltipBuilder("Info").actions.tooltip>
  Hover me
</div>

Реактивность и store-интеграция

Builders могут использовать Svelte store для управления состоянием:

import { writable } from 'svelte/store';

function toggleBuilder() {
  const active = writable(false);

  return {
    props: {},
    events: {
      click: () => active.update(v => !v)
    },
    state: {
      active
    }
  };
}

Использование:

<script>
  const toggle = toggleBuilder();
</script>

<button on:click={toggle.events.click}>
  {$toggle.state.active ? 'ON' : 'OFF'}
</button>

Headless-паттерн и Builders

Builders являются фундаментом headless UI:

  • отсутствие встроенной разметки
  • контроль над DOM у пользователя
  • логика переиспользуется независимо от UI

Пример: dropdown builder

function dropdownBuilder() {
  let open = false;

  return {
    state: { open },
    events: {
      toggle: () => open = !open,
      close: () => open = false
    }
  };
}

Возможные реализации:

<!-- Вариант 1 -->
<button on:click={dropdown.events.toggle}>Menu</button>

<!-- Вариант 2 -->
<div class="custom-trigger" on:click={dropdown.events.toggle}>
  Open
</div>

Паттерн Slot Props

Builders хорошо сочетаются с slot props:

<script>
  const dropdown = dropdownBuilder();
</script>

<slot {dropdown} />

Использование:

<MyDropdown let:dropdown>
  <button on:click={dropdown.events.toggle}>Open</button>
</MyDropdown>

Расширение Builders

Builder можно расширять через обёртки:

function withLogging(builder) {
  return {
    ...builder,
    events: Object.fromEntries(
      Object.entries(builder.events).map(([key, fn]) => [
        key,
        (e) => {
          console.log(key);
          fn(e);
        }
      ])
    )
  };
}

Типизация Builders (TypeScript)

type Builder = {
  props?: Record<string, any>;
  events?: Record<string, (e: Event) => void>;
  state?: Record<string, any>;
};

function composeBuilders(...builders: Builder[]): Builder {
  // типобезопасная композиция
}

Улучшение через generics:

function createBuilder<TState>(state: TState): {
  state: TState;
}

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

Builders минимизируют избыточные перерендеры:

  • нет лишних компонентов
  • логика вне DOM
  • реактивность точечная

Оптимизации:

  • мемоизация builders
  • ленивое создание
  • минимизация store

Практический пример: Accessible Button

function accessibleButton(options) {
  let disabled = options.disabled ?? false;

  function handleKeydown(e) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      options.onClick?.(e);
    }
  }

  return {
    props: {
      role: 'button',
      tabindex: disabled ? -1 : 0,
      'aria-disabled': disabled
    },
    events: {
      click: options.onClick,
      keydown: handleKeydown
    }
  };
}

Сравнение с альтернативами

Подход Особенности
Компоненты Жёсткая структура
HOC Сложность вложенности
Hooks (React) Привязка к framework
Builders Гибкость + композиция

Ограничения

  • необходимость ручной композиции
  • риск конфликтов
  • усложнение типов
  • отсутствие стандарта API

Лучшие практики

  • изолировать одну ответственность на builder
  • избегать мутации shared state
  • использовать именованные namespaces
  • документировать контракт (props/events/state)
  • применять композицию вместо наследования

Связь с архитектурой SvelteKit

Builders органично вписываются в:

  • load-функции (инициализация состояния)
  • server/client boundary
  • progressive enhancement
  • form actions

Они позволяют создавать UI-логику, независимую от среды выполнения, что особенно важно для SSR и hydration.