/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
前端组件通信的边界设计:Props、Context与事件总线的权衡

在大型前端系统架构中,“组件通信(Component Communication)”的设计质量直接决定了整个项目的代码耦合度与后期维护成本。
很多新手架构师或开发者容易陷入两种极端:
为了在代码可读性、架构解耦与渲染性能之间建立清晰的边界,本文深入拆解三种主流通信模式的适用场景与权衡决策。
组件通信的三维决策模型
┌─────────────────────────────────────────────────────────────┐
│ 组件通信机制选型决策矩阵 │
├──────────────────────────────┬──────────────────────────────┤
│ 1. 明确的直接父子组件 │ ──► Props + Callbacks (首选) │
│ 2. 局部受控复合组件 (Tabs) │ ──► Context API / Compound │
│ 3. 跨完全隔离的微前端模块 │ ──► CustomEvent 统一事件网关 │
│ 4. 全局跨路由核心业务状态 │ ──► Zustand / Pinia 全局Store │
└──────────────────────────────┴──────────────────────────────┘
场景一:直接父子通信——坚定坚守 Props 下发与事件回调
对于层级 ≤ 2 的直接嵌套组件,Props 是唯一正确且最清晰的通信方式:
- 单向数据流清晰可辨:在阅读代码时,一眼就能看出子组件的数据来源与触发的操作;
- 组件纯粹性与可测试性:使用 Props 的组件不依赖任何外部全局环境,单测可以直接通过传入不同的 Mock Props 极速完成覆盖。
// 纯粹、易测试的标准父子组件设计
interface ReportCardProps {
title: string;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
export const ReportCard: React.FC<ReportCardProps> = ({ title, onEdit, onDelete }) => (
<div className="flex justify-between items-center p-4 bg-white border rounded-lg">
<span className="font-medium text-slate-800">{title}</span>
<div className="space-x-2">
<button onClick={() => onEdit('123')} className="text-sm text-blue-600">编辑</button>
<button onClick={() => onDelete('123')} className="text-sm text-red-600">删除</button>
</div>
</div>
);
场景二:局部子树共享——复合组件模式(Compound Components + Context)
当你在设计如 <Tabs>, <Accordion>, <Form> 这种由多个紧密协作的子组件构成的 UI 容器时,Props Drilling 会破坏组件调用的声明式美感。
此时,局限于该子树内部的私有 Context 是最佳解法:
// 复合组件模式:外部调用优雅,内部通过私有 Context 通信
import React, { createContext, useContext, useState } from 'react';
const TabsContext = createContext<{ activeTab: string; setActiveTab: (id: string) => void } | null>(null);
export const Tabs: React.FC<{ defaultTab: string; children: React.ReactNode }> = ({ defaultTab, children }) => {
const [activeTab, setActiveTab] = useState(defaultTab);
return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider>;
};
export const TabItem: React.FC<{ id: string; label: string }> = ({ id, label }) => {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('TabItem 必须包裹在 Tabs 内部使用');
const isActive = ctx.activeTab === id;
return (
<button
onClick={() => ctx.setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 ${
isActive ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{label}
</button>
);
};
场景三:跨模块与微前端解耦——受约束的统一事件网关(Event Gateway)
在微前端子应用之间、或者完全隔离的独立浮层(如全局 Toast、未挂载在同一个 React 树下的音频播放器)之间,使用原生 CustomEvent 是唯一可行的通信手段。
但为了避免事件总线沦为混乱灾难,必须对事件名称与载荷进行严格的 TypeScript 类型契约约束,并统一通过网关收敛:
// 严格类型约束的事件网关
export type AppEvents = {
'USER_LOGIN_EXPIRED': { timestamp: number; reason: string };
'REPORT_GENERATED': { reportId: string; tokens: number };
};
export class SafeEventHub {
public static emit<K extends keyof AppEvents>(eventName: K, payload: AppEvents[K]) {
window.dispatchEvent(new CustomEvent(`APP_${eventName}`, { detail: payload }));
}
public static on<K extends keyof AppEvents>(
eventName: K,
callback: (payload: AppEvents[K]) => void
): () => void {
const handler = (e: Event) => callback((e as CustomEvent).detail);
window.addEventListener(`APP_${eventName}`, handler);
// 强制返回注销清理函数,杜绝内存泄漏
return () => window.removeEventListener(`APP_${eventName}`, handler);
}
}



