《互联网产品的客户端架构·端形态与体验基础》8.2 受控与非受控

受控与非受控不是二选一,而是同一条状态转移链上的三种模式。本节以「回声」搜索框为例,用纯函数实现 value 接管、defaultValue 作为种子、受控移除后的回退,以及状态提升的四个判据,并用真实测试说明切换模式时哪些值会保留、哪些警告必须记录。

一个输入框,两种所有权

搜索框的值到底该由谁保存?如果组件自己保存,调用方想读取当前输入就得通过 ref 或回调;如果调用方保存,每次按键都要绕一圈 props 再回来。两种做法都能工作,但它们的适用场景不同,混用时的行为更需要被明确定义。

受控与非受控讨论的不是“组件内部有没有 state”,而是“当前值的第一来源在哪里”。第一来源在调用方,就是受控;第一来源在组件内部,就是非受控;两者同时存在,就是混合模式。边界不清时,最常见的症状是输入框“打一个字跳回去”或者“外部改了值但界面不动”。

受控:值在外面

受控模式下,组件显示的永远是 value prop。用户输入时,组件不修改自己的显示值,只发出 change 事件,等调用方更新 value 后再重渲染。这条链路多了一次往返,但换来三个能力:

  • 调用方可以在值进入界面前做校验、格式化或截断。
  • 多个组件可以共享同一个值,比如搜索框和筛选标签同步。
  • 值可以被外部重置,比如点击“清空”或切换路由。

代价是调用方必须实现变更处理器。只传 value 不传处理器,输入框会变成只读,用户打字没有任何反应。这不是组件 bug,而是受控契约没有被满足。

非受控:值在里面

非受控模式下,组件用内部状态保存当前值,defaultValue 只作为首次渲染的种子。调用方不需要监听每次按键,只在提交或失焦时读取结果。它适合三类场景:一次性表单、不需要实时联动的输入、以及包裹原生表单元素的薄封装。

defaultValue 的语义是“初始值”,不是“当前值”。组件挂载后再修改 defaultValue,不应该改变已经显示的内容。很多实现把 defaultValue 当成受控值来同步,结果用户在输入时被外部默认值覆盖,体验非常糟糕。

混合模式与边界

混合模式指组件同时接受 value 和 defaultValue,并且允许在生命周期内从非受控切换到受控。它不是一个推荐的新写法,而是现实里必须定义清楚的一条迁移路径:老页面用 defaultValue,新页面逐步改成 value,中间必然出现两者并存。

本节的规则只有四条:

  1. value 存在时,受控优先,defaultValue 只触发警告,不参与显示。
  2. value 不存在时,defaultValue 只做首次种子,后续输入写内部值。
  3. 从非受控切到受控,保留内部值,但显示切换到 value。
  4. 从受控切回非受控,回退到最后一次内部值,并记录警告。

第四条最容易被忽略。受控期间用户输入不会写入内部值,所以“最后一次内部值”可能已经过期。组件必须显式警告,让调用方知道这次回退不是无损的。

用纯函数描述状态转移

下面的实现把模式判定、初始化、输入、props 更新和状态提升判据都写成纯函数。它不依赖 React、Vue 或 DOM,可以在 Node 里直接测试。

// file: src/view/control.ts
// tier: L1
// verify: node --test test/view/control.test.ts

export type FieldMode = "controlled" | "hybrid" | "uncontrolled";

export interface FieldProps {
  value?: string;
  defaultValue?: string;
}

export interface FieldState {
  mode: FieldMode;
  value: string;
  internal: string;
  dirty: boolean;
  warnings: string[];
}

export interface FieldResult {
  state: FieldState;
  events: { type: "change"; value: string }[];
}

export interface FieldInput {
  type: "input";
  next: string;
}

export interface OwnershipSignals {
  readBySibling: boolean;
  validatedWithOtherFields: boolean;
  resetByParent: boolean;
  persistedAcrossRoute: boolean;
  onlySelfUsed: boolean;
}

export interface OwnershipDecision {
  ownership: "local" | "lifted";
  reasons: string[];
}

