解决 antd-mobile 异步弹窗首次无动画问题的最佳实践
在移动端 React 项目中,弹窗(Popup/Modal)是非常常见的交互组件。为了优化首屏加载性能,我们常常会将弹窗组件异步加载(即点击时才加载对应 JS 文件)。antd-mobile 提供了 Popup
组件和 renderImperatively
函数式调用方式,非常适合业务场景。
但在实际开发中,我遇到了一个细节问题:异步加载的弹窗,首次弹出时没有动画,直接闪现出来,后续弹出才有动画。以下是我解决这个问题的记录与方案。
1. 问题复现
假设我们有如下代码:
javascript
import React from "react";
import { Popup, Button } from "antd-mobile";
export function AsyncPopup({ visible, onClose }) {
return (
<Popup
visible={visible}
onMaskClick={onClose}
onClose={onClose}
bodyStyle={{
minHeight: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ textAlign: "center" }}>
<h3>我是业务弹窗内容</h3>
<Button color="primary" onClick={onClose}>
关闭
</Button>
</div>
</Popup>
);
}
异步加载 + 函数式调用:
javascript
import React, { Suspense } from "react";
import { renderImperatively } from "antd-mobile/es/utils/render-imperatively";
const AsyncPostTypeSelectPopup = React.lazy(() =>
import("./AsyncPopup").then((mod) => ({
default: mod.AsyncPopup,
}))
);
export const AsyncPopupProxy = (props) => (
<Suspense fallback={<div>加载中...</div>}>
<AsyncPostTypeSelectPopup {...props} />
</Suspense>
);
export function showAsyncPopup(props = {}) {
const handler = renderImperatively(
<AsyncPopupProxy onCloseIconClick={() => handler.close()} />
);
return handler;
}
调用:
ini
<Button
color="primary"
onClick={async () => {
await showAsyncPopup();
}}
>
打开异步弹窗(函数式)
</Button>
现象:
第一次点击按钮,弹窗直接"闪现"出来,没有动画。后续再点,动画正常。
2. 问题分析
2.1 为什么会这样?
-
弹窗动画的原理:
Popup
组件的visible
属性从false
变为true
时,才会有动画。 -
但异步加载时,流程如下:
- 点击按钮,
showAsyncPopup
被调用。 - 由于
React.lazy
+Suspense
,先渲染 fallback(比如"加载中...")。 - 弹窗组件 JS 加载完毕后,
AsyncPopup
组件第一次挂载,此时visible
直接就是true
。 - 由于没有经历
false -> true
的过程,动画不会触发,弹窗直接显示。
- 点击按钮,
2.2 关键点
- 动画的触发依赖于 visible 的变化,而不是初始值。
- 异步加载时,组件一挂载 visible 就是 true,动画不会执行。
3. 解决思路
- 目标: 让弹窗组件先以
visible=false
挂载,然后再变为true
,这样动画就能正常触发。 - 方案: 不用
React.lazy
+Suspense
,而是自己用import()
动态加载组件,加载完后先渲染visible=false
,再异步 setState 变为true
。
4. 代码实现
4.1 新的 Proxy 组件
javascript
import React, { useState, useEffect } from "react";
import { renderImperatively } from "antd-mobile/es/utils/render-imperatively";
export const AsyncPopupProxy = ({ imperativelyClose, ...props }) => {
const [Comp, setComp] = useState(null);
const [visible, setVisible] = useState(false);
// 1. 异步加载弹窗组件
useEffect(() => {
let unmounted = false;
import("./AsyncPopup").then((mod) => {
if (!unmounted) {
setComp(() => mod.AsyncPopup);
}
});
return () => { unmounted = true; };
}, []);
// 2. 组件加载完后,下一帧再设置 visible=true,触发动画
useEffect(() => {
if (Comp) {
setTimeout(() => setVisible(true), 0);
}
}, [Comp]);
// 3. 关闭弹窗
const handleClose = () => {
setVisible(false);
imperativelyClose && imperativelyClose();
};
if (!Comp) return <div>加载中...</div>;
return (
<Comp
{...props}
visible={visible}
onClose={handleClose}
onMaskClick={handleClose}
/>
);
};
4.2 函数式调用
ini
export function showAsyncPopup(props = {}) {
let handler;
handler = renderImperatively(
<AsyncPopupProxy
{...props}
imperativelyClose={() => handler.close()}
/>
);
return handler;
}
4.3 使用方式
javascript
function App() {
return (
<div style={{ padding: 40 }}>
<Button
color="primary"
onClick={async () => {
await showAsyncPopup();
}}
>
打开异步弹窗(函数式)
</Button>
</div>
);
}
这样就正常啦,这种方式适用于 antd-mobile、antd、mui 等所有依赖 visible 控制动画的弹窗组件。