组件组合 — Salesforce LWC Composition 完全指南

全面介绍 Lightning Web Components 的组件组合系统。涵盖组件层次四角色(Owner/Container/Parent/Child)、原始值与只读代理属性传递、单向数据流、子组件方法调用(@api)、lwc:spread 属性展开与覆盖、lwc:on 动态事件监听、完整 Slot 系统(未命名/命名/条件渲染/slotchange)、声明式 vs 数据驱动组合方案及 12 条最佳实践总结。...

📅 2026/7/19 ✍️ ponybai 🏷️ lwc, salesforce, frontend

组件组合(Composition)

组件组合

组合是 Lightning Web Components 中构建复杂 UI 的核心设计模式。通过在组件内部嵌套其他组件,你可以将复杂的界面分解为简单、可重用的构建块。一个模板所有者(owner)可以将组件嵌套在容器组件(container)内部,容器组件称为父组件(parent),每个嵌套组件称为子组件(child)。

虽然继承在技术上是允许的,但强烈不推荐——组合通常更有效。要在组件之间共享逻辑,应使用仅包含逻辑的独立 JavaScript 模块(服务组件/API 模块组件)。如果选择继承,注意它在 Lightning Locker 下不能跨命名空间工作——必须启用 Lightning Web Security(LWS)。无论哪种安全架构,都不能扩展 lightning 命名空间。

父组件可以:设置子组件的属性、调用子组件的方法、通过 lwc:spread 批量传递属性、以及通过插槽(Slots)传递标记。

什么是组合?

什么是组合

组合层次中的四个关键角色

Owner(所有者):拥有模板的组件。在 todoApp 示例中,c-todo-app 是 owner。Owner 控制其包含的所有组合组件,可以:设置组合组件的公共属性、调用组合组件的方法、监听组合组件触发的任何事件(无论事件是否冒泡,owner 都能完全感知)。Owner 拥有最大的权力——它是顶层编排者,控制数据流、处理事件、协调所有嵌套组件的行为。

Container(容器):包含其他组件但自身也被包含在 owner 组件中的组件。例如 c-todo-wrapper。Container 的权力比 owner :它可以读取但不能更改所包含组件的公共属性、可以调用组合组件的方法、但只能监听从所包含组件冒泡上来的部分事件(不冒泡的事件对 container 不可见)。这种设计强制执行单向数据流——只有真正的 owner 可以修改数据。

Parent(父组件)和 Child(子组件):当一个组件包含另一个组件时形成包含层次。在项目目录中,parent 和 child 嵌套在同一个 lwc 文件夹中,关系在父组件的 HTML 模板中定义。这种物理分离强制执行封装——每个组件独立管理自己的模板、逻辑和样式,组合在模板级别通过标签包含实现。

组合基础模式

组合基础模式

将应用和组件与更小的组件组合起来,使代码更可重用、更易维护。lightning 命名空间包含许多基础组件(如 lightning-buttonlightning-datatable)供你组合使用。

<!-- todoApp.html -->
<template>
  <c-todo-wrapper>
    <c-todo-item item-name="Milk"></c-todo-item>
    <c-todo-item item-name="Bread"></c-todo-item>
  </c-todo-wrapper>
</template>

此示例展示了三个关键角色:c-todo-app 是 Owner(拥有模板,完全控制)、c-todo-wrapper 是 Container(被包含但自身也包含其他组件)、c-todo-item 实例是 Child(叶组件)。在实际应用中,实例数量是可变的,通过 for:each 循环动态填充。可在 lwc-recipes 仓库中查看更多高级组合示例。

Owner —— 模板的所有者

Owner

Owner 是组件层次中权力最大的角色。它是拥有模板的组件。Owner 可以做三件事:

  1. 设置公共属性:通过 HTML 属性控制组合组件的数据,如 <c-todo-item item-name="Milk">
  2. 调用方法:通过 this.template.querySelector('c-child').someMethod() 直接调用子组件上使用 @api 装饰的公共方法
  3. 监听所有事件:Owner 可以监听到组合组件触发的所有事件,无论这些事件是否设定为冒泡。这给了 owner 完全的可见性——它知道所有子组件中发生的一切

Owner 是编排者——它控制数据流、处理事件、协调所有嵌套组件的行为。在 todoApp 中,c-todo-app 负责管理 todo 列表的数据、处理条目的添加/删除/完成状态变更。