/** 只根据 props 判定模式:value 优先,defaultValue 次之。 */
export function resolveMode(props: FieldProps): FieldMode {
  if (props.value !== undefined) return "controlled";
  if (props.defaultValue !== undefined) return "hybrid";
  return "uncontrolled";
}

/** 初始化字段;defaultValue 只在首次渲染时作为种子。 */
export function initField(props: FieldProps): FieldState {
  const warnings: string[] = [];
  if (props.value !== undefined && props.defaultValue !== undefined) {
    warnings.push("controlled-overrides-default");
  }
  const initial = props.value ?? props.defaultValue ?? "";
  return { mode: resolveMode(props), value: initial, internal: initial, dirty: false, warnings };
}

/** 输入事件:受控模式只发事件,非受控模式同时更新内部值。 */
export function applyInput(state: FieldState, input: FieldInput): FieldResult {
  const events: FieldResult["events"] = [{ type: "change", value: input.next }];
  if (state.mode === "controlled") {
    return { state: { ...state }, events };
  }
  return {
    state: { ...state, value: input.next, internal: input.next, dirty: true },
    events,
  };
}

/** 父组件传来新 props:value 出现即接管,value 移除则退回内部值。 */
export function applyProps(state: FieldState, props: FieldProps): FieldState {
  const mode = resolveMode(props);
  const warnings: string[] = [];
  if (mode === "controlled") {
    if (props.defaultValue !== undefined) warnings.push("controlled-overrides-default");
    if (state.mode !== "controlled") warnings.push("switched-to-controlled");
    return { ...state, mode, value: props.value as string, warnings };
  }
  if (state.mode === "controlled") {
    warnings.push("controlled-to-uncontrolled");
    return { ...state, mode, value: state.internal, warnings };
  }
  if (props.defaultValue !== undefined && state.dirty && props.defaultValue !== state.internal) {
    warnings.push("default-ignored-after-dirty");
  }
  return { ...state, mode, value: state.internal, warnings };
}

/** 状态提升判据:只要存在外部消费者或跨字段约束,就提升。 */
export function decideOwnership(signals: OwnershipSignals): OwnershipDecision {
  const reasons: string[] = [];
  if (signals.readBySibling) reasons.push("sibling-read");
  if (signals.validatedWithOtherFields) reasons.push("cross-field-validation");
  if (signals.resetByParent) reasons.push("parent-reset");
  if (signals.persistedAcrossRoute) reasons.push("route-persistence");
  if (reasons.length > 0) {
    if (signals.onlySelfUsed) reasons.push("self-only-overridden");
    return { ownership: "lifted", reasons: reasons.sort() };
  }
  return { ownership: "local", reasons: signals.onlySelfUsed ? ["self-only"] : ["no-external-consumer"] };
}

代码里有几个值得展开的点。

resolveMode 的优先级是固定的:value 先于 defaultValue,defaultValue 先于“都没有”。这个顺序不能靠调用方自觉,必须由函数保证,否则同一组 props 在不同代码路径里会得到不同模式。

initField 同时保存 value 和 internal。value 是当前显示值,internal 是组件自己的最后一份草稿。受控期间 internal 不更新,但也不清空;这样从受控切回非受控时,至少还有一个确定的值可以回退。

applyInput 在受控模式下返回原状态的浅拷贝,只发事件。它不修改 internal,因为受控期间内部值没有语义。非受控模式下才同时更新 value、internal 和 dirty。

applyProps 处理模式切换与警告。switched-to-controlled 表示组件从自管切到外部接管;controlled-to-uncontrolled 表示反向切换;default-ignored-after-dirty 表示用户在编辑后,外部又传了一个不同的 defaultValue,但组件选择忽略它。警告不是错误,它们应该被开发环境记录,帮助定位“值为什么没变”。

状态提升的时机

受控与非受控的选择,本质上是状态放在哪一层。状态提升不是“越往上越好”,而是“有外部消费者时才往上”。代码里的 decideOwnership 用四个信号判断:

信号含义处理
readBySibling兄弟组件要读这个值提升
validatedWithOtherFields要和别的字段联合校验提升
resetByParent父组件要能重置它提升
persistedAcrossRoute跨路由或刷新后还要保留提升

四个信号任意一个成立,状态就应该提升。onlySelfUsed 只是一个反向证据:如果组件自己读自己写、没有外部消费者,保持本地更简单。但它不能推翻前四个信号——当兄弟组件确实要读这个值时,“组件自己用”只是局部视角,不是全局事实。代码在冲突时返回 self-only-overridden,把这次判断记录下来。

还有一个常被忽略的成本:提升之后,每次按键都会触发父组件重渲染。如果父组件树很大,输入会变卡。解决办法不是退回非受控,而是把高频输入与低频派生拆开:输入值留在靠近输入框的层,派生结果(比如搜索请求)用防抖后再提升。这属于 5.2 节讨论的缓存与派生边界,这里只需记住“提升的是共享状态,不是每一次按键”。

测试与真实输出

测试覆盖三条主线:模式判定与初始化、输入与 props 切换、状态提升判据。完整测试文件如下:

// file: test/view/control.test.ts
// tier: L1
// verify: node --test test/view/control.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import {
  applyInput,
  applyProps,
  decideOwnership,
  initField,
  resolveMode,
} from "../../src/view/control.ts";

const input = (next: string) => ({ type: "input" as const, next });

test("没有 value 和 defaultValue 时是非受控", () => {
  const state = initField({});
  assert.equal(state.mode, "uncontrolled");
  assert.equal(state.value, "");
});

test("defaultValue 只作为首次种子", () => {
  const state = initField({ defaultValue: "未读" });
  assert.equal(state.mode, "hybrid");
  assert.equal(state.value, "未读");
  const edited = applyInput(state, input("已读")).state;
  assert.equal(edited.value, "已读");
  const later = applyProps(edited, { defaultValue: "全部" });
  assert.equal(later.value, "已读");
  assert.ok(later.warnings.includes("default-ignored-after-dirty"));
});

test("受控输入不修改内部值,只发出 change", () => {
  const state = initField({ value: "a" });
  const result = applyInput(state, input("b"));
  assert.equal(result.state.value, "a");
  assert.equal(result.state.internal, "a");
  assert.deepEqual(result.events, [{ type: "change", value: "b" }]);
});

test("非受控输入同时更新显示值和内部值", () => {
  const state = initField({});
  const result = applyInput(state, input("hello"));
  assert.equal(result.state.value, "hello");
  assert.equal(result.state.internal, "hello");
  assert.equal(result.state.dirty, true);
});

test("value 出现时接管,移除后保留最后一次内部值", () => {
  const local = applyInput(initField({ defaultValue: "old" }), input("draft")).state;
  const controlled = applyProps(local, { value: "server" });
  assert.equal(controlled.mode, "controlled");
  assert.equal(controlled.value, "server");
  assert.ok(controlled.warnings.includes("switched-to-controlled"));
  const back = applyProps(controlled, {});
  assert.equal(back.mode, "uncontrolled");
  assert.equal(back.value, "draft");
  assert.ok(back.warnings.includes("controlled-to-uncontrolled"));
});

test("value 与 defaultValue 同时出现时受控优先并给出警告", () => {
  const state = initField({ value: "v", defaultValue: "d" });
  assert.equal(state.value, "v");
  assert.deepEqual(state.warnings, ["controlled-overrides-default"]);
});

test("resolveMode 的优先级固定", () => {
  assert.equal(resolveMode({ value: "v", defaultValue: "d" }), "controlled");
  assert.equal(resolveMode({ defaultValue: "d" }), "hybrid");
  assert.equal(resolveMode({}), "uncontrolled");
});

test("只有组件自己使用状态时保持本地", () => {
  const decision = decideOwnership({
    readBySibling: false,
    validatedWithOtherFields: false,
    resetByParent: false,
    persistedAcrossRoute: false,
    onlySelfUsed: true,
  });
  assert.deepEqual(decision, { ownership: "local", reasons: ["self-only"] });
});

