vue3 打造一个可以发挥compositionAPI优势的轻量级状态管理

Vuex 的遗憾

Vuex 是基于 Vue2 的 option API 设计的,因为 optionAPI 的一些先天问题,所以导致 Vuex 不得不用各种方式来补救,于是就出现了 getter、mutations、action、module、mapXXX 这些绕圈圈的使用方式。想要使用 Vuex 就必须先把这些额外的函数给弄明白。

Vue3 发布之后,Vuex4 为了向下兼容只是支持了 Vue3 的写法,但是并没有发挥 composition API 的优势,依然采用原有的设计思路。这个有点浪费 compositionAPI 的感觉。

如果你也感觉 Vuex 太麻烦了,那么欢迎来看看我的实现方式。

轻量级状态(nf-state):

轻量级状态.png

compositionAPI 提供了 reactive、readonly 等好用的响应性的方式,那么为啥不直接用,还要套上 computed?又不需要做计算。我们直接使用 reactive 岂不是很爽?

可能有同学会说,状态最关键的在于跟踪,要知道是谁改了状态,这样便于管理和维护。

这个没关系,我们可以用 proxy 来套个娃,即可以实现对 set 的拦截,这样可以在拦截函数里面实现 Vuex 的 mutations 实现的各种功能,包括且不限于:

  • 记录状态变化日志:改变状态的函数、组件、代码位置(开发模式)、修改时间、状态、属性名(含路径)、原值、新值。
  • 设置钩子函数:实现状态的持久化,拦截状态改变等操作。
  • 状态的持久化:存入indexedDB,或者提交给后端,或者其他。
  • 其他功能

也就是说,我们不需要专门写 mutations 来改变状态了,直接给状态赋值即可。

以前是把全局状态和局部状态放在一起,用了一段时间之后发现,没有必要合在一起。

全局状态,需要一个统一的设置,避免命名冲突,避免重复设置,但是局部状态只是在局部有效,并不会影响其他,那么也就没有必要统一设置了。

于是新的设计里面,把局部状态分离出去,单独管理。

因为 proxy 只支持对象类型,不支持基础类型,所以这里的状态也必须设计成对象的形式,不接受基础类型的状态。也不支持ref。

轻量级状态的整体结构设计

状态整体设计.png

整体采用 MVC设计模式,状态( reactive 和 proxy套娃)作为 model,然后我们可以在单独的 js文件里面写 controller 函数,这样就非常灵活,而且便于复用。

再复杂一点的话,可以加一个 service,负责和后端API、前端存储(比如 indexedDB等)交换数据。

在组件里面直接调用 controller 即可,当然也可以直接获取状态。

定义各种状态

好了开始上干货,看看如何实现上面的设计。

我们先定义一个结构,用于状态的说明:

