小程序&Redux实现状态管理

Why?

为什么要使用状态管理?

不论是开发小程序,H5,后台管理等等Web应用,总会存在多个 页面/组件 共用一些数据(状态)的情况。举一个常见的例子,在更新完用户信息后,在其他依赖用户信息的 页面/组件 也需要做到相应的数据更新,类似的一处修改。多处更新的场景比比皆是,这时候如果有一个数据中心管理器帮助我们做这些事情,就会很大程度提升开发效率和代码的可读性。最开始的时候我是在小程序的App.js中定义一个globalData的属性来存放这些数据,可是这些数据很容易被修改,所以我决定使用Redux来管理小程序的状态,让小程序的状态变得安全可控。

What?

mini-store

我结合Redux构建了一个小程序端的状态管理器,可以通过这个状态管理器很方便的管理小程序中的一些公共状态。mini-store

mixinPage

为了方便我自己的开发,我又写了个小程序页面公共页面混入方法mixinPage帮助我更好地处理一些页面的公共操作(比如注入store,分享等等)。

依赖项

必须依赖项: redux

基本必须依赖项: redux-thunk 处理异步acition

建议依赖项: runtime 用于使用async await

代码分析

demo--使用mixinPage

demo--不使用mixinPage

mini-store

mixinPage

mini-store

mini-store主要由三部分组成:

  • 初始化store数据(注册store),这里我参考vuex的使用,在小程序中创建一个storeData的属性来收集这个页面所需要使用的store中的数据,用法如下:
storeData: { // 在下面说到的mixinPage会解析这个属性
    user: { // user表示reducer的名称
        // 左边的userInfo表示在当前page中的属性
        // 右边的userInfo表示在reducer中的属性名
        userInfo: 'userInfo' 
    }
}

initStore代码,initStore主要是用来收集上面的storeData中声明的属性与store中reducer的属性的一一对应关系,code:

/**
 * 这里我使用IIFE的写法只是为了方便我后期拓展,可以不使用这种写法
 * 获取每个页面storeData,并进行收集存储
 * 这个方法就是为了收集依赖的映射关系
 */
export const initStoreData = (function() {
  return function(
    storeData = {}, // 页面中定义的storeData
    $store // 这里这样写主要是为了保证$store确实注入了,也可直接导入
  ) {
    const stateCache = new Map() // 缓存storeData中属性与store中值的对应关系
    const labelCache = new Map() // 缓存storeData中属性与store中属性的对应关系
    if (!$store)
      throw new Error(`can't find any store, please inject store first`)
    // 获取数据仓库初始数据
    const storeState = $store.getState()
    // 获取storeData中定义的本页面需要用的reducer
    const reducerList = Object.keys(storeData)
    // 遍历每一个reducer initData
    for (let reducer of reducerList) {
      const stateReducer = storeState[reducer]
      const dataReducer = storeData[reducer]
      if (stateReducer) {
        Object.keys(dataReducer).map(attr => {
          if (dataReducer[attr]) {
            // 记录store属性链和data中属性的映射
            labelCache.set(`${reducer}.${dataReducer[attr]}`, attr)
            // 记录store属性链和其初始值的映射
            stateCache.set(
              `${reducer}.${dataReducer[attr]}`,
              stateReducer[dataReducer[attr]]
            )
          }
        })
      } else {
        throw new Error(
          `can't find ${reducer} reducer, please define reducer before using`
        )
      }
    }
    return {
      labelCache,
      stateCache
    }
  }
})()

这部分代码写注释写起来有些复杂,图解一些上面的生成的两个cachemap的生成:

生成两个cachemap

完成initStore后,得到两个cachemap用于辅助后续的监听store变化中使用

  • 监听store变化,准确的说,这一步应该是赋初始值和监听store变化两部分
/**
 * 监听页面store变化
 */
export const listenStore = (function() {

  return function(caches, $store, ctx) {
    // 这两个cache就是在上面计算得到的两个cache
    const labelCache = caches.labelCache
    const stateCache = caches.stateCache
    // 先执行一次数据初始化
    ;(function() {
      const obj = {}
      for(let current of stateCache.keys()) {
        const stateValue = getValue($store.getState(), current)
        if (stateCache.get(current) !== stateValue) {
          stateCache.set(current, stateValue)
        }
        obj[labelCache.get(current)] = stateValue
      }
      ctx.setData(obj)
    })()
    // 注册监听器,state改动触发脏检查方法
    ctx._unsubscribe = $store.subscribe(() => {
      for(let current of stateCache.keys()) {
        const stateValue = getValue($store.getState(), current)
        if (stateCache.get(current) !== stateValue) {
          const obj = {}
          obj[labelCache.get(current)] = stateValue
          stateCache.set(current, stateValue)
          ctx.setData(obj)
        }
      }
    })
  }
})()

/**
 * 解析链式属性值
 */
function getValue(obj = {}, attrStr) {
  if (!attrStr) throw new Error("please use right attr")
  let attrs = attrStr.split(".")
  for (let attr of attrs) {
    obj = obj[attr]
  }
  return obj
}

  • 卸载store 当前页面在卸载时,其对应的监听器也要一并卸载
/**
 * 监听页面卸载
 */
export const unInstallListener = (function() {
  return function(ctx) {
    ctx._unsubscribe && ctx._unsubscribe()
    ctx._unsubscribe = null
  }
})()

