Перейти к содержимому

13. Actions

Action — это функция, которая получает DOM-узел и делает с ним что угодно. Это как хук жизненного цикла, привязанный к конкретному элементу. Хочешь tooltip? Drag and drop? Lazy loading? Actions — твой инструмент 🔧


Проблема без actions:
- Логика работы с DOM размазана по onMount/onDestroy
- Нельзя переиспользовать между компонентами
- Нужно хранить ref на элемент вручную
Решение с actions:
✅ Логика инкапсулирована в одной функции
✅ Переиспользуется директивой use:
✅ Автоматическая очистка (destroy)
✅ Реакция на изменение параметров (update)

import type { Action } from 'svelte/action'
// Типизированный action
// Action<ElementType, ParametersType, EventsType>
const myAction: Action<HTMLElement, { color: string }> = (node, params) => {
// node — DOM элемент, к которому применяется action
// params — начальные параметры
// Инициализация
node.style.color = params?.color ?? 'red'
return {
// Вызывается при изменении параметров
update(newParams) {
node.style.color = newParams.color
},
// Вызывается при удалении элемента из DOM
destroy() {
// Очищаем: удаляем обработчики, таймеры, subscription'ы
}
}
}
<!-- Использование -->
<script>
import { myAction } from './actions'
let color = 'blue'
</script>
<!-- Без параметров -->
<div use:myAction>...</div>
<!-- С параметрами -->
<div use:myAction={{ color }}>...</div>

Классика! Закрывать модальные окна при клике за пределами:

actions/clickOutside.ts
import type { Action } from 'svelte/action'
interface ClickOutsideAttributes {
'on:clickoutside': (event: CustomEvent) => void
}
export const clickOutside: Action<HTMLElement, undefined, ClickOutsideAttributes> = (node) => {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node)) {
node.dispatchEvent(new CustomEvent('clickoutside'))
}
}
document.addEventListener('click', handleClick, true) // capture phase!
return {
destroy() {
document.removeEventListener('click', handleClick, true)
}
}
}
<script>
import { clickOutside } from './actions/clickOutside'
let dropdownOpen = false
</script>
<div use:clickOutside on:clickoutside={() => dropdownOpen = false}>
<button on:click={() => dropdownOpen = !dropdownOpen}>
Меню ▼
</button>
{#if dropdownOpen}
<ul class="dropdown">
<li>Профиль</li>
<li>Настройки</li>
<li>Выйти</li>
</ul>
{/if}
</div>

actions/longPress.ts
import type { Action } from 'svelte/action'
interface LongPressOptions {
duration?: number
}
interface LongPressAttributes {
'on:longpress': (event: CustomEvent<{ duration: number }>) => void
}
export const longPress: Action<HTMLElement, LongPressOptions, LongPressAttributes> = (
node,
options = {}
) => {
let { duration = 500 } = options
let timer: ReturnType<typeof setTimeout>
let startTime: number
function handleMouseDown() {
startTime = Date.now()
timer = setTimeout(() => {
node.dispatchEvent(
new CustomEvent('longpress', {
detail: { duration: Date.now() - startTime }
})
)
}, duration)
}
function handleMouseUp() {
clearTimeout(timer)
}
node.addEventListener('mousedown', handleMouseDown)
node.addEventListener('mouseup', handleMouseUp)
node.addEventListener('touchstart', handleMouseDown)
node.addEventListener('touchend', handleMouseUp)
return {
update(newOptions) {
duration = newOptions.duration ?? 500
},
destroy() {
clearTimeout(timer)
node.removeEventListener('mousedown', handleMouseDown)
node.removeEventListener('mouseup', handleMouseUp)
node.removeEventListener('touchstart', handleMouseDown)
node.removeEventListener('touchend', handleMouseUp)
}
}
}
<script>
import { longPress } from './actions/longPress'
function handleLongPress(event: CustomEvent<{ duration: number }>) {
alert(`Зажато на ${event.detail.duration}мс!`)
}
</script>
<button
use:longPress={{ duration: 800 }}
on:longpress={handleLongPress}
>
Зажми меня! 🤌
</button>

actions/tooltip.ts
import type { Action } from 'svelte/action'
interface TooltipOptions {
text: string
position?: 'top' | 'bottom' | 'left' | 'right'
delay?: number
}
export const tooltip: Action<HTMLElement, TooltipOptions> = (node, options) => {
let tooltipEl: HTMLDivElement | null = null
let timer: ReturnType<typeof setTimeout>
function createTooltip() {
tooltipEl = document.createElement('div')
tooltipEl.className = 'svelte-tooltip'
tooltipEl.textContent = options.text
// Позиционирование
const rect = node.getBoundingClientRect()
const position = options.position ?? 'top'
Object.assign(tooltipEl.style, {
position: 'fixed',
background: '#1e293b',
color: '#e2e8f0',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
pointerEvents: 'none',
zIndex: '9999',
top: position === 'top'
? `${rect.top - 30}px`
: `${rect.bottom + 8}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)',
})
document.body.appendChild(tooltipEl)
}
function handleMouseEnter() {
timer = setTimeout(createTooltip, options.delay ?? 300)
}
function handleMouseLeave() {
clearTimeout(timer)
tooltipEl?.remove()
tooltipEl = null
}
node.addEventListener('mouseenter', handleMouseEnter)
node.addEventListener('mouseleave', handleMouseLeave)
return {
update(newOptions) {
options = newOptions
if (tooltipEl) tooltipEl.textContent = newOptions.text
},
destroy() {
clearTimeout(timer)
tooltipEl?.remove()
node.removeEventListener('mouseenter', handleMouseEnter)
node.removeEventListener('mouseleave', handleMouseLeave)
}
}
}
<button use:tooltip={{ text: 'Удалить навсегда! ⚠️', position: 'top' }}>
🗑️ Удалить
</button>
<button use:tooltip={{ text: 'Сохранить в облако', delay: 100 }}>
💾 Сохранить
</button>

actions/autofocus.ts
import type { Action } from 'svelte/action'
export const autofocus: Action<HTMLElement> = (node) => {
// Ждём следующего тика (элемент должен быть видимым)
const frame = requestAnimationFrame(() => {
if (node instanceof HTMLElement) {
node.focus()
}
})
return {
destroy() {
cancelAnimationFrame(frame)
}
}
}
{#if showModal}
<div class="modal">
<!-- Автоматически получает фокус при появлении! -->
<input use:autofocus placeholder="Введи что-нибудь" />
</div>
{/if}

actions/lazyLoad.ts
import type { Action } from 'svelte/action'
interface LazyLoadOptions {
src: string
placeholder?: string
threshold?: number
}
export const lazyLoad: Action<HTMLImageElement, LazyLoadOptions> = (
node,
options
) => {
const { placeholder = '/placeholder.jpg', threshold = 0.1 } = options
// Пока не видим — показываем placeholder
node.src = placeholder
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
node.src = options.src
node.onload = () => node.classList.add('loaded')
observer.unobserve(node)
}
})
},
{ threshold }
)
observer.observe(node)
return {
update(newOptions) {
options = newOptions
},
destroy() {
observer.unobserve(node)
}
}
}
{#each photos as photo}
<img
use:lazyLoad={{ src: photo.url, placeholder: photo.thumbnail }}
alt={photo.title}
class="photo"
/>
{/each}

actions/draggable.ts
import type { Action } from 'svelte/action'
interface DraggableOptions {
axis?: 'x' | 'y' | 'both'
bounds?: HTMLElement | 'parent' | 'window'
}
interface DraggableAttributes {
'on:dragstart': (e: CustomEvent<{x: number; y: number}>) => void
'on:drag': (e: CustomEvent<{x: number; y: number}>) => void
'on:dragend': (e: CustomEvent<{x: number; y: number}>) => void
}
export const draggable: Action<HTMLElement, DraggableOptions, DraggableAttributes> = (
node,
options = {}
) => {
let startX: number, startY: number
let initialLeft: number, initialTop: number
let isDragging = false
node.style.position = 'absolute'
node.style.cursor = 'grab'
function onMouseDown(event: MouseEvent) {
isDragging = true
startX = event.clientX
startY = event.clientY
initialLeft = node.offsetLeft
initialTop = node.offsetTop
node.style.cursor = 'grabbing'
node.dispatchEvent(new CustomEvent('dragstart', {
detail: { x: initialLeft, y: initialTop }
}))
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(event: MouseEvent) {
if (!isDragging) return
const deltaX = event.clientX - startX
const deltaY = event.clientY - startY
const { axis = 'both' } = options
const newLeft = axis !== 'y' ? initialLeft + deltaX : initialLeft
const newTop = axis !== 'x' ? initialTop + deltaY : initialTop
node.style.left = `${newLeft}px`
node.style.top = `${newTop}px`
node.dispatchEvent(new CustomEvent('drag', {
detail: { x: newLeft, y: newTop }
}))
}
function onMouseUp() {
isDragging = false
node.style.cursor = 'grab'
node.dispatchEvent(new CustomEvent('dragend', {
detail: {
x: node.offsetLeft,
y: node.offsetTop,
}
}))
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
node.addEventListener('mousedown', onMouseDown)
return {
update(newOptions) {
options = newOptions
},
destroy() {
node.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
}
}

<script>
import { tooltip } from './actions/tooltip'
import { longPress } from './actions/longPress'
let tooltipText = 'Начальный текст'
let longPressDuration = 500
// Параметры реактивны! При изменении переменных
// вызывается action.update()
</script>
<input bind:value={tooltipText} />
<button use:tooltip={{ text: tooltipText }}>Наведи мышь</button>
<input type="range" min={200} max={2000} bind:value={longPressDuration} />
<button use:longPress={{ duration: longPressDuration }}>
Зажми ({longPressDuration}мс)
</button>

Когда использовать use: (action):
✅ Логика работы с DOM (фокус, позиция, размер)
✅ Сторонние библиотеки (chart.js, maps)
✅ Intersection/Resize Observer
✅ Drag and drop
✅ Tooltip, popover, dropdown
✅ Keyboard shortcuts
✅ Переиспользуемая DOM-логика
Когда использовать on: (event directive):
✅ Простые обработчики событий
✅ Встроенные DOM события (click, input, keydown)
✅ Одноразовая логика в компоненте
Когда НЕ нужен action:
❌ Только изменить стиль → используй class:name или style:
❌ Простое условие → используй {#if}
❌ Реактивное значение → используй $:

// actions/chart.ts — интеграция Chart.js
import type { Action } from 'svelte/action'
import type { Chart, ChartData } from 'chart.js'
export const chart: Action<HTMLCanvasElement, ChartData> = (node, data) => {
let chartInstance: Chart | null = null
import('chart.js').then(({ Chart, registerables }) => {
Chart.register(...registerables)
chartInstance = new Chart(node, {
type: 'bar',
data,
options: {
responsive: true,
animation: { duration: 500 }
}
})
})
return {
update(newData) {
if (chartInstance) {
chartInstance.data = newData
chartInstance.update()
}
},
destroy() {
chartInstance?.destroy()
}
}
}
<script>
import { chart } from './actions/chart'
$: chartData = {
labels: months,
datasets: [{ label: 'Продажи', data: sales }]
}
</script>
<canvas use:chart={chartData}></canvas>