ข้ามไปยังเนื้อหา

Events & Attributes

ใน Svelte 5 DOM event handler เป็น attribute ธรรมดา ที่ตั้งชื่อเหมือน property: onclick, oninput, onsubmit คุณส่ง function เข้าไปเหมือนที่ทำใน HTML/JS ธรรมดาเป๊ะ

<script>
let count = $state(0);
</script>
<button onclick={() => count++}>+1</button>
<input oninput={(e) => console.log(e.currentTarget.value)} />

นี่คือการเปลี่ยนที่ตั้งใจจาก Svelte 4 ซึ่งใช้ directive พิเศษ on:click ถ้าคุณอ่าน code หรือ tutorial เก่า จะเห็น on:click={handler} — เทียบเท่าแบบใหม่คือ onclick={handler} รูปแบบใหม่เป็นแค่ property จึง compose ได้เป็นธรรมชาติและ spread รวมกับ attribute อื่นได้

Svelte ไม่ได้ผูก native listener ให้กับทุก element ที่มี onclick docs พูดตรง ๆ เรื่องนี้:

To reduce memory footprint and increase performance, Svelte uses a technique called event delegation. This means that for certain events… a single event listener at the application root takes responsibility for running any handlers on the event’s path.

คุณเห็นสิ่งนี้ได้ตรง ๆ จาก output ของ compiler เอง — compile counter เล็ก ๆ ด้วย compile() ของ svelte/compiler:

src/lib/compile-tiny.test.ts
import { test, expect } from 'vitest';
import { compile } from 'svelte/compiler';
const source = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>clicked {count} times</button>
`;
test('a click handler compiles to delegated dispatch, not a per-element listener', () => {
const { js } = compile(source, { filename: 'Tiny.svelte' });
// No addEventListener anywhere in the output...
expect(js.code).not.toMatch(/addEventListener/);
// ...instead, the handler is registered for delegation, and the event
// type is collected into a single call that wires up the root listener.
expect(js.code).toMatch(/\$\.delegated\('click', button, /);
expect(js.code).toMatch(/\$\.delegate\(\['click'\]\)/);
});
✓ a click handler compiles to delegated dispatch, not a per-element listener

ผลที่ตามมา: stopPropagation() ระหว่างทางจาก root ถึง element ของคุณ กิน event ได้

หัวข้อที่มีชื่อว่า “ผลที่ตามมา: stopPropagation() ระหว่างทางจาก root ถึง element ของคุณ กิน event ได้”

เพราะ dispatch logic ของ Svelte จะ run ก็ต่อเมื่อ native event ไปถึง root จริง ๆ อะไรก็ตามที่คั่นกลางแล้วหยุด native bubbling ทำให้ Svelte ไม่มีทางรู้เลยว่า click เกิดขึ้น — ทั้งที่ click นั้นตกลงบนปุ่มของคุณจริง ๆ docs พูดเรื่องนี้ตรง ๆ:

When using addEventListener directly, avoid calling stopPropagation, as it prevents the event from reaching the root and invoking handlers.

พิสูจน์ด้วย ancestor จริงที่ทำแบบนั้นเป๊ะ — addEventListener ธรรมดา (ผ่าน attachment เพื่อให้ run บน DOM node จริง) ระหว่าง root กับปุ่ม ที่เรียก stopPropagation():

src/lib/DelegationDemo.svelte
<script lang="ts">
let { events }: { events: string[] } = $props();
function blockBubble(node: HTMLElement) {
function handle(e: MouseEvent) {
events.push('ancestor-vanilla-listener');
e.stopPropagation(); // stops NATIVE bubbling before it reaches the app root
}
node.addEventListener('click', handle);
return () => node.removeEventListener('click', handle);
}
</script>
<div {@attach blockBubble}>
<button onclick={() => events.push('svelte-onclick')}>click</button>
</div>
src/lib/delegation.test.ts
import { test, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import DelegationDemo from './DelegationDemo.svelte';
test('a vanilla stopPropagation() on an ancestor prevents the delegated Svelte onclick from firing', async () => {
const events: string[] = [];
render(DelegationDemo, { props: { events } });
await fireEvent.click(screen.getByRole('button'));
// The ancestor's own listener ran — but the button's onclick never did.
expect(events).toEqual(['ancestor-vanilla-listener']);
});
✓ a vanilla stopPropagation() on an ancestor prevents the delegated Svelte onclick from firing

นี่คือเหตุผลที่ docs แนะนำ on (import จาก svelte/events) แทน addEventListener ดิบ ๆ สำหรับโค้ดที่ต้องอยู่ร่วมกับ handler ของ Svelte เอง: มัน “ensure[s] correct execution order and proper handling of stopPropagation” แทนที่จะแข่งกันเงียบ ๆ กับกลไกที่โค้ดของคุณมองไม่เห็น

รายละเอียด DOM event อีกสองอย่างที่ Svelte 5 ยังให้เข้าถึงตรง ๆ โดยไม่มี modifier syntax แบบเก่า:

  • capture phase: ต่อ capture ท้ายชื่อ event — onclickcapture={...} fire ตอน capture phase ก่อน handler แบบ bubble-phase ที่ delegate
  • passive / once: สิ่งเหล่านี้ต้องการให้ listener รู้ options ของตัวเอง ตอนที่ผูก ซึ่ง attribute ธรรมดาบอกไม่ได้ — ให้ใช้ function on จาก svelte/events แทน ข้างใน attachment หรือ $effect
src/lib/CaptureOnce.svelte
<script lang="ts">
import { on } from 'svelte/events';
let { events }: { events: string[] } = $props();
function passiveOnce(node: HTMLElement) {
// `on` returns a cleanup function, like addEventListener + removeEventListener in one call.
return on(node, 'click', () => events.push('on()-passive-once'), { passive: true, once: true });
}
</script>
<div onclickcapture={() => events.push('capture')}>
<button {@attach passiveOnce} onclick={() => events.push('bubble-onclick')}>click</button>
</div>
src/lib/capture-once.test.ts
import { test, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import CaptureOnce from './CaptureOnce.svelte';
test('onclickcapture runs in the capture phase; on() with { once: true } only fires once', async () => {
const events: string[] = [];
render(CaptureOnce, { props: { events } });
const button = screen.getByRole('button');
await fireEvent.click(button);
// Capture phase first, then the on()-attached listener, then the delegated onclick.
expect(events).toEqual(['capture', 'on()-passive-once', 'bubble-onclick']);
await fireEvent.click(button);
// Second click: capture and the delegated onclick fire again — but on()'s
// `once: true` listener has already removed itself.
expect(events).toEqual([
'capture', 'on()-passive-once', 'bubble-onclick',
'capture', 'bubble-onclick',
]);
});
✓ onclickcapture runs in the capture phase; on() with { once: true } only fires once

เมื่อ variable มีชื่อเดียวกับ attribute ให้ใช้ shorthand {value}:

<script>
let src = $state('/logo.png');
let alt = $state('Logo');
</script>
<img {src} {alt} /> <!-- shorthand for src={src} alt={alt} -->

spread object ของ attribute ทั้งก้อนด้วย {...obj} — สะดวกเวลา forward props ไปให้ element:

<script>
let { ...rest } = $props(); // collect remaining props
</script>
<button {...rest}>click</button> <!-- forward them all to the button -->

เมื่อ spread กับ attribute ที่เขียนตรง ๆ ตั้งชื่อเดียวกัน Svelte apply attribute ตาม ลำดับใน document — ตัวไหนเขียนทีหลังในตัว markup ชนะ เหมือนกับการ assign key เดียวกันของ object สองครั้งใน JavaScript พิสูจน์แล้วทั้งสองทิศทาง:

src/lib/SpreadBefore.svelte
<script lang="ts">
let rest: Record<string, string> = { title: 'from-rest' };
</script>
<!-- explicit title AFTER the spread: explicit wins -->
<button {...rest} title="explicit" data-testid="before">go</button>
src/lib/SpreadAfter.svelte
<script lang="ts">
let rest: Record<string, string> = { title: 'from-rest' };
</script>
<!-- explicit title BEFORE the spread: spread wins -->
<button title="explicit" {...rest} data-testid="after">go</button>
src/lib/spread-order.test.ts
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SpreadBefore from './SpreadBefore.svelte';
import SpreadAfter from './SpreadAfter.svelte';
test('an explicit attribute written after a spread overrides it', () => {
render(SpreadBefore);
expect(screen.getByTestId('before').getAttribute('title')).toBe('explicit');
});
test('a spread written after an explicit attribute overrides it', () => {
render(SpreadAfter);
expect(screen.getByTestId('after').getAttribute('title')).toBe('from-rest');
});
✓ an explicit attribute written after a spread overrides it
✓ a spread written after an explicit attribute overrides it

เมื่อ shape ของ spread รู้แบบ static (ไม่ใช่แค่ Record<string, string> แต่เป็น object literal ที่ infer property title ตรง ๆ) svelte-check ไปไกลกว่านั้นอีก โดย flag ความกำกวมตอน compile แทนที่จะเลือกผู้ชนะแบบเงียบ ๆ:

src/lib/DuplicateAttrDemo.svelte
<!-- @expect-error "title" is specified more than once, so this usage will be overwritten. -->
<script lang="ts">
let rest = { title: 'from-rest' }; // inferred as { title: string }, not Record<string, string>
</script>
<button title="explicit" {...rest}>go</button>
"title" is specified more than once, so this usage will be overwritten.

ถ้าคุณอยาก prop ตัวหนึ่งชนะเสมอไม่ว่า caller จะ spread อะไรเข้ามา (หรือแพ้เสมอ) ตำแหน่งของ {...rest} เทียบกับ attribute ที่เขียนตรง ๆ คือกลไกทั้งหมด — ไม่มี concept “priority” แยกต่างหากให้หยิบใช้

การ toggle class และ inline style แบบ reactive เกิดขึ้นบ่อยพอที่ Svelte จะให้ directive เฉพาะ

class:name={condition} เพิ่ม class เมื่อ condition เป็น truthy:

<div class:active={isActive} class:done={isDone}>…</div>

ตั้งแต่ Svelte 5.16 attribute class ธรรมดายังรับ object หรือ array ได้ด้วย ประมวลผลแบบเดียวกับที่ helper clsx ยอดนิยมทำ (Svelte ใช้ algorithm เดียวกันนั้นข้างใน): key ของ object ที่ truthy กลายเป็น class; entry ของ array ถูก flatten และตัวที่ falsy ถูกทิ้ง — array กับ object ซ้อนกันได้:

<div class={{ active: isActive, done: isDone }}>…</div> <!-- object -->
<div class={[faded && 'opacity-50', large && 'scale-200']}>…</div> <!-- array -->

ฟอร์ม array compose ได้ดีเป็นพิเศษเมื่อ component มี class ของตัวเองและรับ class จาก caller ด้วย:

Button.svelte
<script>
let props = $props();
</script>
<button {...props} class={['btn', props.class]}>{@render props.children?.()}</button>

style:prop={value} ตั้งค่า CSS property แบบ inline อย่าง reactive:

<div style:color={textColor} style:font-size="{size}px">…</div>

ทั้งหมดนี้ compile เป็นการอัปเดตที่แม่นยำ — เมื่อ isActive พลิก จะ toggle เฉพาะ class นั้น; เมื่อ textColor เปลี่ยน จะตั้งเฉพาะ style property นั้น

flowchart LR
  cls["class:active={isActive}"] --> u1["toggle exactly that class"]
  arr["class={[a, b]} (5.16+, clsx-style)"] --> u1
  sty["style:color={textColor}"] --> u2["set exactly that CSS property"]
  ev["onclick={handler}"] --> u3["registered for root-level delegation"]
directive compile เป็นการอัปเดตแบบเจาะจง

การจำโค้ด legacy: ตารางเดียวสำหรับสี่รูปแบบพร้อมกัน

หัวข้อที่มีชื่อว่า “การจำโค้ด legacy: ตารางเดียวสำหรับสี่รูปแบบพร้อมกัน”

โค้ดที่เขียนสำหรับ Svelte 4 — หรือ generate โดย model ที่ train ส่วนใหญ่จากตัวอย่าง Svelte 4 — ผสมสี่รูปแบบที่เปลี่ยนหมดใน Svelte 5 การจำทั้งสี่แบบได้ในเสี้ยววินาทีคือทักษะจริง ๆ นี่คือทั้งหมดเทียบกัน:

Svelte 4 (legacy)Svelte 5 (runes)เกิดอะไรขึ้นถ้าผสม legacy เข้ากับ component ที่เป็น runes mode
export let valuelet { value } = $props()compile error: Cannot use export letin runes mode — use$props() instead
$: doubled = count * 2let doubled = $derived(count * 2)compile error: `$:` is not allowed in runes mode, use `$derived` or `$effect` instead
<slot /> / <slot name="x" />{@render children?.()} / snippet propscompile ผ่าน พร้อม deprecation warning — <slot> ยัง render ได้
on:click={handler}onclick={handler}compile ผ่าน พร้อม deprecation warning — on:click ยังใช้ได้
createEventDispatcher() + dispatch('x', detail)callback prop เช่น onchange?.(detail)compile ผ่าน ไม่มี warning เลย — ยังรองรับเต็มที่ แค่ไม่ idiomatic

component เข้าสู่ runes mode อัตโนมัติทันทีที่ใช้ rune ตัวใดตัวหนึ่ง ($state, $derived, $effect, $props, …) ที่ไหนก็ตามในไฟล์ หรือเมื่อตั้ง runes: true ตรง ๆ ใน compiler options เมื่อเข้า runes mode แล้ว primitive ด้าน reactivity จาก legacy mode (export let, let ระดับบนสุดที่เป็น implicit state, $:) ถูกปฏิเสธทันที — แต่การฉาย content (<slot>) และ syntax ของ event (on:click) ถือเป็น deprecation แบบอ่อนกว่าที่แยกต่างหาก ซึ่งยัง compile ผ่าน

พิสูจน์กับ compiler จริง — ผสม $state กับ export let หรือกับ $: ในไฟล์เดียวกัน:

src/lib/compile-errors.test.ts
import { test, expect } from 'vitest';
import { compile } from 'svelte/compiler';
test('`export let` alongside a rune is a hard compile error in runes mode', () => {
const source = `<script>
let count = $state(0);
export let label;
</script>
<p>{label}: {count}</p>`;
expect(() => compile(source, { filename: 'BadExportLet.svelte' })).toThrowError(
/Cannot use `export let` in runes mode/,
);
});
test('`$:` alongside a rune is a hard compile error in runes mode', () => {
const source = `<script>
let count = $state(0);
$: doubled = count * 2;
</script>
<p>{doubled}</p>`;
expect(() => compile(source, { filename: 'BadReactive.svelte' })).toThrowError(
/`\$:` is not allowed in runes mode/,
);
});
test('`<slot>` and `on:click` still compile in runes mode — with warnings, not errors', () => {
const slotSource = `<script>\n let count = $state(0);\n</script>\n<slot />`;
const slotResult = compile(slotSource, { filename: 'StillSlot.svelte' });
expect(slotResult.warnings.map((w) => w.code)).toContain('slot_element_deprecated');
const onColonSource = `<script>\n let count = $state(0);\n</script>\n<button on:click={() => count++}>{count}</button>`;
const onColonResult = compile(onColonSource, { filename: 'StillOnColon.svelte' });
expect(onColonResult.warnings.map((w) => w.code)).toContain('event_directive_deprecated');
});
✓ `export let` alongside a rune is a hard compile error in runes mode
✓ `$:` alongside a rune is a hard compile error in runes mode
✓ `<slot>` and `on:click` still compile in runes mode — with warnings, not errors

error จริง คำต่อคำ (svelte-check รายงานข้อความเดียวกับที่ compile() throw):

Cannot use `export let` in runes mode — use `$props()` instead
https://svelte.dev/e/legacy_export_invalid
`$:` is not allowed in runes mode, use `$derived` or `$effect` instead
https://svelte.dev/e/legacy_reactive_statement_invalid
Using `<slot>` to render parent content is deprecated. Use `{@render ...}` tags instead
https://svelte.dev/e/slot_element_deprecated
Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead
https://svelte.dev/e/event_directive_deprecated

มองทุก feature ที่เกี่ยวกับ DOM ในบทนี้ว่า compiler เลือก ณ build time วิธีที่ถูกต้องและถูกที่สุดในการไปถึงผลลัพธ์เดียวกัน: listener เดียวที่ root แทนที่จะมีหลายตัว (delegation), การเขียนตามลำดับ document แทนระบบ “priority” (spread), string builder แบบเดียวกับ clsx แทน library class-list ที่ต้อง run ตอน runtime (class={{...}}/class={[...]}) ไม่มีอะไรในนี้เป็น magic ที่ซ้อนทับ DOM — มันคือ DOM ที่ generate มาให้ในรูปร่างที่คุณจะเขียนเองสำหรับกรณีนั้นเป๊ะ ๆ legacy mode กับ runes mode เป็น code path สองอันแยกกันของ compiler ไม่ใช่โหมดเดียวที่มี feature ทางเลือก — นั่นคือเหตุผลที่การผสม primitive ด้าน reactivity ของทั้งสอง (export let + $state, $: + $derived) เป็น error จริง ไม่ใช่แค่ “ไม่คาดคิดแต่ยอมรับได้” — compiler ผสมกลยุทธ์ rewrite ทั้งสองแบบในรอบเดียวไม่ได้จริง ๆ

ได้อะไรประโยชน์ต้นทุน
Event delegationlistener น้อยลงให้ browser จัดการ; output ที่ generate เล็กลงstopPropagation() ที่ไหนก็ตามระหว่าง root กับ element ของคุณ เอาชนะ delegated handler แบบเงียบ ๆ (พิสูจน์แล้วด้านบน)
on จาก svelte/events แทน addEventListener ดิบ ๆลำดับถูกต้องเทียบกับ dispatch ของ Svelte เอง; เรียกครั้งเดียวจัดการทั้ง add + removeต้อง import เพิ่มอีกตัว แทนที่จะใช้ DOM API ที่คุณรู้จักอยู่แล้ว
class={[...]}/class={{...}} (5.16+)compose กับ props ได้เป็นธรรมชาติกว่า (class={['btn', props.class]}) เทียบกับสาย class:xต้องการ 5.16+; object/array ใหญ่ ๆ อ่านปราดเดียวยากกว่า list class: สั้น ๆ
runes mode ปฏิเสธ legacy reactivity ตรง ๆผสม reactivity system สองระบบที่เข้ากันไม่ได้ในไฟล์เดียวแบบเงียบ ๆ ไม่ได้อีกต่อไปsnippet ที่ AI generate ซึ่ง pattern-match จาก training data เก่า พังดัง ๆ แทนที่จะ “ทำงานครึ่ง ๆ กลาง ๆ”
  • “เรียก stopPropagation() บน wrapper กระทบแค่ listener อื่นบน wrapper นั้น” พิสูจน์แล้วด้านบน: มันยังกัน event ไม่ให้ไปถึง root เลย จึงเอาชนะ onclick ที่ delegate อยู่หลายชั้นเข้าไปข้างในแบบเงียบ ๆ ไม่มี error ให้เห็นเลย
  • “{...rest} ชนะเสมอ เพราะมัน spread object ทั้งก้อน” มันเป็นแค่ attribute ธรรมดาตามลำดับ source — พิสูจน์แล้วด้านบนทั้งสองทิศทาง: ตัวไหนเขียนทีหลัง ไม่ว่า spread หรือ explicit ตัวนั้นชนะ
  • “syntax legacy throw เสมอทันทีที่แตะ rune” มีแค่ export let กับ $: เท่านั้นที่ทำแบบนั้น (มันคือ reactivity primitive ที่ไม่มีอะไรให้ fallback); <slot>, on:click และ createEventDispatcher ยัง compile และ run ได้ทั้งหมด บางตัวมี deprecation warning บางตัวไม่มีเลย — พิสูจน์แล้วด้านบน
  • “class={{...}} ต้องติดตั้ง library อย่าง clsx” ตั้งแต่ 5.16 พฤติกรรมนี้ถูกสร้างไว้ใน output ของ compiler เอง — ไม่มี dependency ไม่มี import

เวลารีวิว markup และ event code ที่ generate มา ให้เช็ค:

  • handler onclick/oninput/ฯลฯ อยู่ข้างใน subtree ที่มีอะไรอื่น (modal library, listener แบบ vanilla ที่ผูกเอง) อาจเรียก stopPropagation() บน ancestor — กิน event แบบเงียบ ๆ ก่อนที่ delegated dispatch ของ Svelte จะเห็นหรือเปล่า?
  • listener แบบ use: หรือผูกเองด้วยมือใช้ addEventListener ดิบ ๆ ทั้งที่ on จาก svelte/events จะการันตีลำดับที่ถูกต้องหรือเปล่า?
  • {...rest} อยู่ในตำแหน่ง (ก่อนหรือหลัง attribute ที่เขียนตรง ๆ) ที่ไม่ตรงกับ intent ที่ตั้งใจไว้หรือเปล่า — เช่น spread หลัง class ที่ explicit ทั้งที่ intent คือให้ตัว explicit ชนะเสมอ?
  • ไฟล์ผสม rune ($state, $derived, $props, …) กับ export let หรือ block $: ระดับบนสุดหรือเปล่า — เป็น compile error จริง ไม่ใช่แค่ style nit (พิสูจน์แล้วด้านบน)?
  • createEventDispatcher ถูกใช้ในโค้ด Svelte 5 ใหม่ตามความเคยชิน ทั้งที่ callback prop ธรรมดาง่ายกว่าและเป็นสิ่งที่ consumer ของ component คาดหวังตอนนี้หรือเปล่า?

🐛 หา bug ในโค้ดนี้

<script>
export let initialCount = 0;
let count = $state(initialCount);
$: label = count === 1 ? '1 item' : `${count} items`;
</script>
<button onclick={() => count++}>{label}</button>
เฉลย

นี่คือรูปร่างแบบ “migrate ครึ่ง ๆ กลาง ๆ” ที่ model ซึ่ง train ส่วนใหญ่จากตัวอย่าง Svelte 4 มักจะ generate ออกมา: count ถูกอัปเกรดเป็น $state(...) ถูกต้องแล้ว แต่ initialCount ยังประกาศด้วย export let และ label ที่ derive ยังใช้ $: ทันทีที่มี rune ตัวไหนก็ตาม โผล่ในไฟล์ ทั้ง component เข้าสู่ runes mode — และทั้ง export let กับ $: กลายเป็น compile error จริง พิสูจน์แล้วด้านบน:

Cannot use `export let` in runes mode — use `$props()` instead
`$:` is not allowed in runes mode, use `$derived` or `$effect` instead

วิธีแก้คืออัปเกรดทั้งสองฟอร์มเป็น rune ที่เทียบเท่า ไม่ใช่แค่ตัวที่บังเอิญถูกแตะก่อน:

<script>
let { initialCount = 0 } = $props();
let count = $state(initialCount);
let label = $derived(count === 1 ? '1 item' : `${count} items`);
</script>
<button onclick={() => count++}>{label}</button>
attach click handler ใน Svelte 5 อย่างไร?
ถ้า blockBubbleVanilla ผูก addEventListener('click', handler) ธรรมดาไว้บน div และ handler นั้นเรียก e.stopPropagation() จะเกิดอะไรขึ้นเมื่อ click ปุ่ม?
<div use:blockBubbleVanilla>
  <button onclick={() => count++}>+1</button>
</div>
attach event listener แบบ passive หรือ once ใน Svelte 5 อย่างไร?
เทียบกับ ถ้า rest = { title: 'from-rest' } title ไหนชนะในแต่ละแบบ?
<button {...rest} title="explicit">go</button>
<button title="explicit" {...rest}>go</button>
ตั้งแต่ Svelte 5.16 attribute class ธรรมดารับอะไรได้อีกนอกจาก string?
component ใช้ $state อยู่แล้วที่อื่น และมี $: doubled = count * 2 ด้วย จะเกิดอะไรขึ้นตอน compile?
ในสี่รูปแบบ legacy นี้ ตัวไหนยัง compile ผ่านใน component แบบ runes mode โดย **ไม่มี warning เลย**?