前言
微信小程序提供了很多类似 wx.showModal
、wx.showLoading
这类 API,这类 API 虽然方便使用,但是样式丑陋,往往不满足我们的需求。
有没有办法让我们的自定义弹窗、loading 等可以通过类似微信的这种 API 进行随心所欲的调用呢?
首先放一下效果图:
可以看到只在 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
方法进行调用即可。
如果将每一个 useState
的 data
和 setData
存到外部,并为其分配一个标识,那么我们就可以在任意地方根据标识拿到 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 文件。
我们团队设计和实现了一套脚本工具来完成这样的功能。
- generated.一个 cli 工具,主要用来运行配置的脚本文件
- generated-plugin-taro-modal-service.使用了 ts-morph 生成弹窗配置文件。
- webpack-plugin-chokidar. 结合 webpack 和 chokidar,优化了 在 webpack 中使用的 api 设计
结语
希望此文能为大家带来帮助。
原文发表在我的博客: Taro 自定义 showModal
本文涉及到的模板在: 这里。