mirror of
https://github.com/codestable/CodeStable.git
synced 2026-09-19 09:03:09 +08:00
675 lines
29 KiB
Markdown
675 lines
29 KiB
Markdown
# 编程通用范式:编排-计算分离与渐进结构化
|
||
|
||
> 本文档从具体语言实现中提炼出语言无关的编程范式。
|
||
> 这套范式适用于任何支持函数/模块/接口抽象的语言。
|
||
|
||
---
|
||
|
||
## 第一部分:核心模型
|
||
|
||
### 0. 基础约束:代码是写给人看的
|
||
|
||
所有规则的根基是一条认知事实:**人在单一时刻能处理的信息量是有限的**。代码的一切组织方式——函数拆分、文件划分、层次结构——最终都是为了让阅读者在任意时刻只需面对"刚好能理解"的信息量。
|
||
|
||
#### 单函数:一屏可读
|
||
|
||
一个函数应该在**一屏之内读完**,阅读者不需要滚动就能看到全貌。这不是审美偏好,而是认知约束——滚动意味着阅读者必须用记忆力去拼接上下文,记忆力是最不可靠的资源。
|
||
|
||
经验数字:
|
||
|
||
- **编排函数**:10-30 行最佳,每一行是一个动作名。超过 50 行几乎必然层级混乱。
|
||
- **计算函数**:不超过 50 行。超了说明它做的事情不止一件,应该继续拆。
|
||
- **硬上限**:单函数超过 100 行,无论什么理由,都是需要拆分的信号。
|
||
|
||
#### 单文件:一个文件一个角色
|
||
|
||
一个文件应该用**一句话说清它的职责**。说不清 → 文件该拆。
|
||
|
||
经验数字:
|
||
|
||
- **编排文件**(Workflow / Handler):50-150 行。它只做编排,超了说明业务逻辑混进来了。
|
||
- **计算文件**(领域服务 / 工具函数):100-300 行。一个内聚的计算主题。
|
||
- **超过 400 行**:几乎必然职责混合。不是"太长了",是"两件事混在一个文件里了"。
|
||
|
||
拆文件的判断方法:**数一下文件里有几组"不相关的函数"**。如果函数 A-D 互相调用但从不调用 E-G,而 E-G 互相调用也从不调用 A-D——它们就是两个文件。
|
||
|
||
#### 单次可见逻辑量
|
||
|
||
在任意一个抽象层级上,阅读者一次看到的**逻辑分支不应超过 7±2 个**(米勒定律)。
|
||
|
||
- 一个编排函数里,步骤超过 7 个 → 考虑分成"阶段",每个阶段是一个子编排
|
||
- 一个 if/switch 里,分支超过 5 个 → 考虑用注册发现或策略模式替代
|
||
- 一个模块的公开接口超过 7 个方法 → 考虑拆成更小的模块
|
||
- 一个函数同时操作超过 7 个概念(order、customer、payment...)→ 拆成两步
|
||
|
||
#### 信息密度的平衡
|
||
|
||
过疏和过密都有害:
|
||
|
||
- **过密**:一个函数里塞了加载、计算、持久化、异常处理、日志——阅读者被淹没
|
||
- **过疏**:每个操作都拆成单独的类和文件,3 行逻辑散落在 5 个文件里——阅读者被迫四处跳转拼接
|
||
|
||
**判断标准:阅读者理解一段逻辑需要同时打开几个文件?** 如果超过 3 个,说明拆得太碎了。所有规则(分层、拆文件、抽函数)都要在"单点信息过载"和"跨文件跳转"之间找平衡。
|
||
|
||
#### 文件内的信息布局
|
||
|
||
一个文件被打开后,阅读者在**前 10 秒**应该能回答:
|
||
|
||
1. **这个文件是干什么的**(文件名 + 顶部注释/类名)
|
||
2. **核心流程是什么**(编排函数在最前面,不要埋在 500 行之后)
|
||
3. **依赖了谁**(import / 构造参数一目了然)
|
||
|
||
具体做法:
|
||
|
||
- **编排函数/主函数放在文件最前面**,不要被辅助函数埋没
|
||
- **公开接口在前,私有实现在后**——阅读者先看"做什么",有兴趣再往下看"怎么做"
|
||
- **命名要承载意图**——好的命名让阅读者跳过实现。`compute_risk(order, customer)` 不需要看函数体就知道它干什么;`process(data)` 什么都没说
|
||
|
||
#### 核心判断标准
|
||
|
||
所有数字(50 行、400 行、7 个概念)都是启发式信号。真正的判断标准只有一个:
|
||
|
||
> **一个新加入团队的人,打开这个文件/函数,能否在 30 秒内理解它的意图?**
|
||
>
|
||
> 能 → 信息密度合适。
|
||
> 不能 → 要么太长(拆)、要么层级混乱(抽取)、要么命名不好(重命名)。
|
||
|
||
### 1. 三段式执行模型
|
||
|
||
任何有副作用的业务操作,都可以分解为三个阶段:
|
||
|
||
```
|
||
加载(Load)→ 计算(Compute)→ 持久化(Persist)
|
||
```
|
||
|
||
- **加载**:从外部世界(数据库、文件、网络)读取状态,装入内存结构
|
||
- **计算**:纯逻辑处理,不接触外部世界,输入决定输出
|
||
- **持久化**:把计算结果写回外部世界
|
||
|
||
这三个阶段的代码**不应混在同一个函数里**。混合是复杂性的第一来源——你无法单独测试计算逻辑,因为它和 IO 纠缠在一起。
|
||
|
||
**不同语言的实现方式不同,但分离原则不变**:
|
||
- 函数式语言天然做到了(纯函数 + IO Monad)
|
||
- 面向对象语言需要显式分层(Loader / Service / Persister)
|
||
- 脚本语言需要纪律约束(把 IO 调用集中到函数首尾)
|
||
|
||
### 2. 状态是显式的容器,不是隐式的环境
|
||
|
||
程序中流转的状态应该是一个**显式的数据结构**,在函数间作为参数传递,而不是藏在全局变量、线程本地存储、闭包捕获、或模块级 mutable state 中。
|
||
|
||
```
|
||
// 原则
|
||
f(state) → state'
|
||
|
||
// 而不是
|
||
f() // 偷偷读写某个外部的 state
|
||
```
|
||
|
||
这个显式的数据结构(我们称之为 **Context**)有三层内容:
|
||
|
||
| 层次 | 性质 | 示例 |
|
||
|------|------|------|
|
||
| 不可变输入 | 请求级标识,全程不变 | request_id, tenant_id, trace_id |
|
||
| 领域状态 | 核心业务数据,可变 | order, customer, account |
|
||
| 过程产物 | 节点间的中间结果 | risk_score, payment_result |
|
||
|
||
**Context 的边界判断**:一个值该不该进 Context,问一个问题——"这个值在不同请求之间会不一样吗?" 会 → 进 Context;不会 → 它是依赖,通过构造/初始化注入。
|
||
|
||
#### 框架提供的状态容器是合法的 Context
|
||
|
||
框架提供的响应式状态容器(Vue ref/reactive、React useState、Redux store)本质上就是 Context——它们是显式的、可追踪的、框架帮你管理的状态。**使用它们不违反本原则。**
|
||
|
||
不同技术栈的 Context 形态:
|
||
|
||
| 技术栈 | 状态容器(Context) | 编排层 | 计算层 |
|
||
|--------|---------------------|--------|--------|
|
||
| Vue | ref / reactive | methods / watch | computed / 纯函数 |
|
||
| React | useState / store | event handler / useEffect | 纯函数 / useMemo |
|
||
| 后端 Java | Context 对象 | Workflow 类 | 领域服务 |
|
||
| Rust | &mut Context | orchestrator 函数 | 纯函数 |
|
||
|
||
**违反本原则的不是"改 ref",而是绕过显式容器传递可变状态**:
|
||
|
||
- 模块级变量被多个组件/函数隐式共享
|
||
- 全局对象/单例被当作垃圾桶随处读写
|
||
- 隐式注入(provide/inject、ThreadLocal)传递可变状态而非只读值或修改函数
|
||
- 闭包捕获了外层的可变变量,且修改不可追踪
|
||
|
||
判断标准:**"这个状态的修改,是否可以从编排层的代码里一眼看出来?"** 可以 → 合法;不可以 → 隐式状态,需要招安。
|
||
|
||
### 3. 编排与计算分离
|
||
|
||
这是整套范式的核心原理。
|
||
|
||
**编排(Orchestration)**= 决定"做什么、以什么顺序、在什么条件下"。
|
||
**计算(Computation)**= 具体"怎么做"。
|
||
|
||
两者必须分处不同的代码单元(函数、模块、文件)。
|
||
|
||
```
|
||
// 编排层:只有流程骨架,每一行是一个动作名
|
||
function process_order(ctx):
|
||
validate(ctx)
|
||
check_risk(ctx)
|
||
charge(ctx)
|
||
ship(ctx)
|
||
|
||
// 计算层:具体逻辑,不知道自己在哪个流程里
|
||
function check_risk(order, customer) -> risk_score:
|
||
// 纯计算,无副作用,不依赖 context 容器
|
||
```
|
||
|
||
**关键约束:计算层不应接收 Context 容器。** 它只接收自己真正需要的参数。编排层负责从 Context 中取出参数、调用计算、把结果写回 Context。
|
||
|
||
精确表述:**计算层接收值、返回值,不接触状态容器。编排层负责从容器取值、调计算、把结果写回容器。**
|
||
|
||
```
|
||
// ✅ 计算函数:接收值,返回值,不碰容器
|
||
function compute_risk(order, customer) -> score:
|
||
score = 0
|
||
if customer.bindDuration < 30: score += 40
|
||
if order.amount > 10000: score += 30
|
||
return score
|
||
|
||
// ✅ 编排层:从容器取值 → 调计算 → 写回容器
|
||
function handle_submit():
|
||
score = compute_risk(order.value, customer.value) // 取值 + 调计算
|
||
risk_score.value = score // 写回容器
|
||
|
||
// ❌ 计算和编排混在一起
|
||
function compute_risk():
|
||
score = 0
|
||
if customer.value.bindDuration < 30: score += 40 // 直接读容器
|
||
risk_score.value = score // 直接写容器
|
||
api.charge(order.value.id) // 连持久化都混进来了
|
||
```
|
||
|
||
**只读派生不算"接触容器"。** 响应式框架中的派生计算(Vue computed、React useMemo)读取容器但不修改状态,数学上是 `f(state) → derived_value`,属于计算层而非编排层。
|
||
|
||
这条约束的意义:
|
||
- 计算函数的签名暴露了它的真实依赖,可读性极高
|
||
- 计算函数可以脱离 Context 独立测试
|
||
- Context 的变更不会波及计算层
|
||
|
||
#### 编排层是一个职责,不是一个固定的架构层
|
||
|
||
编排的本质是**"组装能力而不实现能力"**。这个职责可以由不同重量级的代码单元承担:
|
||
|
||
```
|
||
最轻 最重
|
||
│ │
|
||
调用点直接组合 提取组合模块 独立编排模块/类
|
||
(模板里写slot组合) (CopyableTip) (OrderWorkflow)
|
||
│ │
|
||
适合:1-2次使用 适合:3+次重复模式 适合:核心业务流程
|
||
不额外建文件 多一个小文件 独立文件,专职编排
|
||
```
|
||
|
||
三者做的事情完全一样——组装能力。区别只是**固化程度**。
|
||
|
||
后端倾向重量级编排(独立 Workflow 类),因为流程涉及事务边界、持久化时机、多入口调用,这些约束迫使编排逻辑必须集中管理。
|
||
|
||
前端组件组合通常没有这些约束——一个页面模板组合几个组件,不涉及事务、调用点通常就一个——所以调用点直接组合就够了。
|
||
|
||
**前端需要重量级编排的信号**:
|
||
- 多步表单/向导:步骤之间有依赖和条件跳转
|
||
- 复杂状态联动:改 A 要联动更新 B、C、D,不该散落在各个 watch 里
|
||
- 同一组合模式重复 3 次以上
|
||
|
||
选择哪个重量级,问三个问题:
|
||
1. **有没有事务/持久化/多入口等约束?** 有 → 独立编排模块
|
||
2. **这个组合模式重复出现 3 次以上了吗?** 是 → 提取组合模块
|
||
3. **都不是?** → 调用点直接组合,最简方案先行
|
||
|
||
### 4. 同层同级原则
|
||
|
||
基础约束(第 0 节)在代码层面的直接推论:**一个函数体内的每一行代码,应该处于同一个抽象层级**。
|
||
|
||
```
|
||
// ✅ 同一层级:都是"做什么"
|
||
validate(ctx)
|
||
check_risk(ctx)
|
||
charge(ctx)
|
||
ship(ctx)
|
||
|
||
// ❌ 层级混乱
|
||
validate(ctx)
|
||
score = 0
|
||
for rule in rules: // 突然掉到"怎么做"
|
||
score += rule.eval(order)
|
||
charge(ctx)
|
||
ship(ctx)
|
||
```
|
||
|
||
判断标准:**每一行能不能用一句自然语言概括为"做了什么"**——如果需要说"它遍历了一个列表然后累加了分数",那这段代码属于下一层。
|
||
|
||
---
|
||
|
||
## 第二部分:扩展模型
|
||
|
||
### 5. 按注册发现,不按分支选择
|
||
|
||
当系统需要支持多个可选功能(插件、特性开关、商业分级)时:
|
||
|
||
```
|
||
// ❌ 分支选择:调用点知道所有实现,改一个加一个都要改调用点
|
||
if feature == "A": do_a()
|
||
elif feature == "B": do_b()
|
||
elif feature == "C": do_c()
|
||
|
||
// ✅ 注册发现:调用点只知道契约,实现自己注册进来
|
||
for feature in registry.discover(FeatureContract):
|
||
if feature.enabled():
|
||
feature.apply(ctx)
|
||
```
|
||
|
||
核心转变:**"要不要做"的判断从调用点移到注册点**。调用点只剩遍历,没有分支。功能增减靠改注册表(配置文件、目录扫描、包管理),不靠改代码。
|
||
|
||
不同语言的注册机制不同,但原理一致:
|
||
- Java:ServiceLoader / Spring Bean
|
||
- Rust:trait object + inventory crate / 编译期 feature flag
|
||
- Python:entry_points / 插件目录扫描
|
||
- TypeScript:依赖注入容器 / 动态 import
|
||
- Go:init() 注册 + 全局 registry map
|
||
|
||
### 6. 契约 + 缺省行为
|
||
|
||
可选功能的契约(接口/trait/protocol)应该提供**有意义的缺省行为**,而不是强制所有实现者处理每一个方法。
|
||
|
||
```
|
||
trait OrderFeature:
|
||
fn apply(ctx) // 必须实现
|
||
fn enabled() -> bool: // 缺省:启用
|
||
return true
|
||
fn order() -> int: // 缺省:居中
|
||
return 100
|
||
```
|
||
|
||
这使得:
|
||
- 新增一个特性只需实现核心方法,其余走缺省
|
||
- 关闭特性时用缺省的空实现顶替(Null Object),调用代码不改
|
||
|
||
**Null Object 原则:缺席也是一种存在。** 当一个功能被裁剪时,不是"不调用它",而是"调用一个什么都不做的实现"。这让编排层永远保持一致的结构。
|
||
|
||
### 6.1 契约的稳定性设计
|
||
|
||
注册机制的稳定性完全取决于契约(接口/trait/protocol)的稳定性。接口的稳定性不靠"不变"来保证,靠**"变了也不破坏"**来保证。
|
||
|
||
**接口变化的三种情况及应对**:
|
||
|
||
| 变化类型 | 应对手段 | 风险 |
|
||
|----------|----------|------|
|
||
| 加新方法 | 给缺省实现,老注册者不受影响 | 低 |
|
||
| 改现有方法签名 | 接口定"厚"了,需要重新审视抽象 | 高 |
|
||
| 接口职责变了 | 版本化接口 + 适配器 | 高,但可控 |
|
||
|
||
**接口厚度的谱系**:
|
||
|
||
接口参数越具体,越容易因业务演化而变:
|
||
|
||
```
|
||
最厚(最容易变)
|
||
│ fn score(order, customer, rules) -> RiskScore // 参数多且具体
|
||
│ fn apply(ctx: &mut OrderCtx) // 只依赖一个 Context
|
||
│ fn handle(event: Event) -> Vec<Event> // 事件驱动,最松耦合
|
||
最薄(最不容易变)
|
||
```
|
||
|
||
**核心原则:接口的参数应该是"稳定的名词",不是"易变的结构"。**
|
||
|
||
**三个稳定性挡板**:
|
||
|
||
1. **新方法给缺省实现**——加方法永远不破坏现有注册者
|
||
2. **Context 设扩展区**——核心字段强类型,新增字段走扩展区(如 HashMap),等稳定后再提升为核心字段
|
||
3. **大版本变更用适配器**——老实现通过适配器在新接口上运行,新老共存
|
||
|
||
```
|
||
// Context 扩展区示例(伪代码)
|
||
struct OrderCtx:
|
||
order: Order // 核心字段,稳定
|
||
customer: Customer // 核心字段,稳定
|
||
extensions: Map<String, Any> // 扩展区,新字段先落在这里
|
||
|
||
fn get_ext<T>(key) -> Option<T>
|
||
fn set_ext<T>(key, val)
|
||
```
|
||
|
||
**选型指导**:
|
||
|
||
| 场景 | 推荐方案 |
|
||
|------|----------|
|
||
| 团队内部、变化可控 | 方法接口 + Context(含扩展区) |
|
||
| 跨团队/跨组织插件 | 版本化接口 + 适配器 |
|
||
| 高度动态、无法预知未来 | 事件/消息契约 |
|
||
|
||
**不要为了还没发生的变化提前设计兼容层**——大多数项目从"方法接口 + Context 扩展区"开始就够了。需要插件体系时再引入版本化接口或事件契约。
|
||
|
||
### 7. 编排层的 Workflow 拓扑
|
||
|
||
当业务流程超出线性 pipeline(需要分支、并行、汇合)时,拓扑需要升级:
|
||
|
||
| 复杂度 | 拓扑模型 | 适用场景 |
|
||
|--------|----------|----------|
|
||
| 顺序 | Pipeline(列表遍历) | 步骤固定、无条件分支 |
|
||
| 分支 | Router(路由表/条件边) | 有 if/else 但无并行 |
|
||
| 并行 | DAG(有向无环图) | 多条支线可同时执行 |
|
||
| 循环 | 状态机 | 审批打回、重试、长等待 |
|
||
|
||
**拓扑结构本身应该是数据,不是控制流。** 分支是图的属性,不是代码里的 if/else。这样拓扑可以被配置、可视化、动态修改。
|
||
|
||
但要注意:**当分支逻辑简单时(2-3 个 if),直接写代码比建图清晰**。不要为了架构正确性牺牲可读性——5 行 if/else 好过 200 行图引擎。
|
||
|
||
---
|
||
|
||
## 第三部分:参数设计
|
||
|
||
### 8. 参数打包的判断框架
|
||
|
||
函数参数是散着传还是打包成结构体/对象,不应由参数个数决定,而由以下维度判断:
|
||
|
||
**维度一:概念内聚性(最重要)**
|
||
|
||
这些参数是不是"一件事"的不同侧面?如果它们总是一起出现、一起变化(Data Clump),就该打包。
|
||
|
||
```
|
||
// 2 个参数但概念上是整体 → 该打包
|
||
fn ship(address: String, zip: String) // ❌
|
||
fn ship(address: Address) // ✅
|
||
|
||
// 5 个参数但各自独立 → 不用打包
|
||
fn transfer(from: Account, to: Account, amount: Money, reason: String, when: Instant) // ✅
|
||
```
|
||
|
||
**维度二:一致性约束**
|
||
|
||
参数之间有约束关系(from < to、lat/lng 配对)→ 必须封装,否则约束泄漏到所有调用者。
|
||
|
||
**维度三:抽象层级一致**
|
||
|
||
参数应在同一抽象层级。高层概念和低层细节并存时,细节该被封装进高层结构。
|
||
|
||
**维度四:变化频率**
|
||
|
||
同一个函数签名改过 3 次参数 → 这里需要一个结构体。
|
||
|
||
**数字参考(启发式)**:
|
||
- 1-3 个:散着传,除非概念上强内聚
|
||
- 4-5 个:检查有无 Data Clump
|
||
- 6+:几乎肯定有东西该打包
|
||
- 10+:函数职责可能过重
|
||
|
||
### 9. 值对象先行
|
||
|
||
从需求中提取类型时,**值对象(Value Object)比实体(Entity)更优先**。
|
||
|
||
- **实体**:有唯一标识、有生命周期、会被修改(Order, User, Account)
|
||
- **值对象**:无 ID、表达度量或描述、不可变(Money, Address, TimeRange, Email)
|
||
|
||
值对象是消除参数散乱的第一工具——它们把原本散着传的基本类型打包成有语义的整体,带上自己的校验规则和行为。
|
||
|
||
```
|
||
// 散着传:调用者必须自己确保 amount > 0 且 currency 合法
|
||
fn charge(amount: f64, currency: String)
|
||
|
||
// 值对象:Money 的构造函数保证了合法性
|
||
fn charge(amount: Money)
|
||
```
|
||
|
||
---
|
||
|
||
## 第四部分:编写规则
|
||
|
||
### 规则 1:值对象先行,实体随后
|
||
|
||
从需求中先识别值对象,再识别实体。值对象定义了系统的"度量衡",实体定义了系统的"角色"。
|
||
|
||
### 规则 2:最简 Workflow 先行
|
||
|
||
写一个最简编排把核心流程跑通,节点先用空实现。先达成结构共识,再填细节。
|
||
|
||
### 规则 3:逐个节点填充,每填一个跑一次测试
|
||
|
||
### 规则 4:同层同级,控制认知负荷
|
||
|
||
编排函数每一行保持同一抽象层级(见第 4 节)。所有长度和密度约束见基础约束(第 0 节),核心数字速查:
|
||
|
||
- **单函数**:编排 10-30 行(上限 50),计算不超过 50 行(上限 100)
|
||
- **单文件**:一个文件一个角色。编排文件 50-150 行,计算文件 100-300 行,超 400 行必拆
|
||
- **逻辑量**:单函数操作的独立概念不超过 7 个,分支不超过 5 个
|
||
- **密度平衡**:理解一段逻辑不应需要同时打开超过 3 个文件
|
||
- **终极判断**:新人打开这个文件,30 秒内能否理解意图?
|
||
|
||
### 规则 5:复制还是复用
|
||
|
||
#### 判断:给它起个名字
|
||
|
||
把"相同的部分"假装抽出来,试着给它起个名字:
|
||
|
||
```
|
||
第一步:能起具体名字吗?
|
||
├─ 需要 Generic / Common / Base / Shared 前缀 → 不是真概念 → 复制
|
||
└─ 有具体名字(CopyableTip、useAsync、Money)→ 进入第二步
|
||
|
||
第二步:不看调用点能理解它吗?
|
||
├─ 不能,要看谁在用才知道它干什么 → 意义依赖场景 → 复制
|
||
└─ 能,它自己就说清了自己是什么 → 是独立概念 → 复用
|
||
```
|
||
|
||
两步都过了才复用。任何一步没过就复制。
|
||
|
||
这条原则在不同层级上的表现:
|
||
|
||
| 层级 | 复制(各自演化) | 复用(同生共死) |
|
||
|------|-----------------|-----------------|
|
||
| 编排/workflow | 两个业务流程看起来像 → 复制,各走各的 | — |
|
||
| 固定结构组合 | — | CopyableTip = HoverTip + CopyButton,没有 if,改一个就该改全部 → 复用 |
|
||
| 领域计算 | — | "税怎么算"不因流程不同而不同 → 必须复用,单一真相源 |
|
||
|
||
#### 判断错了怎么办:识别信号,内联回去
|
||
|
||
预测不可能总对。关键不是判断准,而是**错了能快速纠正**。错误的复用会发出四个明确的代码信号:
|
||
|
||
**信号一:参数里出现了 boolean / enum 开关。** 某个调用点说"我要不一样的行为",你加了 `useIcon=true`、`feedbackMode="color"`。开关越多,说明调用点分化越严重——当初的"同一个概念"假设已经不成立。
|
||
|
||
**信号二:内部出现"只为某个调用点服务"的 if。** 打开共享模块,发现某个分支只有一个地方在走。它不再是通用模式,而是特殊需求的拼盘。
|
||
|
||
**信号三:改代码时脑子里在想"别影响别的调用点"。** 这是最直觉的信号——你已经在为错误的复用买单了。
|
||
|
||
**信号四:新人看不懂这个抽象。** 需要看调用点才能理解它是干什么的,说明它不是独立概念。
|
||
|
||
出现任何一个信号,执行**内联**:
|
||
|
||
```
|
||
1. 把共享模块的代码复制回每个调用点(出现重复代码是正确的中间状态)
|
||
2. 删掉共享模块
|
||
3. 让每个调用点独立演化
|
||
4. 观察是否有新的真实共性浮现——演化后的共性才是真共性
|
||
```
|
||
|
||
#### 默认策略:先复制,等待复用浮现
|
||
|
||
```
|
||
复制 → 发现是真共性 → 提取复用 容易,两份代码都是完整的
|
||
复用 → 发现不该复用 → 内联拆开 痛苦,N 个调用点已经依赖它
|
||
```
|
||
|
||
**错误的复用比错误的复制代价大得多。** 所以默认倾向复制——等到命名测试两步都通过、重复出现 3 次以上、而且没有出现上述四个信号时,再提取复用。
|
||
|
||
### 规则 6:新功能叠加,骨架不动
|
||
|
||
新功能以子流程、装饰器、Null Object 替换的方式叠加到现有 workflow 上。主 workflow 骨架尽量保持不变。功能上下 = 子流程的挂载或卸载。
|
||
|
||
在组件/模块层面同样适用:**新功能是组合出新组件,不是往老组件里加 prop/开关**。
|
||
|
||
#### 判断方法:细化还是组合?
|
||
|
||
遇到"要改老模块还是建新模块"时,问一个问题:
|
||
|
||
> **新功能和老模块是"同一个概念的细化",还是"两个概念的组合"?**
|
||
|
||
- **细化** → 改老模块。比如 tooltip 支持不同方向(top/bottom/left/right),方向是 tooltip 本身的属性。
|
||
- **组合** → 建编排层,把多个独立能力组装在一起。比如 tooltip + 复制、tooltip + 确认按钮。
|
||
|
||
#### 实例:HoverTip + 点击复制
|
||
|
||
需求:已有 HoverTip 组件(鼠标悬停显示提示),现在某个页面需要 tip 内容可以点击复制。
|
||
|
||
分析:
|
||
|
||
- **HoverTip**:鼠标悬停 → 显示文字 → 离开消失。核心是"被动信息展示"。
|
||
- **点击复制**:用户主动点击 → 写入剪贴板 → 反馈"已复制"。核心是"主动用户动作"。
|
||
|
||
一个是被动展示,一个是主动交互——**不是同一个概念,是两个独立的能力**。
|
||
|
||
```
|
||
❌ 在原组件里加 prop(瑞士军刀式膨胀)
|
||
|
||
<HoverTip content="xxx" :copyable="true" />
|
||
|
||
今天加 copyable,明天加 closable,后天加 hasAction,
|
||
半年后 15 个 prop、8 个 slot、300 行模板。
|
||
本质错误:把编排逻辑(哪个页面需要什么组合)塞进了计算层(组件本身)。
|
||
```
|
||
|
||
正确方向:**HoverTip 提供 slot,CopyButton 作为独立能力,组合发生在调用点**。
|
||
|
||
```
|
||
// CopyButton:纯"复制"能力,不知道自己会被用在哪里
|
||
component CopyButton(text):
|
||
copied = false
|
||
fn copy():
|
||
clipboard.write(text)
|
||
copied = true
|
||
after 1.5s: copied = false
|
||
render: button(onClick=copy, label=copied ? "已复制" : "复制")
|
||
```
|
||
|
||
最简方案——**调用点直接用 slot 组合**,不建新组件:
|
||
|
||
```
|
||
// 调用点就是编排层,决定 tip 里放什么
|
||
HoverTip:
|
||
slot content:
|
||
text("可复制内容")
|
||
CopyButton(text="可复制内容")
|
||
<span>悬停我</span>
|
||
```
|
||
|
||
HoverTip 和 CopyButton 各自不知道对方的存在,组合关系只在调用点体现。
|
||
|
||
#### 判断链:何时提取组合组件?
|
||
|
||
```
|
||
1. 老模块有 slot / 扩展点吗?
|
||
├─ 没有 → 先加 slot(这是"细化",支持自定义内容是它本身的能力)
|
||
└─ 有 → 在调用点直接用 slot 组合新能力
|
||
|
||
2. 这个组合模式重复出现了几次?
|
||
├─ 1-2 次 → 保持调用点组合,不建新模块
|
||
└─ 3+ 次 → 提取组合组件固化模式,消除重复
|
||
```
|
||
|
||
只有当"tip + copy"的组合出现 3 次以上,才值得提取 CopyableTip:
|
||
|
||
```
|
||
// CopyableTip:固化重复的组合模式
|
||
component CopyableTip(content):
|
||
render:
|
||
HoverTip:
|
||
slot content:
|
||
text(content)
|
||
CopyButton(text=content)
|
||
slot default:
|
||
<children/>
|
||
```
|
||
|
||
**先用最简单的方式(slot 组合),等重复逼你封装的时候再封装。** 这是规则 2(最简方案先行)在组件层面的体现。
|
||
|
||
通用原则:**出现 1 次是用法,出现 3 次是模式,模式才值得封装。**
|
||
|
||
---
|
||
|
||
## 第五部分:重构规则
|
||
|
||
### 规则 7:安全手法蚕食,不重写
|
||
|
||
操作顺序:**重命名 → 抽取函数 → 移到新模块 → 清理新模块内部**。
|
||
|
||
核心纪律:
|
||
- 抽取函数和移到新模块是**一个完整动作**,只做抽取不做搬移 = 做了一半
|
||
- **搬和改永远不在同一次提交**——搬的 commit 只移动代码不改逻辑,改的 commit 只优化不移动
|
||
- 每步之间运行测试,确保行为不变
|
||
|
||
安全手法按风险从低到高:
|
||
|
||
1. **重命名**:零风险,工具一键完成
|
||
2. **抽取函数**:极低风险,工具一键完成
|
||
3. **引入参数对象**:低风险
|
||
4. **移到新模块**:中低风险
|
||
5. **内联后重新抽取**:中风险,用于修正之前抽错的结构
|
||
|
||
### 规则 8:让结构浮现,再让结构裸露
|
||
|
||
从大函数中抽取小函数 → **结构浮现**(编排逻辑从细节中露出来)。
|
||
|
||
把小函数搬到独立模块 → **结构裸露**(编排模块只剩编排,打开就是流程图)。
|
||
|
||
最终目标:编排模块 20-30 行,一眼可读,每一行是一个动作名。所有实现细节在各自的独立模块里。
|
||
|
||
**浮现了但没裸露,等于没做完。**
|
||
|
||
---
|
||
|
||
## 第六部分:重构实操节奏
|
||
|
||
### 绞杀者模式
|
||
|
||
新结构从老代码外围长出来,逐步替换。整个过程老代码始终能跑、老功能始终不坏。
|
||
|
||
### 渐进阶段
|
||
|
||
| 阶段 | 动作 | 产出 |
|
||
|------|------|------|
|
||
| 0 | 画现状流程图(不动代码) | 节点清单 + 每个节点的读写依赖 |
|
||
| 1 | 抽取函数 | 主函数变成 workflow 雏形 |
|
||
| 2 | 识别隐式状态(全局变量、线程本地、闭包捕获) | 需要显式化的状态清单 |
|
||
| 3 | 引入 Context 结构体 | 所有状态传递显式化 |
|
||
| 4 | 分离加载/计算/持久化 | 纯计算函数可独立测试 |
|
||
| 5 | 搬到独立模块 | 老模块只剩编排 |
|
||
| 6 | 拆出 Loader / Workflow / Persister | 完整分层结构 |
|
||
|
||
每个阶段都可停下来,代码始终可运行。
|
||
|
||
### 关键技巧
|
||
|
||
- **特征测试做安全网**:录制真实输入输出,每次改完对比行为一致性
|
||
- **开关切流**:新老流程并存,小比例流量先走新流程,逐步放量
|
||
- **提交粒度越细越好**:每搬一个函数就提交一次
|
||
- **先别追求完美 Context**:重构中期先用最简单的结构,稳定后再优化封装
|
||
- **不值得重构的代码别动**:又烂又稳定、无人修改的代码没有重构 ROI
|
||
|
||
---
|
||
|
||
## 附录:范式速查
|
||
|
||
| 原则 | 一句话 |
|
||
|------|--------|
|
||
| 基础约束 | 函数一屏读完,文件一眼知主题,逻辑不用记就能跟上;理解一段逻辑不超过 3 个文件 |
|
||
| 三段式 | 加载、计算、持久化不混在一个函数里 |
|
||
| 显式容器 | 状态放在显式容器里,不藏在全局/闭包/魔法中;框架的响应式容器是合法 Context |
|
||
| 编排-计算分离 | 编排说"做什么",计算说"怎么做",分处不同模块 |
|
||
| 编排是职责不是架构层 | 调用点组合→提取组合模块→独立编排类,按复杂度和重复度选择重量级 |
|
||
| 计算层不碰容器 | 计算层接收值、返回值;编排层负责从容器取值和写回;只读派生不算"碰" |
|
||
| Context at the Edge | Context 只出现在编排层,计算层只接收真实依赖 |
|
||
| 同层同级 | 一个函数体内每一行同一抽象层级 |
|
||
| 注册发现 | 功能增减靠注册表,不靠 if/else |
|
||
| 缺省行为 | 契约提供有意义的缺省,缺席用空实现顶替 |
|
||
| 契约稳定性 | 新方法给缺省、新字段走扩展区、大版本用适配器 |
|
||
| 复制还是复用 | 能起具体名字且脱离调用点能理解→复用;否则复制。默认先复制 |
|
||
| 错误复用的纠正 | 出现 boolean 开关 / 专属 if / 怕影响别人 / 新人看不懂 → 内联回去 |
|
||
| 细化改原模块,组合建新模块 | 同一概念的细化→改;两个概念的组合→slot 组合;重复 3 次→提取组合模块 |
|
||
| 领域计算必须复用 | 业务规则单一真相源,复制了就是定时炸弹 |
|
||
| 值对象先行 | 先识别度量衡,再识别角色 |
|
||
| 浮现再裸露 | 抽取函数 + 搬到新模块是一个完整动作 |
|
||
| 搬和改分离 | 移动代码和修改逻辑永远不在同一次提交 |
|