Container —— 被包含在 Owner 中

Container

Container 在层次中位于 Owner 和 Child 之间。以 c-todo-wrapper 为例。Container 的权力比 Owner ,这是有意为之的设计:

  • 可以读取但不能更改所包含组件的公共属性。属性变更只能由 Owner 发起
  • 可以调用组合组件上的方法,这一能力与 Owner 相同
  • 只能监听到部分事件:Container 只能听到从所包含组件中冒泡上来的事件。那些设定为不冒泡的事件——或者在中途被停止冒泡的事件——对 Container 是不可见的

这种设计强制执行了单向数据流原则:只有真正的 Owner 才能修改数据,防止中间组件造成意外的副作用。Container 的职责是协调子组件的布局和交互,而不是控制数据。

Parent 和 Child

Parent 和 Child

当组件包含另一个组件时,我们有了包含层次(containment hierarchy)。Parent 和 Child 描述了直接的包含关系。

<!-- parentComponent.html -->
<template>
  <div>
    <child-component></child-component>
  </div>
</template>

在 Salesforce DX 项目结构中,两者都嵌套在同一个 lwc 文件夹中,关系在父组件的 HTML 模板中声明式定义:

force-app/main/default/lwc/
  ├── parentComponent/
  │   ├── parentComponent.css
  │   ├── parentComponent.html
  │   └── parentComponent.js
  └── childComponent/
      ├── childComponent.css
      ├── childComponent.html
      └── childComponent.js

这种物理分离强制执行封装——每个组件独立管理自己的模板、逻辑和样式,组合在模板级别通过标签包含实现。父组件不需要知道子组件的内部实现细节。

在子组件上设置属性

设置属性

要在包含层次中向下传递数据,owner 可以在子组件上设置属性。HTML 中的属性变为 JavaScript 中的属性赋值。这是 LWC 中最基础的数据传递机制。

两类传递值

原始值(Primitive —— String, Number, Boolean):通过 HTML 属性直接设置,简单直观。父模板中 item-name="Milk" 直接映射为子组件 JS 中的 this.itemName = "Milk"

非原始值(Non-Primitive —— Object, Array):作为只读代理传递给子组件。LWC 框架通过 JavaScript Proxy 将对象和数组包装起来——你不能直接修改嵌套值。要修改,必须创建浅拷贝(shallow copy),且修改只影响子组件自己的副本,不会影响父组件

命名映射:JavaScript 属性名用 camelCase(itemName),HTML 属性名用 kebab-case(item-name),以符合 HTML 标准。

在子组件上设置原始值

设置原始值

这是最简单的属性传递方式。子组件用 @api 装饰器暴露公共属性:

// todoItem.js
import { LightningElement, api } from "lwc";
export default class TodoItem extends LightningElement {
  @api itemName = "New Item";  // 默认值
}
<!-- todoItem.html -->
<template>
  <div class="view"><label>{itemName}</label></div>
</template>

父组件在模板中设置属性值:

<c-todo-item item-name="Milk"></c-todo-item>
<c-todo-item item-name="Bread"></c-todo-item>
<!-- kebab-case item-name → camelCase itemName -->

Owner 还可以通过 DOM 属性以点号方式访问公共属性的值:

// todoApp.js
const myItem = this.template.querySelector("c-todo-item").itemName;
提示:此示例使用静态值 Milk 和 Bread,但真实组件会使用 for:each 循环遍历集合。参考 lwc-recipes compositionBasics 获取更高级示例,或在 lwc.dev 在线试验。

设置非原始值 —— 父端与子端

设置非原始值 —— 父端

父组件定义数据并在模板中绑定:

<!-- parent.html -->
<template>
  <div>Parent: {serializedObj}</div>
  <c-child obj={obj}></c-child>
</template>

// parent.js
obj = { msg: "hello" };

get serializedObj() {
  return JSON.stringify(this.obj);  // 显示当前状态用于调试
}
子端与修改规则

这是 LWC 组合中最重要的模式之一。子组件显示两个按钮来演示正确和错误的做法:

<!-- child.html -->
<lightning-button label="Update original" onclick={updateOriginal}></lightning-button>
<lightning-button label="Update shallow" onclick={updateShallow}></lightning-button>

