跳转到内容

Signals 不会让糟糕的架构变好

Signals 很好。

真的很好。

更细粒度的响应式能力、更清晰的 Dependency Tracking、更少间接的 Subscription Mechanic、更容易表达局部派生状态——这些都是真实的改进。

但 Signals 是一种 Reaktivitätsprimitive。

不是架构。

误解正是从这里开始的。

一个新的响应式模型不会自动修复错误的职责划分。它不会阻止 DTO Leakage,不会替你画出 Layer Boundary,不会决定 State Ownership,也不会自动让 Component 保持小而清晰。

如果之前结构就很差,Signals 只会帮你更快地写出响应非常灵敏的坏代码。

糟糕与良好的 Signals 架构。

每当 Framework 出现一个重要新 Feature,团队往往会经历一段短暂的集体乐观期。

终于简单了。 终于少 Boilerplate 了。 终于不用再处理 Subscription Hell。 终于不用为了一个小 UI State 就搭半套 RxJS。

这些说法很多时候都是真的。

Signals 的确可以让代码更简单,让局部响应式关系更容易理解,让 Derivation 更靠近 State,也能避免为了微不足道的 UI 状态引入过重的 Observable 结构。

但它们没有回答任何架构问题。

Signals 不会告诉你:

  • 业务 State 应该住在哪里?
  • 谁允许修改它?
  • UI Layer 在哪里结束?
  • DTO 在哪里被翻译?
  • Use Case 在哪里被编排?
  • 多个应用部分如何响应一个业务 Event?
  • 哪一层认识 Infrastructure?
  • 哪一层认识 Presentation?

Signals 给你一根非常好的电线。

但它不会告诉你应该把线铺在哪里。

如果把 API Structure 直接写进 Signal,它依然处在错误的 Layer。

type TaskDto = {
task_id: string;
created_at: string;
assigned_to_user_id: string | null;
};
@Component(...)
export class TaskCardComponent {
readonly task = signal<TaskDto | null>(null);
}

task 变成 Signal,并没有让这段代码更干净。

Component 还是知道 API 字段名。 Template 还是知道 Transport Detail。 created_at 还是 String。 assigned_to_user_id: null 仍然需要某个地方做业务解释。

Signals 不会自动生成边界。

它只是让错误的边界变得响应式。

更好的方式依然是:

type TaskViewModel = {
id: string;
createdAtLabel: string;
assigneeLabel: string;
};
function toTaskViewModel(dto: TaskDto): TaskViewModel {
return {
id: dto.task_id,
createdAtLabel: formatDate(dto.created_at),
assigneeLabel: dto.assigned_to_user_id ?? 'Unzugewiesen',
};
}

UI 应该消费 ViewModel。

而不是 API 的 Transport Model。

Component 中出现 Signal 并不自动意味着坏架构。

局部 UI State 完全可以放在那里。

@Component(...)
export class OrderDialogComponent {
readonly open = signal(false);
show() {
this.open.set(true);
}
close() {
this.open.set(false);
}
}

这是 UI Behavior。

真正的问题,是 Component 开始拥有业务流程。

@Component(...)
export class OrderComponent {
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor(
private readonly http: HttpClient,
private readonly router: Router,
private readonly toast: ToastService,
) {}
submitOrder(data: OrderData) {
if (data.items.length === 0) {
this.error.set('Bestellung ohne Positionen ist nicht erlaubt');
return;
}
const payload = this.mapToPayload(data);
this.loading.set(true);
this.http.post('/api/orders', payload).subscribe({
next: () => {
this.toast.success('Bestellung gespeichert');
this.router.navigate(['/confirmation']);
},
error: () => {
this.error.set('Speichern fehlgeschlagen');
},
complete: () => {
this.loading.set(false);
},
});
}
}

这里的问题不是 signal()

问题是 Component 突然拥有了所有东西:

  • 业务规则
  • Mapping
  • HTTP
  • Loading State
  • Error State
  • Toast
  • Navigation
  • Flow Control

