likes
comments
collection
share

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读

作者站长头像
站长
· 阅读数 11

Redux(如果你想深入探究一下Redux你可以看React源码解析系列(九) -- Redux的实现原理与简易模拟实现这篇文章)一样,vuex也是一个公共状态管理库,pinia作为vuex的升级版本,它不仅适用于Vue2.x,还适用于Vue3.x,作为Vue3.x的开发者,我们已经全面拥抱了pinia,所以不仅要会用pinia,还要懂它的实现原理。那么我们来一起看看它们俩的相关内容吧。

vuex的核心概念

  • state:存放状态数据的地方,其中数据是响应式的。
  • getter:可理解为store的计算属性,能读取state中的数据。
  • mutations:是改变state中数据的唯一方法,修改数据是同步的。
  • actions:提交mutations修改数据,与mutations功能类似,但修改数据是异步的。
  • modules:当store过于臃肿时,可使用modulesstore分割成模块,每个模块中都有自己的stategettermutationsactions

注意

  • state为什么不能在组件中修改,却只能通过mutations修改?
    • 是因为要保证数据的单向流动,原则上更加符合通过vuex来管理状态的操作规范。
  • actions也能修改state,但是不建议这么做?
    • 是因为在actions中操作state会使得state变得难以管理,且不会被devTools观测到。换句话说能在mutations中操作,为什么要在actions中去操作呢?那就会有背vuex的设计,为了解决这个问题,pinia删除掉了mutations,后面我们会细讲。
  • vuex的单向数据流与vue的双向绑定是否冲突?
    • 首先来说一说答案:肯定是不冲突的。因为双向绑定阐述的是数据视图之间的关系,单向数据流阐述的是数据的流动关系。

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读

vuex的基础实践

codeSandBox上面的目录结构

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读

// store/index.js

import { createStore } from "vuex";

const store = createStore({
  //state存放状态,
  state: {
    name: "一溪之石", //需要共用的数据
    age: "22"
  },
  //getter为state的计算属性
  getters: {
    getName: (state) => state.name, //获取name
    getAge: (state) => state.age
  },
  //mutations可更改状态的逻辑,同步操作
  mutations: {
    setName: (state, data) => (state.name = data),
    setAge: (state, data) => (state.age = data)
  },
  //提交mutation,异步操作
  actions: {
    acSetName(context, name) {
      setTimeout(() => {
        //延时1秒提交至mutations中的方法
        context.commit("setName", name);
      }, 1000);
    },

    acSetAge(context, age) {
      setTimeout(() => {
        context.commit("setAge", age);
      }, 1000);
    }
  },
  // 将store模块化
  modules: {}
});
export default store
// ComponentOne.vue
<template>
  <div class="wrapper">
    <!--读取mapGetters中的getName与getAge-->
    <div>
      name:<span>{{ getName }}</span>
    </div>
    <div>
      age:<span>{{ getAge }}</span>
    </div>
  </div>
</template>

<script>
import { mapState, mapGetters } from "vuex"; //导入vuex的辅助函数
export default {
  components: {},
  //计算属性computed无法传递参数
  computed: {
    // 映射 state 中的数据为计算属性
    ...mapState(["name", "age"]),
    // 映射 getters 中的数据为计算属性
    ...mapGetters(["getName", "getAge"]),
  },
};
</script>
<style scoped>
.wrapper {
  /* color: #fff; */
}
</style>
// ComponentTwo.vue
<template>
  <div class="wrapper">
    <div>
      <span>同步修改:</span>
      <!--直接回车调用mapMutations中的setName方法与setAge方法-->
      <input
        v-model="nameInp"
        @keydown.enter="setName(nameInp)"
        placeholder="同步修改name"
      />
      <input
        v-model="ageInp"
        @keydown.enter="setAge(ageInp)"
        placeholder="同步修改age"
      />
    </div>

    <div>
      <span>异步修改:</span>
      <!--直接回车调用mapAtions中的acSetName方法与acSetAge方法-->
      <input
        v-model="acNameInp"
        @keydown.enter="acSetName(acNameInp)"
        placeholder="异步修改name"
      />
      <input
        v-model="AcAgeInp"
        @keydown.enter="acSetAge(AcAgeInp)"
        placeholder="异步修改age"
      />
    </div>
  </div>
