Yihui’s Blog

第七章:组件化构建 - 组合与通信

本章核心问题: 组件之间如何通信?父子组件如何传递数据?如何避免属性钻探(prop drilling)?<slot> 的命名插槽怎么用?

本章你将掌握:

  • “属性向下,事件向上”的黄金法则
  • 自定义事件的分发与监听(bubbles 和 composed 选项)
  • <slot> 和命名插槽实现内容投影
  • Context API 解决跨层级数据共享问题

7.1 黄金法则:属性向下,事件向上

在构建由多个组件构成的复杂应用时,清晰的数据流至关重要。Lit遵循了Web组件社区广泛采纳的黄金法则:“属性向下,事件向上”(Properties Down, Events Up)。  

  • 属性向下 (Properties Down):父组件通过设置子组件的公共属性(用@property声明)来向其传递数据。这是一种单向数据流,使得应用状态的变化可预测且易于追踪。在父组件的模板中,可以直接通过属性绑定将数据传递给子组件。

  • 事件向上 (Events Up):当子组件内部发生变化(通常由用户交互引起)需要通知父组件时,它不应直接修改父组件的状态。相反,子组件应该分发一个自定义事件,将相关信息作为事件的载荷。父组件则监听这个事件,并根据事件信息来决定如何更新自己的状态 。  

这个模式构成了组件间通信的核心循环,确保了组件的低耦合和高复用性。

7.2 分发与监听自定义事件

让我们来具体看看“事件向上”是如何实现的。

  1. 在子组件中分发事件:子组件使用标准的DOM API this.dispatchEvent()来触发一个CustomEvent 。为了让事件能够冒泡并穿透Shadow DOM边界,到达父组件,必须在创建事件时设置  

    bubbles: true和composed: true选项 。  

    JavaScript

    // 在子组件的方法中
    _onButtonClick() {
      const event = new CustomEvent('item-deleted', {
        detail: { itemId: this.id },
        bubbles: true,
        composed: true 
      });
      this.dispatchEvent(event);
    }

    detail属性可以携带任何需要传递给监听者的数据。

  2. 在父组件中监听事件:父组件在其模板中使用@语法来监听子组件分发的自定义事件 。  

    JavaScript

    // 在父组件的模板中
    html`<child-component @item-deleted=${this._handleItemDeleted}></child-component>`

    当child-component分发item-deleted事件时,父组件的_handleItemDeleted方法就会被调用,事件对象会作为参数传入,可以通过event.detail来获取子组件传递的数据。

7.3 使用<slot>和命名插槽进行内容投影

除了通过属性传递结构化数据,父组件还可以直接将HTML内容传递给子组件进行渲染,这个过程称为“内容投影”(Content Projection)。Lit通过标准的<slot>元素来实现这一点 。  

  • 默认插槽:在子组件的模板中放置一个<slot></slot>元素,它会成为一个占位符。父组件放置在子组件标签之间的所有内容,都会被“投影”到这个slot的位置。

    JavaScript

    // card-component.js 模板
    html`<div class="card-body"><slot></slot></div>`
    
    // 使用
    html`<card-component><p>This content will be projected.</p></card-component>`
  • 命名插槽 (Named Slots):如果一个组件需要多个投影区域,可以使用命名插槽。在子组件中为<slot>元素添加name属性,然后在父组件中,使用slot特性将内容分配到对应的插槽中 。  

    JavaScript

    // card-component.js 模板
    html`
      <header><slot name="header"></slot></header>
      <div class="card-body"><slot></slot></div> <footer><slot name="footer"></slot></footer>
    `
    
    // 使用
    html`
      <card-component>
        <h2 slot="header">Card Title</h2>
        <p>This is the main content.</p>
        <button slot="footer">Action</button>
      </card-component>
    `

插槽机制使得创建高度灵活和可组合的布局组件成为可能。

7.4 共享全局数据:Context API简介

当应用层级很深时,“属性向下”的传递方式可能会变得非常繁琐,即所谓的“属性钻探”(prop drilling)。为了解决这个问题,Lit生态系统提供了一个官方的@lit/context包,用于在组件树中共享全局或跨级数据 。  

Lit的Context API是基于一个由W3C社区组制定的、基于DOM事件的协议实现的 。其工作原理如下:  

  • 数据消费者 (Consumer):当一个组件需要某个上下文数据时,它会向上层DOM树分发一个特殊的context-request事件。

  • 数据提供者 (Provider):位于DOM树上层的某个祖先组件会监听这个事件,并提供所请求的数据。

@lit/context包将这个协议封装成了易于使用的装饰器和控制器:

  • @provide:用在提供者组件中,将其某个属性声明为可供下层消费的上下文数据。

  • @consume:用在消费者组件中,将其某个属性与上层提供的上下文数据关联起来。

