第一版写完以后,我很快又觉得它不够好。不是因为三千字还是七千字的问题,也不是简单地再补几个例子就能解决。那一版把 Shell、Media、Navigation、Attention、Communication、Content 六个 Domain 都写到了,也写了 Canonical Truth、Coupling Map 和 Coupled Acceptance,但它更像一篇“我们现在有哪些耦合”的技术说明,没有真正把我最开始想梳理的那件事完整留下来:为什么 LMN516 最近会反复出现一个地方修好了、另一个地方又变形;为什么很多问题表面上属于不同页面,实际上却在暴露同一类架构关系;以及以后能不能在改动发生之前,就提前知道它会牵动哪里。

我最开始是故意从几个看起来很具体的地方问起的。前台侧边栏和后台侧边栏,前台顶栏和后台顶栏,音乐精选里的专辑封面、歌词、顶栏音乐组件,这些东西明显不是完全独立的。后来继续改系统,类似现象越来越多:前后台切换时音乐组件会闪一下,尺寸也会有变化;大的专辑封面和小的封面虽然都在转,但可能不同步;播放器某次修改以后,收藏、随机播放之类的控制又可能缺掉;后台剥离以后,一些入口从后台跳出去时又出现“这条路暂时没有开放”。这些现象未必全部有同一个直接原因,有些还需要分别排查,但它们不断逼着我问同一个问题:我们到底是在维护一堆页面,还是在维护一组跨页面存在的系统关系?

我现在更倾向于后者。页面当然还存在,但页面只是 Surface。真正需要长期治理的是页面背后的 State、Runtime、Semantic、Resource、Contract、Owner,以及一个地方变化以后必须一起接受验证的其他 Surface。如果这些关系没有显式化,那么 LMN516 越长大,越容易进入一种状态:每个局部都能修,但每次修复都可能重新制造下一处漂移。

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

一、问题不是“耦合太多”,而是我们以前按页面理解变化

以前面对一个问题,最自然的工程路径往往是:看到哪个页面不对,就去找这个页面对应的组件;找到以后做一个最小修改;当前页面恢复正常;验一下;结束。对于真正独立的页面,这个方法完全合理,而且效率很高。

但 LMN516 现在越来越多的东西已经不是这样了。

音乐就是最明显的例子。一首歌开始播放以后,专辑封面、歌词、播放进度、顶栏播放器、Media Session、音量、前后台切换后的连续性,都在描述同一件正在发生的事情。如果其中一个 Surface 自己维护一份 currentTrack,另一个 Surface 又维护一份,短期看起来可能都能工作,但系统里已经出现了两份“现在到底在播放什么”的答案。真正危险的不是两个组件长得不一样,而是它们可以在事实层面彼此矛盾。

Shell 也是一样。给 Sidebar 增加一个入口,看起来只是多一行菜单;实际上它可能同时改变当前路由身份、Topbar 标题、页面 H1、Back/Breadcrumb、权限边界,甚至跨 Public/Admin Shell 的跳转关系。一个入口如果只在自己所在的 Sidebar 里是正确的,但 Search、Sitemap、移动端导航或者后台跳转仍然认识旧的名字和旧的路径,那么“这个链接已经改好了”只是局部事实。

所以我现在会把问题分成三个层次:

Surface 层
某个页面 / 某个按钮 / 某个组件表现异常

        ↓

Contract 层
多个 Surface 对同一状态、语义、行为或资源的约定发生漂移

        ↓

Domain 层
谁拥有真相、谁拥有 Runtime、谁能修改它、哪些 Consumer 必须同步验证

很多过去看起来像“修了又坏”的问题,真正难的地方往往不是修复本身,而是我们只在第一层结束了。

这也解释了一个我现在越来越在意的区别:修改范围不等于验收范围。 一个正确修复可能只需要改一份 owner 文件,但只要这份 owner 位于高耦合位置,验收就必须跨越多个 Surface。反过来,一次样式整理可能改十几个文件,却没有改变任何共享事实。以后不能再简单用“改了几个文件、多少行代码”判断它到底是不是一次大变化。

