为什么状态总是“乱跑”?——高效的状态管理:从 `@State` 到 `@Prop` 的最佳实践!

举报
bug菌 发表于 2025/10/27 18:05:56 2025/10/27
【摘要】 🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8 ❓前言你是否也被这样的场景折磨过:父组件给了个 props,子组件又在...

🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!

环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8

❓前言

你是否也被这样的场景折磨过:父组件给了个 props,子组件又在内部偷偷维护了个 state,兄弟组件彼此“眼神交流失败”,再配上跨页面的数据共享……UI 一会儿是旧的,一会儿又突然刷新,仿佛一锅翻滚的麻辣烫🌶️。
  别急!这篇文章就带你从数据流与状态传递机制出发,把跨组件数据同步与 UI 刷新的坑填平,再用性能优化与无状态组件的实操把稳定性拉满,最后基于 Stencil(Web Components) 给出一个跨页面组件的数据共享系统完整示例。
  是的,本文会大量使用 @State@Prop 两位“重量级选手”,并且通俗又不失专业有梗但绝不油腻,保证你看完就能开工!🚀

🧭 目录指北

🪄 前言:状态别再“乱跑”

状态管理是前端工程永恒的话题。你可能用过 React/Vue/Angular,也可能在原生 Web Components 世界里“自食其力”。不管框架怎么换,“数据怎么流、UI怎么刷新、性能怎么顶住”这三问永远绕不开。
  本文选择 Stencil 做演示(因为它天然有 @Prop@State@Event 等装饰器,语义清晰,还编译成原生 Web Components,兼容性友好)。如果你来自 React/Vue 阵营,也可以把这里的 @Prop 映射到 props@State 映射到 state/useState;理念是互通的,套路都一样🙂。

🔌 一、状态的来龙去脉:从 @Prop@State

🧰 角色分工

  • @Prop():外部输入。父级(或宿主 DOM)以属性/属性绑定的方式把数据传进来

    • 特点:不可在子组件内部修改(只读语义),反映外部意图。
  • @State():组件内部状态。由组件自己管理(生命周期内可变),变更会触发重渲染

    • 特点:只在组件内部使用,不对外暴露为属性。
  • @Event() + EventEmitter对外输出的事件通道。

    • 特点:用于把内部变化告诉外部,或通知父/宿主“我准备好了、我改变了”。
  • @Method()(可选):对外暴露的可调用方法,用于命令式交互。

🧪 一个极简示例:@Prop 输入,@State 驱动 UI

import { Component, h, Prop, State, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'counter-card',
  styleUrl: 'counter-card.css',
  shadow: true,
})
export class CounterCard {
  /** 外部给的初始值:不可内部直接改 */
  @Prop() initial = 0;

  /** 内部状态:变更会触发重渲染 */
  @State() count = 0;

  /** 对外派发:告知“我变了” */
  @Event() changed!: EventEmitter<number>;

  componentWillLoad() {
    this.count = this.initial; // 仅初始化读取一次
  }

  private inc = () => {
    this.count += 1;       // ✅ 内部管理
    this.changed.emit(this.count); // ✅ 通知外部
  }

  render() {
    return (
      <div class="card">
        <div class="value">🔥 {this.count}</div>
        <button onClick={this.inc}>+1</button>
      </div>
    );
  }
}

关键点:

  • @Prop() 进入组件后不要在内部直接修改(否则违反单向数据流)。
  • 基于 @State() 的更新会自动触发渲染,无需手动 setState。
  • 通过 @Event() 把变化同步给父级,形成“父传子(prop)→ 子内变(state)→ 子告父(event)”的稳定循环。

🔁 二、数据流与状态传递机制:单向是根,事件是桥

🧭 单向数据流(Single Source of Truth)

最稳定的系统,往往只有一个真相来源(SSOT)。父组件是“真相源”,子组件只是表现与交互

  • 父组件:持有数据,把一部分传给子组件(@Prop)。
  • 子组件:展示与上报@State 管理局部交互态,@Event 告知父级变化)。
  • 父组件收到事件后,更新自己的数据,进而通过 @Prop 影响子组件

🔗 兄弟通信:事件冒泡 or 上提再下发

兄弟组件千万别直接“串线”!维护地狱警告⚠️。

  • 推荐:兄弟 → 冒泡给父 → 父更新数据 → 再通过 @Prop 下发
  • 或者:使用全局 Store(见后文),兄弟共同订阅。

🧪 示例:父→子传值,子→父上报(含“受控组件”)

// parent-host.tsx
import { Component, h, State, Listen } from '@stencil/core';

