实用的VUE系列——所谓的全局状态管理pinia到底是什么?

1,334 阅读7分钟

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

21世纪的码农江湖里最重要的是什么? 规则

所谓规则,就是你必须要遵循一套书写规范,不能在代码里胡搞乱搞,请大家不要小看这一个小小的约束

鲁迅说过,没有规矩不成方圆

可在这个纷繁复杂的世界中,你一言我一语,看似开明,却办不成事, 要知道,谁都想自己的的东西,能够影响别人,被采纳,甚至被敬仰!!

你折腾jsx ,我就搞模板语法, 你发明函数式编程,我偏要面向对象!前端这个工种从兴起到现在,十几年来,各种人吵来吵去,掐架,撕逼,骂街!

其实说穿了,就一个目的,我做的或者我信仰的东西牛x

你是牛x 了,可我们痛苦了 vue 要学,react 要学,angular 也要学, 函数式编程,jsx ,依赖注入,hooks ,各种创新的东西层出不穷!

却苦了我们这帮 每天搬砖的工友,我们是在乎技术牛不牛逼吗?

不是的,我们只在乎砖头烫不烫手。

我们只想要有一套好用的,能快速解决问题的规则,让我们早点下班回家陪老婆孩子!

我们不想知道,到底什么是最长递增子序列,不想了解 diff 算法的核心原理,更不想痛苦的学习rxjs这么复杂的概念!

我们的诉求就是快速的解决问题,我不想忙碌到半夜,状态憔悴,身心疲惫

那么到底怎样状态管理科学健康的能管理我们的状态?

很显然,pinia 这个优秀的状态管理工具(谐音梗),就能给你答案!

image.png

追根溯源

pinia 出现之前 ,vue官方的状态管理工具叫做vuex 他是为vue 量身定做的优秀的状态管理工具

其实之所以优秀,是因为比他更优秀的 pinia 还没出来,他只能顺理成章的成为首选

但是他有一个非常麻烦的缺点,规矩太多,用起来太麻烦!

我们是需要规则,可我们需要的是一个符合直觉,并且简单的规则

依稀记得

这个状态自管理应用包含以下几个部分:

  • 状态,驱动应用的数据源;
  • 视图,以声明方式将状态映射到视图;
  • 操作,响应在视图上的用户输入导致的状态变化。

接下来让我们感受一下,vuex的复杂程度!

我们知道,vue 是一个中庸的框架,他汲取了很多框架的营养 ,那么vuex 作为他的附属产品!

同样借鉴了借鉴了 FluxRedux 和 The Elm Architecture

通过单项数据流,对于跨组建的状态做统一的管理以及维护

image.png

当然,这个规则很好,就是

太麻烦了

例子如下:

首先创建store

// store.js
import Vue from 'vue';
import Vuex from 'vuex';
 
Vue.use(Vuex);
 