二、耦合不是坏东西,要区分“必要关系”和“危险关系”

第一版里,ChatGPT 当时把耦合分成了强耦合、契约耦合和一致性耦合。这个区分让我第一次比较清楚地看到:两个 UI 长得一样不一定真的耦合,两个 UI 长得完全不同,只要表达的是同一份状态,反而可能是强耦合。

继续往下梳理以后,我觉得长期治理还需要把它拆得更细一点。不是为了多造几个术语,而是因为不同关系需要不同的治理方法。

1. State Coupling:共享同一份事实

例如当前歌曲、播放状态、播放时间、歌词位置、当前登录身份、某条消息的已读状态。这些东西应该尽量有唯一的 Canonical Truth。Surface 可以用不同方式显示,但不能各自再发明一份事实。

2. Runtime Coupling:共享同一个持续运行的东西

Audio、WebSocket、Push listener、Call session、某些全局缓存,并不是“状态字段”那么简单,它们有生命周期。如果 Runtime 被某个 Route 或某个页面拥有,那么页面卸载时它也会消失。前后台切换音乐曾经暴露出来的,就是这种问题最容易被忽视的一面:用户觉得自己只是换了一个页面,但系统实际上把拥有 Audio 的那棵组件树整个卸载了。

3. Semantic Coupling:共享同一个“它是谁”

“网址”到底叫什么、某个入口到底对应哪个 canonical route、一个内容实体属于哪个栏目、一个页面的 current title 是什么,这些属于语义事实。Navigation Registry 要解决的并不是把菜单代码写得更漂亮,而是避免同一个实体在 Sidebar、Search、Sitemap、Homepage、Mobile Nav 中被重新定义五遍。

4. Resource Coupling:争夺同一个全局资源

Audio 的扬声器、屏幕浮层区域、用户注意力、通知通道、键盘快捷键甚至某些网络连接,都可能被不同功能共同争夺。音乐、铃声、通话音频、游戏音效看起来属于不同产品功能,但它们都要面对“此刻谁可以占用扬声器”这个现实问题。

5. Interaction Contract Coupling:代码可以不同,行为必须一致

前后台 Sidebar 不一定要强行写成一个 React Component;桌面和移动端也不应该为了复用而完全共用结构。但折叠、拖动、返回、弹层关闭、滚动、全屏、通知反馈等行为,需要有系统级 Contract。这里应该共享的是规则,不一定是 JSX。

6. Acceptance Coupling:一个地方改了,另一些地方必须一起验

这是最容易漏掉的一种耦合。它平时不一定体现在 import graph 里,但在真实交付里非常关键。修改 Music Playback owner 后,Cover spin、Lyrics、Topbar、Progress、Media Session、Public ↔ Admin continuity 都必须进入验收面;修改 Navigation canonical entry 后,Sidebar、Search、Sitemap、Mobile、Admin jump 都需要看。它决定了“什么时候才算真的修完”。

这样看以后,“解耦”就不能成为一个抽象目标。歌词天然应该和播放时间耦合,Topbar 当前标题天然应该和 Route identity 耦合,来电铃声天然要和 Call event、Audio session 耦合。真正应该消灭的是重复真相、隐式关系、错误 ownership 和无人负责的耦合,而不是关系本身。

三、六个 Domain 只是地图的第一层,不应该被写成六座孤岛

第一版最片面的地方之一,是虽然列出了 Shell、Media、Navigation、Attention、Communication、Content 六个 Domain,但读起来还是一块一块的。真正的系统并不是六个盒子平行放在那里,而是它们之间存在很多有方向的边。

我现在会这样看它们:

