小程序store:支付宝的“pinia”实现——简易版

219 阅读3分钟

背景

项目需要将功能从web端迁移到支付宝小程序端,其中逻辑层是相似的,可以直接迁。但是其中用到的pinia找不到相应的组件,使用global没有setData之类的限制,到处能用赋值语句进行修改的话,页面会很乱,于是有自己写一个类似pinia这样的需求,在此记录一下 github地址可点击

使用方式

首先明确想要的使用方式,大致如下

1、定义一个工具

2、通过data将变量注入页面

3、返回一个变量,可以在js/ts中拿到该store所有的action、getter、state

希望能实现如下使用

在store/module下定义一个工具

import {defineStore} from '../index'
export const useTest = defineStore('textStore',{
  state(){
    return {
      firstName:'first',
      lastName:'last'
    }
  },
  getter:{
    name(){
      return this.firstName + this.lastName
    }
  },
  action:{
    setFirstName(data){
      this.firstName = data
    }
  },
})

页面中引用

index.axml

<view class="blank-page">
  <view>
    <text>{{textStore.name}}</text>
  </view>
  <button onTap="handerClick" size="default" type="primary">setNewName</button>
</view>

index.js


import {useTest} from '/store/module/test'
// 返回变量会挂载一些函数 不知道data编译的底层逻辑,为避免出错就不给他赋值函数了
let testStore =  useTest()
Page({
  data: {
    SDKVersion: '',
    textStore:{}
  },
  onLoad(query) {
    testStore = useTest.call(this)
    // 页面加载
    console.info(`Page onLoad with query: ${JSON.stringify(query)}`);
  },
  onReady() {
    // 页面加载完成
  },
  onShow() {
  },
  onUnload() {
    // 页面被关闭
  },
  handerClick(){
    testStore.setFirstName('newFirst')
  }
});

相关效果如下

pinia_pay.gif

基本实现

reactive和readonly

需要知道的是readOnly返回值后续导出对象,读取操作基本来源它,而reactive更主要是在action中承担了修改值的角色

function reactive(data) {
    for (let key in data) {
      if (Object.prototype.toString.call(data[key]) === '[object Object]') {
        data[key] = reactive(data[key]);
      }
    }
    return new Proxy(data, {
      set(target, key, value, receiver) {
        Reflect.set(target, key, value, receiver);
        // 将收集到的watcher逐一遍历执行
        if(easyWatcher[key]){
          easyWatcher[key].array.forEach(fn => {
            fn()
          });
        }
        // 执行setData之类的操作,让页面能实时响应
        resetStore()
        return true;
      },
    });
  }
  function readOnly(data) {
    for (let key in data) {
      if (Object.prototype.toString.call(data[key]) === '[object Object]') {
        data[key] = readOnly(data[key]);
      }
    }
    return new Proxy(data, {
      get(target, key, receiver) {
        const result = Reflect.get(target, key, receiver);
         // 收集当前getter中使用了的依赖
        if(setGetter){
          if(easyWatcher[key]){
            easyWatcher[key] = new Set([setGetter])
          }else{
            let setData = easyWatcher[key]
            setData.add(setGetter)
          }
        }
        if (typeof result === 'function' && target.hasOwnProperty(key)) {
          setGetter = result.bind(target)
          // 标记好状态开始收集getter
          const resultData = result.call(target);
          setGetter = false
          return resultData;
        }
        
        return result;
      },
      set() {
        throw new Error(
          storeName
            ? `store:${storeName}不能通过外部修改`
            : `strore不可通过外部修改`,
        );
      },
    });
  }

state、getter、action处理

接下来处理state、action、getter

 const { state: stateFn, action: actionData, getter: getterData } = config;
 const state = typeof stateFn === 'function' ? stateFn() : stateFn;

