React官方不推荐这样使用useImperativeHandle,我偏要用,和官方对着干!

4,606 阅读2分钟

前言

React.useImperativeHandle 不知道这个hook你们使用的频率如何,我反正是经常用在业务的Modal组件上。今天查看react新的官方文档发现,我这种使用方式居然是react官方不推荐的,这让我不禁思考起来“难道我一直都踩在陷阱里?”

再仔细翻阅文档,我并没有详细地看到官方告诉我,我这种用法到底有什么问题?单凭下面几句无法说服我,我反而觉得ModalRef.open()这种写法更加优雅。

image.png

我的写法

在以下代码案例中,我举例一个Modal组件,通过useImperativeHandle向外expose了一个方法open。 该方法中接受一个传参id(而不是通过props传入),同时执行了request等副作用。

const _IssueModal = (props: IProps, ref: Ref<IRef>) => {
    //变量声明、解构
    const {} = props;
    const { issueApi, roomTreeApi, issueListApi } = settingScreen$;
    //组件状态
    const uiIdRef = useRef('');
    const deviceIdsRef = useRef<string[]>([]);
    const [open, setOpen] = useState(false);

    //网络IO

    //数据转换
    const options = transformOptions(roomTreeApi.data || []);

    //逻辑处理函数
    const handleReset = () => {
        setOpen(false);
        // 重置数据
        uiIdRef.current = '';
        deviceIdsRef.current = [];
    };
    const handleConfirm = async () => {
        try {
            await issueApi.request();
            // 更新issue后,关闭模态框,刷新外面的List
            // FIXME: 刷新List逻辑不应该放在Modal中执行
            handleReset();
            issueListApi.request();
        } catch(e) {
            // console.error(e);
        }
        
    };

    //组件Effect
    useImperativeHandle(ref, () => ({
        open: (id: string) => {
            if (!id) return;
            uiIdRef.current = id;
            setOpen(true);
            // 初始化
            roomTreeApi.request();
        },
    }));

    //组件渲染
    return (
        <Modal
            open={open}
            title='主题资源包下发'
            onCancel={handleReset}
            onOk={handleConfirm}
            destroyOnClose
            maskClosable={false}
            okButtonProps={{
                disabled: !deviceIdsRef.current.length,
            }}
        >
            <p>请选择要更新的设备</p>
            <Cascader
                className={styles.CascaderSelect}
                options={options}
            />
        </Modal>
    );
};

//props类型定义
interface IProps {}

interface IRef {
    open: (id: string) => void;
}

//prop-type定义,可选
const IssueModal = observer<IProps, IRef>(_IssueModal, { forwardRef: true });
export { IssueModal };

使用ModalRef.open的好处

  1. 可读性好

我把相关的业务逻辑与state,都封装在一个单独的组件里,而不是把所有逻辑平铺到父组件。 特别是当前业务场景有多个Modal,如果采用传统写法的代码就像拉面条,每个open状态还需要单独命名,相当痛苦且不美观。

  1. 解耦

父组件不需要知道我这个Modal是干什么的,也不应该直接修改Modal的内部状态,只需要按照声明的接口IRef调用open就可以。

  1. 复用

上述例子中,IssueModal是个具有完整业务逻辑的组件,其他地方需要复用这段逻辑,直接引用Ref就可以。

    // 使用React.ComponentRef 获取IRef类型,不要再写any了!!!
    const issueModalRef = useRef<ComponentRef<typeof IssueModal>>(null);
    // ...
    const handleClick = (record) => {
        issueModalRef.current?.open(record.id);
    }
    // ...
    <IssueModal ref={issueModalRef} />

同道中人

google了一下,发现了不少人也和我一样是这样写Modal组件的。举个几个例子:

后话

这篇文章并不是说我的写法是那么牛逼,而是解答我心中疑惑,“React官方推荐的一定是对吗?有没有更好的写法?当面对不同的业务场景我应该选择哪种写法会更好?”

感谢React官方提供那么棒的前端开源框架,只是基于OOP的开发思维&经验,面对我的业务场景时,我觉得使用useImperativeHandle会更优雅。

NiceModalReact

感谢大家的评论&建议,受评论区提及的eBay/nice-modal-react的启发,给上述modal的写法来个更新。