@Component({ tag: 'parent-host', shadow: true })
export class ParentHost {
  @State() total = 0;

  @Listen('changed')
  onChildChanged(ev: CustomEvent<number>) {
    this.total = ev.detail; // 父更新自己的真相
  }

  render() {
    return (
      <section>
        <counter-card initial={this.total}></counter-card>
        <p>合计:{this.total}</p>
      </section>
    );
  }
}

节奏:

  1. 父把 total 作为初始值给子;
  2. 子点击 +1,内部 @State 增长并 changed.emit(count)
  3. 父监听到 changed,更新自己的 @State → 触发父刷新 → 新值再通过 @Prop 下发给子。
    单向数据回路闭环 ✅

🔗 三、跨组件数据同步与 UI 刷新:从兄弟到全局

当数据要被多个不在同一路径的组件使用时(甚至在不同页面/路由),我们需要一个共享层来承载“真相源”。在 Stencil 世界里可以选:

  1. 轻量 Store(官方 @stencil/store 或自建)
  2. Context API(Provider/Consumer)
  3. 事件总线 + 本地缓存(兼容多页面/多 Tab)
  4. Service Worker + BroadcastChannel(跨页面/离线增强)

📦 方式一:轻量 Store(最易落地)

创建一个全局 store 文件,组件订阅它并响应更新。

// store/user.ts
import { createStore } from '@stencil/store';

type User = { id: string; name: string; avatar?: string };
const { state, onChange, reset } = createStore<{ user: User | null }>({
  user: null
});

export const getUser = () => state.user;
export const setUser = (u: User | null) => state.user = u;
export const subscribeUser = (cb: (u: User | null) => void) => onChange('user', cb);
export const resetUser = () => reset({ user: null });

组件使用:

// profile-badge.tsx
import { Component, h, State } from '@stencil/core';
import { getUser, subscribeUser } from '../store/user';

@Component({ tag: 'profile-badge', shadow: true })
export class ProfileBadge {
  @State() user = getUser();

  connectedCallback() {
    subscribeUser(u => this.user = u); // 订阅变化 → 触发重渲染
  }

  render() {
    if (!this.user) return <span>未登录</span>;
    return (
      <div class="badge">
        <img src={this.user.avatar || ''} alt="avatar"/>
        <span>{this.user.name}</span>
      </div>
    );
  }
}

🧰 方式二:Context API(父域批量下发)

适合同一树内多层传递,避免“层层 props drilling”。

// user-context.tsx
import { createContext } from '@stencil/core';

export const UserContext = createContext<{ name: string } | null>(null);
// user-provider.tsx
import { Component, h, State } from '@stencil/core';
import { UserContext } from './user-context';

@Component({ tag: 'user-provider', shadow: true })
export class UserProvider {
  @State() value = { name: 'Rebecca' };

  render() {
    return (
      <UserContext.Provider value={this.value}>
        <slot></slot>
      </UserContext.Provider>
    );
  }
}
// any-child.tsx
import { Component, h, Consume } from '@stencil/core';
import { UserContext } from './user-context';

@Component({ tag: 'any-child', shadow: true })
export class AnyChild {
  @Consume({ context: UserContext }) user!: { name: string } | null;

  render() {
    return <p>Hi, {this.user?.name ?? 'Guest'} 👋</p>;
  }
}

🌐 方式三:事件总线 + 本地缓存(跨页面/多 Tab)

使用 BroadcastChannel 在同源的多个页面之间实时广播数据变更;用 localStorage 作持久化兜底。

// shared/bus.ts
type Payload = { type: 'USER_UPDATE'; data: any };
const CH = 'APP_CHANNEL';

export class AppBus {
  private bc = new BroadcastChannel(CH);
  on(cb: (p: Payload) => void) {
    this.bc.onmessage = e => cb(e.data);
  }
  emit(p: Payload) {
    this.bc.postMessage(p);
  }
}
export const bus = new AppBus();
// shared/user-repo.ts
import { bus } from './bus';

const KEY = 'APP_USER';

export const saveUser = (u: any) => {
  localStorage.setItem(KEY, JSON.stringify(u));
  bus.emit({ type: 'USER_UPDATE', data: u });
};
export const loadUser = () => {
  const raw = localStorage.getItem(KEY);
  return raw ? JSON.parse(raw) : null;
};

组件订阅:

// cross-tab-badge.tsx
import { Component, h, State } from '@stencil/core';
import { loadUser, saveUser } from '../shared/user-repo';
import { bus } from '../shared/bus';

@Component({ tag: 'cross-tab-badge', shadow: true })
export class CrossTabBadge {
  @State() user = loadUser();