// child.js
@api obj;

// 错误!直接修改 —— 抛出 "Invalid mutation" 错误
updateOriginal() {
  this.obj.msg += "!!!";  // Error: "[object Object]" is read-only
}

// 正确!浅拷贝 —— 但只更新子组件的副本
updateShallow() {
  this.obj = { ...this.obj, msg: this.obj.msg + "!" };
}
关键:updateShallow() 创建浅拷贝并重新赋值给 this.obj——这是合法的。但浅拷贝只更新子组件自己的副本,父组件的原始 obj 不受影响。如果父组件需要感知变更,子组件必须触发事件。

单向数据流 —— 从 Parent 到 Child

单向数据流

为了防止代码复杂性和意外副作用,数据必须单向流动:从 Parent 到 Child,绝不反向。这是 LWC 的核心设计原则:

  • @api 字段:组件在用 @api 暴露字段后,应仅在初始化时设置值。初始化后,只有 Owner 才应设置该值
  • Child 应将其收到的值视为只读
  • 要触发 owner 提供的数据的变更:Child 发送事件 → Owner 处理并修改数据 → 变更通过单向数据绑定向下传播到 Child

双向绑定会导致子组件中的更改默默影响父组件状态,造成难以调试的问题。单向数据流确保你始终知道数据来源,变更有清晰的来源

传递给组件的对象是只读的

对象只读

LWC 框架通过 JavaScript Proxy(代理) 强制执行对象的只读性质。当父组件传递对象或数组给子组件时,框架用 Proxy 包装它,拦截任何对嵌套属性的 set 操作并抛出错误:

// 尝试直接修改嵌套值 → 浏览器控制台错误:
// "Uncaught Error: Invalid mutation: Cannot set 'msg' on '[object Object]'.
//  '[object Object]' is read-only."

// 正确的修改方式有三种:
// 1. 完全重新赋值:
this.obj = { msg: 'My new message' };

// 2. 浅拷贝(仅复制顶层属性,嵌套对象仍引用原值):
this.obj = { ...this.obj, msg: this.obj.msg + '!' };

// 3. 通过事件通知 owner,让 owner 修改数据后向下传播(推荐模式)

组件拥有数据时可以自由重新赋值。但从父组件收到数据时,必须通过浅拷贝或事件来"修改"。这种 Proxy 保护是 LWC 安全架构的关键支柱,确保组件树中的数据完整性。

为公共属性使用原始值

使用原始值

Salesforce 强烈建议使用原始数据类型(String, Number, Boolean)作为 @api 属性,而不是对象或数组。在高层组件中将复杂数据结构切片,将原始值传递给后代组件。原因有三:

  1. 自文档化:独立的 @api 原始值属性明确定义了数据形状。对象/数组需要外部文档说明形状——如果形状变更,消费者会静默破坏,不会有编译时警告
  2. 遵循 Web 标准:标准 HTML 元素只接受原始值作为属性。<table data={...}> 在 HTML 中无效——只有 LWC 支持这种语法
  3. 遵循 Web 平台模式:需要复杂形状时用子元素表达——<table> 使用 <tr><td> 子元素,<select> 使用 <option> 子元素。你的 LWC 组件应遵循同样的模式

调用子组件的方法

调用子组件方法

@api 装饰器公开方法后,Owner 和 Parent 可以通过 querySelector 调用它们。这是向通信的方式(向上通信用事件):

// 在父组件 JS 中:
this.template.querySelector("c-child-component").methodName();
  • @api 使方法成为组件公共 API 的一部分
  • 父组件使用 this.template.querySelector() 获取子组件元素的引用
  • 然后直接调用暴露的方法
  • querySelector 只搜索组件自己的模板——不会穿透 Shadow DOM 边界

参考 lwc-recipes apiMethod 组件查看完整示例。

定义方法 —— videoPlayer 完整示例

定义方法

这个例子展示了如何为 c-video-player 组件定义完整的公共 API:公共属性、公共 getter、公共方法和私有 getter。

// videoPlayer.js
import { LightningElement, api } from "lwc";

export default class VideoPlayer extends LightningElement {
  @api videoUrl;           // 公共属性 —— 视频源 URL

