微信小程序提供了很多类似 wx.showModalwx.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>
  )
}

如果将每一个 useStatedatasetData 存到外部,并为其分配一个标识,那么我们就可以在任意地方根据标识拿到 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 小程序中,浮层类元素的打开方案。实际上,代码还有很多优化空间,限于篇幅,没有把每个步骤细细写来。但基本原理什么的都已经讲清楚了,希望大家有所收获。