最近我在梳理 LMN516 的时候,故意问了一个以前很容易被当成“UI 一致性”的问题:前台侧边栏和后台侧边栏、前台顶栏和后台顶栏、音乐精选里的专辑封面、歌词、顶栏音乐组件,这些东西显然不是完全独立的。那除了这些,现在还有没有别的地方其实已经耦合在一起?

这个问题是从几个很具体的现象开始的。比如我在音乐精选里点开一首歌,封面开始旋转,歌词开始滚动,顶栏音乐组件也应该同时显示同一首歌、同一个播放状态、同一个进度。如果其中一个说“正在播放 A”,另一个却显示 B,那并不是三个小组件分别出了 Bug,而是系统对“现在到底在播放什么”出现了多份真相。

讨论到这里以后,问题就已经不再是“几个地方要做得一样”。我让 ChatGPT 继续往代码里看,它把这类关系进一步拆成了强耦合、契约耦合和一致性耦合。这个区分对我很有用,因为它提醒我:两个东西长得一样,不一定真的耦合;两个 UI 看起来完全不同,只要它们表达的是同一份状态,就可能是强耦合。

LMN516 当前最明显的六个耦合域

这次继续梳理以后,我现在更愿意把 LMN516 看成几个正在形成的长期系统域,而不是一堆页面。至少已经可以比较清楚地看到 Shell、Media、Navigation、Attention、Communication 和 Content 六个 Domain。页面仍然存在,但页面更像这些系统域在不同位置上的 Surface。

一、先把“耦合”分成三种

第一种是强耦合。多个地方共享同一份运行状态,例如当前歌曲、是否播放、播放时间、音量、歌词、Audio owner。这些东西原则上不能各自维护一份“自己的正确答案”。一旦存在 Parallel Truth,系统迟早会漂移。

第二种是契约耦合。前台 Sidebar 和后台 Sidebar 不一定应该强行变成同一个 React Component,因为它们的菜单、权限和路由范围确实不同;但宽度、折叠、拖动、动画、active 状态和 Shell 的几何关系应该遵守同一套 contract。真正应该共享的是行为规则,而不是为了“复用”把所有 JSX 都揉成一个组件。

第三种是一致性耦合。返回、留白、弹层关闭方式、滚动、全屏、通知反馈这些东西,代码上可以来自不同模块,但用户体验必须遵守同一套系统规范。它们不是同一份 state,却仍然需要被整体治理。

这个区分以后很重要,因为“解耦”不能被当成目标本身。一个系统里本来就有很多必要耦合。真正的问题是:这些耦合是不是显式的,有没有唯一 owner,有没有契约,出了变化以后能不能自动知道要验哪些地方。

二、Shell Domain:前后台已经不是两套完全独立的外壳

这次从代码里确认得比较清楚的一组关系,是前台和后台的 Sidebar / Topbar。

前台 DesktopShell 和后台 AdminDesktopShell 已经共同使用 useDesktopSidebarControl 和同一套 Sidebar motion 样式。它们各自有自己的导航数据,也有各自的折叠存储 key,这些差异是合理的;但拖动、宽度、折叠、动画这些行为已经天然属于同一套 Shell Contract。

顶栏更明显。前后台都在消费 TopbarActionCluster,Quick Contact、Quick Call、Weather、Subscription、Search、Account Menu 这些能力并不是各写一份。真正的差别主要在 Surface 和权限。

前后台外壳共享的 Shell Chrome Contract

这里还有一条以前容易被忽略的链:

Sidebar 当前项
    ↓
Topbar 当前标题
    ↓
页面自己的 H1 / PageIntro
    ↓
Back / Breadcrumb

如果这一条链没有统一,页面就很容易同时出现 Sidebar 里的“音乐”、Topbar 里的“音乐”、正文里的“音乐”和一个“返回收藏目录”。现在 DesktopShell 已经会用 active sidebar entry 生成 current title,并在桌面端抑制重复的 page intro;后台也在做类似事情。

这说明以后“给 Sidebar 加一个入口”不能只被理解成“加一个链接”。它同时可能改变当前路由身份、顶栏标题、正文标题、返回关系和页面层级。

更关键的是播放器。以前前台和后台各挂一个播放器实例,路由跨到 /admin 时旧播放器会卸载,音乐自然会停或者状态重置。现在结构已经变成 DesktopShell → GlobalAudioDock → GlobalAudioPlayer,Public / Admin 只是两个视觉 slot。Runtime 只有一个,Surface 只负责它出现在哪里。