</template>

<script>
import { mapMutations, mapActions } from "vuex"; //导入vuex的辅助函数
export default {
  components: {},
  data() {
    return {
      nameInp: "", //绑定输入框的值
      ageInp: "",
      acNameInp: "",
      AcAgeInp: "",
    };
  },
  methods: {
    //用于生成与 mutations 对话的方法,即:包含 $store.commit(xx) 的函数
    ...mapMutations(["setName", "setAge"]),
    //用于生成与 actions 对话的方法,即:包含 $store.dispatch(xx) 的函数
    ...mapActions(["acSetName", "acSetAge"]),
  },
};
</script>
<style scoped>
.wrapper {
}
</style>

效果图

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读

vuex的实现原理

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读

  • Vue组件通过MapXxxx方法使用module中的数据,方法。
  • 在组件需要修改state的时候,通过使用方法派发给actions或者mutations
  • 如果是actions,则可以跟接口交互,并且需要commitmutations
  • mutations去更新state

Vuex的源码解读

注意:本源码为github仓库上面master分支最新版的代码。

// vuex/vuex/src/index.js
export {
  Store, // Store类
  storeKey, // 唯一key
  createStore, // 创建仓库
  useStore,
  mapState, // 方法
  mapMutations,// 方法
  mapGetters,// 方法
  mapActions,// 方法
  createNamespacedHelpers,
  createLogger
}

createStore

export function createStore (options) {
  // 在低版本的vuex中是直接,new Vuex.store的
  return new Store(options)
}

// Store.js
export class Store {
  constructor (options = {}) {
    const {
      plugins = [], // 存储插件
      strict = false, // 严格模式
      devtools
    } = options

    // 存储状态
    this._committing = false // 与严格模式挂钩
    this._actions = Object.create(null) // action: {}
    this._actionSubscribers = [] // 存放action订阅者
    this._mutations = Object.create(null) // mutations: {}
    this._wrappedGetters = Object.create(null) // getters: {}
    this._modules = new ModuleCollection(options) // action: {}
    this._modulesNamespaceMap = Object.create(null)// 以命名空间存放module
    this._subscribers = []
    this._makeLocalGettersCache = Object.create(null)

    // 组件卸载的时候不会把相关状态卸载掉,effectScope函数
    this._scope = null

    this._devtools = devtools

    // 关联dispatch与commit
    const store = this
    const { dispatch, commit } = this
    this.dispatch = function boundDispatch (type, payload) {
      return dispatch.call(store, type, payload)
    }
    this.commit = function boundCommit (type, payload, options) {
      return commit.call(store, type, payload, options)
    }

    // strict mode
    this.strict = strict

    const state = this._modules.root.state
    // 注册模块,遍历处理Getter,Actions,Mutation,子模块
    installModule(this, state, [], this._modules.root)

    // 重置Getter缓存,把state处理成响应式
    resetStoreState(this, state)

    // 启用插件
    plugins.forEach(plugin => plugin(this))
  }

  // Vue.Component.use必须要有一个install方法或者包含install字段的对象
  install (app, injectKey) {
    app.provide(injectKey || storeKey, this)
    app.config.globalProperties.$store = this
    ...
  }
  //获得state
  get state () {
    return this._state.data
  }
  
  // 设置state
  set state (v) {
    ...
  }

  commit (_type, _payload, _options) {
    // check object-style commit
    const {
      type,
      payload,
      options
    } = unifyObjectStyle(_type, _payload, _options)
    
    // 取到type与payload,type用来取到对应的方法
    const mutation = { type, payload }
    const entry = this._mutations[type]
    
    if (!entry) {
      if (__DEV__) {
        console.error(`[vuex] unknown mutation type: ${type}`)
      }
      return
    }
    // 执行mutation中的所有方法
    this._withCommit(() => {
      entry.forEach(function commitIterator (handler) {
        handler(payload)
      })
    })
    // 执行所有订阅事件
    this._subscribers
      .slice()
      .forEach(sub => sub(mutation, this.state))
  }