Domain主要治理对象典型事实 / Runtime最危险的漂移
Shell前后台外壳与页面身份Sidebar、Topbar、Page title、Shell geometry前后台行为分叉、重复标题、跨 Shell 跳转失真
Media音乐、歌词、音量与音频生命周期currentTrack、Audio owner、time、volume、Media SessionParallel Truth、双播放器、切路由中断、控制不同步
Navigation“一个入口是谁、去哪里”canonical item、href、label、active identity同名多定义、桌面已改移动端未改、旧路由残留
Attention谁能占用屏幕和注意力Bubble、Capsule、Toast、浮层优先级多浮层争抢、遮挡、不同页面出现规则不一致
Communication消息、Push、提醒、通话事件message event、reachability、ringing/call lifecycle重复提醒、漏提醒、前后台呈现不一致
Content内容实体与来源post identity、unified source、publication state多 source 同时 canonical、重复文章、消费面不一致

真正值得治理的是 Domain 之间的边:

结构图
Navigation ─────→ Shell
     │             │
     │             └────→ Media
     │                       │
     ▼                       ▼
Content                 Attention
                            ▲  │
                            │  ▼
                      Communication
                            │
                            └────→ Media

这张图不是说所有东西都互相依赖,而是提醒我们:Domain boundary 不是墙。

Navigation × Shell 决定一个路由进入以后 Sidebar、Topbar、正文标题和返回关系如何认识当前页面;Shell × Media 决定前后台切换时全局播放 Runtime 是否继续存在;Media × Attention 决定歌词 Bubble、播放器展开态和其他浮层怎么共享空间;Communication × Attention 决定同一条消息到底是站内 Toast、系统 Push、角标还是某种声音;Communication × Media 决定来电铃声、通话 Audio 与当前音乐谁让位给谁;Navigation × Content 决定同一个内容实体如何被搜索、栏目、首页和其他入口发现。

一旦把这些边画出来,我才觉得 Coupling Map 真正开始像一张系统地图,而不是模块目录。

四、Shell Domain:统一的不是“长得一样”,而是页面身份和外壳契约

代码层面已经能看到一些比较明确的基础。前台 DesktopShell 和后台 AdminDesktopShell 共同使用 useDesktopSidebarControl 和同一套 Sidebar motion 规则。两边拥有不同菜单、权限和各自的折叠存储 key,这些差异本来就应该保留;但宽度、拖动、折叠、动画,以及 Sidebar 与主体区域之间的几何关系,已经天然属于同一个 Shell Contract。

Topbar 也是类似的。前后台都在消费 TopbarActionCluster,Quick Contact、Quick Call、Weather、Subscription、Search、Account Menu 这些能力不需要在两套 Shell 里再分别发明一次。Surface 和权限可以不同,但能力本身应该有清楚的 owner。

前后台外壳共享的 Shell Chrome Contract

Shell 里还有一条非常容易被低估的关系:

Route identity
      ↓
Sidebar active entry
      ↓
Topbar current title
      ↓
Page H1 / PageIntro
      ↓
Back / Breadcrumb

以前前台出现过 Sidebar 一份标题、Topbar 一份标题、正文再来一个标题,实际上就是系统对“当前页面是谁”没有完全收敛。现在 DesktopShell 会用 active sidebar entry 生成 current title,并在桌面端抑制重复的 PageIntro;后台也在做类似事情。这个方向比单独把某个 H1 隐藏掉重要得多,因为它是在收敛页面身份的来源。

最近后台进一步剥离以后,一些入口从管理界面跳转时出现“这条路暂时没有开放”,也再次说明 Shell 的治理不能只到视觉层。这个现象的具体原因仍然应该按代码和路由分别排查,但架构层面至少能确定一件事:当 Public/Admin 的边界发生变化时,route ownership、canonical href、跨 Shell transition 和 fallback 行为必须一起进入契约。 否则前后台看起来已经成功拆开,语义上仍然可能残留旧世界的路径假设。

播放器更能说明 Runtime ownership。以前如果前台和后台各自挂一个播放器实例,从 Public 切到 /admin 时旧播放器所在的树卸载,音乐就可能停止或重新初始化。现在结构已经走向:

结构图
DesktopShell
   ↓
GlobalAudioDock
   ↓
GlobalAudioPlayer
       ├─ Public visual slot
       └─ Admin visual slot

关键不在于这几个组件名字,而在于Runtime 只有一个,Public/Admin 只是不同 Surface。这个原则以后也可以用于其他真正全局的 Runtime:路由可以改变它在哪里出现,但不应该因为换路由就重新创造一份它。

五、Media Domain:最典型的不是播放器,而是“一份事实、多处控制”

Media 目前仍然是 LMN516 最容易看清强耦合的地方。

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

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

这套结构真正有价值的地方,是把“显示播放器”和“拥有播放”分开了:

结构图
Audio owner / playback source
          │
          ├── publish state
          ▼
   MusicComponentState
          │
          ├── Album Cover
          ├── Lyrics
          ├── Global Player
          ├── Progress
          └── Media Session

Global Player / other controls
          │
          └── command ──→ owner

所以如果以后又出现“大封面和小封面都在转,但两者不同步”,第一反应不应该只是给两个 CSS animation 调成同一个 duration。那当然可能是最后的表现层修复,但更重要的是确认:两个 Surface 的 rotation state 到底来自同一个 playback fact,还是各自从不同生命周期起跑。如果某次修改后顶栏播放器的收藏、随机播放等控制消失,也不能只理解成“少了两个按钮”,还要检查 Surface contract 有没有被新实现截断。

Media Domain 再往外扩,就不只是音乐。Radio、Narration、后台音乐试听、Media Session、全局 Volume,包括以后可能继续接入的 Voice、游戏音效、通知铃声,都在争夺同一个现实资源:扬声器和用户的听觉注意力。music-audio-session 做的是 Audio owner arbitration,media-volume 则把音量从“某个播放器的小设置”提升成跨 Surface 的 Media Preference。

这也让我重新理解“全局音量调平”这类需求。它不是在五个功能里分别调五次,而应该尽量进入同一个 Resource Contract:谁可以发声、当前 owner 是谁、切换 owner 时前一个如何让位、系统音效和音乐音量是否共享偏好、来电是否需要暂停或 duck 当前音乐。只要这些问题仍然由各功能自己回答,Media 的耦合就还没有真正被治理。

六、Navigation、Attention、Communication、Content:隐藏耦合往往比显式 import 更危险

Navigation 的方向已经比较明确:navigation-registry.ts 开始承担 canonical navigation registry 的角色,Desktop Sidebar、Sitemap、Search,以及部分 Homepage / Workspace 结构可以从同一语义来源消费。这样“网站”改成“网址”、某个 route 改名或者一个实体移动分类时,不需要在每个 Surface 重新定义一次它是谁。

Navigation Registry 与当前 Mobile Nav 分叉风险

但 MobileAppNav 仍然硬编码“今日 / 记录 / 收藏 / 搜索 / 我的”和对应 href,这就是一个很典型的 Architecture Drift Candidate。它现在即使没有立即报错,也已经具备未来漂移的条件:桌面端的信息架构改变以后,移动端可能继续停留在旧定义。这里真正应该共享的是 semantic identity,不是强迫桌面和移动端用同一套 JSX。

Attention Domain 更隐蔽。Moment Bubble、Lyrics Bubble、Floating Capsule、Toast 这些东西代码上可能完全不在一个目录里,但它们共同占用屏幕上的稀缺区域。MusicLyricBubbleHost 会接管一部分浮动区域,FloatingMomentBubble 又组合 Moment 与 Capsule。这意味着以后不能靠“每个功能自己 fixed 一个位置”继续增长。需要有一套全局的 attention priority:哪些路由允许显示、谁能覆盖谁、两个事件同时到来怎么办、桌面和移动端空间不足时怎么降级。