const store = new Vuex.Store({
  state: {
    cart: []
  },
  // 必须是同步的
  mutations: {
  addItemToCart(state, item) {
    state.cart.push(item);
  },
  // 可以是同步也可以是异步
  actions: {
  addItemToCart({ commit }, item) {
    // 假设我们需要调用API去检查库存
    if (checkInventory(item)) {
      commit('addItemToCart', item);
    }
  }
}
});
 
export default store;

在项目中使用

<template>
  <div>
   <ul>
      <li v-for="item in cart" :key="item.id">{{ item.title }} - ${{ item.price }}</li>
    </ul>
    <button @click="addItemToCart(product)">Add to Cart</button>
  </div>
</template>
 
<script>
export default {
   computed: {
    cart() {
      return this.$store.state.cart;
    }
  }

  methods: {
    addItemToCart(product) {
      this.$store.dispatch('addItemToCart', product);
    }
  }
};
</script>

以上例子中,我们可以发现,如果想改一个直,可以直接改同步的mutations ,如果要是异步

我们必须首先 dispatch 一个actions,然后在actions 调用mutations 中的方法,在这个方法中再去改 state

依稀记得,我在最初学习的时候一连有两个想不通的问题?

  • 1、mutations 为什么不能异步
  • 2、mutations 为啥要多此一举难道就不能不要吗

1、mutations 为什么不能异步

这个问题在我当年作为一个刚进前端新手村的我来说,困惑了很久,困惑的点在于,他们都是函数,怎么能做到不能异步呢? 我为什么就不能改state呢?

他到底有什么魔力呢?

直到,我看到了vuex作者的一个回答,醍醐灌顶

image.png

啥?,原来同步异步都行啊,他制定如此规则的原因竟然是 为了vue-devtool ,虽然这个理由有点牵强,可既然规则是别人制定的,那咱就得遵守,毕竟制定规则的那都是无与伦比的聪明人,虽然咱觉得不好用,但咱也提不出更好的办法不是

2、mutations 为啥要多此一举难道就不能不要吗?

在最开始的时候,我也很困惑,mutations 既然你只是为了给 vue-devtool用的,那么怎么就不能想个办法直接用Actionvue-devtool不就行了吗,然后经过我仔细的研究发现

确实不行

因为每一条 mutation 被记录,devtools 都需要捕捉到前一状态和后一状态的快照。然而, Action 中的异步函数中的回调让这不可能完成:因为当 mutation 触发的时候,回调函数还没有被调用,devtools 不知道什么时候回调函数实际上被调用——实质上任何在回调函数中进行的状态的改变都是不可追踪的。

所以,虽然很麻烦,却不能不用

基于以上原因,人们都在苦vuex 久矣,甚至在vue3 发布的时候大家直接提出了,vuex5的提案

image.png

其实官方团队也早就意识到这个问题,于是vuex 的职业生涯也走到了头,Pinia 横空出世,他几乎解决了vuex 所有痛点

Pinia 核心特性

  • Pinia 没有 Mutations(最终要的)

  • Actions 支持同步和异步

  • 没有模块的嵌套结构

    • Pinia 通过设计提供扁平结构,就是说每个 store 都是互相独立的,谁也不属于谁,也就是扁平化了,更好的代码分割且没有命名空间。当然你也可以通过在一个模块中导入另一个模块来隐式嵌套 store,甚至可以拥有 store 的循环依赖关系
  • 更好的 TypeScript 支持

    • 不需要再创建自定义的复杂包装器来支持 TypeScript 所有内容都类型化,并且 API 的设计方式也尽可能的使用 TS 类型断
  • 不需要注入、导入函数、调用它们,享受自动补全,让我们开发更加方便

  • 无需手动添加 store,它的模块默认情况下创建就自动注册的

  • Vue2 和 Vue3 都支持

    • 除了初始化安装和SSR配置之外,两者使用上的API都是相同的
  • 支持 Vue DevTools

    • 跟踪 actions, mutations 的时间线
    • 在使用了模块的组件中就可以观察到模块本身
    • 支持 time-travel 更容易调试
    • 在 Vue2 中 Pinia 会使用 Vuex 的所有接口,所以它俩不能一起使用
    • 但是针对 Vue3 的调试工具支持还不够完美,比如还没有 time-travel 功能
  • 模块热更新

    • 无需重新加载页面就可以修改模块
    • 热更新的时候会保持任何现有状态
  • 支持使用插件扩展 Pinia 功能

  • 支持服务端渲染

使用方式也非常简单

声明全局状态

import { defineStore } from 'pinia'
export const useUserStore = defineStore({
    id: 'user',
    state: () => ({
        name: '老骥farmer',
        email: 'farme@example.com'
    }),
    actions: {
        changeName(newName) {
            this.name = newName
        }
    }
})

在项目中使用

<template>
    <div>
        <h1>{{ user.name }}</h1>
        <button @click="user.changeName('老骥')">Change name</button>
    </div>
</template>

<script>
import { useUserStore } from '@/store/user'

export default {
    setup() {
        const user = useUserStore()
        return { user }
    }
}
</script>

基于以上内容,我相信大家已经基本了解了pinia到底是什么?

但是其实这些东西,很多jym也都讲过很多遍了,只是作为一篇文章,不讲似乎又不行。

但他却不是我们本次的重点是要看源码的

真真意义上的解剖pinia,pinia到底是什么?

解剖pinia

所谓解剖pinia 其实我们只需要浅浅的搞明白4个核心问题:

  • 1、他为什么比vuex 好用
  • 2、 他的运行原理
  • 3、 他是怎么省略Mutations
  • 4、他的响应式是怎么实现的

好,接下来我们一个个解惑

pinia为什么比vuex 好用

在上方的解释中,我们介绍过 pinia的特性,可老话说得好,光说不练假把式, 我们当然要来对比一下了

现在假设你有个需求, 有的jym就说了,我没有

。。。。。 你有!!!

我现在有个全局name 并且,项目中的多处使用,此时,我们必须要引入全局状态管理工具了,

在vuex 中我们需要怎么做呢?

声明store

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    // 定义一个name,以供全局使用
    name: '张三',
  },
  mutations: {
    setName(state,name) {
      state.name = name;
    }
   
  },
});
export default store;