最后,这三个步骤触发的时间分别为:

  • initStore 小程序加载时。所有的页面均会触发自己的initStore
  • listenStore 当前页面onLoad时触发
  • unInstallListener 当前页面onUnload时触发

这些定义都在 mixinPage 中

mixinPage

mixinPage主要由两部分组成,含代码注释:

1、baseOptions 基础配置项,用于配置所有页面的公共配置

function baseOptions() {
  const result = {
    /**
    * 初始化数据,可以在这里设置页面都会存在的一些数据
    * 比如这里我的每个页面都需要一个isLoad和存储页面参数的options
    */
    data: {
      isLoad: false,
      options: {}
    },

    /**
     * 自定义方法,用于处理页面中onLoad之前要进行的操作
     * 页面加载前置处理
     */
    _beforeLoad(initResult, options) {
      this.data.isLoad = true
      this.data.options = options
      // 监听store数据变化
      listenStore(initResult, this.$store, this)
    },

    /**
     * 自定义方法,用于处理页面中onUnLoad之前要进行的操作
     * 页面卸载前置处理
     */
    _beforeUnLoad() {
      // 卸载store
      unInstallListener(this)
    },

    /**
     * 用户点击右上角分享
     * 不用每个页面都再去写一遍分享方法了
     */
    onShareAppMessage: function () {
      return {
        path: `/pages/index/index`
      }
    }
  }
    
  // 给页面绑定$store属性
  Object.defineProperty(result, '$store', {
    value: store,
    writable: false,
    configurable: true,
    enumerable: true
  })
  return result
}

2、混入属性方法,用户将页面中的属性和方法和上面的baseOptions做一个mixin

/**
 * 混入属性方法, 
 * options 为SelfPage接收的
 */
const mixinFn = (options) => {

  if (!options || typeof options !== 'object') {
    return baseOptions()
  }
  // 执行初始化Store操作,并获取到上面提到的两个cachemap
  const initResult = initStoreData(options.storeData, baseOptions().$store)
  // 将data属性做混入处理
  const data = {
    ...baseOptions().data,
    ...options.data || {}
  }
  // 除了data外其他属性,则直接进行替换处理
  options = {
    ...baseOptions(),
    ...options,
    data
  }

  const onLoad = options.onLoad
  const onUnload = options.onUnload
  
  // 集成_beforeLoad,做onLoad前置操作
  options.onLoad = function(options) {
    this._beforeLoad(initResult, options)
    onLoad && onLoad.call(this, options)
  }
    
  // 集成_beforeUnLoad,做onUnload前置操作
  options.onUnload = function() {
    this._beforeUnLoad()
    onUnload && onUnload.call(this)
  }

  return options
}

store

这里就和正常的Redux中的store的写法一样,然后在mixinPage中引入这个store即可实现

store/index.js 代码,这部分代码应该没什么说的了,下面两个reducer就是我demo中的。

import { createStore, combineReducers, applyMiddleware} from '../libs/redux.js'
import thunkMiddleware from '../libs/redux-thunk'
import numberReducer from './reducer/number'
import userReducer from './reducer/user'

const allReducer = {
  number: numberReducer,
  user: userReducer
}

const rootReducer = combineReducers(allReducer)
const store = createStore(rootReducer, applyMiddleware(thunkMiddleware))

export default store

actions

actions用于定义行为,在redux中,state只能通过触发action来进行修改

numberAction:

export const INCREMENT_NUMBER = 'INCREMENT_NUMBER' // 增加数字
export const DECREMENT_NUMBER = 'DECREMENT_NUMBER' // 减少数字

/**
 * 增加数字action
 */
export function incrementNumberAction(i = 1) {
  return {
    type: INCREMENT_NUMBER,
    num: i
  }
}

/**
 * 减少数字action
 */
export function decrementNumberAction(i = 1) {
  return {
    type: DECREMENT_NUMBER,
    num: i
  }
}


userAction
这个有异步处理方法

import regeneratorRuntime from '../../libs/runtime'
import {getUserInfo} from '../../apis/user.api'

export const GET_USER_INFO = 'GET_USER_INFO' // 获取用户信息

/**
 * 获取用户信息action
 */
export function getUserInfoAction() {
  return async function(dispatch, getState) {
    const res = await getUserInfo()
    dispatch({
      type: GET_USER_INFO,
      payload: {
        userInfo: res.data
      }
    })
  }
}

reducers

reducer用于在接收actions触发的行为后,对state做相应的修改

numberReducer:

import {
  INCREMENT_NUMBER,
  DECREMENT_NUMBER
} from '../actions/number'

const initialState = {
  number: 0
}

export default function(state = initialState, action) {
  switch(action.type) {
    case INCREMENT_NUMBER:
      return {
        ...state,
        number: state.number + action.number
      }
    case DECREMENT_NUMBER:
      return {
        ...state,
        number: state.number - action.number
      }
    default:
      return state
  }
}

demo

这里使用万能的加减数字的状态管理,为了体现redux-thunk的重要性,我用easy-mock模拟一个获取用户信息的接口。可能有的小伙伴不喜欢用mixinPage这种模式,我也准备了不使用mixinPage的写法

使用mixinPage模式

demo--使用mixinPage

不使用mixinPage

demo--不使用mixinPage

最后,我的这种写法肯定还是存在不少问题,希望大佬能够指正,也希望我的一些想法能对您带来帮助

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