  connectedCallback() {
    bus.on(p => {
      if (p.type === 'USER_UPDATE') this.user = p.data;
    });
    // 兜底:localStorage 事件(不同 Tab)
    window.addEventListener('storage', e => {
      if (e.key === 'APP_USER') this.user = loadUser();
    });
  }

  private logout = () => saveUser(null);

  render() {
    return this.user
      ? <button onClick={this.logout}>Hi, {this.user.name}(退出)</button>
      : <span>未登录</span>;
  }
}

效果: 在任何一个页面登录/退出,其他页面 UI 即时刷新;断开 BroadcastChannel 时也能靠 storage 事件兜底,体验稳稳的👍。


🚀 四、性能优化与无状态组件:重渲染管控的艺术

🎯 原则一:把状态放到最合适、最少的地方

  • 尽量让父级持有共享真相,子组件用 @Prop 展示。
  • 只有与展示/交互耦合的局部状态放 @State
  • 组件能无状态就无状态(即纯渲染组件,输入 = 输出)。

🧩 原则二:划分“数据型 props”与“配置型 props”

  • 数据型(会频繁变动,如列表、计数)要谨慎传递,尽量切小粒度。
  • 配置型(主题、尺寸、只读开关)可放心作为 @Prop,基本不会造成频繁重渲染。

🚫 原则三:避免无意义重渲染

  • 在 Stencil 中,变更 @State/@Prop 才会触发渲染。
  • 不要在无意义的地方频繁 set 状态(例如定时器里 set 相同值)。
  • 大体量数据建议分页/虚拟滚动、或只传分页切片

🧪 小技巧:防抖/节流 + 批量更新

// utils/batch.ts
export const rafBatch = (() => {
  let queued = false;
  const cbs: Function[] = [];
  return (cb: Function) => {
    cbs.push(cb);
    if (!queued) {
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        cbs.splice(0).forEach(fn => fn());
      });
    }
  };
})();

在组件里把多个更新包进一次 rafBatch,减少渲染震荡。

🌿 无状态组件(Pure Functional TSX)

// pure-avatar.tsx
import { FunctionalComponent, h } from '@stencil/core';

interface AvatarProps { src?: string; alt?: string; size?: number }

export const PureAvatar: FunctionalComponent<AvatarProps> = ({ src, alt = 'avatar', size = 24 }) => (
  <img src={src} alt={alt} width={size} height={size} />
);

无状态 = 更容易复用 + 更容易推理 + 更不容易出 bug


🧰 五、示例实战:构建一个“跨页面组件的数据共享系统”

目标:多页面/多路由/多组件共享用户数据(登录态 + Profile),任何地方更新都能自动刷新
技术选择:Stencil + @Prop / @State / 事件 + 轻量 Store + BroadcastChannel + localStorage 兜底。
Bonus:提供跨页面任务列表共享(模拟小型协作)。

🏗️ 1. 项目结构(示意)

src/
  components/
    profile-badge/
    user-login/
    task-list/
    task-input/
  shared/
    store/
      user.ts
      tasks.ts
    bus.ts
    repo.ts

