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

🏆本文收录于「滚雪球学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 两位“重量级选手”,并且通俗又不失专业,有梗但绝不油腻,保证你看完就能开工!🚀
🧭 目录指北
- 🪄 前言:状态别再“乱跑”
- 🔌 一、状态的来龙去脉:从
@Prop到@State - 🔁 二、数据流与状态传递机制:单向是根,事件是桥
- 🔗 三、跨组件数据同步与 UI 刷新:从兄弟到全局
- 🚀 四、性能优化与无状态组件:重渲染管控的艺术
- 🧰 五、示例实战:构建一个“跨页面组件的数据共享系统”
- 🧩 六、进阶话题:一致性、延迟与“最终一致”UX
- ✅ 七、检查清单:最佳实践 15 条
- 🌈 八、收尾:把复杂留给系统,把简单还给使用者
🪄 前言:状态别再“乱跑”
状态管理是前端工程永恒的话题。你可能用过 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>
);
}
}
节奏:
- 父把
total作为初始值给子; - 子点击
+1,内部@State增长并changed.emit(count); - 父监听到
changed,更新自己的@State→ 触发父刷新 → 新值再通过@Prop下发给子。
单向数据回路闭环 ✅。
🔗 三、跨组件数据同步与 UI 刷新:从兄弟到全局
当数据要被多个不在同一路径的组件使用时(甚至在不同页面/路由),我们需要一个共享层来承载“真相源”。在 Stencil 世界里可以选:
- 轻量 Store(官方
@stencil/store或自建) - Context API(Provider/Consumer)
- 事件总线 + 本地缓存(兼容多页面/多 Tab)
- 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 条
@Prop只读:不要在子组件内部直接改它。@State只承载局部:全局共享放 Store/Context。- 单向数据流:父管数据,子发事件。
- 兄弟通信上提:兄弟→父→下发,不要互相引用实例。
- 全局共享用 Store:
createStore/Context 任选其一或组合。 - 跨页面用 BroadcastChannel + storage 双通道。
- 持久化兜底:localStorage/IndexedDB 都行,按体量选择。
- 合并策略先定好:时间戳优先/版本号优先/用户确认。
- 防抖与节流:热路径必须控频。
- 无状态优先:能纯渲染就纯渲染。
- 切小粒度:只把必要的数据作为
@Prop传递。 - 避免派发风暴:多改动合并成一次广播。
- 错误处理:广播/存储失败要有降级与提示。
- 可观测性:在开发态打印关键流转日志,便于追查。
- 边界测试:多 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-
- 点赞
- 收藏
- 关注作者
评论(0)