  @api get isPlaying() {   // 公共 getter —— 只读访问播放状态
    const player = this.template.querySelector("video");
    return player !== null && player.paused === false;
  }

  @api play() {            // 公共方法 —— 播放视频
    const player = this.template.querySelector("video");
    if (player) player.play();  // null 检查很重要
  }

  @api pause() { }          // 公共方法 —— 暂停视频

  get videoType() { }       // 私有 getter(无 @api)—— 内部使用
}
<!-- videoPlayer.html -->
<template>
  <div class="fancy-border">
    <video autoplay>
      <source src={videoUrl} type={videoType} />
    </video>
  </div>
</template>

每个方法使用 this.template.querySelector 在组件自己的模板中查找 video 元素。null 检查(if (player))很重要——调用方法时 video 元素可能尚未在 DOM 中。私有 getter videoType@api 装饰——只能在组件内部访问。这是公共 API 和内部实现的清晰分离。

调用方法 —— methodCaller 完整示例

调用方法

c-method-caller 包含 c-video-player 并提供按钮调用其 play() 和 pause() 方法:

<!-- methodCaller.html -->
<template>
  <div>
    <c-video-player video-url={video}></c-video-player>
    <button onclick={handlePlay}>Play</button>
    <button onclick={handlePause}>Pause</button>
  </div>
</template>

// methodCaller.js
import { LightningElement } from "lwc";

export default class MethodCaller extends LightningElement {
  video = "https://www.w3schools.com/tags/movie.mp4";

  handlePlay() {
    this.template.querySelector("c-video-player").play();
  }

  handlePause() {
    this.template.querySelector("c-video-player").pause();
  }
}

handlePlay() 函数在 c-method-caller 中调用 c-video-player 元素上的 play() 方法。this.template.querySelector('c-video-player') 返回子组件元素。在实际应用中,c-video-player 通常自己内置控件——这里将控件分离到父组件纯粹是为了演示调用子组件方法的模式。

返回值、参数与查询选择器

返回值与参数

返回值

@api get isPlaying() {
  const player = this.template.querySelector('video');
  return player !== null && player.paused === false;
}
// 使用标准 return 语句向调用者返回布尔值

方法参数

@api play(speed) { ... }
// 为方法定义一个或多个参数,接收调用者传入的数据

Query Selectors

  • querySelector():返回匹配选择器的第一个元素
  • querySelectorAll():返回所有匹配 DOM 元素的数组(static NodeList)
  • 遍历数组时,添加 classdata-* 属性来选择特定元素
关键警告:绝不要给 querySelector 传递 id!HTML 模板渲染时,id 值会被转换为全局唯一值。在 JS 中使用 id 选择器不会匹配转换后的 id。请使用 class 或 data-* 属性代替。

属性展开 —— lwc:spread

lwc:spread

lwc:spread 指令让你将对象的属性一次性批量传递给子组件,无需手动在模板中逐个列出。这对于大型配置对象特别有用:

<!-- 不用 lwc:spread(手动逐个列出) -->
<c-mycomponent name={config.name} country={config.country}></c-mycomponent>

<!-- 用 lwc:spread(自动展开) -->
<c-child lwc:spread={childProps}></c-child>

// app.js
childProps = { name: "James Smith", country: "USA" };

// child.js
@api name;
@api country;

关键规则

  • 仅展开顶层属性:嵌套对象不会自动展开
  • 最后应用:lwc:spread 在所有直接声明的属性之后应用,因此会覆盖直接声明
  • 每元素一个:每个元素只能使用一个 lwc:spread 实例

参考 lwc-recipes apiSpread 组件。

lwc:spread —— 覆盖行为与事件处理器

lwc:spread 覆盖

覆盖行为

<!-- app.html -->
<c-child name="lwc" lwc:spread={childProps}></c-child>

// app.js
childProps = { name: "Lightning Web Components" };
// 子组件最终收到 "Lightning Web Components"(spread 覆盖了直接属性)

因为 lwc:spread 最后应用,对象中的 name 属性覆盖了模板中直接声明的 name="lwc"

事件处理器

<c-child lwc:spread={simpleProps}></c-child>

simpleProps = { name: "LWC", onclick: this.spreadClick.bind(this) };