Communication Domain 则更像一个事件路由系统。站内信、Message Panel、Web/PWA Push、Toast、提示音、Voice Call、Ringtone、Call Audio、Reachability 等能力大量挂在 PublicRootRuntime。它们真正共享的不是某一个 UI,而是同一个 communication event 的生命周期。比如一条消息到来,网页正打开时可能需要 in-app 呈现,页面在后台时可能需要系统 Push,但两边不能重复打扰;一次来电则可能同时触发视觉 UI、铃声、Push 和 Audio session。这里如果没有 event identity、deduplication、priority 和 reachability 的统一 contract,“提醒功能越多”反而越容易重复、漏发或者互相抢占。

Content Domain 的问题则是 source ownership。getUnifiedPosts / getUnifiedPostBySlug 会影响文章列表、共创、周记、Wall、Mini App、Narration、Highlights。之前 WordPress 实时读取和已经迁移到 personal_content 的内容发生重复,就是一个很典型的教训:一个内容实体如果在两套 source 中都被当成 canonical,所有消费它的 Surface 都可能一起出现重复或身份混乱。这个问题不是列表页去重就能真正解决的,根上必须回答“谁是这一类内容现在唯一的 canonical source”。

这四个 Domain 放在一起以后,会发现一个共同点:最危险的耦合不一定能从 import graph 里直接看到。 “两个文件互相 import”当然是一种关系,但“两个完全不同的系统共同定义同一个名字”“三个浮层争夺同一块空间”“两种通知通道表达同一个事件”“两套数据库 source 都声称自己是权威”,这些关系对长期稳定性的影响可能更大。

七、LMN516 已经暴露出来的几种反模式

我现在不想把 Coupling Map 只写成一个漂亮的新架构概念。它应该能解释我们为什么过去会反复遇到一些熟悉的问题。至少下面这些反模式已经值得单独记住。

Parallel Truth

同一领域存在两份可以独立变化的事实。例如两个播放器各自有 currentTrack,两份导航配置各自有 label/href,两套内容 source 都被当成 canonical。它最麻烦的地方是:平时两边一样时看不出来,一旦其中一边先变,系统才开始“莫名其妙”漂移。

Route-owned Global Runtime

真正应该跨路由持续存在的 Runtime,却挂在某个页面或某一侧 Shell 下。页面卸载时 Runtime 一起销毁,于是用户看到切后台音乐停、切页面连接重建、状态重置。正确方向不是给每个页面再补恢复逻辑,而是把 owner 放到 route lifecycle 之上。

Semantic Fork

同一个入口、名称、route identity 在多个地方重复硬编码。它的风险不是多几行代码,而是一个地方已经进入新语义,另一个地方还活在旧语义里。

Shared Markup ≠ Shared Contract

为了“统一”把两个东西强行合成一个组件,或者反过来因为两个组件不同就认为它们互不相关,都是误判。真正该先问的是共享状态、共享行为、共享 ownership 到底是什么。Markup duplication 是维护成本,Truth duplication 才是架构风险。

Invisible Coupling by Memory

“改这里时记得顺便看一下那里”如果只存在于我或者某个 Worker 的脑子里,就不算治理。系统增长以后,人一定会忘。耦合关系只有进入可读取的 Map、Registry、Contract 或验收规则,才真正从记忆变成基础设施。

Local Acceptance

改了 A,只验 A。当前页面正常就宣布完成,而没有根据 owner 和 contract 展开联动面。这类问题最容易形成“这不是刚修好吗,怎么又坏了”的体验,因为第一次交付实际上只证明了一个 Surface。

这些反模式背后其实都是同一件事:系统里真实存在的关系,比我们的修改模型更大。

八、Coupling Map 不应该是一张蜘蛛网,而应该是一种可执行的数据结构

第一版已经提出了 Coupling Map,但那时它更像一个方向。现在我觉得需要把它定义得更清楚:它不是把 25 万行代码所有引用关系画成一张巨大的图,而是按 Domain 维护高价值、可执行的关系。