我觉得这一点以后可以成为很多全局能力的参考:真正属于全局的 Runtime,尽量放在 route 生命周期之上,不要让某个页面拥有它。

三、Media Domain:这可能是目前最典型的强耦合

一开始我说“专辑、歌词、音乐组件应该耦合”,继续往下看以后发现,它实际上比这三个东西大得多。

MusicCoverGrid 现在会创建自己的 player source id,并把当前 track、active、isPlaying、currentTime、duration 通过 MusicComponentState 发布出去;GlobalAudioPlayer 接收这份状态以后,如果当前播放源来自外部 grid,就用 ExternalMusicPlayer 在顶栏展示,而不是重新创建第二份播放状态。顶栏再通过 MusicComponentCommand 把 play / pause / seek 发回真正拥有 Audio 的 source。

Music Playback Domain:一份播放真相,多处 Surface

这其实已经形成了一个比较清楚的协议:

结构图
真正拥有 Audio 的 source
        │
        ├─ publish state
        ▼
MusicComponentState
        │
        ├─ Album Cover
        ├─ Lyrics
        ├─ Global Player
        └─ Progress

Global Player
        │
        └─ command → source

所以顶栏播放器不是“再播放一次”,而是当前播放事实的另一个控制 Surface。

再继续向外看,Media Domain 还包括 Radio、Narration、后台音乐试听、Media Session 和全局 Volume。它们看起来是不同功能,但都在争夺同一个现实资源:扬声器。music-audio-session 做的就是这个资源的 owner arbitration;media-volume 则让多个音频 Surface 共享同一份音量偏好。

这意味着音量不应该被理解成“某个播放器自己的设置”,而更接近 LMN516 的 Media Preference。Audio 也不是某一个页面私有的 HTMLAudioElement,而是一个需要全局 owner 的稀缺资源。

如果以后还继续加入 Voice、游戏音效、系统铃声,这个 Domain 只会更重要。

四、Navigation Domain:一个名字不能再改五遍

导航是另一种典型的“契约耦合”。

现在 navigation-registry.ts 已经开始承担 canonical navigation registry 的角色,Desktop Sidebar、Sitemap、Search,以及部分 Homepage / Workspace 结构可以从同一份语义来源消费。这个方向很重要,因为一旦“网站”改成“网址”,或者某个路由从 A 改成 B,系统不应该要求我们去 Sidebar、Search、Sitemap、Homepage 里分别手工修改。

Navigation Registry 与当前 Mobile Nav 分叉风险

这次检查里也发现了一个很有代表性的反例:MobileAppNav 目前仍然硬编码“今日 / 记录 / 收藏 / 搜索 / 我的”和对应 href。它和 Desktop Navigation 在产品语义上应该耦合,但实现上还存在 fork。

这种地方就是典型的 Architecture Drift Candidate。它现在未必立即出 Bug,但以后只要改名、改路由、改信息架构,就有可能桌面已经更新,移动端仍然停在旧世界。

所以 Navigation Domain 最值得坚持的是:一个实体只定义一次,Surface 可以决定怎么展示,但不要重新定义它是谁。

五、还有两类以前不太容易被看成“系统”的耦合

第一类是 Attention Domain。Moment Bubble、Lyrics Bubble、Floating Capsule、Toast 这些东西争夺的不是数据库,也不是音频,它们争夺的是屏幕空间和用户注意力。MusicLyricBubbleHost 已经会接管一部分浮动区域,FloatingMomentBubble 里又组合了 Moment 与 Capsule,所以这些东西不能无限制地各自再加一层 floating widget。以后它们需要的是一个明确的全局优先级:谁能覆盖谁、哪些路由显示、同时出现怎么办、桌面和移动端怎样降级。

第二类是 Communication Domain。站内信、Message Panel、Web/PWA Push、Toast、提示音、Voice Call、Ringtone、Call Audio、Reachability 这些能力现在大量挂在 PublicRootRuntime。它们也不应该被理解成“七个提醒功能”。更准确的模型是:同一个 communication event,在不同前后台状态下应该如何呈现。

例如一条消息到来,前台打开时可能是 in-app toast,后台时可能走系统 push;一次来电则同时涉及视觉 UI、铃声、push 和 audio session。真正需要统一的是事件生命周期、去重和优先级。

