Yihui’s Blog

第四章:响应式与状态 - 让组件动起来

本章核心问题: 什么时候用 @property,什么时候用 @state?响应式更新是如何触发的?为什么直接修改数组不会更新组件?

本章你将掌握:

  • @property 和 @state 的区别与最佳实践
  • Lit 响应式更新周期的完整流程
  • 生命周期方法的调用时机和使用场景
  • 处理对象和数组的正确方式(不可变数据模式)

4.1 公共API与内部状态:@property和@state

在Lit中,组件的状态管理围绕着“响应式属性”这一核心概念展开。响应式属性是指当其值发生变化时,能够自动触发组件重新渲染的类属性。Lit通过两个主要的装饰器来区分不同类型的响应式属性 。  

  • @property:用于声明一个公共响应式属性。这类属性构成了组件的公共API,是外部世界与组件交互的“门户”。父组件通过设置这些属性来向子组件传递数据。默认情况下,@property会将其关联到一个HTML特性上,允许通过HTML来配置初始值 。例如,一个按钮组件可能会有一个  

    label属性,以便使用者可以自定义按钮的文本。

  • @state:用于声明一个内部响应式状态。这类属性是组件私有的,不应被外部直接访问,因此它们不会关联到HTML特性上 。它们就像是组件的“私人笔记”,用于追踪和管理组件自身的内部逻辑状态。例如,一个计数器组件,其当前计数值就适合用  

    @state来声明。

这种明确的区分有助于引导开发者进行良好的组件设计:将用于接收外部输入的接口定义为@property,而将纯粹用于内部逻辑控制的状态定义为@state。

4.2 响应式更新周期详解

当一个响应式属性(无论是@property还是@state)的值发生改变时,Lit会启动一个高效的、异步的更新周期 。这个过程被精心设计以确保高性能,它会将短时间内的多个属性变更合并到一次更新中,避免不必要的重复渲染 。  

更新周期的核心流程和相关的生命周期方法如下 :  

  1. 变更触发:当设置一个响应式属性时,Lit会调用requestUpdate()方法。

  2. 变更检测:Lit内部会比较新旧值(默认使用严格不等于!==检查),如果值确实发生了变化(hasChanged()返回true),一个更新就会被异步地调度。

  3. 更新执行:在浏览器下一次绘制之前,更新任务会被执行。这个过程会依次调用一系列生命周期方法,为开发者提供了在更新过程的不同阶段介入的机会。

对于初学者而言,最重要的生命周期方法及其用途如下表所示:

方法名调用时机常见用例
constructor()组件实例被创建时。初始化非响应式属性,设置默认值。
connectedCallback()组件被插入到DOM中时。添加对外部(如window或document)的事件监听。
render()在更新周期中,willUpdate()之后。定义并返回组件的HTML模板。
firstUpdated()组件完成第一次更新和渲染后。执行仅需一次的DOM操作,如获取元素焦点、初始化第三方库。
willUpdate()render()之前,当接收到新的属性值时。根据变化的属性计算派生状态,在渲染前准备数据。
updated()组件完成更新和渲染后。响应属性变化,执行与更新后的DOM相关的操作。
disconnectedCallback()组件从DOM中移除时。清理工作,如移除在connectedCallback()中添加的外部事件监听。

这个生命周期提供了一个清晰且可预测的流程,让开发者能够精确地控制组件的行为。

📊 完整生命周期流程图

组件创建
   ↓
constructor()  ━━━ 初始化非响应式属性
   ↓
connectedCallback()  ━━━ 插入DOM,添加外部事件监听
   ↓
[首次更新开始]
   ↓
willUpdate(changedProperties)  ━━━ render前,计算派生状态
   ↓
render()  ━━━ 返回模板
   ↓
firstUpdated(changedProperties)  ━━━ 首次渲染后,一次性DOM操作
   ↓
[后续更新循环]
   ↓
属性变更 → hasChanged() === true?
   ↓ Yes
willUpdate(changedProperties)
   ↓
render()
   ↓
updated(changedProperties)  ━━━ 每次更新后,执行副作用
   ↓
[重复更新循环...]
   ↓
disconnectedCallback()  ━━━ 移除DOM,清理资源

💡 完整示例:数据加载组件

以下是一个结合多个生命周期方法的完整示例,展示如何实现一个从 API 加载数据的组件:

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

interface User {
  id: number;
  name: string;
  email: string;
}

@customElement('user-list')
export class UserList extends LitElement {
  static styles = css`
    :host { display: block; padding: 16px; }
    .loading { color: #666; }
    .error { color: #d32f2f; }
    ul { list-style: none; padding: 0; }
    li { padding: 8px; border-bottom: 1px solid #eee; }
  `;

  // 公共属性:从外部传入 API 端点
  @property({ type: String }) apiUrl = '/api/users';

  // 内部状态:数据加载相关
  @state() private users: User[] = [];
  @state() private loading = false;
  @state() private error: string | null = null;

  // 私有字段:用于清理
  private abortController?: AbortController;

  // 1. 组件插入 DOM 时,开始数据加载
  connectedCallback() {
    super.connectedCallback();
    console.log('✅ 组件已插入 DOM');
    this.fetchUsers();
  }

  // 2. 当 apiUrl 变化时,重新加载数据
  willUpdate(changedProperties: Map<PropertyKey, unknown>) {
    if (changedProperties.has('apiUrl') && changedProperties.get('apiUrl') !== undefined) {
      console.log('🔄 API URL 变化,重新加载数据');
      this.fetchUsers();
    }
  }