在上述IssueModal的例子中,新增了一个备注FIXME;相信有不少朋友和我一样犯下同一个错误。但这是不应该的,因为刷新List并不是该Modal的职责。他应该是单一职责:UpdateIssueById

FIXME 在Modal内更新数据后需要刷新外侧的List数据,顺其自然地把List的更新请求逻辑在Modal中执行。

那么正确的做法应该把Update的结果告诉外侧,也就是issueModalRef.current.open的调用方。如何告诉呢?

  • 用props传入函数来触发?
  • 用EventBus去监听?
  • 其他方案?

在阅读eBay/nice-modal-react的源码,我才发现原来Promise就是最优解。大家可以直接看看它的Demo ,简单来说就是把modal的open到close视为一次异步任务,把modal中的执行结果在return给外侧。

更新后的代码

    const issueModalRef = useRef<ComponentRef<typeof IssueModal>>(null);
    // ...
    const handleClick = async (record) => {
        try {
            const res = await issueModalRef.current?.open(record.id);
            issueListApi.request();
        } catch(e) {
            // 用户主动关闭modal,没有更新issue,会触发catch
        }
        
    }
    // ...
    <IssueModal ref={issueModalRef} />
const _IssueModal = (props: IProps, ref: Ref<IRef>) => {
    //变量声明、解构
    const {} = props;
    const { issueApi, roomTreeApi } = settingScreen$;
    //组件状态
    const uiIdRef = useRef('');
    const deviceIdsRef = useRef<string[]>([]);
    const [open, setOpen] = useState(false);
    const PromiseRef = React.useRef<{
        resolve: (value: TPromiseResolve) => void;
        reject: (reason?: any) => void;
    } | null>(null);

    //网络IO

    //数据转换
    const options = transformOptions(roomTreeApi.data || []);

    //逻辑处理函数
    const handleReset = () => {
        uiIdRef.current = '';
        deviceIdsRef.current = [];
        // do something to reset
        PromiseRef.current?.reject();
        setOpen(false);
    };
    const handleConfirm = async () => {
        try {
            await issueApi.request();
            // 更新issue后,关闭模态框,返回更新结果
            PromiseRef.current?.resolve();
            handleReset();
        } catch(e) {
            // 更新issue失败,但模态框不关闭,让用户自行重试
            // console.error(e);
        }
        
    };

    //组件Effect
    useImperativeHandle(ref, () => ({
        open: (id: string) => {
            if (!id) return Promise.reject();
            uiIdRef.current = id;
            roomTreeApi.request();
            // do something to init
            setOpen(true);
            const promise = new Promise<TPromiseResolve>((resolve, reject) => {
                PromiseRef.current = { resolve, reject };
            });
            return promise;
        },
    }));

    //组件渲染
    return (
        <Modal
            open={open}
            title='主题资源包下发'
            onCancel={handleReset}
            onOk={handleConfirm}
            destroyOnClose
            maskClosable={false}
            okButtonProps={{
                disabled: !deviceIdsRef.current.length,
            }}
        >
            <p>请选择要更新的设备</p>
            <Cascader
                className={styles.CascaderSelect}
                options={options}
            />
        </Modal>
    );
};

//props类型定义
interface IProps {}

interface IRef {
    open: (id: string) => Promise<TPromiseResolve>;
}

type TPromiseResolve = void;

//prop-type定义,可选
const IssueModal = observer<IProps, IRef>(_IssueModal, { forwardRef: true });
export { IssueModal };

为什么不直接用eBay/nice-modal-react

简单来说:维护Modal的方式不同,我不需要如此复杂的维护方式

尽管API调用上类似,但维护Modal却不同。

我的方式是自行声明Modal组件,同一个Modal组件可以创建多个相互独立的Modal,各自维护其销毁&创建。

而ebay会自动挂载在声明的Context.Provider下,并对每个Modal生成一个唯一id,收集起来。 在其内部称之为register,使用到了观察者模式 的设计模式。

其好处在于:

  1. 统一管理Modal的生命周期
  2. 同一Modal组件全局只需要创建一次,可重复open。
  3. 可控制&监听某一个Modal的显隐

image.png