获取store

<template>
  <div>{{$store.state.name}}</div>
</template>

<script>
export default {
  mounted() {
    // 使用this.$store.state.XXX可以直接访问到仓库中的状态
    console.log(this.$store.state.name);
  },
};
</script>

修改store

<script>
export default {
  mounted() {
    this.$store.commit('setName', '李四');
    console.log(`新值:${this.$store.state.name}`);
  },
};
</script>

在pinia 我们需要怎么做呢?

声明store

   //src/store/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore({
 id: 'user',
 state: () => {
   return {
     name: '张三'
   }
 }
})

获取store

<template>
 <div>{{ userStore.name }}</div>
</template>

<script lang="ts" setup>
import { useUserStore } from '@/store/user'

const userStore = useUserStore()
</script>

甚至修改store 也可以很简单

userStore.name = '李四'

通过对比我们可以很直观的发现,pinia有以下优点

  • 1、 代码量,我就不解释了,高下立判, 如果包含异步,差距会更大
  • 2、 代码清晰度,这个相信谁强谁弱,大家也是了然于心,毕竟总是通过this去找代码的关联,总是很难的,而我直接引入使用,你好歹改代码就能瞬间找到源码的位置
  • 3、 易用性,当然也是pinia首屈一指,因为大家可以惊奇的发现,他非常符合vue 的代码习惯,我么基本没有学习成本,想用值,引入用就行,想改值,直接改就行,完全符合我们的直觉!
  • 4、pinia没有modules嵌套结构,是一个平面的结构,可创建不同的 Store

综上所述,你要非要用vuex 我也不拦着你!

pinia的运行原理

谈到pinia 的原理,其实我们都是知道的,并且,可能很多人还用过,因为他也是站在巨人的肩膀上 本质上就是利用了Vue 3提供的reactive函数和watch函数。当状态存储中的状态发生变化时,Pinia会自动更新依赖于该状态的组件。在组件中,可以使用computedwatch函数来监听状态存储中的状态,当状态发生变化时,组件会自动更新。

接下来我们简单的实现一个defineStore方法来创建全局状态

既然要创建全局状态我们首先得写个defineStore方法

import {
  computed,
  ComputedRef,
  effectScope,
  EffectScope,
  inject,
  markRaw,
  reactive,
  toRaw,
  toRefs,
} from "vue";
import { getCurrentInstance } from "vue";
export const piniaSymbol = Symbol("pinia");
export function defineStore(options: {
  id: string;
  state: any;
  getters: any;
  actions: any;
}) {
  let { id } = options;
  // 实际运行函数
  function useStore() {
    const currentInstance = getCurrentInstance(); // 获取实例
    let pinia: any;
    if (currentInstance) {
      pinia = inject(piniaSymbol); // 获取install阶段的pinia
    }
    if (!pinia) {
      throw new Error("super-mini-pinia在mian中注册了吗?");
    }
    if (!pinia._s.has(id)) {
      // 第一次会不存在,单例模式
      createOptionsStore(id, options, pinia);
    }
    const store = pinia._s.get(id); // 获取当前store的全部数据
    return store;
  }
  useStore.$id = id;
  return useStore;
}

上述方法中,大家发现,其实本质上还是引用 vue 的composition API 这个库解决的问题,其实就是建立一种规范,以及编程范式

上述方法 其实就是创建一个hooks 然后hooks中返回各种容错处理之后的store

接下来就是怎么创建store

