v0.2.0 to v0.4.0

59 added, 421 removed. Audit A to A.

---
name: code-plugin-architecture
description: >-
- Use when the user's pain is "adding/removing one more X means editing N files" and X is a recurring variant kind: popup, banner, modal, ad slot, payment method, AI model/tool, form field type, connector, sub-site, command, menu item, agent, extension point, or data source. Use when they want to design, refactor, review, name, or explain a pluggable mechanism using registry, interface/trait contract, runtime core, and convention folders; mention pluginize, pluggable, plugin architecture, extension point, registry pattern, or extensibility. Use when explaining the first-principles rationale, DDD/SOLID/OCP mapping, or industry analogies behind that structure. Use for cross-stack mapping to VSCode contributes, Webpack/Vite plugins, Rust/Tauri connectors, Python entry_points, or cargo features. Skip one variant's internals/styles/hooks/copy/bugs, and skip register/registry meaning DI container, user signup, or package registry.
+ Design, review, refactor, or explain recurring extension mechanisms when adding or removing one variant requires scattered coordinated edits. Use for plugin architecture, registries, extension points, and cross-stack contract/host mappings. Assess whether a typed map, existing framework mechanism, or lifecycle-owning core is justified. Skip one plugin's internal styling or bugs, user signup, DI containers, and package registries.
metadata:
author: adonis
- version: "0.2.0"
+ version: "0.4.0"
---
# Code Plugin Architecture
- 把"会持续新增的一类东西"改造成插件化结构的**方法论 skill**。
-
- 不是脚手架,不绑定具体框架/语言。React / Vue / Rust / Python / CLI / VSCode extension / Webpack plugin / 后台菜单都适用——所有这些**本质上都是这套模式的实例**。
-
- ## 这个 skill 解决什么
-
- 工程里反复出现的同一类痛点:
-
- | 症状 | 真正问题 |
- |---|---|
- | "每次加一个新弹窗都要改 5 个文件" | 缺中央注册表 + 占位符引导 |
- | "加新工具有半天要踩坑找改哪儿" | 没有约定式目录 |
- | "几个相似模块各自 if-else 判断 type" | 缺核心 factory,business 写在调用方 |
- | "首屏变慢,因为所有模态框都被打包进来" | 缺延迟加载层(仅 frontend / runtime loading 场景) |
- | "新人改 A 弹窗结果 B 弹窗坏了" | 单插件没有自治目录 |
- | "命名一会儿 camelCase 一会儿 snake_case,dataName 还不一样" | 没有从 key 推导其他命名 |
- | "下线一个旧支付方式要改 8 处" | 注册表与实现耦合 |
-
- 如果用户描述命中其中任何一条,**这就是要这个 skill 介入的时刻**。
-
- ## 何时不要用
-
- - 只有 1~2 个变种,且**未来确定不会再加**的功能:直接 if-else 比插件化便宜。
- - 变种之间根本不共享行为(接口/输出/生命周期都不同):强行套同一抽象会出现"5 个可选属性 4 个用不上"。
- - 用户已经在用成熟的、强约定的框架插件机制(Next.js pages、VSCode extension manifest、Tauri plugin)——直接顺着用,不要发明第二套。
- - 用户问的是**单个具体插件的内部实现**("这个 modal 的样式怎么改"),而不是"插件机制本身的设计"。
-
- ## 核心结构 — 五件套
-
- 任何插件化系统的最小骨架是这五层。**只要少一层,扩展性就会在 3 个月内塌掉**。
-
- > 注:第一版方法论曾把它叫"四件套"(把 Contract 隐藏在 Registry 类型里)。实践证明把 Contract 显式拎出来更稳——绝大多数事故都来自 Contract 不清晰。
-
- ### 1. Identity(主键)
-
- 每个插件必须有一个**唯一、稳定、来自最权威层**的 key。
-
- ```
- 站点: hostKey: 'site-a.example.com' ← 来自 SiteKey 枚举(顶层)
- 工具: appKey: 'Motion' ← 来自后端 Labels schema(最权威)
- 弹窗: configKey: 'npsSurveyModal' ← camelCase 自定义
- 模型: modelId: 'gpt-4o' ← 来自供应商 ID
- ```
-
- **原则**:
- - 主键由**最权威的层**定义(后端 schema、外部供应商 ID、顶层枚举),**不要在调用侧重新发明**。
- - 主键派生其他所有命名:组件名、文件夹名、埋点名、route 段、CSS 变量。把推导关系列成一张表,加新插件就照表填。
-
- 错误示范:弹窗的 `configKey = "promoModal"`,组件名 `<PromoModalV2>`,埋点名 `promo_pop`,文件夹 `PromoPopup/`——四个名字互相猜不出,半年后没人敢删。
-
- ### 2. Contract(契约)
-
- 明确**每个插件必须提供什么、可以提供什么**的契约 — 通常是一个 interface / trait / TypedDict / Protocol。
-
- ```ts
- // 必填字段(结构同质)
- interface PluginContract {
- key: PluginKey
- component: ComponentRef
- // ...
-
- // 可选行为钩子
- shouldShow?: (ctx: Ctx) => boolean
- onMount?: (ctx: Ctx) => Cleanup
- }
- ```
-
- **原则**:
- - Contract 是 **Core ↔ Plugin 之间的唯一通讯协议**。Core 通过 Contract 看插件,插件通过 Contract 看 Core 暴露的能力。
- - 必填字段尽量少,可选字段表达变种差异。但要给"宽核心,窄实现"留余地——core 容忍各种可选,单插件只填用到的。
- - 一旦 Contract 上字段变了,所有插件要同步——所以**Contract 改动是高代价的**,加新字段优先用可选。
- - **Identity 是字段,不是 Contract 本身**。这是常见误解——很多人把"插件的 key 类型"当作 Contract,但 Contract 实际是"key + 行为"。
-
- ### 3. Registry(中央注册表)
-
- 一处显式的"我这里有哪些插件"清单。
-
- ```ts
- // ✅ 注册表只引用,不实现
- import { DynamicNpsSurveyModal } from './popups/dynamic'
-
- export const popupsConfig = {
- npsSurveyModal: initialPopupConfig({
- priority: 504,
- component: DynamicNpsSurveyModal,
- cache: { type: 'interval', timeout: 1, upperLimit: 2 },
- dataWidgetName: 'nps_survey_popup',
- }),
- // [Popup-Config]:(add) ← 占位符
- }
- ```
-
- **原则**:
- - **只引用,不实现**。注册表里不能出现任何插件的业务逻辑——一旦出现,下次加插件就要改注册表里的判断分支。
- - **结构同质**。每条记录的字段集合完全一致(用 `pluginConfig({...})` 工厂统一兜底默认值)。
- - **位置稳定**。所有同类插件的注册表只此一份,别因为"分组"拆成多个——分组的代价是丢失全局视野。
-
- ### 4. Runtime Core(运行时内核)
-
- 通用、无业务的内核,**对具体插件保持完全无知**。Runtime Core 负责"机制",不负责"内容"。
-
- ```ts
- export function createPluginCoreFactory<
- T extends PluginRegistry,
- S extends Record<string, any>,
- >(initialState, options) {
- return (...a) => {
- // 通用状态机
- return {
- init: (options) => { /* cache decision */ },
- closeAndTerminate: () => { /* ... */ },
- openForce: () => { /* ... */ },
- // ...
- }
- }
- }
- ```
-
- **原则**:
- - 内核通过**泛型** `<T extends Registry>` 接收注册表形状,自己不 import 任何具体插件。
- - 内核负责**横切同质的关注点**:状态机、缓存策略、生命周期、横切的埋点("插件被打开时统一埋一次曝光")、统一持久化。
- - 业务相关的事("这个弹窗要不要弹"、"用户登录后才显示")**不能写在内核里**——那是单插件 `init.ts` 的事。
- - 不要在内核里设 "if (key === 'X') ..." 的特殊分支——这是注册表机制失败的明确信号。
-
- > **关于 Provider Orchestration**:在 React/Vue/前端场景里,"何时选择 openKey"、"渲染哪个插件"这种**编排决策**通常落在 Provider/Orchestrator 层(订阅 Core store,按 Contract 字段做决策),不在 Core 里。Core 是状态机和切面 SDK,Orchestrator 是策略实现。两者都属于"业务零知识",但职责不同。把它当 Core 的子层即可,不必单列。
-
- ### 5. Convention Folder(约定式目录)
-
- 单个插件自治在一个目录内,所有相关物聚拢。
-
- ```
- popups/[Name]/
- ├── index.tsx ← UI(默认 export 一个组件)
- ├── init.ts ← 何时打开(条件 / AB / 登录态)+ 该插件特有的副作用
- └── constants.ts ← 这个插件专属的常量、文案
- ```
-
- **原则**:
- - 删除这个插件 = `rm -rf` 这个目录 + 注册表删一行。**没有跨目录的反向引用**。
- - 单插件内可以**完整理解**而不需要跳出去看核心机制。
- - 文件分工固定(一两种模式即可),让阅读者闭着眼也知道找什么去哪个文件。
- - 目录名 = 主键派生的 PascalCase。
-
- ## 第一性原理:五件套不是发明,是必要推论
-
- 开篇那张痛点表,业界有个正式名字叫 **Shotgun Surgery**(霰弹式修改)——"一个逻辑改动要散着改很多处",根因是"同一个职责(管理这一类东西)被切散到了多个文件"。插件化要解决的就是它。
-
- 从第一性原理看,目标只有一句话:**把"加一个新变种"的成本从 O(N)(改 N 处)降到 O(1)(新建一个自治单元 + 注册表加一行)**。一旦把目标定死成 O(1),五件套就不是谁拍脑袋的设计,而是逻辑上**绕不开的必要条件**——少任何一件,成本就回不到 O(1):
-
- | 要达成 | 否则会怎样 | 推出的件 | 业界可类比 |
- |---|---|---|---|
- | 所有变种共享稳定"形状" | 核心要为每种特判 | **Contract** | 可充当 Core↔Plugin 边界的 *Published Language*(DDD) |
- | 有唯一权威"身份" | 命名漂移、无法机器推导 | **Identity** | 插件的 name/id |
- | 有且仅有一处"名单" | "有哪些"这一知识被切散 | **Registry** | Fowler *Plugin*:"在配置期而非编译期把实现接上";Microkernel 的中心清单 |
- | 核心对具体变种零知识 | 核心随变种增长而改 | **Runtime Core** | *OCP*:对扩展开放、对修改封闭(用抽象/多态替代 switch) |
- | 单变种自治、无外部反向引用 | 删一个要满地找 | **Convention Folder** | 高内聚 + 单一变更点 |
-
- > 这就是"高内聚低耦合"在这里的精确落点:**高内聚** = Convention Folder(一个变种的东西聚一处)+ Registry(一类东西的名单聚一处);**低耦合** = Contract 是唯一通道,依赖方向单向 `Plugin → Contract ← Core`(谁都不依赖对方实现,即 DIP)。
- >
- > 注意类比是"可类比",不是等号——例如 Microkernel 的 registry 含动态发现/协议协商/版本治理,本方法论的 Registry 通常只是"中心元数据清单"那一层。完整推导、DDD 全映射与出处见 [references/first-principles.md](references/first-principles.md)。
-
- ## 递归组合:插件化是分形的
-
- 五件套不是只能用一次。当**单个 plugin 内部**也长出"会持续新增的变种"时,就在它内部**再套一层五件套**——一级注册表挂这个 plugin,它内部有自己的二级 Identity / Contract / Registry。这能避免把 N 个内部变种全炸成一级 key(否则一级编排的优先级/调度会爆炸)。
-
- 关键洞察:**Contract 不规定"插件必须是一个组件"**。同一个 Contract 下,变种可以是不同"种类"的东西——有的渲染 UI,有的只执行一个副作用。用一个可辨识联合表达就够了:
-
- ```ts
- type VariantStrategy =
- | { mode: 'view'; render: ComponentRef } // 渲染型:交给渲染层
- | { mode: 'effect'; perform: (deps) => void } // 副作用型:直接执行一个动作
- ```
-
- 编排层按 `mode` 分流,渲染层只认 `view`、副作用层只认 `effect`,核心依旧零业务。这其实是 **Strategy 模式与插件化的结合**,也天然对应 DDD 的"领域 / 应用 / UI"分层(详见 [references/first-principles.md](references/first-principles.md))。
-
- ## 八条设计要点
-
- 五件套之外,还有八个让系统真的"用起来顺"的要点。前七条是"结构舒适度",第八条是"运行时正确性"。
-
- ### a. 配置 / 行为分离
-
- ```
- config.ts ← 静态(priority、cache 策略、埋点名、默认 disabled)
- init.ts ← 动态(订阅 store、判断登录态、调用 AB 实验、调 init({open: true/false}))
- ```
-
- **同一属性只在一处写**。比如 cache 策略一律放 `config.ts`,`init.ts` 不重复传——否则双写就是双倍 bug。
-
- ⚠️ **历史债的迁移建议**:真实项目里这条很容易腐烂——比如某个 banner 的 `init.ts` 同时在 `config.ts` 和 `init.ts` 写 `cache: { type: 'count', count: 2 }`,原因往往是早期没有 config 层、后来加上时没回收旧 init 里的 cache。这类双写应该作为技术债清理,而不是当作"规范"复制粘贴。
-
- ### b. 延迟加载默认开(仅对应用场景)
-
- 每个插件实现都用 `dynamic()` / `lazy()` / 框架的延迟加载机制包一层。
-
- ```ts
- // dynamics.ts — 集中维护所有动态 import
- export const DynamicNpsSurveyModal = dynamic(
- () => import('./NpsSurveyModal'),
- { ssr: false },
- )
- ```
-
- 注册表只引用 `Dynamic*` 名字。**0 个用户用的插件不该进 bundle**。
-
- **适用范围限定**:这条对前端 / 用户面应用(首屏 bundle 重要)成立;后端内部插件、CLI 工具、内部 admin 工具可以省。Rust 编译型项目用 cargo `features` 是等价手段。
-
- ### c. 占位符引导
-
- 在中央注册表里留注释占位符,引导下一个加插件的人在哪一行操作:
-
- ```ts
- const config = {
- [SiteKey.SiteA]: siteAConfig,
- [SiteKey.SiteB]: siteBConfig,
- // [SiteKey-Site]:(config) ← 占位符
- }
- ```
-
- 让"加新插件"成为**机械动作**而非脑力税。也是后续做 codegen / 模板插入的锚点。
-
- ### d. 命名推导表
-
- 每个插件主键能机械推导出所有其他命名。列成表写在 README 里:
-
- | 占位符 | 规则 | 例子 |
- |---|---|---|
- | `__KEY__` | camelCase | `npsSurveyModal` |
- | `__COMPONENT__` | PascalCase | `NpsSurveyModal` |
- | `__DIR__` | = `__COMPONENT__` | `NpsSurveyModal` |
- | `__HOOK__` | `use__COMPONENT__Init` | `useNpsSurveyModalInit` |
- | `__DATA_NAME__` | snake_case | `nps_survey_modal` |
- | `__DYNAMIC__` | `Dynamic__COMPONENT__` | `DynamicNpsSurveyModal` |
-
- 无歧义、可机器化。这一张表是 skill 落地阶段最值钱的产出。
-
- ### e. 副作用边界明确
-
- | 副作用类型 | 归属 |
- |---|---|
- | **横切同质副作用**(每个插件都做同样的事) | Runtime Core 一处 subscribe |
- | **插件特有副作用**(只这一个插件需要) | 该插件的 `init.ts` 或 render adapter |
- | **跨插件编排副作用**("A 打开后强制开 B") | Provider/Orchestrator 层 |
-
- ```ts
- // ✅ 横切:所有 plugin 都要的曝光埋点 → core
- store.subscribe(
- (state) => state.openKey,
- (openKey) => openKey && tracker?.trackEvent({...}),
- )
-
- // ✅ 特有:NPS 弹窗专属的"提交后 closeAndTerminate" → init.ts
- // ✅ 编排:用户登录后强制打开 onboardingExperiment → Provider
- ```
-
- 错误:把所有插件可能的副作用都塞进 core,导致 core 出现 `if (key === 'X')` 分支。
+ 让新增或移除一种变体所需的知识集中在清晰边界内。目标是减少真实的修改传播和回归,结构按问题选择。
- ### f. 一份 Core,多个变种
+ ## 先确定用户要什么
- 实际工程里"插件"往往不只一种(弹窗、横幅、抽屉、Banner、Toast……)。把它们建在**同一个 core factory** 上,每种变种用自己的 store + Provider 注入:
+ | 当前请求 | 本轮产出与边界 |
+ | ------------------------ | ---------------------------------------------------------------------- |
+ | 解释 / 命名 / 第一性原理 | 解释当前机制、取舍和反例;不创建实现、README 或评审工程 |
+ | 只读评审 | 读取约定范围,报告当前伤害、证据和最小建议;不为验收删除插件或创建分支 |
+ | 设计 | 给所需契约、落点、行为和验证方案;示例是建议,不声称已实现 |
+ | 实现 / 重构 | 检查工作树后修改授权范围,保留既有行为与标识,运行对应验证 |
- ```
- PluginProvider/
- ├── _factory/core.ts ← 共享内核(缓存策略、生命周期、横切埋点)
- ├── popups/ ← 变种 A:弹窗
- │ ├── store.ts ← createPopupsStore (cachePrefix='popups')
- │ └── Provider/ ← 弹窗 Orchestrator(按 priority 选 openKey)
- └── banners/ ← 变种 B:横幅
- ├── store.ts ← createBannersStore (cachePrefix='banners')
- └── Provider/ ← 横幅 Orchestrator(按 priority 选 openKey + renderHeight)
- ```
+ 解释、评审、设计不因加载本 skill 自动变成实现。已有授权继续有效;新增范围、外部动作、破坏性迁移仍需对应授权。用户只问一个变体的内部问题时,处理那个问题,不扩成机制改造。
- 复用所有共性,分隔状态隔离。变种之间的差异(横幅有 renderHeight、弹窗没有)通过 slice 扩展实现。
+ 默认只读本文件。仅当当前任务需要时加载对应引用,不用为一个简单问题读完案例库:
- ### g. 全局调试探针
+ - 需要论证必要性、OCP / DIP / DDD 类比 → [references/first-principles.md](references/first-principles.md)。
+ - 需要检查具体反模式 → [references/anti-patterns.md](references/anti-patterns.md)。
+ - 有真实生命周期、订阅、缓存、异步加载或多账号状态 → [references/stateful-runtime.md](references/stateful-runtime.md)。
+ - 跨团队发布、第三方插件或版本兼容 → [references/governance.md](references/governance.md)。
+ - 要迁移到 Rust / Tauri / Python 或框架插件 API → [references/cross-stack.md](references/cross-stack.md)。
+ - 需要多站点、弹窗或工具目录的参考形状 → [references/case-studies.md](references/case-studies.md) 的相关案例;示意代码不是当前仓库证据。
- 开发环境把 store 挂到 `window`(或等价的运行时全局):
+ ## 从变化出发选择结构
- ```ts
- if (!IS_PROD) {
- (window as any).__POPUPS_STORE__ = store
- (window as any).clearPluginCache_popups = () => clearCacheWithPrefix('popups')
- }
- ```
+ 先沿最近一次新增或移除变体的路径,读现有 key、消费方、配置和测试。区分“同一事实必须同步改多处”与“实现、测试、文案各有合法职责”。文件数量、变体数量或出现 switch 本身都不是重构证据。
- 加新插件后第一次没显示——直接打开 DevTools 看 store 状态、清缓存就能定位。**省下"为啥不显示"的 30 分钟来回猜**。
+ 检查能推翻方案的最便宜证据:真实变体是否共享输入、输出、生命周期;重复的是知识还是外观;现有框架是否已经提供所需扩展点。能从代码查到的事实先查。普通可逆工程默认值自行选择,只有影响产品偏好、难回退取舍、授权或用户独有事实的问题才询问。
- ### h. 运行时重算 + 状态维度隔离
+ | 当前复杂度 | 合理起点 |
+ | ---------------------------------------- | --------------------------------------------------------------------- |
+ | 少量稳定分支,变化不传播 | 保留函数或 switch;不因未知的未来需求建插件系统 |
+ | 同步静态条目,共享简单输入/输出 | 类型明确的映射或函数表;无需 store、Provider、factory、缓存或异步边界 |
+ | 多变体确实共享订阅、调度、资源或状态转换 | 提取拥有这些生命周期的 Core,变体提供窄契约;按需分目录 |
+ | 框架已有注册和生命周期机制 | 使用现有机制,只补业务所需的契约;不再复制一套 host |
+ | 第三方独立安装或动态发现 | 先确认安装模型、兼容性与权限边界;静态注册无法自动满足这一要求 |
- 这条是"声明式注册"在真实运行时会撞上的两个坑,框架无关。
+ 收益不成立时给出“保留现状”及证据。用户要求证明某种结构必然正确时,先检查前提,不制造证明。
- **① 重算逃生阀**。注册通常做成幂等的(同一个 key 注册第二次就短路,避免重复初始化)。但当一个插件的"要不要显示 / 要不要启用"**取决于会变化的运行时上下文**(登录态、权限、AB 实验分组、远程开关),幂等就会把后续更新吃掉——上下文从"未登录"变成"已登录",插件状态却停在第一次注册的结果。
+ ## 五个概念,按需落地
- ```ts
- register({ key, enabled: deriveEnabled(ctx), force: true })
- // ^ 绕过幂等短路,按新 ctx 重算
- ```
+ Identity、Contract、Registry、Runtime Core、Convention Folder 是分析职责的词汇,不要求五个文件、五个模块或每次齐备。简单类型映射可以同时表达前三项;没有生命周期就无需独立 Core。目录负责组织实现,不决定架构是否成立。
- 判断边界:注册入口要不要 `force`,取决于"注册意图是否会随上下文失效"。纯静态插件不需要;意图依赖外部可变状态的,必须留这个逃生阀,否则会出"切换账号后弹窗永不出现"这类静默 bug。
+ 1. **Identity**:沿用最权威的稳定 key 和兼容语义。外部供应商 ID、埋点名、已发布路由不应为了命名整齐被重命名;本地目录可保留显式映射。外部字符串先校验,再查注册表。
+ 2. **Contract**:写清输入、输出、失败、生命周期和能力。Identity 只标识条目,不能替代行为契约。共享字段确实适用于全部变体才必填;可选字段有明确默认值;互斥能力用可辨识联合或不同契约,不为“结构同质”堆可选字段。
+ 3. **Registry**:给“有哪些条目”一个可追溯的组合入口。显式表、框架 manifest 或由模块贡献并汇总的表均可;单一事实来源不等于只许一个物理文件。保留重复 key、未知 key 和缺失能力的既有处理。
+ 4. **Runtime Core**:只抽出真实共享的调度与生命周期,依赖 Contract,不硬编码具体插件的业务条件。单插件条件归变体,跨插件产品顺序归编排层。没有共享状态时,普通调用函数就够。
+ 5. **Convention Folder**:当单变体有多份相关资源时聚拢;简单条目留在一个文件也成立。依赖方向应让移除一个变体不破坏无关变体;不为消除所有直接 import 而引入总线。
- **② 状态维度隔离**。Core 持有的横切状态(缓存、关闭计数、已读标记、冷却时间)如果要按**维度**隔离(按用户 / 租户 / 环境),用"主键 + 维度后缀"组合出存储键,而不是为每个维度新造一个主键:
+ 最小静态例子只表达共同函数签名和可追溯的名单:
```ts
- storageKey = `${prefix}_${pluginKey}_${dimensionSuffix}` // 如 ..._${userId}
- ```
-
- 否则 A 用户的"已关闭一次"会串到同设备的 B 用户身上。维度后缀让同一个插件主键在多用户/多环境下天然隔离,且不污染主键命名体系。
-
- ## 落地流程(最小步骤)
-
- 接到"把这块改成插件化"或"我要建一套 X 的插件机制"任务时,按这个顺序做。
-
- ### Step 1: 找 Identity
-
- 回答三个问题:
- 1. **谁来命名?** 后端 schema、供应商 ID、还是自定义?尽量挂最权威的那层。
- 2. **类型是什么?** 字符串字面量联合、enum、还是来自外部包的类型?
- 3. **新增 key 时改哪里?** 这条路径将出现在所有插件作者的工作流里,要短。
-
- ### Step 2: 设计 Plugin Contract
+ type Formatter = (value: string) => string;
- 写出每条插件**必须**有什么、**可以**有什么:
+ const formatters = {
+ upper: (value: string) => value.toUpperCase(),
+ lower: (value: string) => value.toLowerCase(),
+ } satisfies Record<string, Formatter>;
- ```ts
- interface PluginContract {
- // 必填(结构同质)
- priority: number
- component: React.ComponentType<any>
- dataWidgetName: string
+ type FormatterKey = keyof typeof formatters;
- // 可选(变种特有)
- cache?: PluginCache
- disabled?: boolean
- closeIconClassName?: string // 只横幅用
+ function format(key: FormatterKey, value: string): string {
+ return formatters[key](value);
}
```
- 写一个 `initialPluginConfig(data) { return { open: false, ...data } }` 兜底默认值。
-
- ### Step 3: 写 Runtime Core
-
- 零业务、纯泛型。核心动作至少:
- - 注册(init)
- - 触发(open / close / openForce)
- - 销毁(terminate)
- - 状态查询
- - 横切同质的副作用切面(同质埋点、cache、持久化)
-
- **绝对不允许**出现具体插件的 key 字符串。
-
- 如果需要"按 Contract 字段做策略决策"(例如 priority 排序选 openKey),把决策放到 **Provider/Orchestrator** 层,而不是 Core 状态机里——Orchestrator 也是业务零知识,但它消费 Core 暴露的 store。
-
- ### Step 4: 建中央 Registry
-
- 一份注册表。注册表里只有引用、配置、占位符,**没有 if-else**。
-
- ### Step 5: 写第一个插件示例
-
- 照 Convention Folder 模板写一个最小可用插件。这是后面所有插件的"复制粘贴模板"。
-
- ### Step 6: 加占位符引导和命名表
-
- 在 README 或注释里:
- - 占位符注释行
- - 命名推导表
- - 5 分钟 onboarding 文档("加一个新 X 的 5 个步骤")
-
- ### Step 7: 写自检清单 + 调试探针
-
- 按下面的"评审清单"对照过一遍。每一条都能回答"是"才算落地完成。挂上全局调试探针。
-
- ## 从存量代码迁移到插件化
-
- 把已有的"散装 if-else"迁移到插件化结构,跟从零搭建是两件事。**强行一刀切迁移 = 大批量回归**。推荐绞杀者模式:
-
- 1. **保留旧实现,建立新机制**。先在新位置建好 Contract / Registry / Core 三层,**不动旧代码**。
- 2. **建 adapter**。让 Registry 既能接旧的"裸组件",也能接新的"完整 plugin contract"。adapter 把旧的塞成 contract 的形状(cache 类型先用 `{type: 'default'}` 占位、埋点名沿用旧值)。
- 3. **新增插件全走新路**。从这一刻起,**新弹窗 / 新工具只允许走新机制**——给团队设硬边界。
- 4. **逐个迁移老插件**。每次只迁一个,PR 必须包含:(a) 迁移前后的展示行为对比截图/视频、(b) 缓存兼容验证(旧用户已存的 localStorage key 仍有效)、(c) 埋点 dataName 对应表。
- 5. **设旧 key 兼容期**。如果新机制变了命名规则,旧的 `localStorage` key / 埋点 name 要兼容一段时间——给 Core 加 `legacyKey?: string` 字段做映射,迁移期过后回收。
- 6. **删旧机制**。所有旧插件迁完 + 兼容期过 → 删旧机制 + 占位符代码 + 旧文档。
-
- **反模式**:一个大 PR 把所有旧弹窗一次性重写——审 review 几乎不可能、回归看不全、出 bug 时无法二分。
-
- ## 评审清单(add / review 通用)
-
- 落地一个插件化结构 / 评审现有插件化结构时,逐条对照。每条都标了 **Check method**——优先用机器化方法验证,能 grep 就别人眼瞅。
-
- | # | 评审项 | Check method |
- |---|---|---|
- | 1 | 加新插件 = 新建一个目录 + 注册表加一行 | 在 onboarding 文档里走一遍"加一个 X"的步骤,数改了几个文件 |
- | 2 | 删插件 = `rm -rf` 目录 + 注册表删一行 | 选一个最简单的插件,真的删一次(branch 上),看构建 + typecheck 报错处 |
- | 3 | 单插件目录内可以完整理解 | human review:随机抽一个插件目录,让没看过的人 10 分钟内说出它做啥 |
- | 4 | Core factory 不知道任何具体插件 | `grep -RE "['\"](pluginKeyA|pluginKeyB)['\"]" core/` 应为空(用真实插件 key 替换;分隔用裸 `|`,标准 ERE,不要转义) |
- | 5 | 同一属性只在一处定义(cache 不要 config + init 双写) | `grep "cache:" plugins/*/init.ts` 应为空 |
- | 6 | Bundle 真的按需加载 | DevTools Network panel / bundle analyzer 看每个插件是否独立 chunk(非 frontend 场景跳过) |
- | 7 | 命名遵循推导表 | 写一个脚本:从 key 推导出所有命名,对照实际文件名/常量名/埋点名 |
- | 8 | 占位符注释存在 | `grep -RE "\[[A-Za-z]+-[A-Za-z]+\]:\(" registry.ts` 应有命中(约定格式:`// [Domain-Action]:(slot)`) |
- | 9 | 横切副作用在 Core subscribe 一处 | `grep "subscribe\|emit\|trackEvent" plugins/` ≈ 0;core/ 集中 |
- | 10 | 开发环境有调试探针 | DevTools console:`__YOUR_REGISTRY_STORE__` 应可访问;清缓存命令应存在 |
- | 11 | 没有 `if (key === 'X')` 这种特例 | `grep -RE "key\s*===\s*['\"]" core/ orchestrator/` 应为空 |
-
- **关于占位符的格式约定**:本 skill 全程统一用 `// [Domain-Action]:(slot)` 形式,例如:
- - `// [Popup-Config]:(add)` — 在 Popups 注册表里加新 popup config 的位置
- - `// [SiteKey-Site]:(config)` — 在站群中央注册表里加新 hostKey config 的位置
- - `// [Plugin-Placeholder]:(register)` — Rust 例子里 vec! 注册插件的位置
-
- `grep` 时用同一种 regex 即可命中所有。**不要混用 `[X-Placeholder]` 和 `[X]:(Y)` 两种格式**——选一种全项目沿用,否则 grep 总会漏。
-
- **Check method 的轻重**:
- - `grep` 类规则可以放进 lint / CI(一旦回归立即拦住)。
- - `typecheck` 类规则可以用 `satisfies Record<Identity, Contract>` 强约束。
- - `human review` 类规则只能进 PR template。
- - `Network panel` 类规则进 release 前 checklist。
-
- 每条都通才算"真的插件化"。任意一条不通——**先回答"为什么这条不适用于本场景",能解释清楚再放过**。
-
- ## 案例研究与跨技术栈应用
-
- 按需读取:
+ 示例假定 key 已由类型内调用方提供。若 key 来自 URL、网络或用户输入,必须在边界校验并保留原有未知值错误;TypeScript 类型不能验证运行时输入。
- - [references/first-principles.md](references/first-principles.md) — 第一性原理完整推导(O(N)→O(1) 为什么逼出五件套)、DDD 全映射、业界出处(Microkernel / Fowler Plugin / OCP / Shotgun Surgery)与 SOLID 关系,**用于说服他人"这套不是编的"或对齐团队架构词汇**。
- - [references/case-studies.md](references/case-studies.md) — 4 个真实案例的对照表(多站点站群、推广横幅、推广弹窗、工具扩展 apps),每个案例都给出"五件套"映射,**用于实际操作前对齐**。
- - [references/anti-patterns.md](references/anti-patterns.md) — 八种最常见的"伪插件化"反模式(按严重程度分层:Must-fix / Smell / Context-dependent),**用于代码评审和 PR review 时挑出问题**。
- - [references/cross-stack.md](references/cross-stack.md) — 这套模式在 Webpack / VSCode / Express / Tailwind / Rust trait / Python entry_points 上的同构映射,**用于跨语言/跨框架 onboarding 时建立类比**。
+ ## 条件式工程细节
- ## 用户给的需求不够明确时
+ - **延迟加载**:仅在加载成本和实际渲染路径支持收益时采用。它会引入等待、失败和首次使用延迟;保留 SSR、首屏与错误行为,不默认 `ssr: false`。用真实网络/产物证据确认收益,不凭 dynamic import 字样宣称成功。
+ - **命名与引导**:存在反复找落点的成本时,给一个添加条目的例子、简短命名映射或仓内已有生成器。占位符、README、脚手架不是插件化的必要条件。
+ - **调试能力**:优先复用已有日志、DevTools 或测试接口。只有实际诊断需要才增加开发环境探针;不默认暴露全局 store、用户数据或清缓存写入口。
+ - **有状态的机制**:登录/权限变化必须重算相关意图;订阅、定时器、资源和异步结果必须有明确 owner 与清理;缓存按实际用户/租户边界隔离。静态机制不需要添加这些状态。具体边界见 stateful runtime 引用。
- 当用户说"帮我把这块改成插件化"但没说细节,主动问:
+ ## 实现与验证
- 1. **这一类东西未来还会加吗?预计多久加一个?** 不会加 → 不该用。
- 2. **主键从哪来?** 后端 / 供应商 / 自定义。回答不上来 → 先讨论主键,不要急着写代码。
- 3. **变种之间共享哪些行为?哪些不一样?** 共享的进 Core,不一样的进单插件。这是抽象边界。
- 4. **现在的痛点是"加慢"、"删难"还是"互相影响"?** 三种痛点对应不同重点(占位符 / 反向引用 / 自治目录)。
- 5. **有没有现成的框架插件机制可以顺?** 有就别造轮子。
- 6. **存量 vs 增量?** 已有插件多少个?新机制能容忍同时存在两套吗?(决定是否走绞杀者迁移)
+ 1. 冻结本轮范围,说明要减少哪条修改传播以及保留哪些行为;不把纯命名整齐或新框架当目标。
+ 2. 做一个最小、可回退的变体迁移,沿用既有 key、路由、持久化数据和错误契约;确认消费方不再重复同一判断后再移除旧路径。
+ 3. 运行与变化对应的验证:静态表检查合法/未知 key 与已有输出;有状态的机制检查更新、卸载、重复挂载、失败和跨账号隔离;公共 UI 行为还需真实浏览器/渲染信号。
- 不要在没回答这六个问题之前开始写 Core——很可能写出来用户用不上。
+ 用隔离 fixture 或只读依赖图验证新增/移除条目的传播,不在用户工作区为了“验收”真的删插件。只有用户要求实现且改动有回归风险时补必要行为测试;解释和只读评审不生成测试工程。
- ## 输出要求
+ 检索只能定位候选:字段不同、存在 switch、静态 import 或缺占位符都不能单独判失败。构建/typecheck 不能替代运行时清理、UI 加载或账号切换验收。未跑的必需验证写 `UNVERIFIED`。
- 执行这个 skill 时,**至少**产出:
+ ## 交付
- 1. 一份 Plugin Contract 类型定义(TypeScript / Rust trait / Python TypedDict 等,按项目栈)。
- 2. 一份中央 Registry 文件(含占位符注释)。
- 3. 一份 Runtime Core(泛型,零业务)。
- 4. 一个完整的示例插件(按 Convention Folder 模板)。
- 5. 一份 README(含命名推导表、"加新插件 5 步"、调试探针使用说明)。
- 6. 评审清单的**逐条勾选回应**(不能直接 copy 清单——要针对当前项目情况说"为什么这条满足/不满足",给出 Check method 的实际结果)。
- 7. 如果是存量代码改造,**附迁移路径**:adapter 设计、旧 key 兼容方案、PR 拆分计划。
+ 只交付用户要求的模式:解释给结论与必要例子;评审给证据、影响与最小修法;设计给适用边界、契约与验证;实现给修改和实际结果。迁移路径、目录树、README、调试探针仅在当前改造确实需要时提供,不固定凑齐产物。