Taro 自定义 showModal

4,031 阅读5分钟

前言

微信小程序提供了很多类似 wx.showModalwx.showLoading 这类 API,这类 API 虽然方便使用,但是样式丑陋,往往不满足我们的需求。

有没有办法让我们的自定义弹窗、loading 等可以通过类似微信的这种 API 进行随心所欲的调用呢?

首先放一下效果图:

Jun-26-2021 15-51-49.gif 可以看到只在 Index 页面写了一个按钮,就可以触发打开弹窗。

再总结一下全文:

  • 利用脚本将每个弹窗文件生成一个弹窗配置
  • 根据弹窗配置,弹窗 wrapper 组件 map 每一个弹窗
  • 使用 webpack-loader 将 wrapper 组件注入到每个页面
  • 使用 chokidar 监听弹窗文件的创建,自动运行脚本,更新弹窗配置
  • 设计 API 式的调用风格

此文基于我一年半的开发小程序经验所写,使用的技术栈为: Taro ReactHook TypeScript。可能不具有通用性,但如果能为您带来一些思考和感悟也是极好的。

目标

首先观察一下特点:

wx.showModal({
  title: "提示",
  content: "操作不合法",
});
  • 1、API 式调用.
  • 2、全局性.在小程序任意地方都可以调用

本文要实现的目标主要是这两点。

API 式的调用

当进行这样一个调用时,我们需要将数据和状态,通过一定的方式,传入到某个组件中,组件再进行响应。

传递数据的方式有 props 与 context。

传递 props 方案首先排除了,因为你不可能每个组件要传入弹窗的 props。

那么使用 context 方案呢?使用 context 需要在应用顶层提供一个 Provider,将所有弹窗数据和显隐状态,与修改数据的方法传入 Provider 中,在 Consumer 中 map 出所有弹窗数据。在需要打开或关闭弹窗的地方,使用 this.context 或者 useContext 拿到修改数据方法,然后才能控制弹窗状态。

下面提供了一份使用 context 方案的伪代码

const ModalContext = createContext(null)

<!---- app.tsx 入口文件 ---->
export default function (props) {
  const [config, setConfig] = useState(modals)
  return (
    <ModalContext.Provider value={{config, setConfig}}>
      {props.children}
    </ModalContext.Provider>
  )
}

<!---- index.tsx 首页文件---->
export default function () {
  const { setConfig } = useContext(ModalContext)

  return (
    <View>
      {/* 这里就不细写了,原理大家可以看懂 */}
      <View onClick={() => setConfig((d) => { name: 'a' ,visible: true, data: 1 })}>打开 A 弹窗</View>
      <Wrapper />
    </View>
  )
}

<!---- wrapper.tsx 弹窗 wrapper 组件---->
export function Wrapper() {
  return (
    <ModalContext.Consumer>
      {
        ({config}) => {
          return (
            <>
              {
                config.map(c => {
                  return (
                    // 每一个弹窗实例
                    <Modal data={c} />
                  )
                })
              }
            </Modal>
          )
        }
      }
    </ModalContext.Consumer>
  )
}

对于 wrapper 组件,需要引入到每一个页面文件,调用弹窗时使用 useContext 也可以接受,但一定注意优化,任何一处 setConfig 都会导致引入 useContext(ModalContext) 的组件或页面重新渲染。

怎么避免这个问题?

如果能将顶层的 setConfig 存到外部,每次从外部引入 setConfig 方法调用、不直接使用 useContext,配合 memo 优化,就可以解决重新渲染的问题。

伪代码如下:

<!---- useStore 自定义 hook ---->
export let setModalConfig

export function useStore(initValue) {
  const [config, setConfig] = useState(initValue)
  setModalConfig = setConfig
  return [config, setConfig]
}

<!---- app.tsx 入口文件 ---->
export default function (props) {
  const [config, setConfig] = useStore(modals)
  return (
    <ModalContext.Provider value={{config, setConfig}}>
      {props.children}
    </ModalContext.Provider>
  )
}

想要打开弹窗时,只需要直接引入 setModalConfig 方法进行调用即可。

如果将每一个 useStatedatasetData 存到外部,并为其分配一个标识,那么我们就可以在任意地方根据标识拿到 useState 中的数据和方法。

