全局状态的局限性
在过去的前端项目中,使用过各种状态管理方案,比如redux
、mobx
、vuex
,最近的项目是vue3的技术栈,所以使用的是Pinia
。
本文将总结项目中全局状态管理的一些限制,并不会涉及到某个具体状态库的使用或者优劣。
全局状态
全局状态在前端应用中应该指的是应用中的数据,比如用户登录信息、主题设置等等。这些数据需要在不同的组件之间共享和使用。
比如,用户登录后,头像和用户名可能在多个页面的顶部显示,这时候就需要这些信息能被各个组件访问到,而不需要每次都从服务器获取或者通过组件层层传递。
全局状态最核心的概念是一个单一数据源,对应大部分框架抽象出的store
概念。
而全局状态管理是一种用于集中管理应用内共享数据的机制,旨在解决跨组件、跨层级的状态共享与同步问题。
避免“Prop Drilling”,无需通过逐层传递
props
或事件来跨组件共享状态。状态一致性,确保不同组件中的状态实时同步(如用户登录状态变化时,所有依赖组件自动更新)。
可维护性,集中管理状态,逻辑更清晰,便于调试和扩展。
跨组件通信,非父子关系的组件也能共享数据(如侧边栏和内容区域联动)。
从定义可以看出,全局状态应该是一个跟业务强相关的数据,一个数据是归属于全局状态、还是组件内部的状态,取决于开发者对于业务的理解。
全局状态的局限性
根据我的经验,在前端项目的全局状态里面,实际上存放了两种数据
- 一种是真正的全局状态,如全局用户信息、配置等数据,这些数据可能被散落在应用各个角落的地方引用
- 一种是某些组件(包括页面组件)的业务数据,为了跨组件传递数据,将这些状态也放在了全局store里面
对于第一种数据,这种数据一般会在系统初始化、或者某个业务节点,请求接口进行数据更新,本身跟页面路由切换没有关系。
但对于第二种数据,就存在一些边界情况
数据竞态
比如从一个/detail/1
跳转到了/detail/2
的页面,在每个页面发送了fetchDetail(id)
的请求,在离开页面1时,fetchDetail(1)
的数据还没有返回,然后进入页面2,又触发了fetchDetail(2)
const Detail = ({id})=>{
const [detail, setDetail] = useStore()
useEffect(()=>{
fetcchDetail(id).then((res)=>{
setDetail(res)
})
},[id])
}
这时候,全局状态detail
,就取决于两个接口哪个后回来,后调用了setDetail
设置全局状态,大多数情况下先请求的被后请求的覆盖,但由于网络问题,或者后端服务,并不全是这样,这就倒是这些问题还不容很容易就可以被测出来。
这种情况被称为数据竞态,其本质原因就是没有识别出本应该被废弃掉的状态更新,一般的处理方式如下:增加一个标记在接口返回时进行判断是否需要更新状态
const isUnmount = ref(false)
useEffect(()=>{
fetcchDetail(id).then((res)=>{
if(isUnmount.current) return
setDetail(res)
})
},[id])
useEffect(()=>{
return ()=>{
isUnmount.current = true
}
},[])
大部分状态库并没有类似的限制机制,异步接口返回回来之后,还是会按照原来的逻辑更新store里面的状态,因为他们本身无法识别这次操作的目的,需要业务开发者自己实现类似的机制
在实际业务中,我编写了一个工具函数来处理这种存在异步更新流程的逻辑
export async function safeTransactionRequest<T>(handler: (params: HandlerParams) => Promise<T>, getKey) {
const key = getKey();
const validate = () => {
return key === getKey();
};
const data = await handler({ validate });
if (!validate()) {
throw new Error('cancel');
}
return data;
}
具体的使用方式为
const getKey = ()=>{
return window.location.href
}
try {
safeTransactionRequest(async ({validate})=>{
const data = await fetchDetail()
// 在更新状态前判断
validate()
store.setDetail(data)
}, getKey)
} catch(e) {
console.log(e)
}
使用局部状态并没有这个问题,因为即使更新了上一个页面的state,但是页面组件已经被卸载了,也不会影响现在的UI展示。
但在使用全局状态时,数据竞态是前端开发者必须要小心翼翼处理的一个问题。
数据缓存
另外一个常见的问题就是数据缓存。
在上面的例子中,假设一切都如设计的这样,没有数据竞态发生,那么也会出现数据缓存的情况,即:进入detail/2
的时候,先渲染了detail/1
的数据,再将页面上的展示更新为detail/2
的内容。
不管是/detail/1
直接跳转到/detail/2
,还是/detail/1
跳转到了其他页面,再跳转到了/detail/2
,都会出现这种情况。
这也是因为对应的页面组件上,会消费全局状态进行展示。既然在页面1中,已经获取到了数据内容,那么进入页面2时,初始化时获取到的也会是页面1的数据,就会渲染出来,之后等待页面2的接口回来,更新全局状态之后,才会将页面2本身的数据渲染出来。
这个问题要看具体的产品设计,比如有时候产品会认为进入页面2,结果先展示了1,再展示2的数据是bug;有时候直接从页面1跳转到页面2,先展示1再展示2又是可以接受的。
了解了具体的产品需求之后,才好做对应的修改,而修改也很简单:就是离开页面时,将这个全局状态重置即可。
useEffect(()=>{
fetchDetail().then((res)=>{
store.setDetail(res)
})
return ()=>{
store.setDetail(null)
}
})
数据缓存在一些场景下,并不是我们使用全局状态的目的,但是却需要我们处理它带来的这个问题。
局部状态并没有这种缓存的问题,因为当前页面的数据,在组件卸载时都被销毁了。当然这也会在某些需要数据缓存的情况下,需要做一些额外的改动。
内存泄露
在某种程度上来说,全局状态等同于与全局变量,因此需要开发者手动释放这些数据,如果没有手动释放,或者因为某些原因没有释放成功,就会造成内存泄露。
下面是一个简单的例子
const store = {
list: [],
addItem(item){
this.list.push(item)
}
}
当开发者频繁的调用addItem
更新全局状态,却没有在某个时机重置数据时,这个list状态就会越来越大,里面的元素没有得到释放,如果其中item引用了某个大对象(比如DOM节点),就产生了内存泄露。
单例限制扩展
全局状态,就表示某个状态的数据是一个单例,操作这个数据的方法,都无法在组件级别上进行复制。
复制的含义是:当页面需要同时展示两个或多个相同的组件时,他们的状态作用域只是全局状态唯一的,而不是每个组件各自的。
比如一个商品详情页组件,把这个goodsDetail
的信息当做了全局状态,并为这个数据封装了各种价格计算、下单的操作,那么后续需求变更为一个商品详情页面上可以同时展示两种商品时,相关的方法都无法复用了,因为他们都依赖于单例数据。
这个问题在开发初期一般不会考虑到,使用全局状态来管理某些数据,在当前开发阶段是合理的;但随着产品功能迭代,可能会导致状态不再全局唯一,这时候就需要对代码进行重构。
总之,前端也需要对数据进行设计和建模,全局状态也并不是解决所有数据问题的银弹,最好是对数据的处理有统一的、纯函数级别的管理,至于全局状态,只是用来提供一个跨组件数据传输的机制而已。
这样,即使需求发生了变化,也可以尽可能少地改动已有代码,降低重构成本。
耦合性增加
全局状态可能导致组件过度依赖全局存储(如 Redux Store),破坏组件的独立性。
比如一个简单的按钮组件为了修改全局主题状态,需要引入复杂的 dispatch
或 action
,导致组件难以复用,因为这个组件与全局状态逻辑紧密绑定,迁移或替换成本变高。
还有比如一个函数
function formatDetail(detail){
// 对detail参数做一些处理
}
如果是全局状态的写法,函数就不是一个纯函数
function formatDetail(){
const detail = store.state.detail
// 对detail参数做一些处理
}
在单元测试时,还需要对store本身进行mock,耦合度变高,单元测试成本也会增大。
有限范围的共享状态
在过去的业务中,我还尝试过一种非全局的、但是在有限范围内可以共享的状态
Context
类似于React的Context,通过 Provider
和 Consumer
跨组件传递状态。
Vue本身也有provide
和inject
,可以实现类Context的功能。
但Context本身也有一些限制
- 只能在组件树中使用,无法在组件(hooks)外使用,因此并不灵活。
- Context变化后,所有消费了Context的组件都会更新,如果Context本身没有做合理的拆分,会导致一些无意义的重复渲染,出现性能问题
类实例
另外一个思路就是类来封装一个特定业务下需要共享的状态和方法
class Data {
x = 1
constructor(public id:string){}
add(){
this.x++
}
}
然后通过在全局store中挂载对象实例
const store = useDataStore()
onMounted(()=>{
const id = query.id
const instance = new Data(id)
store.setDataInstance(id, instance)
})
之后在根据特定的参数(如id)获取到这个实例,获取对应的状态和方法
const id = query.id
const instance = store.getInstanceById(id)
instance?.add()
小结
在传统的全局唯一store中,所有的全局状态都放在了store中,导致Store 体积庞大,难以维护。
甚至还有同学将组件的状态(比如一个switch开关状态)、组件的ref引用等也放在store里面,然后统一在store处实现业务逻辑等。
区分全局状态(如用户信息)和局部状态(如表单输入)是比较考验开发者功力的一件事情。
本文想表达的观点是:并不是所有的数据都适合用全局状态来保存,谨慎选择全局状态。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