function createOptionsStore(id: string, options: any, pinia: any) {
  const { state, actions, getters } = options;
  function setup() {
    pinia.state.value[id] = state ? state() : {}; // pinia.state是Ref
    const localState = toRefs(pinia.state.value[id]);
    return Object.assign(
      localState, // 被ref处理后的state
      actions, // store的action
      Object.keys(getters || {}).reduce((computedGetters, name) => {
        computedGetters[name] = markRaw(
          computed(() => {
            const store = pinia._s.get(id)!;
            return getters![name].call(store, store);
          })
        );
        return computedGetters;
      }, {} as Record<string, ComputedRef>) // 将getters处理为computed
    );
  }
  let store = createSetupStore(id, setup, pinia);

  //将 store 重设为初始状态。
  store.$reset = function $reset() {
    const newState = state ? state() : {};
    this.$patch(($state: any) => {
      Object.assign($state, newState);
    });
  };

  return store;
}

上述方法,就是创建store 的核心方法,但是光创建store 是不够的,我们还有很多辅助函数,于是


/**
 * 处理action以及配套API将其加入store
 * @param $id
 * @param setup
 * @param pinia
 */
function createSetupStore($id: string, setup: any, pinia: any) {
  
  // 将状态补丁应用于当前状态
  function $patch(partialStateOrMutator: any): void {
    // 简易版实现仅支持传入function
    if (typeof partialStateOrMutator === "function") {
      partialStateOrMutator(pinia.state.value[$id]);
    }
  }

  // 停止store的所有effect,并且删除其注册信息
  function $dispose() {
    scope.stop(); // effect作用于停止
    pinia._s.delete($id); // 删除effectMap结构
  }

  // 所有pinia的methods
  let partialStore = {
    _p: pinia,
    $id,
    $reset: () => {}, // 在createOptionsStore实现
    $patch,
    $dispose,
    $onAction: () => console.log("onAction"), // 该版本不实现
    $subscribe: () => console.log("subscribe"), // 该版本不实现
  };

  // 将effect数据存放如pinia._e、setupStore
  let scope!: EffectScope;
  const setupStore = pinia._e.run(() => {
    scope = effectScope();
    return scope.run(() => setup());
  });

  // 合并methods与store
  // 这里实际返回的就是响应式后的内容,此时已经在全局和页面中的模板联系起来了
  // 如此一来就能根据全局状态响应式的更改页面的内容
  const store: any = reactive(
    Object.assign(toRaw({}), partialStore, setupStore)
  );
  // 将其加入pinia
  pinia._s.set($id, store);

  return store;
}

ok齐活了, 上述方法,给pinia 这个实例中的所有内容配齐了并且全员响应式

其实,我们通过上述源码中,发现,pinia 的原理朴实无华,他难得地方,就一个——架构设计

你会发现,他一个创建的方法,要分为三层,并且各层各司其职!这才是我么应该学习的榜样!

pinia是怎么省略Mutations

我们在之前说过,在vuex中Mutations的必要性,是为了配合vue-devtools 。所以不能删除,必须按照规行事。那pinia 是怎么解决问题的呢?

经过我探究源码发现, 真的是只要思想不滑坡,方法总比困难多

我们先说一下为什么vuex 必须要有Mutations 本质原因很简单,不能监听,必须通过主动出发解决问题!

所以我们只能在同步的方法主动触发,在触发的时候同步给dev-tools

pinia做了什么事情呢?

用 watch,深层监听

源码如下 :

 $subscribe(callback, options = {}) {
      console.log("$subscribe");
      // 注册修改响应监听
      const removeSubscription = addSubscription(
        subscriptions,
        callback,
        options.detached,
        () => stopWatcher() // 执行stopWatcher实际上执行的是scope.run返回的watch,而执行watch的返回函数,也就是停止当前watch
      );
      const stopWatcher = scope.run(() =>
        watch(
          () => pinia.state.value[$id] as UnwrapRef<S>,
          (state) => {
            // 如果等于sync会在修改后立即执行该watch,此时的isSyncListening为false 不会触发callback
            // 如果不等于sync,修改后不会立刻触发watch
            if (options.flush === "sync" ? isSyncListening : isListening) {
              callback(
                {
                  storeId: $id,
                  type: MutationType.direct,
                  events: debuggerEvents as DebuggerEvent,
                },
                state
              );
            }
          },
          assign({}, $subscribeOptions, options) // watch的第三个参数 默认deep为true
          // 如果希望副作用函数在组件更新前发生,可以将flush设为'post'(默认是'pre')
          // 如果flush设置为'sync',一​​旦值改变,回调将被同步调用。
          // 对于'pre'和'post',回调使用队列进行缓冲。回调只会被添加到队列中一次,即使被监视的值改变了多次。中间值将被跳过并且不会传递给回调。
          // 默认值为'pre'
        )
      )!;

      return removeSubscription;
    },

