3、表现层设计:UI组件的状态管理、View与ViewModel的分离、单向数据流
表现层,说白了就是用户能看到、能交互的那一层。很多团队把这一层搞得很「脏」——业务逻辑、API调用、UI渲染全揉在一起。我见过一个项目,一个按钮的点击事件里居然塞了三次网络请求和两次数据库写入。嗯,这种代码,三个月后连原作者都不敢碰。
今天我们就聊聊,怎么把表现层设计得清爽、可测、好维护。核心就三件事:UI组件的状态管理、View与ViewModel的分离、单向数据流。
3.1 UI组件的状态管理:别让组件「太聪明」
UI组件应该有多「聪明」?我个人习惯是:越笨越好。一个按钮只管「我被点击了」,一个输入框只管「用户输入了文本」。至于点击之后要做什么、输入之后要校验什么——那是上层的事。
我在项目中遇到过一种典型反模式:组件内部自己维护了业务状态。比如一个「用户头像」组件,它自己调接口、自己缓存、自己处理加载态。看起来挺方便,但一旦需求变化——比如头像需要支持裁剪、需要和用户信息联动——你就得改这个组件,甚至可能影响所有用到它的页面。
那正确的做法是什么?组件只管理「UI状态」,比如:
- 当前是否处于加载中
- 输入框的值是什么
- 下拉菜单是否展开
- 滚动位置在哪里
这些状态是纯粹的「视图状态」,不涉及任何业务逻辑。而业务状态——比如用户是否已登录、购物车里有几件商品——应该交给ViewModel去管理。
核心原则:组件只关心「怎么画」,不关心「画什么」和「为什么画」。
举个例子,一个搜索框组件:
// 组件只管理UI状态
class SearchBox {
state = {
inputValue: '',
isFocused: false,
showSuggestions: false
}
// 组件只负责通知「用户做了什么」
onInputChange(value) {
this.state.inputValue = value
this.props.onSearch(value) // 把事件抛出去
}
}
你看,组件自己不做搜索、不做防抖、不做历史记录。它只是把用户的输入「传递」出去。谁接了这个事件,谁去处理业务逻辑。
3.2 View与ViewModel的分离:划清边界
View和ViewModel的分离,是分层架构的基石。View就是UI层,ViewModel是「UI的业务逻辑层」。怎么区分?我有个简单的判断标准:
- View:包含if/else判断,但判断的是「显示什么样式」、「显示什么文案」
- ViewModel:包含if/else判断,但判断的是「用户有没有权限」、「数据是否合法」
说白了,View的代码里不应该出现「调用API」、「计算折扣」、「校验手机号」这类逻辑。这些都应该在ViewModel里完成。
我曾经接手过一个项目,一个页面文件有2000多行。里面混着模板、样式、API调用、状态管理、甚至还有路由跳转逻辑。你想想看,这种代码怎么维护?改一个需求,你得在2000行里找那几行相关的代码,找到之后还不敢改,因为不知道会影响什么。
正确的分离方式是这样的:
// View层:只负责渲染和事件绑定
class UserProfileView {
render() {
const vm = this.viewModel
return `
<div>
<h1>${vm.userName}</h1>
<p>${vm.userEmail}</p>
<button onclick="vm.onLogout()">退出登录</button>
</div>
`
}
}
// ViewModel层:负责业务逻辑和状态管理
class UserProfileViewModel {
@observable userName = ''
@observable userEmail = ''
async loadUser() {
const user = await api.getUser()
this.userName = user.name
this.userEmail = user.email
}
onLogout() {
auth.logout()
router.navigate('/login')
}
}
小技巧:写ViewModel的时候,可以假设自己是在写「没有UI」的代码。如果一段逻辑不依赖DOM、不依赖CSS,那它大概率属于ViewModel。
3.3 单向数据流:让数据变化「有迹可循」
单向数据流,听起来高大上,其实核心就一句话:数据只能从上层流向下层,不能反向。在分层架构里,这个「上层」就是ViewModel,「下层」就是View。
为什么会这样?你想想看,如果View可以随意修改ViewModel的数据,那调试的时候就麻烦了——你看到一个UI变了,但不知道是谁改的。可能是用户操作,可能是网络回调,也可能是另一个组件偷偷改了。
单向数据流的做法是:
- ViewModel 持有状态,并提供修改状态的方法
- View 只读状态,并调用ViewModel的方法
- 状态变化 自动触发View重新渲染
我建议用响应式编程来实现这个机制。比如Vue的响应式系统、React的useState、或者MobX的observable。它们都能做到「数据变了,UI自动更新」。
// 单向数据流的典型流程
class ViewModel {
@observable count = 0
increment() {
this.count++ // 1. ViewModel修改状态
}
}
class View {
render() {
// 2. View读取状态
return `<button onclick="vm.increment()">${vm.count}</button>`
}
// 3. 状态变化后,框架自动重新渲染
}
注意:单向数据流不等于「不能有事件」。事件是允许的,但事件只能从View流向ViewModel,不能反过来。ViewModel可以通过「回调」或「观察者模式」通知View,但View不能直接修改ViewModel的内部状态。
3.4 实战中的常见坑
聊了这么多理论,说说我踩过的坑吧。
坑一:ViewModel太「胖」
我曾经把一个页面的所有逻辑都塞进一个ViewModel里,结果那个类有30多个方法、20多个状态。后来需求变了,我改一个方法,测试全挂了。怎么解决?拆。一个页面可以有多个ViewModel,比如「搜索ViewModel」、「列表ViewModel」、「筛选ViewModel」。每个只负责一块逻辑。
坑二:View里偷偷改了数据
有一次排查Bug,发现一个列表项的状态不对。查了半天,发现是某个View在渲染时直接修改了ViewModel里的数组。嗯,从那以后我强制团队使用「不可变数据」——每次修改都返回新对象,不碰原数据。
坑三:状态同步不及时
单向数据流最怕「异步」。比如用户点了按钮,ViewModel去调接口,接口还没回来,用户又点了。怎么办?我建议在ViewModel里加一个「请求锁」:
class ViewModel {
isLoading = false
async submit() {
if (this.isLoading) return // 防止重复提交
this.isLoading = true
await api.submit()
this.isLoading = false
}
}
3.5 总结一下
表现层设计,说白了就是三件事:
- 组件只管UI状态,别掺和业务
- View和ViewModel划清界限,各司其职
- 数据流单向走,别搞出「双向奔赴」的混乱
做到这三点,你的表现层就会变得清晰、可测、好维护。下次改需求的时候,你会感谢自己当初的「克制」。
嗯,今天就聊到这里。下一章我们聊聊「业务层的设计」,看看怎么把那些「又臭又长」的业务逻辑拆得干干净净。