  // 3. 数据加载方法(支持取消)
  private async fetchUsers() {
    // 取消之前的请求
    this.abortController?.abort();
    this.abortController = new AbortController();

    this.loading = true;
    this.error = null;

    try {
      const response = await fetch(this.apiUrl, {
        signal: this.abortController.signal
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      this.users = await response.json();
      console.log('📦 数据加载成功:', this.users.length, '条记录');
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        this.error = err.message;
        console.error('❌ 加载失败:', err);
      }
    } finally {
      this.loading = false;
    }
  }

  // 4. 首次渲染后,聚焦第一个用户项(仅执行一次)
  firstUpdated() {
    console.log('🎉 首次渲染完成');
    const firstItem = this.shadowRoot?.querySelector('li');
    firstItem?.setAttribute('tabindex', '0');
    firstItem?.focus();
  }

  // 5. 每次更新后,输出调试信息
  updated(changedProperties: Map<PropertyKey, unknown>) {
    console.log('🔧 组件已更新,变更属性:', Array.from(changedProperties.keys()));

    // 示例:用户数据变化时的副作用
    if (changedProperties.has('users')) {
      // 可以在这里执行如埋点上报等操作
      console.log('📊 用户数据已更新');
    }
  }

  // 6. 组件移除时,清理未完成的请求
  disconnectedCallback() {
    super.disconnectedCallback();
    this.abortController?.abort();
    console.log('🧹 组件已移除,清理完成');
  }

  // 7. 渲染模板
  render() {
    if (this.loading) {
      return html`<div class="loading">加载中...</div>`;
    }

    if (this.error) {
      return html`<div class="error">加载失败: ${this.error}</div>`;
    }

    return html`
      <h2>用户列表 (${this.users.length})</h2>
      <ul>
        ${this.users.map(user => html`
          <li>
            <strong>${user.name}</strong>
            <br>
            <small>${user.email}</small>
          </li>
        `)}
      </ul>
    `;
  }
}

使用示例:

<user-list api-url="https://jsonplaceholder.typicode.com/users"></user-list>

关键技术点:

  1. connectedCallback:组件插入 DOM 时自动触发数据加载
  2. willUpdate:监听 apiUrl 变化,重新加载数据(避免重复调用)
  3. AbortController:取消未完成的请求,防止内存泄漏
  4. firstUpdated:首次渲染后的一次性操作(聚焦)
  5. updated:每次更新后执行副作用(调试、埋点等)
  6. disconnectedCallback:清理资源,避免内存泄漏

4.3 处理复杂数据:对象与数组

这是Lit初学者最容易遇到的陷阱之一。默认情况下,Lit通过严格不等于(!==)来判断属性值是否发生变化 。对于对象和数组这样的引用类型,如果你直接修改它们内部的内容(例如,  

this.myArray.push(item)),数组本身的引用地址并没有改变,因此Lit会认为“属性未发生变化”,从而不会触发更新。

为了正确地处理对象和数组的更新,有两种推荐的模式:

  1. 不可变数据模式 (Immutable Data Pattern):这是首选的方法。不要直接修改原始的对象或数组,而是创建一个新的实例来替代它。对于数组,可以使用扩展运算符...或slice()、filter()等返回新数组的方法。对于对象,可以使用Object.assign()或扩展运算符 。  

    JavaScript

    // 正确:创建一个新数组
    this.myArray = [...this.myArray, newItem];

    这种方式清晰地向Lit表明了状态已经改变,并且符合现代前端开发中函数式编程和状态管理的最佳实践。

  2. 手动触发更新:在某些情况下,如果必须直接修改数据,可以在修改后手动调用this.requestUpdate()方法来强制调度一次更新 。  

    JavaScript

    // 可行,但需谨慎使用
    this.myArray.push(newItem);
    this.requestUpdate();

    需要注意的是,requestUpdate()只会更新当前组件。如果这个数组被传递给了子组件,子组件仍然会因为引用未变而不更新。因此,不可变数据模式通常是更健壮和可预测的选择。


📝 本章小结

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

  1. 属性区分:@property 用于公共 API(外部传入),@state 用于内部状态(私有管理)
  2. 响应式更新机制:
    • 属性变更 → hasChanged() 检查 → 异步调度更新 → 生命周期方法依次执行
    • 默认使用 !== 严格不等于检查,引用类型需特别注意
  3. 关键生命周期方法:
    • connectedCallback() - 组件插入 DOM 时,添加外部事件监听
    • willUpdate() - render 前,计算派生状态
    • render() - 返回模板,定义 UI
    • firstUpdated() - 首次渲染后,做初始化操作
    • updated() - 每次更新后,执行副作用
    • disconnectedCallback() - 组件移除时,清理资源
  4. 处理复杂数据:对象和数组必须使用不可变数据模式(创建新引用)或手动 requestUpdate()

⚠️ 重要提醒:直接修改对象/数组不会触发更新!始终创建新副本。

在下一章中,我们将专门为 React 开发者讲解如何将已有知识快速迁移到 Lit。

🤔 思考题

  1. 如果一个属性既需要从外部传入,又需要在内部修改,应该用 @property 还是 @state?
  2. willUpdate() 和 updated() 都能响应属性变化,它们的使用场景有什么区别?
  3. 为什么不可变数据模式是”更健壮和可预测的选择”?它有什么额外开销吗?
维护与整理 · Yihui在 GitHub 上编辑