侧边栏

全局状态的局限性

发布于 | 分类于 前端/前端业务

在过去的前端项目中,使用过各种状态管理方案,比如reduxmobxvuex,最近的项目是vue3的技术栈,所以使用的是Pinia

本文将总结项目中全局状态管理的一些限制,并不会涉及到某个具体状态库的使用或者优劣。

全局状态

全局状态在前端应用中应该指的是应用中的数据,比如用户登录信息、主题设置等等。这些数据需要在不同的组件之间共享和使用。

比如,用户登录后,头像和用户名可能在多个页面的顶部显示,这时候就需要这些信息能被各个组件访问到,而不需要每次都从服务器获取或者通过组件层层传递。

全局状态最核心的概念是一个单一数据源,对应大部分框架抽象出的store概念。

而全局状态管理是一种用于集中管理应用内共享数据的机制,旨在解决跨组件、跨层级的状态共享与同步问题

  • 避免“Prop Drilling”,无需通过逐层传递 props 或事件来跨组件共享状态。

  • 状态一致性,确保不同组件中的状态实时同步(如用户登录状态变化时,所有依赖组件自动更新)。

  • 可维护性,集中管理状态,逻辑更清晰,便于调试和扩展。

  • 跨组件通信,非父子关系的组件也能共享数据(如侧边栏和内容区域联动)。

从定义可以看出,全局状态应该是一个跟业务强相关的数据,一个数据是归属于全局状态、还是组件内部的状态,取决于开发者对于业务的理解。

全局状态的局限性

根据我的经验,在前端项目的全局状态里面,实际上存放了两种数据

  • 一种是真正的全局状态,如全局用户信息、配置等数据,这些数据可能被散落在应用各个角落的地方引用
  • 一种是某些组件(包括页面组件)的业务数据,为了跨组件传递数据,将这些状态也放在了全局store里面

对于第一种数据,这种数据一般会在系统初始化、或者某个业务节点,请求接口进行数据更新,本身跟页面路由切换没有关系。

但对于第二种数据,就存在一些边界情况

数据竞态

比如从一个/detail/1跳转到了/detail/2的页面,在每个页面发送了fetchDetail(id)的请求,在离开页面1时,fetchDetail(1)的数据还没有返回,然后进入页面2,又触发了fetchDetail(2)

jsx
const Detail = ({id})=>{
  const [detail, setDetail] = useStore()
	useEffect(()=>{
    fetcchDetail(id).then((res)=>{
      setDetail(res)
		})
  },[id])
}

这时候,全局状态detail,就取决于两个接口哪个后回来,后调用了setDetail设置全局状态,大多数情况下先请求的被后请求的覆盖,但由于网络问题,或者后端服务,并不全是这样,这就倒是这些问题还不容很容易就可以被测出来。

这种情况被称为数据竞态,其本质原因就是没有识别出本应该被废弃掉的状态更新,一般的处理方式如下:增加一个标记在接口返回时进行判断是否需要更新状态

jsx
const isUnmount = ref(false)
useEffect(()=>{
  fetcchDetail(id).then((res)=>{
    if(isUnmount.current) return 
    setDetail(res)
  })
},[id])

useEffect(()=>{
  return ()=>{
    isUnmount.current = true
  }
},[])

大部分状态库并没有类似的限制机制,异步接口返回回来之后,还是会按照原来的逻辑更新store里面的状态,因为他们本身无法识别这次操作的目的,需要业务开发者自己实现类似的机制

在实际业务中,我编写了一个工具函数来处理这种存在异步更新流程的逻辑

ts
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;
}

具体的使用方式为

js
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又是可以接受的。

了解了具体的产品需求之后,才好做对应的修改,而修改也很简单:就是离开页面时,将这个全局状态重置即可。

js
useEffect(()=>{
  fetchDetail().then((res)=>{
	  store.setDetail(res)
  })
	return ()=>{
    store.setDetail(null)
	}
})

数据缓存在一些场景下,并不是我们使用全局状态的目的,但是却需要我们处理它带来的这个问题。