这个模式非常适合用于共享应用级别的主题、用户认证信息、国际化配置等全局状态,而无需让中间的每一层组件都参与数据的传递。

🔗 完整示例:待办事项应用(父子组件完整通信)

以下是一个完整的待办事项应用,演示属性向下、事件向上、插槽的综合运用:

父组件:待办列表管理器(todo-app.ts)

import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

@customElement('todo-app')
export class TodoApp extends LitElement {
  static styles = css`
    :host {
      display: block;
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }
  `;

  @state() private todos: Todo[] = [
    { id: 1, text: '学习 Lit 基础', completed: true },
    { id: 2, text: '掌握组件通信', completed: false }
  ];

  // 处理子组件发来的切换事件
  private handleToggle(e: CustomEvent<{ id: number }>) {
    const { id } = e.detail;
    this.todos = this.todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
  }

  // 处理子组件发来的删除事件
  private handleDelete(e: CustomEvent<{ id: number }>) {
    const { id } = e.detail;
    this.todos = this.todos.filter(todo => todo.id !== id);
  }

  render() {
    return html`
      <h1>待办清单</h1>
      ${this.todos.map(todo => html`
        <todo-item
          .todo=${todo}
          @todo-toggle=${this.handleToggle}
          @todo-delete=${this.handleDelete}
        ></todo-item>
      `)}
    `;
  }
}

子组件:待办项(todo-item.ts)

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

@customElement('todo-item')
export class TodoItem extends LitElement {
  static styles = css`
    .todo {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px;
      border: 1px solid #ddd;
      margin-bottom: 8px;
    }

    .completed .text {
      text-decoration: line-through;
      color: #999;
    }
  `;

  // 从父组件接收数据(属性向下)
  @property({ type: Object }) todo!: Todo;

  // 分发切换事件(事件向上)
  private handleToggle() {
    this.dispatchEvent(new CustomEvent('todo-toggle', {
      detail: { id: this.todo.id },
      bubbles: true,
      composed: true
    }));
  }

  // 分发删除事件(事件向上)
  private handleDelete() {
    this.dispatchEvent(new CustomEvent('todo-delete', {
      detail: { id: this.todo.id },
      bubbles: true,
      composed: true
    }));
  }

  render() {
    return html`
      <div class="todo ${this.todo.completed ? 'completed' : ''}">
        <input
          type="checkbox"
          .checked=${this.todo.completed}
          @change=${this.handleToggle}
        />
        <span class="text">${this.todo.text}</span>
        <button @click=${this.handleDelete}>删除</button>
      </div>
    `;
  }
}

数据流向图:

TodoApp (父组件)
  │
  ├─ 属性向下 (.todo) ────> TodoItem (子组件)
  │                          │
  │                          ├─ 事件向上 ──> todo-toggle
  │                          └─ 事件向上 ──> todo-delete

关键技术点:

  1. 属性向下:父组件通过 .todo=${todo} 传递对象给子组件
  2. 事件向上:子组件通过 this.dispatchEvent(new CustomEvent(...)) 通知父组件
  3. 不可变数据:使用 map() 和 filter() 创建新数组触发更新
  4. 事件选项:bubbles: true + composed: true 让事件穿透 Shadow DOM

📝 本章小结

通过本章学习,你应该掌握了:

  1. 组件通信黄金法则:属性向下(父 → 子通过 @property 传数据),事件向上(子 → 父通过 CustomEvent 通知变化)
  2. 自定义事件机制:
    • 子组件:this.dispatchEvent(new CustomEvent('event-name', { detail, bubbles: true, composed: true }))
    • 父组件:@event-name=${this.handler} 监听并通过 event.detail 获取数据
    • bubbles: true 允许事件冒泡,composed: true 允许穿透 Shadow DOM
  3. 内容投影(Slot):
    • 默认插槽:<slot></slot> 接收父组件传入的所有内容
    • 命名插槽:<slot name="header"></slot> + <h2 slot="header"> 实现多区域内容分配
  4. Context API:通过 @lit/context 包的 @provide 和 @consume 装饰器,解决跨层级数据共享问题,避免属性钻探

⚠️ 重要提醒:自定义事件必须设置 composed: true 才能穿透 Shadow DOM 边界!

在下一章中,我们将学习 Lit 的高级概念——控制器、指令和生态系统工具。

🤔 思考题

  1. 为什么组件通信要遵循”属性向下,事件向上”而不是”事件双向”?这种单向数据流的优势是什么?
  2. 如果忘记设置 composed: true,自定义事件会发生什么?如何在开发中快速定位这类问题?
  3. Context API 和全局状态管理(如 Redux)有什么区别?什么场景下应该选择 Context API?
维护与整理 · Yihui在 GitHub 上编辑