本章核心问题: 组件之间如何通信?父子组件如何传递数据?如何避免属性钻探(prop drilling)?
<slot>的命名插槽怎么用?本章你将掌握:
- “属性向下,事件向上”的黄金法则
- 自定义事件的分发与监听(bubbles 和 composed 选项)
<slot>和命名插槽实现内容投影- Context API 解决跨层级数据共享问题
7.1 黄金法则:属性向下,事件向上
在构建由多个组件构成的复杂应用时,清晰的数据流至关重要。Lit遵循了Web组件社区广泛采纳的黄金法则:“属性向下,事件向上”(Properties Down, Events Up)。
-
属性向下 (Properties Down):父组件通过设置子组件的公共属性(用
@property声明)来向其传递数据。这是一种单向数据流,使得应用状态的变化可预测且易于追踪。在父组件的模板中,可以直接通过属性绑定将数据传递给子组件。 -
事件向上 (Events Up):当子组件内部发生变化(通常由用户交互引起)需要通知父组件时,它不应直接修改父组件的状态。相反,子组件应该分发一个自定义事件,将相关信息作为事件的载荷。父组件则监听这个事件,并根据事件信息来决定如何更新自己的状态 。
这个模式构成了组件间通信的核心循环,确保了组件的低耦合和高复用性。
7.2 分发与监听自定义事件
让我们来具体看看“事件向上”是如何实现的。
-
在子组件中分发事件:子组件使用标准的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属性可以携带任何需要传递给监听者的数据。 -
在父组件中监听事件:父组件在其模板中使用
@语法来监听子组件分发的自定义事件 。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
关键技术点:
- 属性向下:父组件通过
.todo=${todo}传递对象给子组件 - 事件向上:子组件通过
this.dispatchEvent(new CustomEvent(...))通知父组件 - 不可变数据:使用
map()和filter()创建新数组触发更新 - 事件选项:
bubbles: true+composed: true让事件穿透 Shadow DOM
📝 本章小结
通过本章学习,你应该掌握了:
- 组件通信黄金法则:属性向下(父 → 子通过 @property 传数据),事件向上(子 → 父通过 CustomEvent 通知变化)
- 自定义事件机制:
- 子组件:
this.dispatchEvent(new CustomEvent('event-name', { detail, bubbles: true, composed: true })) - 父组件:
@event-name=${this.handler}监听并通过event.detail获取数据 bubbles: true允许事件冒泡,composed: true允许穿透 Shadow DOM
- 子组件:
- 内容投影(Slot):
- 默认插槽:
<slot></slot>接收父组件传入的所有内容 - 命名插槽:
<slot name="header"></slot>+<h2 slot="header">实现多区域内容分配
- 默认插槽:
- Context API:通过
@lit/context包的@provide和@consume装饰器,解决跨层级数据共享问题,避免属性钻探
⚠️ 重要提醒:自定义事件必须设置
composed: true才能穿透 Shadow DOM 边界!
在下一章中,我们将学习 Lit 的高级概念——控制器、指令和生态系统工具。
🤔 思考题
- 为什么组件通信要遵循”属性向下,事件向上”而不是”事件双向”?这种单向数据流的优势是什么?
- 如果忘记设置
composed: true,自定义事件会发生什么?如何在开发中快速定位这类问题? - Context API 和全局状态管理(如 Redux)有什么区别?什么场景下应该选择 Context API?