局部状态并没有这种缓存的问题,因为当前页面的数据,在组件卸载时都被销毁了。当然这也会在某些需要数据缓存的情况下,需要做一些额外的改动。

内存泄露

在某种程度上来说,全局状态等同于与全局变量,因此需要开发者手动释放这些数据,如果没有手动释放,或者因为某些原因没有释放成功,就会造成内存泄露。

下面是一个简单的例子

js
const store = {
  list: [],
  addItem(item){
    this.list.push(item)
  }
}

当开发者频繁的调用addItem更新全局状态,却没有在某个时机重置数据时,这个list状态就会越来越大,里面的元素没有得到释放,如果其中item引用了某个大对象(比如DOM节点),就产生了内存泄露。

单例限制扩展

全局状态,就表示某个状态的数据是一个单例,操作这个数据的方法,都无法在组件级别上进行复制。

复制的含义是:当页面需要同时展示两个或多个相同的组件时,他们的状态作用域只是全局状态唯一的,而不是每个组件各自的。

比如一个商品详情页组件,把这个goodsDetail的信息当做了全局状态,并为这个数据封装了各种价格计算、下单的操作,那么后续需求变更为一个商品详情页面上可以同时展示两种商品时,相关的方法都无法复用了,因为他们都依赖于单例数据。

这个问题在开发初期一般不会考虑到,使用全局状态来管理某些数据,在当前开发阶段是合理的;但随着产品功能迭代,可能会导致状态不再全局唯一,这时候就需要对代码进行重构。

总之,前端也需要对数据进行设计和建模,全局状态也并不是解决所有数据问题的银弹,最好是对数据的处理有统一的、纯函数级别的管理,至于全局状态,只是用来提供一个跨组件数据传输的机制而已。

这样,即使需求发生了变化,也可以尽可能少地改动已有代码,降低重构成本。

耦合性增加

全局状态可能导致组件过度依赖全局存储(如 Redux Store),破坏组件的独立性。

比如一个简单的按钮组件为了修改全局主题状态,需要引入复杂的 dispatchaction,导致组件难以复用,因为这个组件与全局状态逻辑紧密绑定,迁移或替换成本变高。

还有比如一个函数

js
function formatDetail(detail){
	// 对detail参数做一些处理
}

如果是全局状态的写法,函数就不是一个纯函数

js
function formatDetail(){
	const detail = store.state.detail
  // 对detail参数做一些处理
}

在单元测试时,还需要对store本身进行mock,耦合度变高,单元测试成本也会增大。

有限范围的共享状态

在过去的业务中,我还尝试过一种非全局的、但是在有限范围内可以共享的状态

Context

类似于React的Context,通过 ProviderConsumer 跨组件传递状态。

Vue本身也有provideinject,可以实现类Context的功能。

但Context本身也有一些限制

  • 只能在组件树中使用,无法在组件(hooks)外使用,因此并不灵活。
  • Context变化后,所有消费了Context的组件都会更新,如果Context本身没有做合理的拆分,会导致一些无意义的重复渲染,出现性能问题

类实例

另外一个思路就是类来封装一个特定业务下需要共享的状态和方法

ts
class Data {
	x = 1
  constructor(public id:string){}
	add(){
		this.x++
	}
}

然后通过在全局store中挂载对象实例

js
const store = useDataStore()
onMounted(()=>{
	const id = query.id
	const instance = new Data(id)
	store.setDataInstance(id, instance)
})

之后在根据特定的参数(如id)获取到这个实例,获取对应的状态和方法

js
const id = query.id
const instance = store.getInstanceById(id)
instance?.add()

小结

在传统的全局唯一store中,所有的全局状态都放在了store中,导致Store 体积庞大,难以维护。

甚至还有同学将组件的状态(比如一个switch开关状态)、组件的ref引用等也放在store里面,然后统一在store处实现业务逻辑等。

区分全局状态(如用户信息)和局部状态(如表单输入)是比较考验开发者功力的一件事情。

本文想表达的观点是:并不是所有的数据都适合用全局状态来保存,谨慎选择全局状态。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。