升级到react18后,react-dom的render()被废弃的替代方案
背景
有个需要写h5活动页的临时需求,开始时需求范围比较小,直接拿原生HTML+JavaScript
去写的,后面需求蔓延,里面涉及了一些不同场景下的弹窗,弹窗中还涉及到收集用户信息,于是产生不如还是用框架来写,然后再构建一个静态包的形式,这样无论是改样式还是需求变更都更好维护一些。这里选择的是React + typescript + less
。
过程
由于有第一版方案是原生的html
,也没有使用UI框架,所以弹窗也是拿原生js
来写的,在移植到React
的过程中,想要把弹窗这部分逻辑抽成公共的hook
。
众所周知,React18
废弃了react-dom
的render
方法,对于页面初始化来说,变化不大,调用createRoot
就好,但是对于这种动态插入DOM
就要做些改动了。之前一直用UI框架直接梭哈,也没太留意升级后的实现,这也算是一种尝试。
但先明确我的需求是,类似实现一个alert
功能,可以直接通过函数调用的形式来插入一段DOM
,像这样
showAlert({ content: "这是一段alert" })
当然,它需要一个关闭按钮,点击按钮就可以关闭这个弹窗。
createPortal
查阅官网,首先就是createPortal
方法,
正如介绍所说,它可以把JSX
即时渲染到DOM
中,来看一下它的使用,
但让人失望的是,它需要把
createPortal
的返回值直接放到JSX
中,这显然无法达成实现函数调用来插入的效果。
createRoot
换个思路,既然createPortal
是生成一个ReactNode
,那或许可以通过createRoot
来生成额外的根节点,然后在这个根节点上渲染我们动态生成的DOM
,
const generate = (children: ReactNode) => {
const wrap = document.createElement("div");
const root = createRoot(wrap);
// 插入DOM
document.body.appendChild(wrap);
// 渲染组件
root.render(createPortal(children, wrap));
};
果然可行!那么接下来就是如何销毁DOM
的问题。
中间尝试了一些方案,想尽可能让外部无感知的去销毁掉,且通过hook
的形式调用,在组件中也许只在顶部调用hook
一次,那就需要考虑多次执行generate
的场景,如何去定点销毁某个弹窗呢?这里最终决定采用生成key
的方式来做一个Map
,Map
的值中保存的是对应render
的destory
方法,它内部的逻辑也很简单,执行root
卸载和销毁DOM
。
const useExtraRender = (props: extraRenderProps = {}) => {
// 记录一下当前的所有生成的root
const rootMap = useRef<Map<string, () => void>>(new Map());
const generate = (children: ReactNode) => {
// 生成一个唯一标识
const key = generateUUID();
const wrap = document.createElement("div");
const root = createRoot(wrap);
// 插入DOM
document.body.appendChild(wrap);
// 渲染组件
root.render(createPortal(children, wrap));
// 注册销毁函数
const destroy = () => {
root.unmount();
document.body.removeChild(wrap);
// 解除引用
rootMap.current.delete(key);
};
rootMap.current.set(key, destroy);
// key作为generate的返回值
return key;
};
const destroy = (key: string) => {
const cb = rootMap.current.get(key);
cb && cb();
};
return {
generate,
destroy,
};
}
至此,想要的效果实现了90%,还差通过弹窗内部的关闭按钮去关闭弹窗功能,这里我选择使用
cloneElement
的形式把destory
作为props
包装进去。
const generate = (children: ReactElement) => {
const wrap = generateContainer();
const root = createRoot(wrap);
const key = generateUUID();
// 注册销毁函数
const destroy = () => {
root.unmount();
document.body.removeChild(wrap);
// 解除引用
rootMap.current.delete(key);
};
// 渲染组件
root.render(
createPortal(
// 克隆组件,并且给组件包装一个销毁方法
cloneElement(children, {
destroy,
}),
wrap
)
);
rootMap.current.set(key, destroy);
return key;
};
实现
最后来看一下实现吧,我拆成了2个hook
,一个用于管理这种渲染,一个封装Alert
。
useExtraRender.tsx
import { ReactElement, cloneElement, useRef } from "react";
import { createPortal } from "react-dom";
import { createRoot } from "react-dom/client";
import { generateUUID } from "@src/utils";
interface extraRenderProps {
generateContainer?: () => HTMLDivElement;
}
const generateDiv = () => {
const wrapper = document.createElement("div");
document.body.appendChild(wrapper);
return wrapper;
};
const useExtraRender = (props: extraRenderProps = {}) => {
const { generateContainer = generateDiv } = props;
const rootMap = useRef<Map<string, () => void>>(new Map());
const generate = (children: ReactElement) => {
const wrap = generateContainer();
const root = createRoot(wrap);
const key = generateUUID();
// 注册销毁函数
const destroy = () => {
root.unmount();
document.body.removeChild(wrap);
// 解除引用
rootMap.current.delete(key);
};
// 渲染组件,如果传入的children是组件则为其注册destroy
root.render(
createPortal(
isComponent(children)
? cloneElement(children, {
destroy,
})
: children,
wrap
)
);
rootMap.current.set(key, destroy);
return key;
};
const destroy = (key: string) => {
console.log("destroy!!");
const cb = rootMap.current.get(key);
cb && cb();
};
useEffect(() => {
const allRoot = rootMap.current;
return () => {
// 记得在组件销毁时及时清理当前的额外渲染
for (const cb of allRoot.values()) {
cb && cb();
}
};
}, []);
return {
generate,
destroy,
};
};
export default useExtraRender;
useAlert.tsx
import { ReactNode } from "react";
import useExtraRender from "../useExtraRender";
import styles from "./index.module.less";
interface alertArgs {
width?: number | string;
}
interface alertContentProps {
content: ReactNode;
maskClosable?: boolean;
destroy?: () => void;
onClose?: (from?: string) => void;
}
const useAlert = (props: alertArgs = {}) => {
const { width = "70vw" } = props;
const { generate } = useExtraRender();
const AlertContent = (props: alertContentProps) => {
const { content, onClose, destroy, maskClosable = false } = props;
const handleClose = (from: string) => {
onClose && onClose(from);
destroy && destroy();
};
return (
<div
className={styles.mask}
onClick={() => maskClosable && handleClose("mask")}
>
<div
className={styles.box}
style={{
width,
}}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.wrap}>{content}</div>
<button
className={styles.closeBtn}
onClick={(e) => {
e.stopPropagation();
handleClose("button");
}}
style={{ marginTop: "10px" }}
></button>
</div>
</div>
);
};
const show = (alertProps: alertContentProps) => {
generate(<AlertContent {...alertProps} />);
};
return {
show,
};
};
export default useAlert;
使用hook
import useAlert from "@hooks/useAlert";
const testAlert = (props) => {
const { show: showAlert } = useAlert();
const handleClickBtn = () => {
showAlert({ content: "一个提示" });
};
return <button onClick={() => handleClickBtn()}>弹出提示</button>;
}
export default testAlert;
只是一个简单的尝试,足够覆盖需求的场景,如果一些复杂的操作当然还是建议使用UI框架,因为通常具有更好的性能调优。
优化空间
目前它的容器是一个生成的空div
,我个人不太想要这种空的容器,但暂时也没有想到太好的方式,如果大家有更好的实现方式,欢迎在评论区讨论。
转载自:https://juejin.cn/post/7357374179647930368