test("任一外部信号都会触发提升", () => {
  const decision = decideOwnership({
    readBySibling: false,
    validatedWithOtherFields: true,
    resetByParent: false,
    persistedAcrossRoute: false,
    onlySelfUsed: false,
  });
  assert.equal(decision.ownership, "lifted");
  assert.deepEqual(decision.reasons, ["cross-field-validation"]);
});

test("自用判断与外部信号冲突时外部信号获胜", () => {
  const decision = decideOwnership({
    readBySibling: true,
    validatedWithOtherFields: false,
    resetByParent: false,
    persistedAcrossRoute: false,
    onlySelfUsed: true,
  });
  assert.equal(decision.ownership, "lifted");
  assert.ok(decision.reasons.includes("self-only-overridden"));
});

在实验工程根目录执行:

node --test test/view/control.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 没有 value 和 defaultValue 时是非受控 (0.530083ms)
✔ defaultValue 只作为首次种子 (0.141958ms)
✔ 受控输入不修改内部值,只发出 change (0.391ms)
✔ 非受控输入同时更新显示值和内部值 (0.443416ms)
✔ value 出现时接管,移除后保留最后一次内部值 (0.093083ms)
✔ value 与 defaultValue 同时出现时受控优先并给出警告 (0.064916ms)
✔ resolveMode 的优先级固定 (0.053792ms)
✔ 只有组件自己使用状态时保持本地 (0.077792ms)
✔ 任一外部信号都会触发提升 (0.081959ms)
✔ 自用判断与外部信号冲突时外部信号获胜 (0.101958ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 143.644291

测试里的 value 出现时接管,移除后保留最后一次内部值 是本节最重要的用例。它把“受控切换不是无损的”这个事实固定下来,避免以后有人把回退逻辑改成“回退到 defaultValue”或者“清空”。

一次受控切换的事故

某次版本把搜索框从非受控改成受控,调用方传了 value,但漏了 change 处理器。组件按契约进入受控模式,用户每次输入都只发事件、不更新显示值。用户看到的现象是“键盘能敲,但输入框始终是空的”。

更麻烦的是,这个页面还有另一个入口用 defaultValue。两个入口共用同一个组件,测试只覆盖了非受控入口,受控入口直到灰度才发现。复盘时确认了两件事:第一,受控模式缺少处理器时,组件应该在开发环境直接报错,而不是静默只读;第二,混合模式必须有测试,不能只测两条单一路径。

这和 8.1 节的校验器是同一个思路:把“调用方漏了处理器”从运行时现象变成编码期错误。区别在于,8.1 检查的是契约声明,本节检查的是状态转移本身。

小结

受控与非受控的区别是值的第一来源,不是组件内部有没有 state。受控把值交给调用方,换取校验、共享与重置能力;非受控把值留在组件内,换取简单与低开销;混合模式定义两者切换时的保留与回退规则。

状态提升只在存在外部消费者时发生。四个信号里任意一个成立就提升,onlySelfUsed 不能推翻它们。提升之后要控制重渲染范围,把高频输入与低频派生拆开,而不是把整棵树都变成受控。

阅读导航:上一节:8.1 设计系统与组件契约 · 下一节:8.3 可访问性与交互边界 。

常见错误与边界

  • 把 defaultValue 当受控值同步。组件挂载后修改它不应该覆盖用户输入。
  • 只传 value 不传处理器。输入框会静默变成只读,必须在开发环境报错。
  • 认为受控切换是无损的。受控期间内部值不更新,回退时可能已经过期。
  • 状态一律提升到全局。没有外部消费者时,提升只会增加重渲染和耦合。
  • 用 onlySelfUsed 推翻兄弟组件的读取需求。局部视角不能否定全局事实。
  • 提升后把每次按键都传给整棵树。应把高频输入与低频派生拆开。
  • 混合模式只测单一路径。受控入口和非受控入口都要有测试。
  • 把警告当成噪音。controlled-to-uncontrolled 这类警告是定位“值为什么没变”的线索。
  • 在组件内部同时维护两套真值,却不定义优先级。优先级必须由函数固定,不能靠约定。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查