spreadClick() {
  this.simpleProps = { name: "Lightning Web Components" };
}
// onclick 作为属性名在 spread 对象中传递
// 需要用 .bind(this) 或箭头函数绑定正确的上下文
注意:lwc:spread 不会自动绑定事件处理器的组件上下文。你必须使用 .bind(this) 或箭头函数确保 this 指向正确。

lwc:spread —— 反射 HTML 属性

反射 HTML 属性

大多数 HTML 属性会反射为对应的 JavaScript 属性:

<c-child lwc:spread={spanProps}></c-child>

// app.js
spanProps = { className: "spanclass", id: "myspan" };

// 渲染输出:
// <c-child class="spanclass" id="mySpan"></c-child>

标准 HTML 属性反射规则:className → classhtmlFor → fortabIndex → tabindex 等。这遵循标准 HTML 元素的行为——属性(properties)和特性(attributes)是同步的。你可以利用这点动态设置 CSS 类、ID 和其他标准 HTML 属性。

动态添加事件监听器 —— lwc:on

lwc:on

lwc:on 指令配合 lwc:spread 使用,前者处理属性,后者处理事件:

<c-spread-on-event-child
    lwc:spread={childProps}
    lwc:on={eventHandlers}>
</c-spread-on-event-child>
<p>Custom event received: {customEventDetail}</p>

// spreadOnEvent.js
childProps = { name: "James Smith", age: "40" };

eventHandlers = {
  customEvent: this.handleCustomEvent,
};

handleCustomEvent(event) {
  this.customEventDetail = event.detail.message;
  this.childProps.name = event.detail.name;
  this.childProps.age = event.detail.age;
  // Parent 更新显示和 childProps —— 基于事件数据
}

lwc:on 将事件名映射到处理器函数。event.detail 携带自定义数据载荷。这是实现响应式父子通信同时保持单向数据流的干净模式。

lwc:on —— 子组件完整实现

lwc:on 子组件

子组件在两个不同生命周期点触发自定义事件:

// spreadOnEventChild.js
@api name;
@api age;

// 组件挂载时触发事件
connectedCallback() {
  this.dispatchEvent(new CustomEvent("customEvent", {
    detail: {
      message: "Hello from child component",
      name: this.name,    // "James Smith"(来自 parent)
      age: this.age,       // "40"(来自 parent)
    },
  }));
}

// 按钮点击时触发事件
handleButtonClick() {
  this.dispatchEvent(new CustomEvent("customEvent", {
    detail: {
      message: "Button clicked in child component",
      name: "LWC",
      age: "8",
    },
  }));
}

初始加载时显示父组件传入的值("James Smith", "40")。按钮点击后通过 event.detail 发送新值("LWC", "8"),父组件通过 lwc:on 接收并更新状态。这是 Child 触发事件 → Parent 处理 → 数据向下传播的标准模式。

传递标记到插槽(Slots)

插槽概述

插槽(<slot></slot>)是父组件传入子组件体内的标记占位符——不只是数据,而是实际的 DOM 元素。组件可以有零个或多个插槽。

<!-- slotDemo.html -->
<template>
  <h1>Add content to slot</h1>
  <div><slot></slot></div>
</template>

两种插槽类型

  • 未命名 Slot:<slot></slot> —— 接受传递给组件体的任何标记
  • 命名 Slot:<slot name="..."> —— 只接受匹配 slot 属性的标记

对于 Experience Cloud LWR 站点,插槽还可用作 Experience Builder 中的放置区域。

重要限制:不能将 Aura 组件传入插槽。嵌套在 Aura 组件中的 LWC 也不能传入插槽。

未命名插槽

未命名插槽

最简单的插槽形式。子组件声明占位符,父组件在子组件标签间放置内容:

<!-- slotDemo.html -->
<template>
  <h1>Add content to slot</h1>
  <div><slot></slot></div>
</template>

<!-- slotWrapper.html (父组件) -->
<c-slot-demo>
  <p>content from parent</p>
</c-slot-demo>

<!-- 渲染输出 -->
<h1>Add content to slot</h1>
<div>
  <slot><p>content from parent</p></slot>
</div>

如果组件有多个未命名插槽,传入的标记会被插入到所有未命名插槽中——通常不是期望的行为。最佳实践是每个组件恰好有 零个或一个未命名插槽。需要多个内容插入点时使用命名插槽

命名插槽

命名插槽

