React 进阶设计与控制权问题
控制权——这个概念在编程中至关重要。比如 轮子 封装层与业务消费层对于控制权的 争夺,就是一个很有意思的话题。这在 React 世界里也不例外。表面上看,我们当然希望 轮子 掌控的事情越多越好:因为抽象层处理的逻辑越多,业务调用时关心的事情就越少,使用就越方便。可是有些设计却 不敢越雷池一步。轮子 与业务在控制权上的拉锯,就非常有意思了。
同时,控制能力与组件设计也息息相关:Atomic components 这样的原子组件设计被受推崇;在原子组件这个概念之上,还有分子组件:Molecules components。不管是分子还是原子,在解决业务问题上都有存在的理由。
这篇文章将以 React 框架为背景,谈谈我在开发当中对于控制权的一些想法和总结。如果你并不使用 React,原则上仍不妨碍阅读。
从受控与非受控组件说起
初入 React 大门,关于控制权概念,我们最先接触到的就是受控组件与非受控组件。这两个概念往往与表单关联在一起。在大部分情况下,推荐使用受控组件来实现表单、输入框等状态控制。在受控组件中,表单等数据都由 React 组件自己处理。而非受控组件,是指表单的数据由 Dom 自己控制。下面就是一个典型的非受控组件:
<form>
<label>
Name:
<input type="text" name="name" />
</label>
<input type="submit" value="Submit" />
</form>
复制代码
对于 React 来说,非受控组件的状态和用户输入都无法直接掌控,只能依赖 form 标签的原生能力进行交互。如果使上例非受控组件变为一个受控组件,代码也很简单:
class NameForm extends React.Component {
state= {value: ''}
handleChange = event => {
this.setState({value: event.target.value});
}
handleSubmit = event => {
alert('A name was submitted: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
)
}
}
这时候表单值和行为都由 React 组件控制,使得开发更加便利。这当然是很基础的概念,借此抛出控制权的话题,请读者继续阅读。
UI 轮子 与 Control Props 模式
前文介绍的样例,我称之为 狭义受控和非受控 组件。广义来说,我认为完全的非受控组件是指:不含有内部 states,只接受 props 的函数式组件或无状态组件。它的渲染行为完全由外部传入的 props 控制,没有自身的“自治权”。这样的组件在很好地实现了复用性,且具有良好的测试性。
但在 UI 轮子 设计当中,半自治 或者 不完全受控 组件,有时也会是一个更好的选择。我们将此称之为 control props 模式。简单来说就是:组件具有自身 state,当没有相关 porps 传入时,使用自身状态 statea 完成渲染和交互逻辑;当该组件被调用时,如果有相关 props 传入,那么将会交出控制权,由业务消费层面控制其行为。
在研究大量社区 UI 轮子 之后,我发现由 Kent C. Dodds 编写的,在 paypal 使用的组件库 downshift 便广泛采用了这样的模式。
简单用一个 Toogle 组件举例,这个组件由业务方调用时:
class Example extends React.Component {
state = {on: false, inputValue: 'off'}
handleToggle = on => {
this.setState({on, inputValue: on ? 'on' : 'off'})
}
handleChange = ({target: {value}}) => {
if (value === 'on') {
this.setState({on: true})
} else if (value === 'off') {
this.setState({on: false})
}
this.setState({inputValue: value})
}
render() {
const {on} = this.state
return (
<div>
<input
value={this.state.inputValue}
onChange={this.handleChange}
/>
<Toggle on={on} onToggle={this.handleToggle} />
</div>
)
}
}
我们可以通过输入框来控制 Toggle 组件状态切换(输入 on 激活状态,输入 off 状态置灰),同时也可以通过鼠标来点击切换,此时输入框内容也会相应变化。
请思考:对于 UI 组件 Toggle 来说,它的状态可以由业务调用方来控制其状态,这就赋予了使用层面上的消费便利。在业务代码中,不管是 Input 还是其他任何组件都可以控制其状态,调用时我们具有完全的控制权掌控能力。
同时,如果在调用 Toggle 组件时,不去传 props 值,该组件仍然可以正常发挥。如下:
<Toggle>
{({on, getTogglerProps}) => (
<div>
<button {...getTogglerProps()}>Toggle me</button>
<div>{on ? 'Toggled On' : 'Toggled Off'}</div>
</div>
)}
</Toggle>
Toggle 组件在状态切换时,自己维护内部状态,实现切换效果,同时通过 render prop 模式,对外输出本组件的状态信息。
我们看 Toggle 源码(部分环节已删减):
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))
class Toggle extends Component {
static defaultProps = {
defaultOn: false,
onToggle: () => {},
}
state = {
on: this.getOn({on: this.props.defaultOn}),
}
getOn(state = this.state) {
return this.isOnControlled() ? this.props.on : state.on
}
isOnControlled() {
return this.props.on !== undefined
}
getTogglerStateAndHelpers() {
return {
on: this.getOn(),
setOn: this.setOn,
setOff: this.setOff,
toggle: this.toggle,
}
}
setOnState = (state = !this.getOn()) => {
if (this.isOnControlled()) {
this.props.onToggle(state, this.getTogglerStateAndHelpers())
} else {
this.setState({on: state}, () => {
this.props.onToggle(
this.getOn(),
this.getTogglerStateAndHelpers()
)
})
}
}
setOn = this.setOnState.bind(this, true)
setOff = this.setOnState.bind(this, false)
toggle = this.setOnState.bind(this, undefined)
render() {
const renderProp = unwrapArray(this.props.children)
return renderProp(this.getTogglerStateAndHelpers())
}
}
function unwrapArray(arg) {
return Array.isArray(arg) ? arg[0] : arg
}
export default Toggle
关键的地方在于组件内 isOnControlled 方法判断是否有命名为 on 的属性传入:如果有,则使用 this.props.on 作为本组件状态,反之用自身 this.state.on 来管理状态。同时在 render 方法中,使用了 render prop 模式,关于这个模式本文不再探讨,感兴趣的读者可以在社区中找到很多资料,同时也可以在我新书中找到相关内容。
盘点一下,control props 模式反应了典型的控制权问题。这样的**“半自治”**能够完美适应业务需求,在组件设计上也更加灵活有效。
Redux 异步状态管理与控制权
提到控制权话题,怎能少得了 Redux 这样的状态管理工具。Redux 的设计在方方面面都体现出来良好的控制权处理,这里我们把注意力集中在异步状态上,更多的内容还请读者关注我的新书。
Redux 处理异步,最为人熟知的就是 Redux-thunk 这样的中间件,它由 Dan 亲自编写,并在 Redux 官方文档上被安利。它与其他所有中间件一样,将 action 到 reducer 中间的过程进行掌控,使得业务使用时可以直接 dispatch 一个函数类型的 action,实现代码也很简单:
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
export default thunk;
但是很快就有人认为,这样的方案因为在中间件实现中的控制不足,导致了业务代码不够精简。我们还是需要遵循传统的 Redux 步骤:八股文似的编写 action,action creactor,reducer......于是,控制粒度更大的中间件方案应运而生。
Redux-promise 中间件控制了 action type,它限制业务方在 dispatch 异步 action 时,action的 payload 属性需要是一个 Promise 对象时,执行 resolve,该中间件触发一个类型相同的 action,并将 payload 设置为 promise 的 value,并设 action.status 属性为 "success"。
export default function promiseMiddleware({ dispatch }) {
return next => action => {
if (!isFSA(action)) {
return isPromise(action) ? action.then(dispatch) : next(action);
}
return isPromise(action.payload)
? action.payload
.then(result => dispatch({ ...action, payload: result }))
.catch(error => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
})
: next(action);
};
}
这样的设计与 Redux-thunk 完全不同,它将 thunk 过程控制在中间件自身中,这样一来,第三方轮子做的事情更多,因此在业务调用时更加简练方便。我们只需要正常编写 action 即可:
dispatch({
type: GET_USER,
payload: http.getUser(userId) // payload 为 promise 对象
})
我们对比一下 Redux-thunk,相对于“轮子”控制权较弱,业务方控制权更多的 Redux-thunk,实现上述三行代码,就得不得不需要:
dispatch(
function(dispatch, getState) {
dispatch({
type: GET_USERE,
payload: userId
})
http.getUser(id)
.then(response => {
dispatch({
type: GET_USER_SUCCESS,
payload: response
})
})
.catch(error => {
dispatch({
type: GET_DATA_FAILED,
payload: error
})
})
}
)
当然,Redux-promise 控制权越多,一方面带来了简练,但是另一方面,业务控制权越弱,也丧失了一定的自主性。比如如果想实现乐观更新(Optimistic updates),那就很难做了。具体详见 Issue #7
为了平衡这个矛盾,在 Redux-thunk 和 Redux-promise 这两个极端控制权理念的中间件之间,于是便存在了中间状态的中间件:Redux-promise-middleware,它与 Redux-thunk 类似,掌控粒度也类似,但是在 action 处理上更加温和和渐进,它会在适当的时机 dispatch XXX_PENDING、XXX_FULFILLED 、XXX_REJECTED 三种类型的 action,也就是说这个中间件在掌控更多逻辑的基础上,增加了和外界第三方的通信程度,不再是直接高冷地触发 XXX_FULFILLED 、XXX_REJECTED,请读者仔细体会其中不同。
状态管理中的控制主义和极简主义
了解了异步状态中的控制权问题,我们再从 Redux 全局角度进行分析。在内部分享时,我将基于 Redux 封装的状态管理类库共同特性总结为这一页 slide。
以上四点都是相关类库基于 Redux 所进行的简化,其中非常有意思的就是后面三点,它们无一例外地与控制权相关。以 Rematch 为代表,它不再是处理 action 到 reducer 的中间件,而是完全控制了 action creator,reducer 以及联通过程。
具体来看:
- 业务方不再需要显示申明 action type,它由类库直接函数名直接生成,如果 reducer 命名为 increment,那么 action.type 就是 increment;
- 同时控制 reducer 和 action creator 合二为一,态管理从未变得如此简单、高效。
我把这样的实践称为控制主义或者极简主义,相比 Redux-actions 这样的状态管理类库,这样的做法更加彻底、完善。具体思想可参考 Shawn McKay 的文章,介绍的比较充分,这里我不再赘述。
总结:码农和控制权
控制权说到底是一种设计思想,是第三方类库和业务消费的交锋和碰撞。它与语言和框架无关,本文只是以 React 举例,实际上在编程领域控制权的争夺随处可见;他与抽象类别无关,本文已经在 UI 抽象和状态抽象中分别例举分析;控制权与码农息息相关,它直接决定了我们的编程体验和开发效率。
可是在编程的初期阶段,优秀的控制权设计难以一蹴而就。只有投身到一线开发当中,真正了解自身业务需求,进而总结大量最佳实践,同时参考社区精华,分析优秀开源作品,相信我们都会得到成长。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论