前端局部状态管理尝试

513 阅读10分钟

针对平时工作开发中遇到的,关于前端数据的问题,有一些探讨和尝试。
个人工作中技术栈多是vue,所以后续内容主要也是围绕这个技术栈展开。

为什么有这方面的尝试?

不知道有没有同学遇到这种情况
拿到一个需要维护的项目时,需求都是很简单,修修bug,改改功能点。
但是当你认真看工程代码时,会发现很多时候工程过于复杂,数据流不清晰。
image.png
有时我们需要修改的内容是很深的子组件,它的数据源是顶层组件的数据。一层一层通过props传递进来。
如果我们要寻找数据源,又不熟悉所有业务的情况下,那就麻烦了。往往一个小功能的修改,我们就得查阅所有相关的组件和数据流向。当然,一些通用的ui组件或功能组件,对于外层,最好还是通过props传递数据,与外层隔绝开来。
数据对我们工程维护的成本影响还是很大的,接着我们再深入看看,除了这点,数据还会有什么影响。

当前遇到的些许痛点

状态管理

目前工作中,逐渐遇到了一些问题。项目工程越来越复杂,逻辑模块、组件模块嵌套越来越深。
而且,大型项目中,不同模块往往是解耦的,内部的状态和逻辑都是完全隔离开的。同样,这些模块或者组件甚至是不同的同学进行维护开发,内部是相对完整的。
比如下图的架构设计:
image.png
乍一看,这不就是普通的vue项目嘛~~~有什么不一样?
的确,大体的逻辑就是一个普通的vue前端项目。
通过一个vue工程或者称之为引擎,引擎像是城市规划、政府建设,提供了

  • 功能基座、视图基座

  • 公共数据状态

  • 通用能力 各个业务视图和功能模块像是商业、住宅、公园等,负责自己的生态管理,但是又基于整个城市规划中。 通过这个引擎将其他系统连接、安装起来,形成一个大的系统。而且关键点是:

  • 子模块完全独立开发、内部需要有自己的状态管理需求。

  • 不同模块之间完全不需要了解其他模块的业务逻辑,通信机制。所有模块的只是和引擎进行双向沟通。

  • 子模块可以是一个相对闭环的系统,像是积木,可以接入到不同的vue根实例。比如可以嵌入引擎二号、引擎三号,可以有效的支持不同项目的功能搭建。

为什么这么设计呢?

这样的架构,状态管理怎么不直接上vuex呢?
试想一下,如果当成普通的vue项目进行开发。统一用vuex进行全局的状态管理。那么随着模块的增加,会产生什么问题?

  • vuex维护的module会越来越多。
  • 与根实例的vuex有耦合,依赖于全局的vuex才能实现功能。
  • 与主工程有代码侵入,逻辑理解复杂的风险。

再举个例子,在项目中引入的组件或者ui框架,比如element-ui。它也是存在相对复杂的组件组合比如表单什么的,它们的状态管理是怎么做的呢?对于你的项目,它是个黑盒子,你不必考虑它需要怎么管理状态,你只需要学会使用它。所以可以看出,局部的状态管理对于组件或者模块的作用还是很大的。

局部状态管理,应运而生。我们需要模仿或者组织一种局部状态管理的形式。在庞大的工程中,维护好它自己的一方天地。

方案思路

需求已经产生了,考虑一下有什么方案或者思路可以实现呢?

看看能借鉴什么

对于数据的状态管理,我们希望是能局部去维护它。其实也有不少方式是可以实现的,但是我们也得考虑项目本身的迁移成本等等因素。比如

  • 仿照一个局部版vuex
  • vue3的工程的话,我们可以尝试利用Composition API实现一个hook形式的局部顶层的数据逻辑文件。
    对这块感兴趣的,可以参考另一篇文章,感受一下思路。

Vue3你还在用Vuex?一个“函数式”状态管理的新思路

借鉴vuex

我们这边是vue2的项目工程,也有使用vuex的基础。所以觉得考虑用仿照vuex局部的方式去改善一些闭环的组件。即使这些组件最后不再使用这个方案,过度成全局的vuex,也很方便。

话说回来,如果去认真还原vuex,其实也完全失去了意义,没必要反复创轮子。而且成本巨大,也未必能做好。考虑后觉得提取其中的思想和功能组合出最适合自己工作业务的插件或者工具就好了。
简单回顾一下vuex几个核心的点

  • State
  • Getters
  • Mutations
  • Actions
  • Module


直接从实用方案的角度入手,看看需要什么。

  • state,局部状态管理,我们就要维护一个局部的状态树,虽然区别于全局的vuex单一状态树。那state肯定是必不可少的,state就是我们存储数据的基础。
  • getters,相当于vuex里的计算属性吧,在我们需要获取state中状态时,再经过一层逻辑计算。根据情况,其实这一层并不是必须的,可以考虑不计入方案。
  • mutations,是在更改state中状态的最后一环,也是提供给devtools开发插件来追踪状态的关键。我们可以没有devtools,但是mutations作为最后一环,也是必不可少的。
  • actions,本来是可以包含异步函数的逻辑的,然后再去提交commit去执行mutaions。但是,其实我不想把局部状态管理弄复杂,异步函数其实可以写在业务逻辑里,状态管理就完完全全的只是处理数据。所以觉得设立舍弃actions机制。所有的状态改变都实用同步函数mutations去改变。
  • module,模块划分还是很重要的,可以将不同类型的数据划分到不同模块中,代码的可读性维护性会高很多。

来个草图

ok,试着规划一下,state,mutations,module,已经可以支持现有的功能和业务需求了。
image.png
其实有了这个流程图,感觉差不多了,感觉脑子已经把代码跑好了,哈哈哈。就差手了。接下来就需要相应的技术实现其中的每一个环节。

