Chapter 04

事件处理与双向绑定

掌握 Svelte 5 的事件处理新语法、事件修饰符与 bind: 指令的完整用法

Svelte 5 事件处理

新版事件语法(Svelte 5)

Svelte 5 对事件处理进行了重大更新:废弃了旧版的 on: 指令(如 on:click),改为使用标准的 HTML 事件处理属性语法(如 onclick)。这使 Svelte 的事件处理与标准 HTML/JS 更加一致,也让代码更易于理解。

<!-- Svelte 4 旧版 -->
<button on:click={handler}>
  点击
</button>

<input
  on:input={handleInput}
  on:keydown|escape={close}
/>
<!-- Svelte 5 新版 -->
<button onclick={handler}>
  点击
</button>

<input
  oninput={handleInput}
  onkeydown={(e) => {
    if (e.key === 'Escape') close();
  }}
/>

内联事件处理器

<script lang="ts">
  let count = $state(0);
  let log = $state<string[]>([]);

  function handleClick(event: MouseEvent) {
    count++;
    log.push(`点击于 (${event.clientX}, ${event.clientY})`);
  }

  function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    // 处理表单
  }
</script>

<!-- 引用函数 -->
<button onclick={handleClick}>点击次数:{count}</button>

<!-- 内联箭头函数 -->
<button onclick={() => count++}>简短形式</button>

<!-- 带参数 -->
<button onclick={(e) => log.push(e.type)}>记录事件类型</button>

<!-- 表单提交 -->
<form onsubmit={handleSubmit}>
  <button type="submit">提交</button>
</form>

手动实现事件修饰符

Svelte 5 移除了旧版的管道修饰符(on:click|preventDefault),改为在处理函数中显式调用。这样代码意图更明确:

<script lang="ts">
  function preventDefault<T extends Event>(fn: (e: T) => void) {
    return (e: T) => { e.preventDefault(); fn(e); };
  }

  function stopPropagation<T extends Event>(fn: (e: T) => void) {
    return (e: T) => { e.stopPropagation(); fn(e); };
  }

  function handleSubmit() {
    console.log('提交!');
  }
</script>

<!-- 阻止默认行为 -->
<form onsubmit={preventDefault(handleSubmit)}>...</form>

<!-- 阻止冒泡 -->
<div onclick={stopPropagation(() => console.log('div'))}>
  <button onclick={() => console.log('button')}>内部按钮</button>
</div>

<!-- once:只触发一次(原生 addEventListener 的 once 选项)-->
<button onclick={(e) => {
  console.log('只执行一次');
  e.target.replaceWith(e.target.cloneNode(true));
}}>只触发一次</button>

双向绑定 bind:

表单元素绑定

bind: 指令用于在响应式变量和 HTML 元素的属性之间建立双向绑定:变量变化时元素属性更新,元素属性变化时变量也自动更新。

<script lang="ts">
  let name = $state('');
  let age = $state(18);
  let subscribed = $state(false);
  let role = $state('user');
  let bio = $state('');
  let file = $state<File | null>(null);
</script>

<!-- 文本输入 -->
<input type="text" bind:value={name} placeholder="输入姓名" />
<p>Hello, {name || '陌生人'}!</p>

<!-- 数字输入(自动转换为 number 类型)-->
<input type="number" bind:value={age} min="0" max="120" />

<!-- 复选框 -->
<input type="checkbox" bind:checked={subscribed} />
<label>{subscribed ? '已订阅' : '未订阅'}</label>

<!-- 单选框 -->
<label><input type="radio" bind:group={role} value="user" /> 用户</label>
<label><input type="radio" bind:group={role} value="admin" /> 管理员</label>
<p>当前角色:{role}</p>

<!-- 下拉选择 -->
<select bind:value={role}>
  <option value="user">普通用户</option>
  <option value="editor">编辑者</option>
  <option value="admin">管理员</option>
</select>

<!-- 多行文本 -->
<textarea bind:value={bio} rows="4"></textarea>

<!-- 文件输入 -->
<input type="file" bind:files={files}
  onchange={() => console.log(files[0]?.name)} />

bind:this — 获取 DOM 引用

bind:this 允许你获取原生 DOM 元素或子组件实例的引用,类似于 React 的 useRef

<script lang="ts">
  let inputEl = $state<HTMLInputElement>();
  let canvasEl = $state<HTMLCanvasElement>();

  // 组件挂载后聚焦输入框
  $effect(() => {
    inputEl?.focus();
  });

  function drawOnCanvas() {
    const ctx = canvasEl?.getContext('2d');
    if (!ctx) return;
    ctx.fillStyle = '#FF3E00';
    ctx.fillRect(10, 10, 100, 100);
  }
</script>

<input bind:this={inputEl} type="text" placeholder="自动聚焦" />
<canvas bind:this={canvasEl} width="200" height="200"></canvas>
<button onclick={drawOnCanvas}>画图</button>
bind:this 的注意事项

bind:this 绑定的引用在组件挂载(DOM 创建)后才有值,在 $effect() 内或用户交互时访问是安全的。不要在组件 script 顶层直接访问,那时 DOM 还未创建。