期待在外部使用时可以在同一个对象上访问state,getter和action,于是做如下处理。

  const getter = getterData || {};
  // new StoreExportFn实例时将state和getter属性都挂载上 
  function StoreExportFn() {
    for (let key in state) {
      this[key] = state[key];
    }
    for (let key in getter) {
      this[key] = getter[key];
    }
  }
  const action = actionData ? createAction(actionData) : {};
  // 将action中的方法挂载到要代理的对象上
  StoreExportFn.prototype = Object.create(action);
  const storeExport = new StoreExportFn();
  const stateProxy = reactive(storeExport);
  const stateReadOnly = readOnly(storeExport);

createAction则是简单的将用户声明的所有action挂载上去,同时修改下this指向

function createAction(config) {
    const data = {};
    for (let key in config) {
      if (key === 'state') {
        console.error('state为保留字段,请重新命名');
        continue;
      }
      const actionItem = config[key];
      data[key] = async function (data) {
        return await actionItem.call(stateProxy, data);
      };
    }
    return data;
  }

页面响应式挂载

写上resetStore

 const resetStore = (instance?) => {
    // 挂载数据到页面
    if (instance) {
      instance.setData({
        [storeName]: stateReadOnly,
      });
    } else {
      for (let [key, pageInstance] of pageMap) {
        pageInstance.setData &&
          pageInstance.setData({
            [storeName]: stateReadOnly,
          });
      }
    }
  };

其中为if分支为页面上调用时创建的,页面初始化会调用如下useStore

function useStore() {
    // 页面上取值的名字 多个页面都要搞
    if (this && this.setData && !pageMap.has(this.$id)) {
      pageMap.set(this.$id, this);
    }
    this && this.setData && resetStore(this);
    return stateReadOnly;
  }

以上只是初步实现,使用中还会面临诸如以下问题

存在问题

ps:git中已解决当下问题

1、若对象的子属性存在形同的属性

文中只是以key值区分watcher,当存在如下结构时,这种简单的方式则无法满足

...
state:{
    baby:{
        name:'babyOne',
        wife:{
            name:'babyTwo'
        }
    }
}
...

如上,name存在两个,我们的处理方式是利用一个bucket的map对象存储对应的target,再用map存对应的key,接着把key对应的函数存成一个set放在其中 具体思路可以参考《vuejs设计与实现》第四章第三节:设计一个完善的响应系统

2、getter未缓存,重复运行

getter由于不知小程序内部运作,可能会多次读取执行。

此处采用类似computed的dirty方式解决,例如上述的name中,当前只当this.firstName或this.lastName发生改变时才会执行getter内容

3、easyWatcher的Set其实会存在重复问题

setGetter = result.bind(target)这操作在每次重新赋值时都是一个新的函数,在set中使用实质上没有意义,因为这块一直是新值,我们可以尝试在挂载getter时修改函数的this指向

function StoreExportFn() {
    for (let key in state) {
      this[key] = state[key];
    }
    for (let key in getter) {
      // 绑定this指向
      this[key] = getter[key].bind(this)
    }
  }

小插曲:

如下所示,我们如下设置页面js


let data = {
  name:'111',
  key:'ccc'
}
const dataP = new Proxy(data,{
  get(target,key,receiver){
    console.log('kkkey',key);
    return Reflect.get(target,key,receiver)
  }
})

Page<{
  dataP:any
},{
  handerClick:()=>void
}>({
  data: {
    textStore:{},
    dataP
  },
 
  handerClick(){
    this.setData({
      'dataP.name':'xiix'
    })
  }
});

<view class="blank-page">
  <view>
    <!-- <text>{{textStore.name}}</text> -->
    <text>{{dataP.name}}</text>
  </view>
  <button onTap="handerClick" size="default" type="primary">setNewName</button>
</view>
 

未点击时打印如下

image-20241004190717770.png

点击后打印如下

image-20241004190734534.png

也就是在值更新时,页面会对所有值进行读值操作,若业务上存在影响,需要注意一下