  dispatch (_type, _payload) {
    // check object-style dispatch
    const {
      type,
      payload
    } = unifyObjectStyle(_type, _payload)
    
    // 与mutation一样
    const action = { type, payload }
    const entry = this._actions[type]
    if (!entry) {
      if (__DEV__) {
        console.error(`[vuex] unknown action type: ${type}`)
      }
      return
    }

    try {
      this._actionSubscribers
        .slice()
        .filter(sub => sub.before)
        .forEach(sub => sub.before(action, this.state))
    } catch (e) {
      ...
    }
    // 永promise.all来保证最有效率的拿到所有异步结果
    const result = entry.length > 1
      ? Promise.all(entry.map(handler => handler(payload)))
      : entry[0](payload)

    return new Promise((resolve, reject) => {
      result.then(res => {
        try {
          this._actionSubscribers
            .filter(sub => sub.after)
            .forEach(sub => sub.after(action, this.state))
        } catch (e) {
          ...
        }
        resolve(res)
      }, error => {
        try {
          this._actionSubscribers
            .filter(sub => sub.error)
            .forEach(sub => sub.error(action, this.state, error))
        } catch (e) {
         ...
        }
        reject(error)
      })
    })
  }
  // 注入事件
  subscribe (fn, options) {
    return genericSubscribe(fn, this._subscribers, options)
  }
  //genericSubscribe
  export function genericSubscribe (fn, subs, options) {
  // 先往subs注入fn
  if (subs.indexOf(fn) < 0) {
    options && options.prepend
      ? subs.unshift(fn)
      : subs.push(fn)
  }
  // 返回一个移除掉fn的函数
  return () => {
    const i = subs.indexOf(fn)
    if (i > -1) {
      subs.splice(i, 1)
    }
  }
}

  subscribeAction (fn, options) {
    const subs = typeof fn === 'function' ?
    { before: fn } : fn
    // 详见上面的genericSubscribe
    return genericSubscribe(subs, this._actionSubscribers, options)
  }

  watch (getter, cb, options) {
    if (__DEV__) {
      ...
    }
    // watch每一个getter,在变更的时候做出相应
    return watch(() => getter(
    this.state,
    this.getters
    ), cb, Object.assign({}, options))
  }

  replaceState (state) {
    this._withCommit(() => {
      this._state.data = state
    })
  }

  // 注册模块。与刚开始的逻辑一样
  registerModule (path, rawModule, options = {}) {
    if (typeof path === 'string') path = [path]

    if (__DEV__) {
      ...
    }

    this._modules.register(path, rawModule)
    installModule(this,
    this.state, path, this._modules.get(path), options.preserveState)
    // reset store to update getters...
    resetStoreState(this, this.state)
  }
  // 卸载模块,
  unregisterModule (path) {
    if (typeof path === 'string') path = [path]

    if (__DEV__) {
     ...
    }

    this._modules.unregister(path)
    this._withCommit(() => {
      // 获得父级的state
      const parentState = getNestedState(this.state, path.slice(0, -1))
      // 从父级中删除此state
      delete parentState[path[path.length - 1]]
    })
    resetStore(this)
  }

  hasModule (path) {
    if (typeof path === 'string') path = [path]

    if (__DEV__) {
     ...
    }

    return this._modules.isRegistered(path)
  }
  // 热更新
  hotUpdate (newOptions) {
    this._modules.update(newOptions)
    resetStore(this, true)
  }
  // 
  _withCommit (fn) {
    // 置为true
    const committing = this._committing
    this._committing = true
    // 非严格模式下支持直接改动state,**不是组件改**
    fn()
    // 重置为false
    this._committing = committing
  }
}

mapState, mapMutations, mapGetters, mapActions等函数,内部就是取出各自命名空间下的state以及与state相关的操作。

pinia的核心概念