最小结构应该至少包含:

Domain
  ↓
Canonical Owner
  ↓
Canonical Truth / Runtime / Resource
  ↓
Contract
  ↓
Consumers / Surfaces
  ↓
Cross-domain Edges
  ↓
Required Acceptance
  ↓
Known Drift / Risk

以 Music Playback 为例:

Domain
  Media / Playback

Owner
  playback source + audio session

Truth / Runtime
  sourceId
  track
  active
  isPlaying
  currentTime
  duration
  volume

Consumers
  MusicCoverGrid
  GlobalAudioPlayer
  Lyrics
  Media Session
  progress surfaces

Cross-domain edges
  Shell: Public ↔ Admin continuity
  Attention: lyric / player floating surfaces
  Communication: ringtone / call audio arbitration

Required acceptance
  cover spin
  lyrics sync
  topbar state
  progress
  play / pause / seek
  volume
  media session
  Public ↔ Admin continuity

Shell/Navigation 也可以有自己的记录:

Domain
  Shell + Navigation

Owner
  canonical route identity / navigation registry

Contract
  label
  href
  active matching
  current title
  back relation
  permission / visibility

Consumers
  Public Sidebar
  Admin Sidebar
  Topbar
  Search
  Sitemap
  Mobile Nav
  Homepage / Workspace entries

Required acceptance
  direct open
  cross-shell jump
  active state
  title deduplication
  back behavior
  mobile / desktop consistency

一旦数据结构明确,Coupling Map 才能被机器消费。Code Steward 可以在 diff 发生以前做 impact analysis;Change Manager 可以据此决定这次改动需要展开哪些 acceptance;Worker 不必靠猜测自己寻找“可能相关”的地方;巡检也可以定期找“Registry 已经变了,但 Consumer 还指向旧定义”的 Architecture Drift。

九、Coupled Acceptance:验收应该跟着 Domain 走,而不是跟着文件走

这可能是整个治理里最现实的一步。

从单点修复转向 Coupled Acceptance

以前的路径是:

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

更适合 LMN516 现在状态的路径应该是:

发现问题
→ 识别 Domain
→ 找 Canonical Owner / Contract
→ 读取 Coupling Map
→ 做最小正确修改
→ 自动展开联动验收面
→ 验所有受影响 Surface
→ 记录新的 Drift / Contract 变化

这里“做最小正确修改”和“扩大验收面”并不矛盾。相反,它们应该同时发生。代码改动仍然可以很小,Microchange 仍然可以走 Fast Path,不需要因为系统治理就把每次按钮微调都变成一次架构评审。但只要命中了高耦合 owner,验收范围就不能继续装作它只是一个按钮。

以后风险判断也应该逐渐从 diff size 转向 domain impact。修改 GlobalAudioPlayer 的一行 owner 逻辑,风险可能高于改十个纯样式文件;改变 canonical navigation item,可能只有一处数据变更,却需要验证桌面、移动、搜索、后台跳转;修改 Content source ownership,可能没有任何视觉变化,却会影响文章墙、共创、Narration 和 Mini App。

这才是 Coupled Acceptance 真正要解决的问题:让系统知道“哪几个地方必须一起证明没坏”。

十、从“治理耦合”再往前一步:系统预治理

如果 Coupling Map 只能等问题出现以后帮助我们查原因,它仍然是一个更好的排障工具,但还没有完全改变工程方式。我现在更想要的是再往前一步:在改动发生以前就展开影响面。

以后一个需求进入系统,不管来自我自己、Worker 还是巡检发现的问题,都可以先经过一层非常轻量的预治理:

User Q / change request
        ↓
Identify Domain
        ↓
Resolve Canonical Owner
        ↓
Load Contract + Coupling Map
        ↓
Expand impacted Surfaces / Resources
        ↓
Choose minimal implementation
        ↓
Generate required acceptance
        ↓
Implement → verify → land