Content Domain 也是同样的道理。getUnifiedPosts / getUnifiedPostBySlug 会影响文章列表、共创、周记、Wall、Mini App、Narration、Highlights。之前 WordPress 实时读取和已经迁移到 personal_content 的文章发生重复,就是典型的 source ownership 不清。一个内容实体如果在两套 source 里都被当成 canonical,最后所有消费它的 Surface 都会一起出问题。

六、我现在更在意的不是“哪里耦合”,而是怎么管理耦合

如果只是把这些关系记在脑子里,这次梳理没有真正解决问题。LMN516 继续增长以后,靠记忆维护“改 A 时别忘了顺便看看 B、C、D”一定会失效。

所以讨论最后形成的方向,是给 Code Steward 增加一张真正的 Coupling Map

它不需要把所有文件画成一张巨大的蜘蛛网,而应该按 Domain 记录几个最重要的东西:

Domain
  ↓
Canonical Owner
  ↓
State / Contract
  ↓
Consumers
  ↓
Required co-verification

比如 Music Playback Domain 可以明确写:

Owner
  Music playback source / audio session

Contract
  sourceId / track / active / isPlaying / currentTime / duration

Consumers
  MusicCoverGrid
  GlobalAudioPlayer
  Lyrics
  Media Session

Change requires verification
  Cover spin
  Lyrics sync
  Topbar state
  Progress
  Play / Pause / Seek
  Volume
  Public ↔ Admin route continuity

这样以后 Code Steward 做 pre-change impact analysis 时,不只是说“这个 commit 改了三个文件”,而是可以说“这个 diff 命中了 Media Playback Domain,所以联动验收面还包括歌词、顶栏、Media Session 和跨路由连续性”。

从单点修复转向 Coupled Acceptance

这会把修改流程从:

发现问题
→ 找到一个文件
→ 修改
→ 验当前页面
→ 结束

变成:

发现问题
→ 识别 Domain
→ 找 Canonical Owner
→ 读取 Coupling Map
→ 做最小正确修改
→ 展开联动验收面
→ 验证所有受影响 Surface

这里有一个我觉得非常重要的区别:修改范围不等于验收范围。

一个正确的修复可能只改一个文件,但如果那个文件处在高耦合 owner 上,验收就必须跨五六个 Surface。相反,一次样式整理可能改很多文件,却并没有改变任何共享 state。以后风险判断应该越来越看 Domain,而不是只看 diff 行数。

七、这次最后留下的几条架构原则

要避免的反模式,以及应该坚持的原则

第一,One domain, one canonical truth。 一个领域只能有一个权威事实源。

第二,Runtime belongs above routes。 真正全局的运行时不要由某个页面生命周期拥有。

第三,Surface consumes state; Surface does not invent state。 页面和组件负责表现,不负责再造一份业务真相。

第四,Shared behavior before shared component。 先统一状态、契约、ownership 和行为,再决定 JSX 要不要复用。强行“一个组件走天下”并不是统一。

第五,Change one domain → verify every coupled surface。 改了 Domain 里的 owner 或 contract,就自动展开联动验收。

第六,Duplication of truth is more dangerous than duplication of markup。 两段类似 JSX 是维护成本;两套不同的 currentTrack 才是架构风险。这两种“重复”不能按同一个严重程度处理。

我现在也不想把这件事理解成“我们接下来要尽量把所有东西都解耦”。恰恰相反,很多耦合是产品本身决定的:歌词天然要和播放时间耦合,Topbar 的当前标题天然要和 Route 耦合,PWA Push 天然要和消息事件耦合。真正要消灭的不是耦合,而是隐式耦合、重复真相和无人负责的耦合

以后再碰到一个新功能,我觉得可以先问七个问题:它和谁共享数据?和谁共享 Runtime?和谁共享用户状态?和谁争夺全局资源?和谁必须保持交互一致?它改变以后哪些地方必须一起验收?最后,谁是唯一 Source of Truth?

如果这些问题开始被 Code Steward 和 Change Manager 自动回答,LMN516 的治理单位就会真正从“页面和文件”变成“系统域和契约”。

这也是这次梳理最后真正留下来的东西:LMN516 以后不能再只按页面治理,而要开始按 Domain + Coupling Map 治理。页面只是 Surface;长期需要维护的是 State、Runtime、Owner、Contract、Consumer 和 Acceptance。