likes
comments
collection
share

升级到react18后,react-dom的render()被废弃的替代方案

作者站长头像
站长
· 阅读数 36

背景

有个需要写h5活动页的临时需求,开始时需求范围比较小,直接拿原生HTML+JavaScript去写的,后面需求蔓延,里面涉及了一些不同场景下的弹窗,弹窗中还涉及到收集用户信息,于是产生不如还是用框架来写,然后再构建一个静态包的形式,这样无论是改样式还是需求变更都更好维护一些。这里选择的是React + typescript + less

过程

由于有第一版方案是原生的html,也没有使用UI框架,所以弹窗也是拿原生js来写的,在移植到React的过程中,想要把弹窗这部分逻辑抽成公共的hook

众所周知,React18废弃了react-domrender方法,对于页面初始化来说,变化不大,调用createRoot就好,但是对于这种动态插入DOM就要做些改动了。之前一直用UI框架直接梭哈,也没太留意升级后的实现,这也算是一种尝试。

但先明确我的需求是,类似实现一个alert功能,可以直接通过函数调用的形式来插入一段DOM,像这样

showAlert({ content: "这是一段alert" })

当然,它需要一个关闭按钮,点击按钮就可以关闭这个弹窗。

升级到react18后,react-dom的render()被废弃的替代方案

createPortal

查阅官网,首先就是createPortal方法,

升级到react18后,react-dom的render()被废弃的替代方案

正如介绍所说,它可以把JSX即时渲染到DOM中,来看一下它的使用,

升级到react18后,react-dom的render()被废弃的替代方案 但让人失望的是,它需要把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的方式来做一个MapMap的值中保存的是对应renderdestory方法,它内部的逻辑也很简单,执行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
评论
请登录