组件设计 —— 重新认识受控与非受控组件

重新定义受控与非受控组件的边界

React 官网中对非受控组件与受控组件作了如图中下划线的边界定义。一经推敲, 该定义是缺乏了些完整性严谨性的, 比如针对非表单组件(弹框、轮播图)如何划分受控与非受控的边界? 又比如非受控组件是否真的如文案上所说的数据的展示与变更都由 dom 自身接管呢?

在非受控组件中, 通常业务调用方只需传入一个初始默认值便可使用该组件。以 Input 组件为例:

// 组件提供方
function Input({ defaultValue }) {
return <input defaultValue={defaultValue} />
}
// 调用方
function Demo() {
return <Input defaultValue={1} />
}

在受控组件中, 数值的展示与变更则分别由组件的 statesetState 接管。同样以 Input 组件为例:

// 组件提供方
function Input() {
const [value, setValue] = React.useState(1)
return <input value={value} onChange={e => setValue(e.target.value)} />
}
// 调用方
function Demo() {
return <Input />
}

有意思的一个问题来了, Input 组件到底是受控的还是非受控的? 我们甚至还可以对代码稍加改动成 <Input defaultValue={1} /> 的最初调用方式:

// 组件提供方
function Input({ defaultValue }) {
const [value, setValue] = React.useState(defaultValue)
return <input value={value} onChange={e => setValue(e.target.value)} />
}
// 调用方
function Demo() {
return <Input defaultValue={1} />
}

尽管此时 Input 组件本身是一个受控组件, 但与之相对的调用方失去了更改 Input 组件值的控制权, 所以对调用方而言, Input 组件是一个非受控组件。值得一提的是, 以非受控组件的使用方式去调用受控组件是一种反模式, 在下文中会分析其中的弊端。

如何做到不管对于组件提供方还是调用方 Input 组件都为受控组件呢? 提供方让出控制权即可, 调整代码如下

codesandbox

// 组件提供方
function Input({ value, onChange }) {
return <input value={value} onChange={onChange} />
}
// 调用方
function Demo() {
const [value, setValue] = React.useState(1)
return <Input value={value} onChange={e => setValue(e.target.value)} />
}

经过上述代码的推演后, 概括如下: 受控以及非受控组件的边界划分取决于当前组件对于子组件值的变更是否拥有控制权。如若有则该子组件是当前组件的受控组件; 如若没有则该子组件是当前组件的非受控组件。

职能范围

基于调用方对于受控组件拥有控制权这一认知, 因此受控组件相较非受控组件能赋予调用方更多的定制化职能。这一思路与软件开发中的开放/封闭原则有异曲同工之妙, 同时让笔者受益匪浅的 Inversion of Control 也是类似的思想。

借助受控组件的赋能, 以 Input 组件为例, 比如调用方可以更为自由地对值进行校验限制, 又比如在值发生变更时执行一些额外逻辑。

// 组件提供方
function Input({ value, onChange }) {
return <input value={value} onChange={onChange} />
}
// 调用方
function Demo() {
const [value, setValue] = React.useState(1)
return <Input value={value} onChange={e =>
// 只支持数值的变更
if (/\D/.test(e.target.value)) return
setValue(e.target.value)}
/>
}

因此综合基础组件扩展性通用性的考虑, 受控组件的职能相较非受控组件更加宽泛, 建议优先使用受控组件来构建基础组件。

反模式 —— 以非受控组件的使用方式调用受控组件

首先何谓反模式(可以看文末的案例)? 笔者将其总结为增大隐性 bug 出现概率的模式, 该模式是最佳实践的对立经验。如若使用了反模式就不得不花更多的精力去避免潜在 bug。官网对反模式也有很好的概括总结

缘何上文提到以非受控组件的使用方式去调用受控组件是一种反模式? 观察 Input 组件的第一行代码, 其将 defaultValue 赋值给 value, 这种将 props 赋值给 state 的赋值行为在一定程度上会增加某些隐性 bug 的出现概率。

比如在切换导航栏的场景中, 恰巧两个导航中传进组件的 defaultValue 是相同的值, 在导航切换的过程中便会将导航一中的 Input 的状态值带到导航二中, 这显然会让使用方感到困惑。codesandbox

// 组件提供方
function Input({ defaultValue }) {
// 反模式
const [value, setValue] = React.useState(defaultValue)
React.useEffect(() => {
setValue(defaultValue)
}, [defaultValue])
return <input value={value} onChange={e => setValue(e.target.value)} />
}
// 调用方
function Demo({ defaultValue }) {
return <Input defaultValue={defaultValue} />
}
function App() {
const [tab, setTab] = React.useState(1)
return (
<>
{tab === 1 ? <Demo defaultValue={1} /> : <Demo defaultValue={1} />}
<button onClick={() => (tab === 1 ? setTab(2) : setTab(1))}>
切换 Tab
</button>
</>
)
}

如何避免使用该反模式同时有效解决问题呢? 官方提供了两种较为优质的解法, 笔者将其留给大家思考。

  1. 方法一: 使用完全受控组件(更为推荐)
  2. 方法二: 使用完全非受控组件 + key

反模式案例

  • 使用过 getDerivedStateFromProps 么? 了解它的反模式么? 如何避免?

来看下使用 Derived 会导致的常见 bug:

反模式一: 无条件复制 props 到 state

案例: 在开发 Picker 组件的时候, 如下图浮层内的数据源有外部传进的 props 属性以及浮层内进行滑动的 state。

如果这样子写大家看看是否有问题

class Picker extends Component {
state = {
data: this.props.data
}
getDerivedStateFromProps(props, state) {
if (JSON.stringify(props.data) !== JSON.stringify(state.data)) {
return {
data: props.data
}
}
return null
}
...
}

这样子写代码当在浮层上进行滑动时, state 的变化并不会生效, 这是为什么呢, 官网上截图如下

此时的 state 变化使 Picker 父组件重新 render 后会造成 getDerivedStateFromProps 的调用, 导致 JSON.stringify(props.data)、JSON.stringify(state.data) 间一直会不相等, 所以返回的一直是 props。

反模式二: 当 props 改变的时候执行重置 state

class Picker extends Component {
state = {
data: this.props.data
}
getDerivedStateFromProps(props, state) {
if (JSON.stringify(props.data) !== JSON.stringify(this.props.data)) {
return {
data: props.data
}
}
return null
}
...
}

这样子比第一种情况好, 大多数情况是没问题的。但是有时候会出现 JSON.stringify(props.data)JSON.stringify(this.props.data) 相等的情况, 这种情况下这样使用就会失效了。