再深入一些细节

尝试依据简单草图的流程实现整个功能,
回顾一下vuex的使用方式?

  • 配置对象,填写state、mutation、action。并用module来划分模块。
  • 组件引入vuex中的state和辅助函数
  • 在视图中或计算属性、观察属性中使用state
  • 在业务逻辑中提交actions或者commit更改数据

列出这些后,其实思路很明确了。我们可以先“不专业的”还原这些功能。
自然而然,实现中相对复杂的逻辑点可以大致列一下

  • state和mutation的注册。解析配置对象,完成state和mutation的注册。
  • 考虑注册时,也要响应考虑配置对象中module的划分,完成不同module的state和mutation的注册。形成state状态树和mutation改变方法树。
  • 注册完成后,我们怎么对state做数据劫持。也就是vue双向数据绑定的核心。这样将state提供给不同组件使用时,才能因为数据的变化而更新视图。
  • 在组件使用局部状态管理插件时,暴露出一些辅助方法,和数据state本身。

具体实现逻辑

根据上面的几个技术点,大致梳理了一下代码逻辑,以简单的方案思路,实现代码也不复杂,并且已经在业务中应用。效果确实是比原本的至上而下,由父到子的数据流舒服了很多。

解析module、state、mutation配置

先回顾看看vuex中module的结构是怎么样的

const moduleA = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

store.state.a // -> moduleA 的状态
store.state.b // -> moduleB 的状态

梳理一下,其实需求和配置对象完全可以一模一样,只是省略了actions的解析。
首先,第一步
当然是解析配置对象里的state、mutations、modules。我们需要构建两个树,分别是state树、和mutations树,通过不同module的命名空间,把这两个树补充完整。
具体代码当然就是各种递归、类型判断、错误提示啦。下面例子是输入的配置,和输出的状态树、和改变方法树。

// 根据配置对象新建 store实例
const store = new myStore({
  state:()=>({
    top:0
  }),
  modules:{
    a:{
      state:()=>({
        val1:1,
        val2:2
      }),
      mutations:{
        set1(state,value){
          // 这里的state,我们需要解析成与当前modules处在同一命名空间下的state
          state.val1 = value
        }
      }
    }
  }
})

// 解析后的完整state树
state = {
  top:0
  a:{
    val1:1,
    val2:2
  }
}

// 解析后的完整mutations树
mutations = {
  a:{
    set1:function(state,value){
      state.val1 = value
    }
  }
} 

第二步
对state数据进行数据劫持。只有对状态树进行数据劫持,才能收集到vue实例中的使用,在数据更改后执行对应vue实例的render方法,从而更新试图。这也是mvvm框架的关键点。
如果是vue技术栈的项目,超级方便,只需要将vue版本更新至2.6以后。
参考这个api

Vue.observable( object )

让一个对象可响应。Vue 内部会用它来处理 data 函数返回的对象。 返回的对象可以直接用于渲染函数和计算属性内,并且会在发生变更时触发相应的更新。

感觉这一点是可以扩展到react,如果react项目的局部状态管理,这里采用react的数据劫持方式或者绑定方式,再修改一下逻辑,实现是同理的。毕竟vuex和redux的用法和逻辑是类似的。

第三步

提供state数据和一些辅助函数

  • commit
    commit方法是我们用来代替dispatch直接来派发状态更新的函数,在解析、生成mutations树时,对函数进行包装,并传入对应命名空间的state就好啦。
// 部分代码,递归遍历mutations树,处理当前命名空间的state传入作为第一参数
const fn = mutations[key]
mutations[key] = function(...params) {
    fn(state, ...params)
}
  • getter
    对于getter的封装,就看你们需要了,相当于state树的计算属性。

结束
这里可以看看大致实现的代码框架

// 局部vuex
class Ss {
  constructor({ state, mutations, getters, modules }) {
    this.state = state
    this.mutations = mutations
    this._init(modules || {})
  }

  _init(modules) {
    if (typeof modules !== 'object') {
      throw new Error('moudels配置参数不是对象')
    }
    /**
     * @test: none
     * @msg: 模块化解析,解析modules中的state,mutations
     * @param {state,mutations,actions,modules}
     * @return {state,mutations,actions}
     */
    const moudelsState = getModels('state', modules)
    this.state = Object.assign(this.state, ...moudelsState)
    const moudelsMutations = getModels('mutations', modules)
    this.mutations = Object.assign(this.mutations, ...moudelsMutations)
    /**
     * @test: none
     * @msg: 劫持state内容,如果是react可以考虑看看什么能替换
     * 判断使用的vue版本,如果高于2.6方可正常使用
     * @param {state}
     * @return {state}
     */
    this.state = Vue.observable(this.state)
    /**
     * @test: none
     * @msg: 处理mutations
     * todo 异常处理
     * @param {*}
     * @return {*}
     */
    handleM.call(this, this.state, this.mutations)
  }

  /**
   * @test: none
   * @msg: 通过commit去提交mutations,使得每一次改变state有统一的入口,能简单的记录改变的过程。
   * @param {*} type 字符串类型 mutations定义时的路径如:'layer.setViews'
   * @param {*} value
   * @return {*}
   */
  commit(...res) {
    ...
  }
}


这三步开发完,其实整个框架就完成,最简单的局部状态管理已经完成。已经可以投入使用了。当然,其实还是缺乏很多能力,比如严格模式,只允许commit提交修改。解析时的异常报错补充。增加一些钩子函数,提供一些可扩展的插件能力等。
讲这么多,其实也是提供一个思考的思路而已。多看多用,多改,慢慢提高自己的认知和业务能力。