它不需要成为一个很重的审批系统。真正理想的状态反而是大部分步骤由 Code Steward 和 Change Manager 自动完成。用户说“让前后台音乐切换时不要闪”,系统不只是搜索 Music Component,而是先知道它同时命中 Shell × Media;用户说“网站改成网址”,系统自动知道 Navigation semantic identity 的 Consumer 包括哪些地方;用户说“发布文章要提醒我”,系统知道这是 Communication × Attention × Content 的跨域事件,而不是单独加一个 Push 调用。

这就是我说的“预治理”:在问题还没有以 Bug 的形式出现以前,先承认关系已经存在。

预治理至少应该回答这些问题:

  1. 这次变化属于哪个 Domain,是否跨 Domain?
  2. 谁是唯一 Canonical Owner?
  3. 它改变的是 State、Runtime、Semantic、Resource 还是 Interaction Contract?
  4. 当前有没有第二份 Parallel Truth?
  5. 哪些 Consumer 只是展示,哪些 Consumer 还能反向发命令?
  6. 这次修改会不会跨 Route、Public/Admin、Desktop/Mobile 生命周期?
  7. 哪些 Surface 必须共同验收?
  8. 改完以后,Coupling Map 本身是否需要更新?

只要这八个问题能被自动回答一部分,LMN516 的修改模型就会发生非常实际的变化:不再是“先改,坏了再发现关系”,而是“先知道关系,再决定最小改法”。

十一、Code Steward、Change Manager、Worker、巡检应该怎样接上这张图

这件事如果最后仍然要靠我每次手动提醒“这个东西和那个东西是耦合的”,那文章写得再完整也没有意义。真正要落地,Coupling Map 必须进入现有协作链。

Code Steward 更适合负责结构事实。它熟悉代码以后,不只回答“这个函数在哪里”,还应该回答“这个 diff 命中了哪个 Domain、哪个 owner、有哪些已知 Consumer、是否出现新的重复实现”。它是 pre-change impact analysis 和 Architecture Drift detection 的主要入口。

Change Manager 更适合把结构影响变成交付范围。它拿到用户的原始 Q 后,不应该简单等价成一个文件清单,而是根据 Coupling Map 生成本次需要修改什么、需要共同验证什么、哪些变化可以走 Fast Path、哪些变化因为碰到 global runtime / source ownership / auth boundary 而需要提高风险等级。

Worker 仍然负责执行最小正确修改。Coupling governance 不应该逼 Worker 为了“看起来系统化”顺手重构一大片代码。相反,它应该让 Worker 更敢于只改 owner,因为验收面已经由 Map 展开,不需要靠扩大实现范围换安全感。

巡检 则应该从“发现页面坏没坏”逐渐增加一类 Architecture Drift 巡检:canonical registry 改了以后是否还有硬编码旧值;global runtime 是否又在新 route 下重复实例化;一个 Domain 是否新增第二份事实源;一个 floating surface 是否绕开 Attention priority;一个新的内容读取链是否重新直接接回旧 source。

这样四者的关系可以变成:

Code Steward   → 看懂关系
Change Manager → 定义变更与验收边界
Worker         → 做最小正确修改
巡检           → 找漂移、找复发、找新的隐式耦合

这比让每个 Worker 都从头理解整个 LMN516 更现实,也更符合一个长期增长系统的治理方式。

十二、落地顺序:先管最危险的真相,再管所有细节

我不觉得下一步应该立刻把全站所有文件都扫描一遍,然后生成一张巨大的“耦合宇宙图”。那样很容易变成信息量很大、实际没人使用的文档。

更现实的顺序是分层做。

第一层:先锁定高风险 Canonical Truth 和 Runtime

优先处理 Media Playback、Shell/Navigation identity、Communication event、Content source ownership 这种一旦分叉就会让多个 Surface 同时出问题的地方。先明确 owner,找 Parallel Truth,避免继续增加新的第二份事实。