命名插槽让组件定义多个不同的插入点,每个都有默认内容。

<!-- namedSlots.html -->
<p>First Name: <slot name="firstName">Default first name</slot></p>
<p>Last Name: <slot name="lastName">Default last name</slot></p>
<p>Description: <slot>Default description</slot></p>

<!-- slotsWrapper.html -->
<c-named-slots>
  <span slot="firstName">Willy</span>     <!-- → firstName slot -->
  <span slot="lastName">Wonka</span>      <!-- → lastName slot -->
  <span>Chocolatier</span>                  <!-- → unnamed slot -->
</c-named-slots>

动态 slot 属性 vs 静态 name 属性

  • 父端(slot 属性):可以动态绑定——<span slot={dynamicName}>。值会被强制转换为字符串(如 4 → "4"),不可转换的类型(如 Symbol)抛出 TypeError
  • 子端(name 属性):必须是静态字符串——<slot name="staticName">。不能使用 {dynamic}

访问通过插槽传入的元素

访问插槽元素

这是 LWC DOM 访问中最细微但最关键的区别:

  • <slot> 元素本身存在于组件的Shadow Tree
  • 通过插槽从父组件传入的 DOM 元素不在组件的 Shadow Tree 中——它们属于父组件的 DOM 作用域
// 访问 SHADOW TREE 中的元素(组件自己的模板/DOM):
this.template.querySelector("slot");      // 找到 slot 元素本身
this.template.querySelectorAll("div");    // 搜索组件自己的 DOM

// 访问通过 SLOTS 传入的元素(父组件传入的 light DOM):
this.querySelector("span");               // 搜索传入的元素
this.querySelectorAll("span");            // 返回所有传入的元素
// namedSlots.js 完整示例
renderedCallback() {
  this.querySelector("span");
  // 返回通过插槽传入的第一个 
  // 例如:"push the green button."

  this.querySelectorAll("span");
  // 返回通过插槽传入的所有 
  // 例如:[span, span]
}

renderedCallback 是查询插槽元素的好时机——此时元素保证已在 DOM 中。再次提醒:绝不用 id 查询!

条件渲染插槽

条件渲染插槽

使用 lwc:if/elseif/else(不是旧版的 if:true/false)条件渲染插槽:

<template>
  <template lwc:if={expression}>
    <div class="my-class"><slot></slot></div>
  </template>
  <template lwc:else>
    <slot></slot>
  </template>
</template>

为什么必须用 lwc:if 而不是 if:true?模板编译器进行静态分析以验证只有一条条件分支可能同时激活——因此保证插槽元素最多渲染一次。这防止了重复插槽投射的问题。旧版的 if:true/if:false 的表达式 getter 可能在连续调用中返回不一致的值,编译器无法保证单一渲染。

slotchange 事件 —— 管理插槽内容生命周期

slotchange

所有 <slot> 元素支持 slotchange 事件——推荐的管理插槽内容生命周期的机制

<!-- container.html -->
<slot onslotchange={handleSlotChange}></slot>

// container.js
handleSlotChange(e) {
  console.log("New slotted content has been added or removed!");
}

触发与不触发的时机

  • 触发时机:插槽的直接子元素被添加或删除时。例如 addOneMore 在 true/false 间切换时
  • 不触发时机:插槽子元素的内部内容发生变化时。例如切换插槽子组件内部的 footer 不会触发 slotchange
  • 事件冒泡通过 DOM 但不跨越 Shadow 边界——只有拥有插槽的组件可以监听
<c-container>
  <c-child></c-child>
  <template lwc:if={addOneMore}>
    <c-child></c-child>     <!-- addOneMore 切换时触发 slotchange -->
  </template>
</c-container>

组合方式对比 —— Slots(声明式) vs Data(数据驱动)

Slots vs Data

创建包含其他组件的组件时,有两种根本不同的方法:

维度声明式(Slots + slotchange)数据驱动(for:each + data)
方式 消费者直接编写标记,父组件通过 slotchange 管理生命周期 父组件传递 JS 配置数据对象,子组件仅响应数据变化
适用场景 组件组合、分组、布局 —— 消费者思考"放哪些组件" 复杂数据显示、表格、动态配置列表 —— 消费者思考"什么数据描述这个"
示例 lightning-button-group lightning-datatable
决策准则 消费者需要控制标记和结构 消费者需要控制配置和状态