bind:this 高级应用:拖拽排序

<script lang="ts">
  let containerEl = $state<HTMLElement>();
  let items = $state([
    { id: 1, label: 'Item A' },
    { id: 2, label: 'Item B' },
    { id: 3, label: 'Item C' }
  ]);
  let draggedId = $state<number | null>(null);

  function handleDragStart(id: number) {
    draggedId = id;
  }

  function handleDrop(targetId: number) {
    if (draggedId === null || draggedId === targetId) return;

    const fromIdx = items.findIndex(i => i.id === draggedId);
    const toIdx = items.findIndex(i => i.id === targetId);

    // 重排数组
    const newItems = [...items];
    const [moved] = newItems.splice(fromIdx, 1);
    newItems.splice(toIdx, 0, moved);
    items = newItems;
    draggedId = null;
  }
</script>

<ul bind:this={containerEl} class="sortable-list">
  {#each items as item (item.id)}
    <li
      draggable="true"
      class:dragging={draggedId === item.id}
      ondragstart={() => handleDragStart(item.id)}
      ondragover={(e) => e.preventDefault()}
      ondrop={() => handleDrop(item.id)}
    >
      {item.label}
    </li>
  {/each}
</ul>

键盘事件与可访问性

处理键盘事件时需要同时照顾到鼠标用户和键盘用户,这是无障碍访问(Accessibility / a11y)的基本要求。

<script lang="ts">
  let isOpen = $state(false);

  function toggle() {
    isOpen = !isOpen;
  }

  // 键盘处理:Enter 和空格键触发点击(模拟按钮行为)
  function handleKeydown(e: KeyboardEvent) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      toggle();
    } else if (e.key === 'Escape' && isOpen) {
      isOpen = false;
    }
  }
</script>

<!-- 可访问的自定义切换组件 -->
<div
  role="button"
  tabindex="0"
  aria-expanded={isOpen}
  aria-label="切换内容"
  onclick={toggle}
  onkeydown={handleKeydown}
>
  {isOpen ? '▲ 收起' : '▼ 展开'}
</div>

{#if isOpen}
  <div role="region">
    折叠内容区域...
  </div>
{/if}

<!-- 全局键盘监听:按 Escape 关闭弹窗 -->
<svelte:window
  onkeydown={(e) => {
    if (e.key === 'Escape') isOpen = false;
  }}
/>
Svelte 的 a11y 警告

Svelte 编译器内置了无障碍访问检查:如果你在非交互元素(div、span)上添加 onclick 但没有 roletabindex,编译器会发出 a11y-click-events-have-key-events 警告。遵循这些警告可以让你的应用对键盘用户和屏幕阅读器用户更友好。

Svelte 特殊元素事件

<!-- svelte:window:监听窗口事件(如滚动、大小变化)-->
<script lang="ts">
  let scrollY = $state(0);
  let windowWidth = $state(0);
  let windowHeight = $state(0);
</script>

<!-- bind: 直接绑定窗口属性 -->
<svelte:window
  bind:scrollY
  bind:innerWidth={windowWidth}
  bind:innerHeight={windowHeight}
  onresize={() => console.log('窗口大小改变')}
/>

{#if scrollY > 200}
  <button class="back-to-top" onclick={() => window.scrollTo(0, 0)}>↑ 回顶部</button>
{/if}

<!-- svelte:document:监听 document 事件 -->
<svelte:document
  onvisibilitychange={() => {
    if (document.hidden) console.log('页面不可见,暂停动画');
    else console.log('页面可见,恢复动画');
  }}
/>

<!-- svelte:body:监听 body 上的事件 -->
<svelte:body
  onpointerenter={() => console.log('鼠标进入文档')}
/>

常见绑定场景汇总

元素/场景绑定语法说明
文本输入bind:value={str}绑定字符串值
数字输入bind:value={num}自动类型转换为 number
复选框bind:checked={bool}绑定布尔值
多选复选框bind:group={arr}选中项加入数组
单选按钮bind:group={str}绑定选中的 value
下拉选择bind:value={selected}绑定选中 option 的 value
文件选择bind:files={fileList}绑定 FileList
contenteditablebind:innerHTML={html}绑定富文本 HTML
元素尺寸bind:clientWidth={w}响应式读取元素宽度
滚动位置bind:scrollY(svelte:window)监听页面滚动
DOM 引用bind:this={el}获取 DOM 元素实例

本章小结

本章核心要点

组件级双向绑定

<!-- NumberInput.svelte -->
<script lang="ts">
  // $bindable() 声明可被父组件双向绑定的 prop
  let { value = $bindable(0), min = 0, max = 100 } = $props<{
    value?: number;
    min?: number;
    max?: number;
  }>();
</script>

<input type="number" bind:value {min} {max} />

<!-- 父组件:使用 bind: 双向绑定子组件 prop -->
<script>
  import NumberInput from './NumberInput.svelte';
  let quantity = $state(5);
</script>

<NumberInput bind:value={quantity} min={1} max={99} />
<p>数量:{quantity}</p>