本文的主旨在于讨论如何在原生小程序中实现状态管理,在开始之前,你需要了解下,redux toolkit, 以及到底,你是否需要接入它?
如果你确实需要用它来管理状态,那我们开始吧。
准备出发
我们知道redux是一个独立的状态管理库, 可以与其他描述的ui框架一起使用,例如react , vue , 简单回顾一下
以最简单的counter为例
counterSlice.js
ts
import { createSlice } from '@reduxjs/toolkit';
// 创建一个名为 "counter" 的 Redux Slice
export const counterSlice = createSlice({
name: 'counter', // Slice 的名称
initialState: {
value: 0, // 初始状态值
},
reducers: {
// 增加操作
increment: (state) => {
state.value += 1;
},
// 减少操作
decrement: (state) => {
state.value -= 1;
},
},
});
// 导出 action creators,即 reducers 中定义的操作
export const { increment, decrement } = counterSlice.actions;
// 下面的函数称为 "selector",允许我们从状态中选择一个值。
// 选择器也可以在使用它们的地方内联定义,而不是在 Slice 文件中定义。
// 例如:`useSelector((state) => state.counter.value)`
export const selectCount = (state) => state.counter.value;
// 导出 reducer,用作 Store 中的一个 reducer
export default counterSlice.reducer;
创建store并使用
ts
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {
counter: counterReducer,
},
});
store.subscribe(() => {
const state = store.getState();
console.log('宝宝订阅了 {number} 的变化', state.counter)
})
// action1 加一
store.dispatch(increment())
// action2 减1
store.dispatch(decrement())
// 以下是打印的结果
宝宝订阅了 {number} 的变化 {value: 1}
宝宝订阅了 {number} 的变化 {value: 0}
我们使用subscribe来订阅了状态的变化 , 当我们 dispatch 某个动作时, 订阅者将会收到通知。 从而更新视图或者其他操作
Give me a more concrete example ???
当然ok
还是上面的计数器例子,这次我们要将它跟我们的视图结合起来, 我们使用 Parcel 来搞个简单的例子
我们在本地安装parcel
yarn add --dev parcel
新建如下结构
ts
demo
├─ package.json
├─ src
│ ├─ counterSlice.mjs
│ ├─ index.html
│ ├─ render.mjs
│ ├─ store.mjs
│ └─ templateEngine.mjs
└─ yarn.lock
这里只需要关注 render.mjs 文件。 他长这样
js
// 1) 引入创建的store, 并且有个counter的领域slice
import store from "./store.mjs";
import { increment, decrement } from './counterSlice.mjs'
// 2) 订阅 store 更新
store.subscribe(render);
const valueEl = document.getElementById('value');
// 3. 当订阅回调运行时:
function render() {
// 3.1) 获取当前 store 状态
const state = store.getState();
// 3.2) 提取你需要的数据 selectorXXX
const newValue = state.counter.value;
console.log('selectorCounter', newValue)
// 3.3) 使用新值更新 UI
valueEl.innerHTML = newValue;
}
// 4) 显示初始 UI,根据初始 store 状态
render();
// 5) 根据用户界面输入分发操作
document.getElementById("increment")
.addEventListener('click', () => {
store.dispatch(increment())
});
document.getElementById("decrement")
.addEventListener('click', () => {
store.dispatch(decrement())
});
当我们运行 yarn parcel src/index.html 时
每当我们点击新增1, 减少1按钮时, 中间的value部分 UI将会变更, 你可以把这个例子看作是 redux 与 UI 层进行一个结合的最简单例子。
当然,不止于此,我提供了一个更为复杂的模版引擎的例子,你可以下载运行试试,这个不是重点demo.zip
事实上 每个 Redux UI 集成层只是这些步骤的一个更高级的版本。
以我们熟知的react-redux connect为例, 它用于将redux状态跟我们的组件关联起来, 它通过mapStateToProps 这个函数从store里面提取组件需要的数据 ,从mapDispatchToProps 获取到dispatch, 从而可以派发动作来更新状态。 👀 进而自动更新新的视图
我向chatGPT询问了connect的设计思想后, 它给出了一个简单的实现,如下
js
function connect(mapStateToProps, mapDispatchToProps) {
// 返回一个高阶组件
return function (WrappedComponent) {
// 返回一个新的组件类
return class Connect extends React.Component {
constructor(props, context) {
super(props, context);
// 获取 Redux store
this.store = context.store;
// 初始化组件的 state
this.state = mapStateToProps(this.store.getState());
}
componentDidMount() {
// 订阅 Redux store 的变化
this.unsubscribe = this.store.subscribe(this.handleChange);
}
componentWillUnmount() {
// 取消订阅 Redux store
if (this.unsubscribe) {
this.unsubscribe();
}
}
handleChange = () => {
// 当 Redux store 的状态变化时,更新组件的 state
const newProps = mapStateToProps(this.store.getState());
// 比较新旧数据是否相同
if (!shallowEqual(this.state, newState)) {
this.setState(newState);
}
};
render() {
// 将 Redux store 的 state 和 dispatch 作为 props 传递给 WrappedComponent
const { dispatch } = this.store;
const mergedProps = {
...this.props,
...this.state,
dispatch,
};
return <WrappedComponent {...mergedProps} />;
}
};
};
}
并给出了原理说明
js
`connect` 函数的原理是通过高阶组件(Higher Order Component,HOC)的方式,将 React 组件与 Redux store 连接起来,实现数据的同步和重新渲染。
以下是 `connect` 函数的基本原理:
1. `connect` 函数接受两个参数:`mapStateToProps` 和 `mapDispatchToProps`。这两个参数决定了如何将 Redux store 中的状态映射到 React 组件的 `props` 上。
2. `connect` 函数返回一个函数,这个函数接受一个 React 组件作为参数(通常是要连接到 Redux store 的组件),并返回一个新的组件类。
3. 在新的组件类(通常称为 `Connect`)的 `constructor` 方法中,获取了 Redux store 的引用,并初始化了组件的 `state`。这个初始的 `state` 是通过调用 `mapStateToProps` 函数来从 Redux store 中获取的。
4. 在新组件 `Connect` 的 `componentDidMount` 生命周期中,订阅了 Redux store 的变化,即通过 `this.store.subscribe(this.handleChange)` 来监听 Redux store 的更新。这样,当 Redux store 中的状态发生变化时,会触发 `handleChange` 方法。
5. 在 `handleChange` 方法中,通过再次调用 `mapStateToProps` 函数来获取最新的 Redux store 状态,并将其更新到组件的 `state` 中。
6. 在 `render` 方法中,`Connect` 组件将 Redux store 的 `state` 和 `dispatch` 作为 `props` 传递给了被包装的 React 组件(通常称为 `WrappedComponent`)。这样,`WrappedComponent` 就可以访问 Redux store 中的数据,并通过 `dispatch` 方法触发 action。
7. 最终,`Connect` 组件返回了 `WrappedComponent`,这样,`WrappedComponent` 就具备了访问 Redux store 中数据和触发 action 的能力。
通过这种方式,`connect` 函数实现了 React 组件与 Redux store 的连接,使得 Redux 中的状态变化可以自动同步到 React 组件的 `props` 中,从而实现了数据的同步和重新渲染。
总结:`connect` 函数的原理是通过高阶组件,在组件的生命周期中监听 Redux store 的变化,将 Redux store 中的数据映射到组件的 `props` 上,实现了 React 组件与 Redux store 的连接。这样,组件可以访问 Redux 中的数据并触发 action,实现了数据的同步和重新渲染。
已经说的很棒了, Dan 老师之前也写过一篇文章解释过 connect.js, 可以点击这里查看
so ? 似乎与标题脱节了, 别急, 铺垫了这么多,一是为了回顾基本的 API 用法, 二则是借鉴经典的设计,有了这两点,我们来开始正文
正文来了
我们的目标是在小程序中使用状态管理工具 redux, 这其实非常简单。 在开始之前我还是想回顾一下小程序,小程序作为一个新的开发者应用载体,在2023年的今天我们可以说无时无刻不在使用,从微信,到支付宝,再到涂鸦中的一个个设备控制小程序。 尤其是涂鸦智能家居体系,真的很适合小程序。 app就是你的家, 家里的每个设备就对应app里面的一个小程序。
那说起小程序,现在的架构都大差不差,对齐微信, 核心是双线程模型:小程序的渲染层和逻辑层分别由 两个线程管理:视图层的界面使用了 WebView 进行渲染,逻辑层采用 JsCore 线程运行 JS脚本; 两个线程之间互相通信,并使用虚拟dom来描述界面, 逻辑层向视图层通信传递数据 (setData), 视图层用最新的数据生成新的虚拟dom, 并将差异应用在真正的dom上去, 也就是我们现在常见的数据驱动视图的开发模式。
视图层页面事件触发时, 向逻辑层传递消息, 逻辑层去执行对应页面的方法。
下面是两个线程之间的交互机制
简单说明下, 两个层同时执行,例如 (视图层view.js , 逻辑层service.js)
视图层 -> 逻辑层(page_init)
1.当微信小程序的视图层(Webview)启动后,它会向逻辑层发送一个名为 "page_init" 的消息。
2.逻辑层接收到这个消息后,会创建页面实例,并获取该页面实例的数据(data)。
3.接着,逻辑层会执行页面实例的 onShow 和 onLoad 方法。
逻辑层 -> 视图层(page_render)
1.逻辑层准备好数据后,会向视图层发送一个消息,通知视图层可以开始渲染页面了,并将原始的数据传递给视图层。
2.视图层接收到消息后,根据页面的渲染规则(如 WXML 等),创建虚拟 DOM 树。
3.将虚拟 DOM 树转换为真实的 DOM 节点树,并将其插入到页面的根元素中,从而完成首次渲染。
视图层 -> 逻辑层(page_method)onReady
1.视图层在首次渲染完成后,向逻辑层发送一个消息,通知逻辑层页面已经渲染完毕,并可以执行 onReady 方法。
2.在逻辑层的 onReady 方法中,可能会执行一些异步操作,如获取云端数据。
3.如果逻辑层在 onReady 中执行了数据变更操作,它会调用页面实例的 setData 方法,将最新的数据传递给视图层。
逻辑层 -> 视图层(page_update)
1.当逻辑层调用 setData 方法更新数据后,它会向视图层发送一个消息,通知视图层需要更新页面的数据。
2.视图层接收到 page_update 消息后,会使用最新的数据重新生成虚拟 DOM 树。
3.然后,视图层会将新的虚拟 DOM 树与旧的进行比较,找出需要变化的部分,并进行相应的更新,以保持页面显示的最新状态。
上面大概描述一个简易的通信过程, 可以简单了解,不用在意
这下真的来了
直接开门见山, 如何使用, 还是以counter为例
小程序页面page/counter.tyml
js
// 省去了创建,引入store的代码
const { dispatch, subscribe, getState } = store;
Page({
data: { value: 0 },
onLoad() {
if (!this.unsubscribe) {
this.unsubscribe = subscribe(() => {
const state = getState();
// 提取你需要的数据 selectorXXX
const newValue = state.counter.value;
// 你可能要比较一下提取的最新数据跟之前数据对比是否变了, 不变的话不更新,变了的最好能局部更新(只更新变了的)
// 调用小程序更新UI的方法
this.setData({
value: newValue
})
});
}
},
onUnload() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
},
handleIncrement() {
dispatch(increment())
}
})
就是这么简单, 你就几个页面的话这样完全可以, 但是这个过程其实是很重复的, 像 订阅存储、检查更新数据和触发重新渲染的过程可以变得更加通用和可重用, 就像react-redux的connect一样。
createConnectPage
我们仿照react-redux connect api 的设计思路, 创建一个 createConnectPage 的函数, 我们整比较简单点,顾名思义,就是创建连接过的Page , 传入三个参数, pageConfig, mapStateToData , mapStateToMethods , 不用说,都知道什么意思吧
js
import { globalStore } from './index';
import getStateFromData from './utils/getStateFromData';
import { shallowEqual } from './utils/shallowEqual';
import { diffData } from './utils/diff';
import { isEmptyObject } from './utils/isEmptyObject';
const defaultMapStateToData = () => ({});
const defaultMapDispatchToMethods = dispatch => ({ dispatch });
export default function createConnectPage(Page) {
const __OriginalPage = Page;
return function connectPage(pageConfig, mapStateToData, mapDispatchToMethods, store?) {
const { subscribe, getState, dispatch } = globalStore || store;
const shouldSubscribe = Boolean(mapStateToData);
const finalMapStateToData = mapStateToData || defaultMapStateToData;
const finalMapDispatchToMethods = mapDispatchToMethods || defaultMapDispatchToMethods;
function computeMappedState(originalData?) {
const state = getState();
const mappedState = finalMapStateToData(state);
return mappedState;
}
return __OriginalPage({
...pageConfig,
data: {
...pageConfig.data,
...computeMappedState()
},
onLoad: function(options) {
// todo 页面参数传入mapStateToData
this.trySubscribe();
this.bindDispatchMethodsToPage()
pageConfig.onLoad && pageConfig.onLoad.bind(this)(options);
},
onUnload: function() {
pageConfig.onUnload && pageConfig.onUnload.bind(this)(...arguments);
this.tryUnsubscribe()
},
onReady: function () {
pageConfig.onReady && pageConfig.onReady.bind(this)(...arguments);
},
bindDispatchMethodsToPage() {
if(typeof finalMapDispatchToMethods === 'function') {
const dispatchMethods = finalMapDispatchToMethods(dispatch)
for (const methodName in dispatchMethods) {
if (dispatchMethods.hasOwnProperty(methodName)) {
// 绑定每个 dispatch 方法到页面实例
this[methodName] = dispatchMethods[methodName];
}
}
}
},
handleChange() {
const mappedState = computeMappedState();
const currentState = getStateFromData(mappedState, this.data);
console.log('新的mappedState', mappedState);
console.log('旧的state', currentState);
console.log('diffData', diffData(mappedState, currentState))
// 比较旧的从redux映射过来state,跟最新的selector state , 如果有差异,则setData局部更新
// diffData 引用微信官网westore diffdata
if(!shallowEqual(mappedState, currentState)) {
const patch = diffData(mappedState, currentState)
if(!isEmptyObject(patch)) {
this.setData(patch);
}
}
},
trySubscribe() {
if(shouldSubscribe && !this.unsubscribe) {
this.unsubscribe = subscribe(this.handleChange)
}
},
tryUnsubscribe() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
})
}
}
globalStore
js
export let globalStore = null;
export function setGlobalStore(store) {
globalStore = store;
}
整体比较简单,就是扩展和修改页面的配置对象,在页面加载的时候订阅store的更新, 并且将mapDispatchToMethods返回的方法挂在在this, 也就是当前页面的实例上面,供其他页面方法调用或者在wxml中调用, 在页面卸载的时候取消监听
每当我们更新store数据时,我们在handleChange方法中获取到最新的state并根据选择器函数mapStateToData去选择当前页面需要的状态, 并且在这里做了一个diffData的操作,将差异使用页面实例的setData方法来更新,从而最小化的更新页面
值得注意的是,diffData采用微信官方提供的westore里面的diff函数,专门用于深比较两个对象,然后找出布局变更的数据,也算是一个小优化,当然,一些场景可能并不需要。
局部更新数据参考智能小程序 运行时优化(setData、事件)
createConnectComponent
这个跟上面代码差不多,因为组件跟页面的配置不太一样,所以单独抽了出来,感兴趣可以看模版代码
如何使用
github地址: github.com/baohuse/min...