$subscrib 方法,其实就是用来响应store 变化的

store.$subscribe(() => { // 响应 store 变化 })

那他跟dev-tools有什么关系呢?

很简单,我们调用store.$subscribe 传入dev-tools 相关api 不就行了吗 ,如此一来,只要sotre 变化,工具中回调就会被执行

代码如下:

      store.$subscribe(
        ({ events, type }, state) => {
        // 通知更新
          api.notifyComponentUpdate()
          api.sendInspectorState(INSPECTOR_ID)

          if (!isTimelineActive) return
          // rootStore.state[store.id] = state

          const eventData: TimelineEvent = {
            time: now(),
            title: formatMutationType(type),
            data: {
              store: formatDisplay(store.$id),
              ...formatEventData(events),
            },
            groupId: activeAction,
          }

          // reset for the next mutation
          activeAction = undefined

          if (type === MutationType.patchFunction) {
            eventData.subtitle = '⤵️'
          } else if (type === MutationType.patchObject) {
            eventData.subtitle = '🧩'
          } else if (events && !Array.isArray(events)) {
            eventData.subtitle = events.type
          }
            // 判断事件类型,改变工具中数据
          if (events) {
            eventData.data['rawEvent(s)'] = {
              _custom: {
                display: 'DebuggerEvent',
                type: 'object',
                tooltip: 'raw DebuggerEvent[]',
                value: events,
              },
            }
          }

          api.addTimelineEvent({
            layerId: MUTATIONS_LAYER_ID,
            event: eventData,
          })
        },
        { detached: true, flush: 'sync' }
      )

pinia的响应式是怎么实现的

pinia的响应式实现方式,我就不再赘述了,他的响应式其实就是vue的响应式,那么又有jym 问了,vue的响应式是怎么实现的

我。。。。

那么就请移步我写的另一个文章# Vue3 从ref 函数入手透彻理解响应式原理

到底啊要不要用全局状态管理

这个问题其实我要讨论的最重要的问题,因为,工具始终是工具,他就摆在那里,而决定你的项目好坏的是使用各种工具的人 于是,到底啊要不要用全局状态管理,就会理所当然的被摆上台面 ,也成了各大公司的热门面试题,

其实这个问题,我相信很多人都没有仔细的思考过,大多数可能会认为Pinia应该是标配

当我们回到事物的本质的,就会发现,得出的结论,可能与你的潜意识截然相反

Pinia 到底的是为了解决什么问题?

答案很简单,只是单纯为了解决各个组件中变量统一维护的问题

意识到这个问题,你就会突然发现,在大多数的情况下,我们根本用不到全局状态管理

举个例子,在我们大多数的项目中都是嵌套层级 ,也就是,一个大积木其中包含无数的小积木

image.png

此时我们用全局状态管理,反倒会增加项目的复杂度,我们只需要在顶层声明变量即可,通过vue自身的api解决组件间传值的问题,例如: provide / inject$attrs / $listeners 等等

那我们什么情况下需要用呢?

这个问题,我思考过很久,因为业界,总没有一个标准,也没有一个度,很多人的解决都很粗暴,不知道需不需要用,那就要用

然而随着我工作经验的慢慢积累,我发现,我们看待问题,其实还是要回到问题的本质

我们使用全局状态管理的目的是什么?

项目可维护,代码不乱!!!

这其实才是最朴实的诉求,因为所有的工具出现,都是为了解决问题

顺着这个原则,其实我相信大多数人都会立马豁然开朗。

就能很自然而然的判断到底需不需要Pinia

例如, 你的用户信息,登录token ,动态路由,等需要夸多组件使用的变量,这时候就需要放在统一的地方维护。自然而然的Pinia就成了必须品,就能很好的保证项目可维护,代码不乱!!!

至于其他情况吗。你开心就好,毕竟千金难买我愿意!