以前用 Observable 写这样不好。

现在用 Signals 写,依然不好。

只是看起来更新了一点。

响应式的坏架构依然是坏架构。

Signals 可以出现在很多地方:

Component。 Service。 Store。 Facade。 Mapper。 甚至一些其实不应该拥有 Signal 的 Utility Class。

这种灵活性是优势。

也是风险。

如果团队没有明确的 State Ownership Model,很快就会出现这些问题:

  • 哪个 Signal 是唯一事实来源?
  • 谁可以调用 .set()
  • 这是 Local State 还是业务 State?
  • 是 UI State 还是 Application State?
  • 为什么同一状态在三个地方被 Mirror?
  • 为什么 Service、Component 和 Store 各自都有一个 Signal?

结果不是架构。

而是一团分布式响应式关系。

@Injectable()
export class OrderService {
readonly selectedOrderId = signal<string | null>(null);
}
@Component(...)
export class OrderListComponent {
readonly selectedOrderId = signal<string | null>(null);
}
@Injectable()
export class OrderFacade {
readonly selectedOrderId = signal<string | null>(null);
}

这并不一定错误。

但如果没有明确规则,就非常可疑。

因为同一个业务概念已经出现在多个地方。

迟早会有人问:

为什么详情页显示 Order 42,但列表里高亮的是 Order 17?

答案通常不是“Signals 有问题”。

真正的答案是:

没有人决定这个状态到底属于谁。

没有 Ownership 的 Signal,只是多几步的 Global Variable

Section titled “没有 Ownership 的 Signal,只是多几步的 Global Variable”

Signal 很诱人,因为它太容易写了。

export const currentUser = signal<User | null>(null);

完成。

到处 Import。 到处读取。 到处写入。

能有什么问题?

很多。

currentUser 虽然是响应式的,却没有任何架构控制。

谁允许设置 User? 什么时候 Reset? Logout 时发生什么? Token Refresh 呢? Tenant Change 呢? 哪些 Test 需要准备它? 哪些 Component 通过间接依赖挂在它上面?

一个没有 Ownership 的 Global Signal 不是 State Management Solution。

它只是 Global State。

API 更好看而已。

Signals 在清晰边界内部非常强。

例如:

Component
konsumiert ViewModel
sendet Intent
Facade / Store
besitzt Zustand
koordiniert UI-nahe Flüsse
bietet abgeleitete Signals an
Use Case / Application Service
führt fachliche Abläufe aus
Infrastructure
spricht mit API, Storage, externen Systemen

在这种结构中,Signals 很合适。

@Injectable()
export class OrderFacade {
private readonly store = inject(OrderStore);
readonly vm = this.store.vm;
readonly isLoading = this.store.isLoading;
loadOrders() {
this.store.loadOrders();
}
submitOrder(data: OrderFormValue) {
this.store.submitOrder(data);
}
}

Component 保持无聊:

@Component(...)
export class OrderComponent {
private readonly facade = inject(OrderFacade);
readonly vm = this.facade.vm;
submit(data: OrderFormValue) {
this.facade.submitOrder(data);
}
}

Component 不知道后面发生什么。

不知道是否发 HTTP Call。 不知道是否出现 Toast。 不知道是否 Navigation。 不知道是否 Reload List。

它只发送 Intent。

这就是它的职责。

有明确 Ownership 的 Signals。

Signal Store 可以成为很好的 Application Facade。

但 Store 也可以切得很差。

如果所有东西都塞进一个 Store,只会得到新的数据垃圾场:

type AppState = {
user: User | null;
orders: Order[];
selectedOrderId: string | null;
dialogOpen: boolean;
formDraft: OrderFormValue | null;
permissions: string[];
theme: string;
toastMessage: string | null;
tempFlag: boolean;
};

它不会因为叫 Signal Store 就变得更好。

Store 同样需要边界。

例如:

AuthStore
user
permissions
OrdersStore
orders
selectedOrderId
loading
error
commands
OrderDialogStore
local dialog state
draft state

并不是所有 State 都属于同一个地方。

Signals 让 State 变得很容易表达。

也正因为如此,更需要明确它应该住在哪里。

computed() 非常适合 Derivation。

readonly fullName = computed(() => {
const user = this.user();
if (!user) {
return 'Unbekannt';
}
return `${user.firstName} ${user.lastName}`;
});

这很自然。

但这里同样存在边界。

如果 computed() 里开始出现复杂业务规则,就需要停下来判断。

readonly discount = computed(() => {
const order = this.order();
if (!order) {
return 0;
}
if (order.customer.type === 'premium' && order.items.length > 10) {
return 0.15;
}
if (order.customer.country === 'DE' && order.total > 1000) {
return 0.1;
}
return 0;
});

它也许合理。

也可能是一条不应该住在 UI-near Derivation 中的业务规则。

真正的问题不是:

我可不可以用 computed?

当然可以。

而是:

这是展示派生,还是业务决策?

展示派生适合 Store、Facade 或 ViewModel Mapping。

业务决策更可能属于 Use Case、Domain Function 或 Application Layer。

effect() 同样不是架构概念。

Effect 可以很适合响应状态变化。

但如果 Effect 随意散布在 Component 和 Service 中,又会形成不可见耦合。

effect(() => {
if (this.saved()) {
this.toast.success('Gespeichert');
this.router.navigate(['/orders']);
this.reload();
}
});

它完全可能工作。

但需要非常明确:为什么这个 Effect 放在这里?

Effect 是 Reaction。

Reaction 也需要 Ownership。

谁拥有 Save Flow? 谁决定 Navigation? 谁决定 Toast? 谁 Reload 哪个 Resource? 怎么测试? 如果另一个地方也对 saved() 做响应,会发生什么?

没有概念约束时,effect() 很快会变成:

反正状态变了之后,某个地方还会发生一点事。

这不是架构。

这是响应式雾霾。

一个好的 Signals Architecture 通常并不炫技。

大概就是:

DTO rein
Mapper übersetzt
Store hält Zustand
computed erzeugt ViewModel
Component rendert
Intent geht raus
Use Case läuft
Result aktualisiert Zustand

这并不新鲜。

恰恰这才是重点。

Signals 没有改变良好前端架构的基本原则。

它只是让其中一些实现方式更舒服。

真正重要的不是 signal()ObservableSubject、Redux 或 Signal Store。

而是职责是否清楚。

Signals 很适合:

  • Local UI State
  • Derived State
  • ViewModel
  • Feature Store
  • Facade
  • 清晰可读的 Reactive Data Flow
  • Fine-grained Update

但 Signals 不会自动解决:

  • Layer Separation
  • DTO Mapping
  • Use Case Orchestration
  • State Ownership
  • 清晰 Event Flow
  • Domain Modeling
  • Testability
  • Component Responsibility

技术问题是:

我能不能用 Signals 做?

架构问题是:

这个状态或这个决定,应该住在这里吗?

第二个问题更重要。

Signals 是很好的工具。

但它不是架构的替代品。

它可以改善好结构的实现方式。

却无法拯救坏结构。

DTO 直接塞进 Component,用 Signals 依然是 DTO Leakage。

Use Case 写在 Component 里,用 Signals 依然是 Business Logic in UI Layer。

State 没有 Ownership 地到处分布,用 Signals 依然是不受控制的 State。

Side Effect 随意藏在 Effect 里,用 Signals 依然是难以追踪的 Flow。

Signals 让 Angular 的响应式编程更舒服。

但它不会回答那个古老又不舒服的问题:

谁负责什么?

简短规则:

Signals 能让好代码更容易读懂。坏架构只会因此更快暴露出来。

或者更坏一点:

以前写的是 Spaghetti,换成 Signals 也不会自动变成 Lasagne。只会得到更细的面条。