pinia也具有stategettersactions,但是他移除了modulesmutationspiniaactions里面可以支持同步也可以支持异步pinia作为Vuex的高级版本,他与Vuex相比主要的优点在于:

  • Vue2Vue3都支持,良好的Typescript支持,体积非常小,只有1KB左右。pinia支持插件来扩展自身功能。
  • 模块式管理,pinia中每个store都是独立的,互相不影响。
  • 支持服务端渲染。

pinia的基础实践

可能codeSandBoxVue3的支持不太好,我机器上一直编译失败,所以我们自己搭建Vue3环境。

npm init vue@latest

  • ✔ Project name: …
  • ✔ Add TypeScript? … No / Yes
  • ✔ Add JSX Support? … No / Yes
  • ✔ Add Vue Router for Single Page Application development? … No / Yes
  • ✔ Add Pinia for state management? … No / Yes
  • ✔ Add Vitest for Unit testing? … No / Yes
  • ✔ Add Cypress for both Unit and End-to-End testing? … No / Yes
  • ✔ Add ESLint for code quality? … No / Yes
  • ✔ Add Prettier for code formatting? … No / Yes

安装依赖、启动项目

cd your-project-name

npm install

npm run dev

启动页页面效果 Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读 项目代码

// main.ts
import { createApp } from "vue";
import App from "./App.vue";
// 引入pinia
import { createPinia } from "pinia";
const pinia = createPinia();

const app = createApp(App);
// 使用pinia
app.use(pinia);
app.mount("#app");
import { defineStore } from "pinia";

// 第一个参数是应用程序中 store 的唯一 id
export const useUsersStore = defineStore("users", {
  state: () => {
    return {
      name: "一溪之石",
      age: 25,
      sex: "男",
    };
  },
  getters: {
    getAddAge: (state) => {
      return (num: number) => state.age + num;
    },
    getNameAndAge(): string {
      return this.name + this.getAddAge; // 调用其它getter
    },
  },
  actions: {
    saveName(name: string) {
      this.name = name;
    },
    // setTimeout模拟异步
    setAge(age: number) {
      setTimeout(() => {
        this.age += age;
      }, 3000);
    },
  },
});
// App.vue
<template>
  <img alt="Vue logo" src="./assets/logo.svg" width="100" />
  <p>姓名:{{ name }}</p>
  <p>年龄:{{ age }}</p>
  <p>性别:{{ sex }}</p>
  <p>新年龄:{{ store.getAddAge(11) }}</p>
  <p>调用其它getter:{{ store.getNameAndAge }}</p>
  <button @click="changeName">更改姓名</button>
  <button @click="reset">重置store</button>
  <button @click="patchStore">批量修改数据</button>
  <button @click="store.saveName('李婷欧巴')">修改name</button>
  <button @click="store.setAge(1)">修改age</button>
  <button @click="saveName('巴哥')">不通过store修改name</button>
  <button @click="setAge(1)">不通过store修改age</button>

  <!-- 子组件 -->
  <child></child>
</template>
<script setup lang="ts">
import child from "@/components/HelloWorld.vue";
import { useUsersStore } from "@/stores/counter";
import { storeToRefs } from "pinia";
const store = useUsersStore();
const { name, age, sex } = storeToRefs(store);
const changeName = () => {
  store.name = "李晓婷";
  console.log(store);
};
// 重置store
const reset = () => {
  store.$reset();
};
// 批量修改数据
const patchStore = () => {
  store.$patch({
    name: "李婷",
    age: 18,
    sex: "女",
  });
};
// 调用actions方法
const saveName = (name: string) => {
  store.saveName(name);
};

//调用setAge
const setAge = (age: number) => {
  store.setAge(age);
};
</script>
// Child.vue
<template>
  <h1>我是child组件</h1>
  <p>姓名:{{ name }}</p>
  <p>年龄:{{ age }}</p>
  <p>性别:{{ sex }}</p>
  <button @click="changeName">更改姓名</button>