第二层:把共享行为变成明确 Contract

Sidebar、Topbar、返回、滚动、全屏、浮层、通知、Volume、Audio arbitration 等,不强求统一组件,但要逐步把行为边界写清楚。以后新 Surface 进入时消费 Contract,而不是复制一个“看起来差不多”的旧实现。

第三层:建立机器可读的 Coupling Map

先覆盖高风险 Domain,不追求一次全量。每一项至少有 Owner、Truth/Runtime、Consumers、Cross-domain edges、Acceptance。随着真实修改逐步补齐,而不是靠一次静态扫描假装完成。

第四层:接入变更前影响分析和验收生成

让 Code Steward / Change Manager 在执行前自动读取 Map。高耦合 owner 一旦被命中,自动把必要 Surface 加入 verification checklist。这样“修改范围不等于验收范围”才从一句原则变成系统行为。

第五层:让巡检负责发现 Architecture Drift

不是只看运行报错,而是主动找违反已知 Contract 的新分叉。这个阶段 Coupling Map 才真正开始形成闭环:系统既知道现在的关系,也能发现关系什么时候悄悄变了。

这个顺序里有一个我觉得需要一直坚持的限制:不要为了治理耦合而过度统一。 前台和后台可以保持不同导航;桌面和移动可以保持不同交互;两个模块也可以有相似但独立的 JSX。只要它们共享的事实、语义、Runtime 和 Contract 有明确 owner,就没有必要把所有东西揉成一个万能组件。

十三、最后留下来的架构原则

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

这次重写以后,我觉得真正值得长期留下来的不是“LMN516 有六个 Domain”这个结论,而是下面这些治理原则:

One domain, one canonical truth。 同一个领域的权威事实尽量只有一个来源。

Runtime belongs above routes。 真正全局的 Runtime 不由某个页面生命周期拥有。

Surface consumes state; Surface does not invent state。 Surface 可以表现、控制,但不要复制一份业务真相。

Shared behavior before shared component。 先统一 ownership、state、contract 和行为,再讨论 JSX 是否复用。

Domain boundary must expose its edges。 Domain 不是孤岛,跨域关系必须显式可见。

Change one owner → verify every coupled surface。 命中高耦合 owner 后,联动验收面必须自动展开。

Diff scope is not acceptance scope。 改一行也可能需要验六个 Surface;改十个样式文件也可能只是低风险视觉调整。

Duplication of truth is more dangerous than duplication of markup。 两段类似 JSX 是维护成本,两套不同的 currentTrack 才是架构风险。

Necessary coupling should be explicit; accidental coupling should be removed。 不是追求“零耦合”,而是让必须存在的关系有 owner、有 contract、有验证。

Govern before drift becomes a bug。 最终目标不是更擅长修复耦合,而是在变化发生之前就知道它会牵动谁。

我最开始只是想梳理:前台侧边栏和后台侧边栏、前台顶栏和后台顶栏、音乐组件、专辑、歌词这些地方到底还有哪些是耦合的。继续往下以后,这个问题已经不适合停在“列一张相关组件清单”上了。

LMN516 如果继续增长,真正需要维护的单位不会只是 Page、Component、File,而会越来越接近:

Domain
+ Owner
+ State / Runtime
+ Semantic
+ Resource
+ Contract
+ Consumer
+ Cross-domain Edge
+ Acceptance

页面仍然重要,但页面只是这些关系最后显出来的地方。

所以这次我最后想留下来的,不是“以后所有东西都要解耦”,也不是“把所有公共部分都抽成一个组件”。恰恰相反,我更想接受系统里那些本来就应该存在的耦合,然后把它们治理清楚:谁负责,真相在哪里,生命周期在哪里,谁依赖它,改变以后谁必须一起验证。

做到这一步以后,LMN516 的工程方式才真正有可能从“哪里坏了修哪里”,走到“在改变之前,系统已经知道这次变化会穿过哪些边”。