slotchange 事件模式 —— button-group 完整示例

button-group 示例

这是声明式组合的标准实现模式,以 lightning-button-group 为例:

<lightning-button-group>
  <lightning-button label="Refresh"></lightning-button>
  <lightning-button label="Edit"></lightning-button>
  <lightning-button label="Save"></lightning-button>
</lightning-button-group>

父组件 lightning-button-group 包含 slot 并处理 slotchange 事件:

<!-- buttonGroup.html -->
<slot onslotchange={handleSlotChange}></slot>

// buttonGroup.js
handleSlotChange(event) {
  const slot = event.target;
  const children = slot.assignedElements() || [];
  this.updateGroupOrder(children);
  // assignedElements() 返回分配到该插槽的所有元素
  // updateGroupOrder 根据位置设置 CSS 类:first, middle, last, single-button
}

子按钮注册模式

每个 lightning-buttonconnectedCallback 中触发自定义 privatebuttonregister 事件,传递回调函数(setOrder、setDeRegistrationCallback)给父组件。父组件用这些回调来管理子组件的顺序和注册状态。当子组件在 disconnectedCallback 中被移除时,调用注销回调通知父组件。

数据驱动组合方式

数据驱动组合

在数据驱动方式中,组件在数据变化时以响应式方式获取变更。父组件拥有所有数据,子组件仅响应来自父组件的数据变化

<template>
  <div class="c-parent">
    <template for:each={itemsData} for:item="itemData">
      <c-child onclick={onItemSelect} id={itemData.id}
          key={itemData.id}></c-child>
    </template>
  </div>
</template>

itemsData = [
  { label: 'custom label 1', id: 'custom-id-1', selected: false },
  { label: 'custom label 2', id: 'custom-id-2', selected: false }
];

适用于 lightning-datatable 等复杂场景——当配置(列、行、排序等)本质上是数据驱动的,通过标记表达会变得很繁琐。选择状态在数据模型中跟踪;onclick 处理器定义在父组件中,点击子组件时更新数据模型。

查看组件依赖关系

查看组件依赖

从 Setup 使用依赖树查看器快速了解组件的层次结构:

  1. Setup → 快速查找框输入 "Lightning Components",选择 Lightning Components
  2. 展开组件行:点击组件名称旁边的倒箭头图标
  3. 依赖树显示最多3 层:组件 → 子 → 孙 → 曾孙
  4. 要查看更深的依赖,点击嵌套组件的名称导航到该组件的依赖视图
  5. 同时显示:自定义组件引用的 Apex 类

这个工具对于理解复杂组件层次的结构和进行变更影响分析非常有用。

组合最佳实践总结

最佳实践总结
  1. 优先组合而非继承 —— 更灵活,避免跨命名空间问题,遵循现代组件设计模式
  2. 数据单向流动:Parent → Child。@api 字段初始化后只有 Owner 设置值。防止副作用,使调试可预测
  3. 为 @api 属性使用原始值 —— 清晰定义数据形状,遵循 Web 标准,自文档化
  4. 非原始值 = 只读代理:用浅拷贝修改({ ...obj, key: newVal }),子组件变更不影响父组件
  5. 向上通信用事件:Child 触发 CustomEvent → Parent 处理 → 修改数据 → 向下传播
  6. lwc:spread:批量传递属性。最后应用(覆盖直接属性)。每元素一个。事件处理器需 .bind(this)
  7. lwc:on:动态事件监听器。事件名映射到处理器函数。event.detail 携带载荷
  8. Slots + slotchange:声明式组合(推荐)。用 slot.assignedElements() 获取子元素。slotchange 不跨越 Shadow 边界
  9. 数据驱动(for:each):用于复杂场景(如 lightning-datatable)。Parent 拥有所有数据
  10. 访问插槽元素用 this.querySelector()——不带 .template。访问模板元素才用 this.template.querySelector()
  11. 绝不用 id 给 querySelector:id 在渲染时转换。用 class 或 data-* 代替
  12. 条件插槽用 lwc:if:编译器验证单一渲染。不用旧版 if:true(无法保证)

感谢阅读本指南。如需继续学习,请参阅下一章:Fields、Properties 和 Attributes。