</template>
<script setup lang="ts">
import { useUsersStore } from "@/stores/counter";
import { storeToRefs } from "pinia";
const store = useUsersStore();
const { name, age, sex } = storeToRefs(store);

const changeName = () => {
  store.name = "黄燕";
};
</script>

页面图示效果

Vue源码解析系列(十六) -- vuex、pinia实现的状态管理原理与源码解读 码云源码地址

代码分析

因为初始版本的pinia的写法就类似下面的一样:

export const store = defineStore("id",{
  state:()=>{
    return {
      xx:'xx'
    }
  },
  getters:{},
  actions:{}  
})

其中提供了几种api:

  • storeToRefs:把数据转为响应式。
  • $patch:批量修改数据
  • 还有其他的api,去查看官网

还有一种defineStore的写法与Vue3本体写法非常类似,比如:

export const store = defineStore('id',()=>{
  const value = ref<number>(1);
  function setAge(){
    // doSomeThing
  }
  return {value,setAge}  
})   

pinia的源码解读

注意:本源码为github仓库上面v2分支的最新版的代码。

// 我们主要关注这三个函数的实现    
export { createPinia } from './createPinia';
export { defineStore } from './store';
export { storeToRefs } from './storeToRefs'    

createPinia

// pinia/packages/pinia/src/createPinia.ts    
export function createPinia(): Pinia {
  // effect作用域  
  const scope = effectScope(true)
  // state响应式对象  
  const state = scope.run<Ref<Record<string, StateTree>>>(() =>
    ref<Record<string, StateTree>>({})
  )!
  // 收集插件
  let _p: Pinia['_p'] = []
  // 在install之前,app.use的时候添加插件  
  let toBeInstalled: PiniaPlugin[] = []
    
  // 创建pinia对象,不会变成proxy包裹的
  const pinia: Pinia = markRaw({
    // install方法
    install(app: App) {
      // setActivePinia => activePinia = pinia
      setActivePinia(pinia)
      // 区分vue版本,vue2版本使用PiniaVuePlugin
      // export { PiniaVuePlugin } from './vue2-plugin'
      if (!isVue2) {
        // app实例赋给pinia._a
        pinia._a = app
        // 把pinia注入到app中
        app.provide(piniaSymbol, pinia)
        // 设置全局变量$pinia,以便获取
        app.config.globalProperties.$pinia = pinia
        if (USE_DEVTOOLS) {
          registerPiniaDevtools(app, pinia)
        }
        // 把插件存起来,存到_p数组中
        toBeInstalled.forEach((plugin) => _p.push(plugin))
        // 置空toBeInstalled
        toBeInstalled = []
      }
    },
    
    // 使用插件
    use(plugin) {
      // 实例不为空&&非vue2情况
      if (!this._a && !isVue2) {
        // 暂存toBeInstalled中
        toBeInstalled.push(plugin)
      } else {
       // 加入到_p当中
        _p.push(plugin)
      }
      // 返回当前pinia
      return this
    },

    _p,// 插件合集
    _a: null, // app实例
    _e: scope, // effect作用域
    _s: new Map<string, StoreGeneric>(), // 注册store
    state, // 响应式对象
  })

  // 兼容ie11
  if (USE_DEVTOOLS && typeof Proxy !== 'undefined') {
    pinia.use(devtoolsPlugin)
  }
  
  //返回pinia:{state, _e, _p, _a, _s}  
  return pinia
}

defineStore

