Chapter 05

组件通信:props 与 snippets

掌握 Svelte 5 中组件间数据传递的完整方式:$props()、$bindable()、snippets 与 {@render}

$props() 接收父组件传值

基本用法

在 Svelte 5 中,$props() 替代了旧版的 export let 语法。它返回一个包含所有传入 props 的对象,使用解构赋值提取各个属性:

<!-- Card.svelte -->
<script lang="ts">
  // 使用 $props() 声明并解构所有 props
  let {
    title,
    description,
    imageUrl = '/default.png',  // 带默认值
    badge,                         // 可选 prop
    variant = 'default'            // 联合类型 prop
  } = $props<{
    title: string;
    description: string;
    imageUrl?: string;
    badge?: string;
    variant?: 'default' | 'featured' | 'compact';
  }>();
</script>

<article class="card {variant}">
  <img src={imageUrl} alt={title} />
  {#if badge}
    <span class="badge">{badge}</span>
  {/if}
  <h2>{title}</h2>
  <p>{description}</p>
</article>

<!-- 父组件使用 -->
<Card
  title="Svelte 5 发布"
  description="Runes 系统彻底改变响应式编程"
  badge="新功能"
  variant="featured"
/>

$$restProps — 传递剩余属性

当你需要将父组件传入的额外 HTML 属性透传给内部元素时,使用 $$restProps(Svelte 5 中通过 rest 解构实现):

<!-- Button.svelte —— 包装原生 button,支持所有 HTML 属性 -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  let {
    variant = 'primary',
    loading = false,
    children,
    ...restProps      // 收集剩余 props(class, disabled, onclick 等)
  } = $props<{
    variant?: 'primary' | 'secondary' | 'danger';
    loading?: boolean;
    children: Snippet;
    [key: string]: unknown;
  }>();
</script>

<button
  class="btn btn-{variant}"
  disabled={loading}
  {...restProps}       <!-- 展开传递所有额外属性 -->
>
  {#if loading}⟳ 加载中...{:else}{@render children()}{/if}
</button>

<!-- 父组件:可以传入任意 button 属性 -->
<Button variant="primary" type="submit" class="w-full">
  提交表单
</Button>

Svelte 5 Snippets(替代 Slot)

什么是 Snippets?

Svelte 5 用 Snippets(片段)替代了 Svelte 4 的 <slot> 机制。Snippets 本质上是可复用的模板片段,可以在组件内部定义,也可以作为 props 从父组件传入子组件。

{#snippet name()}
定义一个名为 name 的片段,可包含参数。在同一组件内或通过 prop 传递给子组件。
{@render name()}
渲染(调用)一个 snippet,类似于调用函数。可传递参数给带参数的 snippet。
children
特殊的 snippet prop——父组件在子组件标签内写的内容,自动成为 children snippet,替代默认 slot。

基本 Snippets 用法

<!-- Layout.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  let {
    children,              // 默认内容片段
    header,                // 自定义 header 片段
    footer,                // 可选 footer 片段
    sidebar               // 可选侧边栏片段
  } = $props<{
    children: Snippet;
    header: Snippet;
    footer?: Snippet;
    sidebar?: Snippet;
  }>();
</script>

<div class="layout">
  <header>{@render header()}</header>
  <div class="body">
    {#if sidebar}
      <aside>{@render sidebar()}</aside>
    {/if}
    <main>{@render children()}</main>
  </div>
  {#if footer}
    <footer>{@render footer()}</footer>
  {/if}
</div>

<!-- 父组件使用 -->
<Layout>
  {#snippet header()}
    <nav><a href="/">首页</a></nav>
  {/snippet}

  {#snippet sidebar()}
    <ul>
      <li><a href="/docs">文档</a></li>
    </ul>
  {/snippet}

  <!-- children:标签内直接写的内容 -->
  <h1>页面主要内容</h1>
  <p>这里是正文...</p>
</Layout>

带参数的 Snippets(替代作用域 Slot)

Snippets 支持参数,这使得子组件可以向父组件提供的模板片段传入数据(替代 Svelte 4 的 slot let:):

<!-- DataTable.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface Column<T> {
    key: keyof T;
    label: string;
  }

  let {
    data,
    columns,
    row              // 父组件提供的行渲染片段,接收行数据
  } = $props<{
    data: Record<string, unknown>[];
    columns: Column<any>[];
    row?: Snippet<[Record<string, unknown>]>;
  }>();
</script>

<table>
  <thead>
    <tr>
      {#each columns as col}
        <th>{col.label}</th>
      {/each}
    </tr>
  </thead>
  <tbody>
    {#each data as item}
      <tr>
        {#if row}
          {@render row(item)}  <!-- 传入 item 给父组件的 snippet -->
        {:else}
          {#each columns as col}
            <td>{item[col.key]}</td>
          {/each}
        {/if}
      </tr>
    {/each}
  </tbody>
</table>

<!-- 父组件:自定义行渲染 -->
<DataTable {data} {columns}>
  {#snippet row(item)}
    <td style:color={item.status === 'active' ? 'green' : 'red'}>
      {item.name}
    </td>
    <td>{item.status}</td>
  {/snippet}
</DataTable>
Snippets 的优势

相比旧版 slot,Snippets 是类型安全的——TypeScript 可以检查 snippet 参数的类型。Snippets 也可以在同一组件内部复用,不必传给子组件,非常适合减少重复模板代码。

$bindable():双向绑定 Props

$bindable()
Svelte 5 新增的 rune,用于声明一个可以从父组件双向绑定的 prop。子组件内部修改该值时,变化会同步回父组件。相当于 Svelte 4 中的 export let value + 父组件 bind:value 的组合。
<!-- Counter.svelte —— 可双向绑定的计数器 -->
<script lang="ts">
  let {
    // $bindable() 标记这个 prop 可被父组件 bind: 绑定
    count = $bindable(0),  // 参数是默认值
    step = 1
  } = $props<{
    count?: number;
    step?: number;
  }>();

  function increment() {
    count += step;  // 修改 count → 自动通知父组件
  }

  function decrement() {
    count -= step;
  }
</script>

<div class="counter">
  <button onclick={decrement}>-</button>
  <span>{count}</span>
  <button onclick={increment}>+</button>
</div>

<!-- 父组件:使用 bind: 双向绑定 -->
<script lang="ts">
  let quantity = $state(1);
</script>

<Counter bind:count={quantity} step={5} />
<p>父组件看到的数量:{quantity}</p>

<!-- 如果 prop 名与变量名相同,可以简写 -->
<Counter bind:count />  <!-- 等价于 bind:count={count} -->
$bindable() 使用原则

实际应用:受控输入组件

<!-- SearchInput.svelte —— 带防抖的搜索输入框 -->
<script lang="ts">
  let {
    value = $bindable(''),  // 双向绑定搜索值
    placeholder = '搜索...',
    debounce = 300
  } = $props<{
    value?: string;
    placeholder?: string;
    debounce?: number;
  }>();

  let timer: ReturnType<typeof setTimeout>;

  function handleInput(e: Event) {
    const input = e.target as HTMLInputElement;
    clearTimeout(timer);
    // 防抖:延迟更新 value,减少触发频率
    timer = setTimeout(() => {
      value = input.value;  // 赋值给 $bindable prop → 通知父组件
    }, debounce);
  }
</script>

<div class="search-input">
  <input
    type="search"
    value={value}
    {placeholder}
    oninput={handleInput}
  />
  {#if value}
    <button onclick={() => value = ''}></button>
  {/if}
</div>

<!-- 父组件:实时响应搜索词变化 -->
<script lang="ts">
  let searchTerm = $state('');

  const results = $derived(
    items.filter(item => item.name.includes(searchTerm))
  );
</script>

<SearchInput bind:value={searchTerm} placeholder="搜索产品..." />
<p>找到 {results.length} 个结果</p>

组件事件:callback props 模式

Svelte 5 移除了 createEventDispatcher,改用更直观的 callback prop 模式:将函数作为 prop 传给子组件,子组件调用该函数来"触发事件"。这与 React 的 onChange/onXxx 模式类似,数据流更清晰。

Callback Prop(回调 prop)
父组件将一个函数作为 prop 传给子组件,子组件在适当时机调用该函数并传入数据。这是 Svelte 5 中组件事件通信的标准方式。
createEventDispatcher(旧版,Svelte 4)
Svelte 4 的事件分发机制,在 Svelte 5 中已废弃。如果需要迁移旧代码,将 dispatch('event', data) 改为调用对应的 callback prop。
<!-- FileUploader.svelte —— 使用 callback prop 通知父组件 -->
<script lang="ts">
  let {
    accept = 'image/*',
    maxSize = 5 * 1024 * 1024,  // 默认 5MB
    // callback props:类型为函数
    onupload,    // 上传成功回调
    onerror,     // 错误回调
    onprogress   // 进度回调(可选)
  } = $props<{
    accept?: string;
    maxSize?: number;
    onupload: (file: File, url: string) => void;
    onerror: (message: string) => void;
    onprogress?: (percent: number) => void;
  }>();

  let uploading = $state(false);

  async function handleFileChange(e: Event) {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;

    if (file.size > maxSize) {
      onerror(`文件超过 ${maxSize / 1024 / 1024}MB 限制`);
      return;
    }

    uploading = true;
    try {
      const formData = new FormData();
      formData.append('file', file);

      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
      });
      const { url } = await response.json();

      onupload(file, url);  // 调用 callback prop,传入文件和 URL
    } catch (err) {
      onerror('上传失败,请重试');
    } finally {
      uploading = false;
    }
  }
</script>

<div class="uploader">
  <input
    type="file"
    {accept}
    disabled={uploading}
    onchange={handleFileChange}
  />
  {#if uploading}<span>上传中...</span>{/if}
</div>

<!-- 父组件:提供 callback -->
<script lang="ts">
  let avatarUrl = $state('');
  let errorMsg = $state('');
</script>

<FileUploader
  accept="image/*"
  maxSize={2 * 1024 * 1024}
  onupload={(file, url) => { avatarUrl = url; }}
  onerror={(msg) => { errorMsg = msg; }}
/>
{#if errorMsg}<p class="error">{errorMsg}</p>{/if}
{#if avatarUrl}<img src={avatarUrl} alt="头像" />{/if}

组件引用:bind:this

有时父组件需要直接调用子组件的方法(命令式接口)。Svelte 5 通过 bind:this 获取组件实例,子组件通过 $bindable() 暴露方法。

<!-- Modal.svelte —— 暴露 open/close 方法 -->
<script lang="ts">
  let {
    title,
    children,
    onclose
  } = $props<{
    title: string;
    children: Snippet;
    onclose?: () => void;
  }>();

  let isOpen = $state(false);

  // 通过 $expose 暴露方法给父组件(Svelte 5.2+)
  export function open() { isOpen = true; }
  export function close() {
    isOpen = false;
    onclose?.();
  }
</script>

{#if isOpen}
  <div class="modal-overlay" onclick={close}>
    <div class="modal" onclick={(e) => e.stopPropagation()}>
      <h2>{title}</h2>
      {@render children()}
      <button onclick={close}>关闭</button>
    </div>
  </div>
{/if}

<!-- 父组件:通过 bind:this 获取组件实例 -->
<script lang="ts">
  import Modal from './Modal.svelte';

  let modal: ReturnType<typeof Modal>;

  function handleDeleteClick() {
    modal.open();  // 命令式调用子组件方法
  }
</script>

<Modal bind:this={modal} title="确认删除">
  <p>此操作无法撤销,确认要删除吗?</p>
</Modal>
<button onclick={handleDeleteClick}>删除</button>

TypeScript 类型化 Props 的最佳实践

在生产级 Svelte 5 项目中,为 props 定义完整类型是必要的。以下是几种常见的 TypeScript 类型定义模式:

<!-- 方式1:内联类型(简单组件)-->
let { name, age = 0 } = $props<{ name: string; age?: number }>();

<!-- 方式2:外部 interface(复杂组件)-->
<script lang="ts">
  import type { Snippet } from 'svelte';

  // 定义 Props 接口(可以导出供父组件使用)
  export interface ButtonProps {
    variant?: 'primary' | 'secondary' | 'ghost';
    size?: 'sm' | 'md' | 'lg';
    disabled?: boolean;
    loading?: boolean;
    icon?: Snippet;
    children: Snippet;
    onclick?: (e: MouseEvent) => void;
  }

  let {
    variant = 'primary',
    size = 'md',
    disabled = false,
    loading = false,
    icon,
    children,
    onclick
  }: ButtonProps = $props();
</script>

<!-- 方式3:泛型组件(如 Select<T>)-->
<script lang="ts" generics="T extends { id: string; label: string }">
  let {
    options,
    value = $bindable(),
    getLabel = (item: T) => item.label
  } = $props<{
    options: T[];
    value?: T | null;
    getLabel?: (item: T) => string;
  }>();
</script>

<select
  value={value?.id}
  onchange={(e) => {
    value = options.find(o => o.id === e.currentTarget.value) ?? null;
  }}
>
  {#each options as opt}
    <option value={opt.id}>{getLabel(opt)}</option>
  {/each}
</select>

组件设计模式

复合组件模式(Compound Components)

复合组件将一个功能拆分为多个协同工作的子组件,通过 Context 共享状态。适合 Accordion、Tabs、Menu 等组件。

<!-- Tabs.svelte —— 复合组件的容器 -->
<script lang="ts">
  import { setContext } from 'svelte';
  import type { Snippet } from 'svelte';

  let {
    defaultTab,
    children
  } = $props<{
    defaultTab: string;
    children: Snippet;
  }>();

  let activeTab = $state(defaultTab);

  // 通过 Context 向子组件共享状态和方法
  setContext('tabs', {
    getActive: () => activeTab,
    setActive: (tab: string) => { activeTab = tab; }
  });
</script>
<div class="tabs">{@render children()}</div>

<!-- Tab.svelte —— 单个标签 -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Snippet } from 'svelte';

  let { id, label, children } = $props<{
    id: string;
    label: string;
    children: Snippet;
  }>();

  const { getActive, setActive } = getContext<any>('tabs');
  const isActive = $derived(getActive() === id);
</script>

<div>
  <button
    class:active={isActive}
    onclick={() => setActive(id)}
  >{label}</button>
  {#if isActive}
    <div class="tab-content">{@render children()}</div>
  {/if}
</div>

<!-- 使用方式 -->
<Tabs defaultTab="profile">
  <Tab id="profile" label="个人资料">
    <ProfileForm />
  </Tab>
  <Tab id="security" label="安全设置">
    <SecurityForm />
  </Tab>
</Tabs>

Svelte 4 → Svelte 5 迁移对照表

Svelte 4Svelte 5 等价写法说明
export let valuelet { value } = $props()接收 prop
export let value = 0let { value = 0 } = $props()带默认值的 prop
export let value + 父组件 bind:let { value = $bindable() } = $props()双向绑定
<slot />{@render children()}默认插槽
<slot name="header" />{@render header()}具名插槽
<slot let:item={item} />{@render row(item)}作用域插槽
createEventDispatcher()callback prop(函数类型的 prop)组件事件
$$restPropslet { a, ...rest } = $props()剩余属性

本章小结

本章核心要点