🧱 2. 用户 Store(@stencil/store

// shared/store/user.ts
import { createStore } from '@stencil/store';

export type User = { id: string; name: string; avatar?: string };
const store = createStore<{ user: User | null }>({ user: null });

export const userState = store.state;
export const setUser = (u: User | null) => (store.state.user = u);
export const onUserChange = (cb: (u: User | null) => void) =>
  store.onChange('user', cb);

🗃️ 3. 任务 Store + 持久化 + 广播

// shared/store/tasks.ts
import { createStore } from '@stencil/store';
import { bus } from '../bus';
const KEY = 'APP_TASKS';

type Task = { id: string; title: string; done: boolean; ts: number };

const persisted = localStorage.getItem(KEY);
const initial: Task[] = persisted ? JSON.parse(persisted) : [];

const store = createStore<{ tasks: Task[] }>({ tasks: initial });
export const tasksState = store.state;

const persist = () => localStorage.setItem(KEY, JSON.stringify(store.state.tasks));
const notify = () => bus.emit({ type: 'TASKS_SYNC', data: store.state.tasks });

export const addTask = (t: Task) => { store.state.tasks = [t, ...store.state.tasks]; persist(); notify(); };
export const toggleTask = (id: string) => {
  store.state.tasks = store.state.tasks.map(t => t.id === id ? { ...t, done: !t.done } : t);
  persist(); notify();
};
export const onTasksChange = (cb: (tasks: Task[]) => void) => store.onChange('tasks', cb);

// 跨 Tab 同步
window.addEventListener('storage', e => {
  if (e.key === KEY && e.newValue) {
    store.state.tasks = JSON.parse(e.newValue);
  }
});
bus.on(p => { if (p.type === 'TASKS_SYNC') store.state.tasks = p.data; });
// shared/bus.ts
type Payload =
  | { type: 'USER_SYNC'; data: any }
  | { type: 'TASKS_SYNC'; data: any };

const CH = 'APP_CHANNEL';
class AppBus {
  private bc = new BroadcastChannel(CH);
  on(cb: (p: Payload) => void) {
    this.bc.onmessage = e => cb(e.data);
  }
  emit(p: Payload) {
    this.bc.postMessage(p);
  }
}
export const bus = new AppBus();

🔐 4. 用户仓库(localStorage + 广播)

// shared/repo.ts
import { bus } from './bus';
const KEY = 'APP_USER';

export const saveUser = (u: any) => {
  localStorage.setItem(KEY, JSON.stringify(u));
  bus.emit({ type: 'USER_SYNC', data: u });
};

export const loadUser = () => {
  const raw = localStorage.getItem(KEY);
  return raw ? JSON.parse(raw) : null;
};

👤 5. 登录组件(写入全局 & 广播)

// components/user-login/user-login.tsx
import { Component, h, State } from '@stencil/core';
import { setUser } from '../../shared/store/user';
import { saveUser, loadUser } from '../../shared/repo';

@Component({ tag: 'user-login', shadow: true })
export class UserLogin {
  @State() name = '';
  @State() avatar = '';

  connectedCallback() {
    const u = loadUser();
    if (u) setUser(u);
  }

  private login = () => {
    const user = { id: Date.now().toString(), name: this.name, avatar: this.avatar };
    setUser(user);
    saveUser(user);
  }

  private logout = () => { setUser(null); saveUser(null); }

  render() {
    return (
      <div class="panel">
        <input placeholder="Your name" onInput={(e: any) => this.name = e.target.value} />
        <input placeholder="Avatar URL" onInput={(e: any) => this.avatar = e.target.value} />
        <button onClick={this.login}>登录</button>
        <button onClick={this.logout}>退出</button>
      </div>
    );
  }
}

🪪 6. 跨页面个人信息徽章(订阅即更新)

// components/profile-badge/profile-badge.tsx
import { Component, h, State } from '@stencil/core';
import { userState, onUserChange } from '../../shared/store/user';
import { bus } from '../../shared/bus';
import { loadUser } from '../../shared/repo';

@Component({ tag: 'profile-badge', shadow: true })
export class ProfileBadge {
  @State() user = userState.user;

  connectedCallback() {
    onUserChange(u => this.user = u);
    bus.on(p => { if (p.type === 'USER_SYNC') this.user = p.data; });
    window.addEventListener('storage', e => {
      if (e.key === 'APP_USER') this.user = loadUser();
    });
  }

  render() {
    return this.user
      ? <div class="badge"><img src={this.user.avatar || ''} /><span>Hi, {this.user.name} 👋</span></div>
      : <span>未登录</span>;
  }
}

📝 7. 任务输入与列表(全站共享)

// components/task-input/task-input.tsx
import { Component, h, State } from '@stencil/core';
import { addTask } from '../../shared/store/tasks';

@Component({ tag: 'task-input', shadow: true })
export class TaskInput {
  @State() title = '';

  private add = () => {
    if (!this.title.trim()) return;
    addTask({ id: crypto.randomUUID(), title: this.title.trim(), done: false, ts: Date.now() });
    this.title = '';
  }

  render() {
    return (
      <div class="input">
        <input value={this.title} onInput={(e: any) => this.title = e.target.value} placeholder="添加任务..." />
        <button onClick={this.add}>添加</button>
      </div>
    );
  }
}
// components/task-list/task-list.tsx
import { Component, h, State } from '@stencil/core';
import { tasksState, onTasksChange, toggleTask } from '../../shared/store/tasks';

@Component({ tag: 'task-list', shadow: true })
export class TaskList {
  @State() tasks = tasksState.tasks;

  connectedCallback() {
    onTasksChange(ts => this.tasks = ts);
  }

  render() {
    return (
      <ul>
        {this.tasks.map(t => (
          <li>
            <label>
              <input type="checkbox" checked={t.done} onChange={() => toggleTask(t.id)} />
              <span>{t.title}</span>
            </label>
          </li>
        ))}
      </ul>
    );
  }
}

现在你拥有了:

  • 任一页面登录 → 所有页面的 profile-badge 立即更新
  • 任一页面添加/勾选任务 → 所有页面的 task-list 同步变化
  • 刷新/关闭/重新打开也没事,localStorage 持久化托底;
  • 多 Tab 通信:BroadcastChannel + storage 事件双重保障。
    体验像 iCloud/Google 同步那般丝滑😎。

🧩 六、进阶话题:一致性、延迟与“最终一致”UX

⏳ 最终一致 = 体验设计题

  • 广播与存储同步之间可能有毫秒级延迟

  • 解决方案:

    • 提示状态:按钮进入“同步中…”短暂态;
    • 冲突合并:任务标题以时间戳为序,较新为准;
    • 乐观更新:先改 UI,再回写持久层与广播(失败再回滚)。

🔀 冲突解决策略(示例)

// merge.ts
export const mergeTasks = (local: Task[], incoming: Task[]): Task[] => {
  const map = new Map<string, Task>();
  [...local, ...incoming].forEach(t => {
    const cur = map.get(t.id);
    if (!cur || t.ts > cur.ts) map.set(t.id, t); // newer wins
  });
  return Array.from(map.values()).sort((a, b) => b.ts - a.ts);
};

🧱 防抖写入与批处理

  • 对频繁操作(如输入法、拖拽排序),防抖再写入持久层。
  • 批量改动一个 raf 提交,减少重渲染。

✅ 七、检查清单:最佳实践 15 条

  1. @Prop 只读:不要在子组件内部直接改它。
  2. @State 只承载局部:全局共享放 Store/Context。
  3. 单向数据流:父管数据,子发事件。
  4. 兄弟通信上提:兄弟→父→下发,不要互相引用实例。
  5. 全局共享用 StorecreateStore/Context 任选其一或组合。
  6. 跨页面用 BroadcastChannel + storage 双通道。
  7. 持久化兜底:localStorage/IndexedDB 都行,按体量选择。
  8. 合并策略先定好:时间戳优先/版本号优先/用户确认。
  9. 防抖与节流:热路径必须控频。
  10. 无状态优先:能纯渲染就纯渲染。
  11. 切小粒度:只把必要的数据作为 @Prop 传递。
  12. 避免派发风暴:多改动合并成一次广播。
  13. 错误处理:广播/存储失败要有降级与提示。
  14. 可观测性:在开发态打印关键流转日志,便于追查。
  15. 边界测试:多 Tab、多网络、隐私模式下都跑一遍。

🌈 八、收尾:把复杂留给系统,把简单还给使用者

优秀的状态管理像优秀的编舞——让每个组件都清楚自己该在什么节拍出现、该做什么动作、怎么与周围配合。我们用 @Prop 定边界,用 @State 管内心戏,用事件与 Store 打通“经络”,再用持久化和跨页面通道把体验抹平。
  当数据流顺了,UI 刷新稳了,性能也被照顾到位了,你会发现:工程的难题不是“能不能做”,而是“做得优雅不优雅”
  最后留个反问——当状态终于不再“乱跑”,你会把省下的脑力,用来写更美的交互,还是实现一个小而精的产品功能呢?😉


📎 附:可复用的类型 & 工具片段(收藏级)

// types/common.ts
export type Nullable<T> = T | null;
export type Json = string | number | boolean | null | Json[] | { [k: string]: Json };

// utils/debounce.ts
export const debounce = <F extends (...args: any[]) => any>(f: F, wait = 200) => {
  let t: any;
  return (...args: Parameters<F>) => {
    clearTimeout(t);
    t = setTimeout(() => f(...args), wait);
  };
};

// utils/throttle.ts
export const throttle = <F extends (...args: any[]) => any>(f: F, wait = 200) => {
  let last = 0;
  return (...args: Parameters<F>) => {
    const now = Date.now();
    if (now - last >= wait) { last = now; f(...args); }
  };
};

🧧福利赠与你🧧

  无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」专栏(全网一个名),bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。

  最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

  同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G pdf电子书籍、简历模板、技术文章Markdown文档等海量资料。

✨️ Who am I?

我是bug菌(全网一个名),CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等社区博客专家,C站博客之星Top30,华为云多年度十佳博主/价值贡献奖,掘金多年度人气作者Top40,掘金等各大社区平台签约作者,51CTO年度博主Top12,掘金/InfoQ/51CTO等社区优质创作者;全网粉丝合计 30w+;更多精彩福利点击这里;硬核微信公众号「猿圈奇妙屋」,欢迎你的加入!免费白嫖最新BAT互联网公司面试真题、4000G PDF电子书籍、简历模板等海量资料,你想要的我都有,关键是你不来拿。

-End-

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。