微信小程序提供了很多类似 wx.showModal
、wx.showLoading
这类 API,这类 API 虽然方便使用,但是样式丑陋,往往不满足我们的需求。
有没有办法让我们的自定义弹窗、loading 等可以通过类似微信的这种 API 进行随心所欲的调用呢?
首先放一下效果图:
可以看到只在 Index 页面写了一个按钮,就可以触发打开弹窗。
这是怎么做到的呢?
目标
首先观察一下特点:
wx.showModal({
title: "提示",
content: "操作不合法",
});
- 1、API 式调用.
- 2、全局性.在小程序任意地方都可以调用
API 式的调用
当进行这样一个调用时,我们需要将数据和状态,通过一定的方式,传入到某个组件中,组件再进行响应。传递数据的方式有 props 与 context。传递 props 方案首先排除了,因为你不可能每个组件要传入弹窗的 props,那么使用 context 方案呢?使用 context 需要在应用顶层提供一个 Provider,将所有弹窗数据和显隐状态,与修改数据的方法传入 Provider 中,在 Consumer 中 map 出所有弹窗数据,在需要打开或关闭弹窗的地方,使用 this.context
或者 useContext
拿到修改数据方法,然后才能控制弹窗状态。
下面提供了一份使用 context 方案的伪代码
const LayerContext = createContext(null)
<!---- app.tsx 入口文件 ---->
export default function (props) {
const [config, setConfig] = useState([])
return (
<LayerContext.Provider value={{config, setConfig}}>
{props.children}
</LayerContext.Provider>
)
}
<!---- wrapper.tsx 弹窗 wrapper 组件---->
export function Wrapper() {
return (
<LayerContext.Consumer>
{
({config}) => {
return (
<>
{
config.map(c => {
return (
<Layer config={c} />
)
})
}
</Layer>
)
}
}
</LayerContext.Consumer>
)
}
<!---- 页面文件---->
export default function () {
const { setConfig } = useContext(LayerContext)
function open() {
setConfig((d) => d.concat[{ name: 'a' ,visible: true, data: 1 }])}
}
return (
<View>
<View onClick={() => open()}>打开 A 弹窗</View>
<Wrapper />
</View>
)
}
对于 wrapper 组件,需要引入到每一个页面文件,调用弹窗时使用 useContext
也可以接受,但一定注意优化,任何一处 setConfig
都会导致引入 useContext(LayerContext)
的组件或页面重新渲染。
怎么避免这个问题?
如果能将顶层 app.tsx 中的 setConfig
存到外部,每次从外部文件引入 setConfig 方法调用、不直接使用 useContext,并配合 memo 就可以解决这个问题
伪代码如下:
<!---- useStore 自定义 hook ---->
export let setLayerConfig
export function useStore(initValue) {
const [config, setConfig] = useState(initValue)
setLayerConfig = setConfig
return [config, setConfig]
}
<!---- app.tsx 入口文件 ---->
export default function (props) {
const [config, setConfig] = useStore(layers)
return (
<LayerContext.Provider value={{config, setConfig}}>
{props.children}
</LayerContext.Provider>
)
}
要打开弹窗,只需要引入并调用 setLayerConfig
即可。
export default function () {
function open() {
setLayerConfig((d) => d.concat[{ name: 'a' ,visible: true, data: 1 }])}
}
return (
<View>
<View onClick={() => open()}>打开 A 弹窗</View>
<Wrapper />
</View>
)
}
如果将每一个 useState
的 data
和 setData
存到外部,并为其分配一个标识,那么我们就可以在任意地方根据标识拿到 useState 中的数据和方法。
基于此,我们封装了一套简单易用的状态管理工具 StateBus
简易实现如下:
class StateBus<T = any> {
constructor(private state?: T | (() => T)) { }
private listeners: RDispatch<T>[] = []
private subscribe(listener: RDispatch<T>) {
this.listeners.push(listener)
}
private unsubscribe(listener: RDispatch<T>) {
const idx = this.listeners.findIndex(fn => fn === listener)
if (idx !== -1) this.listeners.splice(idx, 1)
}
// 发布消息
setState(data) {
this.listeners.forEach(i => i(data))
}
useState() {
const [data, setData] = useState<T>(this.state)
useEffect(() => {
this.subscribe(setData)
return () => {
this.unsubscribe(setData)
}
}, [])
return [data, this.setState.bind(this)] as [T, RDispatch<T>]
}
}
根据我们设计的状态管理工具,就可以完全摒弃 context 的方案了。
那么怎么使用呢?
首先抽象出一个 Layer 的概念,包含了 modal、popup、toast 等浮层类元素。
设计 Layer 元素的数据结构:
interface Layer<T> {
visible: boolean // 浮层显影状态
model: T // 传入浮层元素的数据
key: string // Layer 可层叠,所以需要 key 来标识
}
用我们上面的状态管理来存储每一个 Layer 元素实例:
class LayerService<T> {
state = new StateBus([])
open(data: T, key: string) {
this.state.setState((prev) => prev.concat([{ model: data, visible: true, key }]))
}
}
const layerService = new LayerService()
可以看到,每次调用 open 时会调用 StateBus 的 setState,向其中 push 一个 Layer 实例。此时订阅了 layerService.useState
的组件,接收到消息后,会rerender 组件。根据每一个 Layer config 的 visible 状态,打开/关闭 Layer 元素即可。
这个组件怎么写呢?
export const LayerContainer = () => {
const [state] = layerService.useState()
return (
<View>
{
state.map(i => {
switch (i.model.type) {
case 'popup': return <Popup config={i} />
}
return <Toast config={i}/>
})
}
</View>
)
}
在 Popup 或者 Toast 等组件中,根据 visible 判断显影,根据 model 拿到要显示的数据。
在每一个页面的最底部,引入 LayerContainer 组件后,就可以通过 layerService.open
打开各种浮层元素了。
export default function () {
function open() {
layerService.open(
{
title: '标题',
content: <View>弹窗内容</View>,
type: 'popup'
},
'popup-1'
)
}
return (
<View>
<View onClick={() => open()}>打开 popup</View>
<LayerContainer />
</View>
)
}
全局调用
小程序中没有办法定义一个全局组件,只能将组件引入到每一个页面中。
借助 webpack-loader,我们可以实现每个页面自动注入组件的能力。
我设计了一个 webpack-loader,来完成这样的事情 taro-inject-component-loader
注入后的每一个页面,都引入了弹窗组件,因而可以在任意地方进行 layerService 弹窗的调用。
结语
上面简单介绍了 Taro React 小程序中,浮层类元素的打开方案。实际上,代码还有很多优化空间,限于篇幅,没有把每个步骤细细写来。但基本原理什么的都已经讲清楚了,希望大家有所收获。