React模式剖析Modal.confirm
欲三更 人气:9引言
这是一篇临时起意的文章,我参与了一点上图中的讨论,正好有点时间,索性拉篇文章专门聊聊。
首先说结论:我不认为Modal.confirm以及类似的API是anti-pattern,尽管antd作者
@偏右悄悄地 也说Modal.confirm“并不符合React哲学”。
什么是“React模式”?
很多人都见过这个公式——
view = f(data)
React确实就是这么运作的。
然而React从来没有建议过,让你的整个应用都这么运作。很显然,一个view=f(data),是涵盖不了前端应用那么多可能性的。那么一个现实中有用的网页,大致应该怎么运作?
看图说话——
这张图我想所有人都能看懂,大部分给你科普前端架构的文章大致都是这么画的,这里只解释两点:
- 第一,蓝箭头代表状态传递,红箭头代表行为传递。比如vdom,其实就是一堆描述某一时刻html状态的数据,但是经过diff,得到的指导react-dom进行操作的patch,其实描述的是“行为”,用现在时髦的说法叫“副作用”。
- 红框里面为什么除了data还有timing?timing指的是时机,就是说你什么时候应该渲染,本质上是model那一层告诉你的,React组件不会自己没事儿渲染着玩(先不考虑state)。
图是很美好,然而如果一个网页就这样的话,它除了展示一下画面(还不能做单页),不会有任何用处。所以这个图要完善一下——
经过完善,图里面多了框,就是红框里那个actions,指的是当你点按钮的时候,发生的回调操作。有的框架里面管这个叫action,有的是store的成员函数,还有的比如redux,虽然它有action概念,但其实真正的“action”是在reducer里面实现的。
另外这里又出现了timing(时机),这其实是我最近很喜欢的一个概念。所谓的timing,就是告诉另一个模块“it’s time to do xxx”,trigger(event)是timing,dispatch(action)是timing,甚至直接调用成员函数,其实也是一种timing。
这张图,看起来能干一些事儿了,至少能点按钮刷新数据了,能做单页了。然而我们的网页,并非是只读的,除了展示数据,还要往后台提交数据,或者执行某个具体的功能(比如VScode里点ctrl+s保存,它需要往硬盘里面写文件)。所以还要完善一下——
相对完整的前端应用架构
哎,这个版本看起来有具体功能了,因为除了浏览器html之外,我们还多了个“IO等”,这样那些除了变动html之外的“副作用”,就可以实现了。基本上一个网页的架构,就是这样了。
注意,在这个版本中,Model也可以调用Actions,这是因为有些Model库是响应式的,比如rx,它本身就可以通过subscribe驱动副作用。
到这里,咱们终于可以进正题了。
我在上面的图中花了三个虚线框,中间那个是“React掌管的部分”,也就是建议view=f(data)那部分。我们看下箭头的颜色,Model到View,应该是状态数据(data),View到Actions,应该是行为指令(timing)。
其他两层,天生就不是React掌管的范围,业务模型主要负责维护数据以及数据之间的驱动关系,Side effect backend主要负责实现各种副作用,React的那套模型,在这里帮不上忙——注意,这不代表那两层就完全不能用React,事实上confirm完全可以理解为IO的一种(类比下c语言的scanf)。
也就是说,哪怕一个带有明显数据驱动特色的React项目,也存在很多部分不是数据驱动而是事件驱动的(我更喜欢成为“时机驱动”),这是天经地义的事情,不然你的程序根本写不出来。原因很简单,数据只能驱动出状态,只有时机才能驱动出行为——学过《数电》应该对这一点深有感触。
那么,对于“用户点击删除按钮,弹出确认框,点击确定删除条目,点击取消不做任何操作”这样一个功能——
- 你觉得他是个状态还是个行为?
- 你觉得他是时机驱动的还是数据驱动的?
答:它是个行为,时机驱动。
那么,对于一个时机驱动的行为,你非得把它硬坳成一个数据驱动的状态,你不觉得很奇怪吗?
一个comfirm,这么写有什么问题呢(伪代码,忽略一部分保护性代码)?
class FoobarComponent extends React.Component { // ... async onRemoveBtnClick (id) { const yes = await Modal.confirm('确认删除吗?') if (!yes) return dispatch(actions.remove({ id })) } render () { const { items } = this.props return <ul> { items.map(el => <li key={el.id}> <span>{el.name}</span> <button onClick={this.onRemoveBtnClick.bind(this, el.id)}>删除</button> </li>) } </ul> } }
那么,为什么有人会觉的这样写是反模式呢?
我觉得可能是因为我们在编写网页的时候,一般都会比较注重纵向分层,却常常忽视横向切分。由于react-router的盛行,基本上我们是在组件这个层面配合前端路由来做横向切分的,但实际上这只是一种特殊情况,如果我们的应用更复杂,我们可能需要这样的结构——
上图中每个模块,都和模块A具有大致相同的结构,各个模块之间的时机和状态,可以通过总线一类的工具来中转。
如果我们写网页的“起手式”就是这样的,那么Modal,可能会被设计成一个单独的模块M,Modal.comfirm,可能会被设计成这样:
const yes = await globalBus.dispachAndWait(Actions.comfirm('确认删除吗?'))
这样,你还会觉得“反模式”吗?
PS:为什么为这件小事儿啰嗦这么一大篇文章呢?其实这本来也是我2018年前端技术总结的一部分想法,只是临时借着这个讨论说出来了。2018年上半年,参与了一个临时前端团队,堆了不少业务表单页面,期间,我跟人强调了无数次“不要无脑数据驱动”,还是有有人非得闪转腾挪用数据驱动和render表达一切,留了一大堆屎给我重构,我这一股无名火憋到现在。
加载全部内容