// pinia/packages/pinia/src/store.ts    
export function defineStore(
  idOrOptions: any,
  setup?: any,
  setupOptions?: any
): StoreDefinition {
  let id: string
  //对象||函数的options  
  let options:
    | DefineStoreOptions<
        string,
        StateTree,
        _GettersTree<StateTree>,
        _ActionsTree
      >
    | DefineSetupStoreOptions<
        string,
        StateTree,
        _GettersTree<StateTree>,
        _ActionsTree
      >
  // 第二参数传入的是对象还是函数
  // isSetupStore 记录函数入参标记  
  const isSetupStore = typeof setup === 'function'
  // 传入的id为string  
  if (typeof idOrOptions === 'string') {
    // eg: defineStore('userInfo', {state:()=>{},....})
    id = idOrOptions
    // 第二参数为函数的话,则可以传入第三参数
    options = isSetupStore ? setupOptions : setup
  } else {
    // eg: defineStore({id:'userInfo',state:()=>{},....})
    options = idOrOptions
    id = idOrOptions.id
  }
  
  // export const useUserStore = defineStore('id' ,{ ... }) 
  // 返回一个useStore函数  
  function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {   
    // 获得当前实例
    const currentInstance = getCurrentInstance()
    
    pinia =
      (__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
      (currentInstance && inject(piniaSymbol))
    if (pinia) setActivePinia(pinia)

    if (__DEV__ && !activePinia) {
      throw new Error(
       ...
      )
    }
    // 把全局变量赋给pinia
    pinia = activePinia!
    // 根据createPinia中的_s来判断是否含有当前id的的仓库,如果没有就根据入参类型创建仓库
    if (!pinia._s.has(id)) {
      // 创建仓库isSetupStore判断是否是对象入参还是函数入参
      if (isSetupStore) {
        createSetupStore(id, setup, options, pinia)
      } else {
        createOptionsStore(id, options as any, pinia)
      }
      ...
    }
    // 通过id获得仓库
    const store: StoreGeneric = pinia._s.get(id)!
    
    // 热更新相关
    if (__DEV__ && hot) {
      const hotId = '__hot:' + id
      const newStore = isSetupStore
        ? createSetupStore(hotId, setup, options, pinia, true)
        : createOptionsStore(hotId, assign({}, options) as any, pinia, true)

      hot._hotUpdate(newStore)

      // cleanup the state properties and the store from the cache
      delete pinia.state.value[hotId]
      pinia._s.delete(hotId)
    }

    ...

    // useStore执行返回一个store
    return store as any
  }
  
  // 仓库的唯一标识,通过useStore().$id可以获取到  
  useStore.$id = id
  
  // 返回当前函数,通过useStore执行,可以获取到state、actions等内容  
  return useStore
}

所以看上面的代码就很容易理解:

export const useStore = defineStore('userInfo',{state:()=>{...})

const store = useStore();

store.xx

storeToRefs

storeToRefs的作用就是转化成响应式对象,在数据驱动的时候能够及时的更改页面上的内容。我们来温习一下api:

  • toRefs() :将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的ref
  • toRef():基于响应式对象上的一个属性,创建一个对应的ref。这样创建的ref与其源属性保持同步:改变源属性的值将更新ref的值。
  • toRaw() :根据一个Vue创建的代理返回其原始对象。
  • isRef() : 检查某个值是否为ref
  • isReactive :检查一个对象是否是由reactive()shallowReactive()创建的代理。
// pinia/packages/pinia/src/storeToRefs.ts       
export function storeToRefs<SS extends StoreGeneric>(
  store: SS
): ToRefs<
  StoreState<SS> & StoreGetters<SS> & PiniaCustomStateProperties<StoreState<SS>>
> {
  // 区分vue2版本
  if (isVue2) {
    // 返回一个普通对象
    return toRefs(store)
  } else {
    //返回原始对象
    store = toRaw(store)
    
    // 创建一个对象,存储属性
    const refs = {} as ToRefs<
      StoreState<SS> &
        StoreGetters<SS> &
        PiniaCustomStateProperties<StoreState<SS>>
    >
    //遍历仓库
    for (const key in store) {
      const value = store[key]
      // 检测是否为Ref或者Reactive对象
      if (isRef(value) || isReactive(value)) {
        // 以store的key为key,对应的value变成响应式作为value
        refs[key] =
          toRef(store, key)
      }
    }
    // 返回处理后的对象
    return refs
  }
}

总结

本文主要讲述了Vuex的基本使用、实现原理与部分源码,也在同时去思考了几点内容,再后来也对比着pinia做了基本使用、源码的分析,不管怎么样,现在除了一部分公司还在用vue2.x,大部分公司都在使用Vue3,所以学习pinia也显得尤为必要了。