基于此,我们封装了一套简单易用的状态管理工具 stook

简易实现如下:

export const stores[] = []

// 外部修改 hook 状态
export function mutate(key, value) {
  const cacheIdx = stores.findIndex(store => store.key === key)
  stores[cacheIdx].cbs.forEach(cb => cb(value))
}

// 外部获取 hooks 状态
export function getState(key) {
  const cacheIdx = stores.findIndex(store => store.key === key)
  return stores[cacheIdx].value
}

export function useStore(key, initValue?: any) {
    const cache = stores.find(store => store.key === key)
    // 对于一个相同 key 的 useState, 首先尝试使用缓存数据初始化
    const [state, setState] = useState(cache?.value || initValue)

    // 防止同一个 setState 被缓存多次
    if (!cache?.cbs.find(cb => cb === setState)) {
      if (!cache) {
        // 对于同名的 key, value应当是相同的,需要将每一个修改状态的函数保存
        stores.push({ key, value: state, cbs: [setState] })
      } else {
        cache.cbs.push(setState)
      }
    }

    useEffect(() => {
      // 组件或页面卸载
      return () => {
        const cacheIdx = stores.findIndex(store => store.key === key)
        const idx = stores[cacheIdx]!.cbs.findIndex(cb => cb === setState)
        cache!.cbs.splice(idx, 1)
        if (!cache?.cbs.length) {
          stores.splice(cacheIdx, 1)
        }
      }
    }, [key, setState])

    return [
      state,
      function (value) {
        let newValue = value
        if (typeof value === 'function') {
          newValue = value(state)
        }
        const cache = stores.find(store => store.key === key)!
        cache.value = newValue
        cache.cbs.forEach(cb => cb(value))
      }
    ]
}

根据我们设计的状态管理工具,那么可以完全摒弃 context 的方案了。

<!---- app.tsx 入口文件 ---->
export default function (props) {
  return props.children
}

<!---- index.tsx 首页文件---->
export default function () {
  return (
    <View>
      <View onClick={() => mutate('modal', (d) => {})}>打开 A 弹窗</View>
      <Wrapper />
    </View>
  )
}

<!---- wrapper.tsx 弹窗 wrapper 组件---->
export function Wrapper() {
  const [modalConfig] = useStore('modal', config)
  return (
    <>
      {
        modalConfig.map(c => {
          return (
            <Modal data={c} />
          )
        })
      }
    </>
  )
}

有了这些基础,通过 API 式的调用弹窗就好实现了很多

class Service {
  openCommonModal(data) {
    mutate("modal", (config) => {
      const commonConfig = config.find(({ name }) => name === "common");
      commonConfig.visible = true;
      commonConfig.data = data;
      return [...config];
    });
  }
}
export const service = new Service();

这样的话,就可以通过 service.openCommonModal 的方式打开弹窗了.

全局调用

小程序中没有办法定义一个全局组件,只能将组件引入到每一个页面中。借助 webpack-loader,我们可以实现每个页面自动注入组件的能力。

我们设计了一个 webpack-loader,来完成这样的事情 taro-inject-component-loader

注入后的每一个页面,都引入了弹窗组件,因而可以在任意地方进行 service 弹窗的调用。

自动化

上面我们一直使用了一个叫做 config 的东西,config 类似路由表文件,是一个 Modal 组件和名称的映射表

const config = [
  {
    name: "common",
    component: CommonModal,
  },
];

为了方便维护,建议将所有的 modal 文件放置到一个文件夹下。

|---src
|----|----modals
|----|------|------common.tsx
|----|------|------loading.tsx
|----|------|------actionSheet.tsx

这样的话,可以使用脚本工具,遍历该文件夹,自动生成一份配置文件。效果与 umi 约定式路由类似。此外,可以使用 chokidar 监听 modals 文件夹下文件的删除和创建,来自动更新 config 配置文件。

其实不光是 config 配置文件、如果仔细观察 service 文件的话,也可以使用脚本,自动生成一份 service 文件。

我们团队设计和实现了一套脚本工具来完成这样的功能。

结语

希望此文能为大家带来帮助。

原文发表在我的博客: Taro 自定义 showModal

本文涉及到的模板在: 这里