const info = { // 状态名称不能重复
   // 全局状态,不支持跟踪、钩子、日志
   state: {
     user1: { // 每个状态都必须是对象,不支持基础类型
       name: 'jyk' //
     }
   },
   // 只读状态,不支持跟踪、钩子、日志,只能用初始化回调函数的参数修改
   readonly: {
     user2: { // 每个常量都必须是对象,不支持基础类型
       name: 'jyk' //
     }
   },
   // 可跟踪状态,支持跟踪、钩子、日志
   track: {
     user3: { // 每个状态都必须是对象,不支持基础类型
       name: 'jyk' //
     }
   },
   // 初始化函数,可以从后端、前端等获取数据设置状态
   // 设置好状态的容器后调用,可以获得只读状态的可写参数
   init(state, _readonly) {}

这里把状态分成了三类:全局状态、只读状态和跟踪状态。

  • 全局状态:直接使用 reactive, 简洁快速,适用于不关心状态是怎么变的,可以变化、可以响应即可的环境。

  • 只读状态:可以分为两种,一个是全局常量,初始设置之后,其他的地方都是只读的;一个是只能在某个位置改变状态,其他地方都是只读,比如当前登录用户的状态,只有登录和退出的地方可以改变状态,其他地方只能只读。

  • 可以跟踪的状态:使用 proxy 套娃reactive 实现,因为又套了一层,还要加钩子、记录日志等操作,所以性能稍微差了一点点,好吧其实也应该差不了多少。

把状态分为可以跟踪和不可以跟踪两种情况,是考虑到各种需求,有时候我们会关心状态是如何变化的,或者要设置钩子函数,有时候我们又不关心这些。两种需求在实现上有点区别,所以干脆设置成两类状态,这样可以灵活选择。

实现各种状态


import { reactive, readonly } from 'vue'
import trackReactive from './trackReactive.js'
/**
 * 做一个轻量级的状态
 */
export default {
  // 状态的容器,reactive 的形式
  state: {},
  // 全局状态的跟踪日志
  changeLog: [],
  // 内部钩子,key:数组
  _watch: {},
  // 外部函数,设置钩子,key:回调函数
  watch: {},
  // 状态的初始化回调函数,async
  init: () => {},

  createStore (info) {
    // 把 state 存入 state
    for (const key in info.state) {
      const s = info.state[key]
      // 外部设置空钩子
      this.watch[key] = (e) => {}
      this.state[key] = reactive(s)
    }
    // 把 readonly 存入 state
    const _readonly = {} // 可以修改的状态
    for (const key in info.readonly) {
      const s = info.readonly[key]
      _readonly[key] = reactive(s) // 设置一个可以修改状态的 reactive
      this.state[key] = readonly(_readonly[key]) // 对外返回一个只读的状态
    }
    // 把 track 存入 state
    for (const key in info.track) {
      const s = reactive(info.track[key])
      // 指定的状态,添加监听的钩子,数组形式
      this._watch[key] = []
      // 外部设置钩子
      this.watch[key] = (e) => {
        // 把钩子加进去
        this._watch[key].push(e)
      }
      this.state[key] = trackReactive(s, key, this.changeLog, this._watch[key])
    }
   
    // 调用初始化函数
    if (typeof info.init === 'function') {
      info.init(this.state, _readonly)
    }

    const _store = this
    return {
      // 安装插件
      install (app, options) {
        // 设置模板可以直接使用状态
        app.config.globalProperties.$state = _store.state
      }
    }
  }
}

代码非常简单,算上注释也不超过100行,主要就是套上 reactive 或者 proxy套娃。

最后 return 一个 vue 的插件,便于设置模板里面直接访问全局状态。

全局状态并没有使用 provide/inject,而是采用“静态对象”的方式。这样任何位置都可以直接访问,更方便一些。

实现跟踪状态


import { isReactive, toRaw } from 'vue'

// 修改深层属性时,记录属性路径
let _getPath = []

/**
 * 带跟踪的reactive。使用 proxy 套娃
 * @param {reactive} _target  要拦截的目标 reactive
 * @param {string} flag 状态名称
 * @param {array} log 存放跟踪日志的数组
 * @param {array} watch 监听函数
 * @param {object} base 根对象
 * @param {array} _path 嵌套属性的各级属性名称的路径
 */
export default function trackReactive (_target, flag, log = [], watch = null, base = null, _path = []) {
  // 记录根对象
  const _base = toRaw(_target)
  // 修改嵌套属性的时候,记录属性的路径
  const getPath = () => {
    if (!base) return []
    else return _path
  }
  
  const proxy = new Proxy(_target, {
    // get 不记录日志,没有钩子,不拦截
    get: function (target, key, receiver) {
      const __path = getPath(key)
      _getPath = __path
      // 调用原型方法
      const res = Reflect.get(target, key, receiver)
      // 记录
      if (typeof key !== 'symbol') {
        // console.log(`getting ${key}!`, target[key])
        switch (key) {
          case '__v_isRef':
          case '__v_isReactive':
          case '__v_isReadonly':
          case '__v_raw':
          case 'toString':
          case 'toJSON':
            // 不记录
            break
          default:
            // 嵌套属性的话,记录属性名的路径
            __path.push(key) 
            break
        }
      }
      if (isReactive(res)) {
        // 嵌套的属性
        return trackReactive(res, flag, log, watch, _base, __path)
      }
      return res
    },
    set: function (target, key, value, receiver) {
      const stack = new Error().stack
      const arr = stack.split('\n')
      const stackstr = arr.length > 1 ? arr[2]: '' // 记录调用的函数

      const _log = {
        stateKey: flag, // 状态名
        keyPath: base === null ? '' : _getPath.join(','), //属性路径
        key: key, // 要修改的属性
        value: value, // 新值
        oldValue: target[key], // 原值
        stack: stackstr, // 修改状态的函数和组件
        time: new Date().valueOf(), // 修改时间
        // targetBase: base, // 根
        target: target // 上级属性/对象
      }
      // 记录日志
      log.push(_log)
      if (log.length > 100) {
        log.splice(0, 30) // 去掉前30个,避免数组过大
      }

      // 设置钩子,依据回调函数决定是否修改
      let reValue = null
      if (typeof watch === 'function') {
        const re = watch(_log) // 执行钩子函数,获取返回值
        if (typeof re !== 'undefined')
          reValue = re
      } else if (typeof watch.length !== 'undefined') {
        watch.forEach(fun => { // 支持多个钩子
          const re = fun(_log) // 执行钩子函数,获取返回值
          if (typeof re !== 'undefined')
            reValue = re
        })
      } 

      // 记录钩子返回的值
      _log.callbackValue = reValue
      // null:可以修改,使用 value;其他:强制修改,使用钩子返回值
      const _value = (reValue === null) ? value : reValue
      _log._value = _value
      
      // 调用原型方法
      const res = Reflect.set(target, key, _value, target)
      return res
    }
  })
  // 返回实例
  return proxy
}

使用 proxy 给 reactive 套个娃,这样可以“继承” reactive 的响应性,然后拦截 set 操作,实现记录日志、改变状态的函数、组件、位置等功能。

  • 为啥还要拦截 get 呢?
    主要是为了支持嵌套属性。
    当我们修改嵌套属性的时候,其实是先把第一级的属性(对象)get 出来,然后读取其属性,然后才会触发 set 操作。如果是多级的嵌套属性,需要递归多次,而最后 set 的部分,修改的属性就变成了基础类型。

  • 如何获知改变状态的函数的?
    这个要感谢乎友(否子戈 https://www.zhihu.com/people/frustigor )的帮忙,我试了各种方式也没有搞定,在一次抬杠的时候,发现否子戈介绍的 new Error() 方式,可以获得各级改变状态的函数名称、组件名称和位置。
    这样我们记录下来之后就可以知道是谁改变了状态。

concole.log(stackstr)打印出来,在F12里面就可以点击进入代码位置,开发环境会非常便捷,生产模式由于代码被压缩了,所以效果嘛。。。

const stack = new Error().stack
const arr = stack.split('\n')
const stackstr = arr.length > 1 ? arr[2]: '' // 记录调用的函数

在 Vue3 的项目里的使用方式

我们可以模仿Vuex的方式,先设计一个 定义的js函数,然后在main.js挂载到实例。
然后设置controller,最后就可以在组件里面使用了。

定义

store-nf/index.js

// 加载状态的类库
import { createStore } from 'nf-state'

import userController from '../views/state/controller/userController.js'

export default createStore({
  // 读写状态,直接使用 reactive
  state: {
    // 用户是否登录以及登录状态
    user: {
      isLogin: false,
      name: 'jyk', //
      age: 19
    }
  },
  // 全局常量,使用 readonly 
  readonly:{
    // 访问indexedDB 和 webSQL 的标识,用于区分不同的库
    dbFlag: {
      project_db_meta: 'plat-meta-db' // 平台 运行时需要的 meta。
    },
    // 用户是否登录以及登录状态
    user1: {
      isLogin: false,
      info:{
        name: '测试第二层属性'
      },
      name: 'jyk', //
      age: 19
    }
  },
  // 跟踪状态,用 proxy 给 reactive 套娃
  track: {
    trackTest: {
      name: '跟踪测试',
      age: 18,
      children1: {
        name1: '子属性测试',
        children2: {
          name2: '再嵌一套'
        }
      }
    },
    test2: {
      name: ''
    }
  },
  // 可以给全局状态设置初始状态,同步数据可以直接在上面设置,如果是异步数据,可以在这里设置。
  init (state, read) {
    userController().setWriteUse(read.user1)
    setTimeout(() => {
      read.dbFlag.project_db_meta = '加载后修改'
    }, 2000)
  }
})

这里设置了两个用户状态,一个是可以随便读写的,一个是只读的,用于演示。

状态名称不可以重复,因为都会放在一个容器里面。

  • 初始化
    在这里可以设置inti初始化的回调函数,state是状态的容器,read 就是只读状态的可以修改的对象,可以通过read来改变只读状态。

这里引入了用户的controller,把 read 传递过去,这样controller里面就可以改变只读状态了。

main.js

import { createApp } from 'vue'
import App from './App.vue'

import store from './store' // vuex
import router from './router' // 路由

import nfStore from './store-nf' // 轻量级状态

createApp(App)
  .use(nfStore)
  .use(store)
  .use(router)
  .mount('#app')

main.js 的使用方式和 Vuex 基本一致,另外和 Vuex 不冲突,可以在一个项目里同时使用。

controller

好了,到了核心部分,我们来看看controller的编写方式,这里模拟一下当前登录用户。

// 用户的管理类
import { state } from 'nf-state'

let _user = null

const userController = () => {
  // 获取可以修改的状态
  const setWriteUse = (u) => {
    _user = u
  }

  const login = (code, psw) => {
    // 假装访问后端
    setTimeout(() => {
      // 获得用户信息
      const newUser = {
        name: '后端传的用户名:' + code
      }
      Object.assign(_user, newUser)
      _user.isLogin = true
    }, 100)
  }

  const logout = () => {
    _user.isLogin = false
    _user.name = '已经退出'
  }

  const getUser = () => {
    // 返回只读状态的用户信息
    return state.user1
  }

  return {
    setWriteUse,
    getUser,
    login,
    logout
  }
}

export default userController

这样是不是很清晰。

组件

准备工作都做好了,那么在组件里面如何使用呢?

  • 模板里直接使用
<template>
  全局状态-user:{{$state.user1}}<br>
</template>
  • 直接使用状态
import { state, watchState } from 'nf-state'

// 可以直接操作状态
console.log(state)

const testTract2 = () => {
  state.trackTest.children1.name1 = new Date().valueOf()
}
 
const testTract3 = () => {
  state.trackTest.children1.children2.name2 = new Date().valueOf()
  state.test2.name = new Date().valueOf()
}
状态的结构

这样就变成了 reactive 的使用,大家都熟悉了吧。

  • 通过controller使用状态
import userController from './controller/userController.js'

const { login, logout, getUser } = userController()

// 获取用户状态,只读
const user = getUser()

// 模拟登录
const ulogin = () => {
  login('jyk', '123')
}
// 模拟退出登录
const ulogout = () => {
  logout()
}

设置监听和钩子

import { state, watchState } from 'nf-state'

// 设置监听和钩子
watchState.trackTest(({keyPath, key, value, oldValue}) => {
  if (keyPath === '') {
    console.log(`\nstateKey.${key}=`)
  } else {
    console.log(`\nstateKey.${keyPath.replace(',','.')}.${key}=` )
  }
  console.log('oldValue:', oldValue)
  console.log('value:', value )
  // return null
})

watchState 是一个容器,后面可以跟一个状态同名的钩子函数,也就是说状态名不用写字符串了。

我们可以直接指定要监听的状态,不会影响其他状态,在钩子里面可以获取当前 set产生的日志,从而获得各种信息。

还可以通过返回值的方式来影响状态的改变:

  • 没有返回值:允许状态的改变。
  • 返回原值:不允许状态的改变,维持原值。
  • 返回其他值:表示把返回值设置为状态改变后的值。

局部状态

局部状态不需要进行统一定义,直接写 controller 即可。
controller 可以使用对象的形式,也可以使用函数的形式,当然也可以使用class。

import { reactive, provide, inject } from 'vue'
import { trackReactive } from 'nf-state'

const flag = 'test2'

/**
 * 注入局部状态
 */
const reg = () => {
  // 需要在函数内部定义,否则就变成“全局”的了。
  const _test = reactive({
    name: '局部状态的对象形式的controller'
  })
  // 注入
  provide(flag, _test)
  // 其他操作,比如设置 watch
  return _test
}

/**
 * 获取注入的状态
 */
const get = () => {
  // 获取
  const re = inject(flag)
  return re
}

const regTrack = () => {
  const ret = reactive({
    name: '局部状态的可跟踪状态'
  })
  // 定义记录跟踪日志的容器
  const logTrack = reactive([])
  // 设置监听和钩子
  const watchSet = (res) => {
    console.log(res)
    console.log(res.stack)
    console.log(logTrack)
  }
  const loaclTrack = trackReactive(ret, 'loaclTrack', logTrack, watchSet)

  return {
    loaclTrack,
    logTrack,
    watchSet
  }
}

// 其他操作

export {
  regTrack,
  reg,
  get,
}

如果不需要跟踪的话,其实就是 provide/inject + reactive 的形式,这个没啥特别的。
如果要实现跟踪的话,需要引入 trackReactive ,然后设置日志数组和钩子函数即可。

点击位置可以定位代码

源码

https://gitee.com/naturefw/vue-data-state

在线演示

https://naturefw.gitee.io/vite2-vue3-demo/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容