【23-持久遮罩层】基于MutationObserverReact 实现持久遮罩层的实践与原理解析-bysking

161 阅读3分钟

前言

在前端开发中,遮罩层(Mask)是一个常见的 UI 组件。但在某些场景下,我们需要确保遮罩层始终显示在页面上,即使发生了 DOM 操作也不会被意外移除。今天就和大家分享一个基于 React 实现的持久遮罩层方案。

核心功能

  • 创建全屏遮罩层
  • 防止遮罩层被意外移除或修改

技术实现解析

1. 基础架构设计

image.png

这里采用函数式 API 设计,通过 options 参数支持自定义配置,返回销毁方法供外部调用。

  1. 遮罩层创建与样式处理
  2. 使用原生 DOM API 创建遮罩容器,并通过 Object.assign 合并默认样式和自定义样式。这种方式比 React 的 Portal 方案更灵活,能更好地控制 DOM 结构。
  3. 使用 React 18 的 createRoot API 创建根节点,将遮罩组件渲染到容器中。这里巧妙地使用 isInitReact 标志位来防止初始化过程中的重复渲染。
  4. DOM 监听与自动恢复

这是整个方案的核心亮点:使用 MutationObserver 监听 DOM 变化,自动恢复被移除或修改的遮罩层。

性能优化与最佳实践

  1. 防抖处理

通过延时重置 isInitReact 标志位,避免初始化阶段的重复渲染。

  1. 资源释放
return {
  destroy: () => {
    observer.disconnect();
    observer = null;
    originRoot?.unmount();
    originRoot = null;
  },
};

提供 destroy 方法,确保资源正确释放,防止内存泄漏。

使用注意事项

  1. 需要注意遮罩层的 z-index 值,确保显示在最上层
  2. 自定义样式时要谨慎覆盖position等关键属性
  3. 在组件卸载时调用 destroy 方法清理资源

实战应用示例

javascript
Copy
// 创建遮罩
const mask = openMask({
  idKey: 'custom-mask',
  style: {
    backgroundColor: 'rgba(0,0,0,0.7)',
  }
});

// 销毁遮罩
mask.destroy();

总结

这个实现方案巧妙地结合了 React 组件系统和原生 DOM API,通过 MutationObserver 实现了遮罩层的持久化显示。虽然实现相对复杂,但在特定场景下(如视频播放、支付场景等)非常实用。

当然,这个方案还可以进一步优化,比如:

  1. 添加动画效果支持
  2. 提供更多自定义配置选项
  3. 优化 MutationObserver 的性能开销

希望这篇文章对大家有所帮助!欢迎在评论区讨论和分享你的想法。

源码

  • index.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import MaskCmp from './MaskCmp';

export const openMask = (
  options: { idKey?: string; style?: React.CSSProperties } = {},
) => {
  /** 基础数据初始化 */
  const idKey = options.idKey || 'mask-for-video';
  let originRoot;
  let observer;
  let isInitReact = false;
  const baseStyle = Object.assign(
    {
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      width: '100%',
      height: '100%',
      zIndex: 9999,
      backgroundColor: 'rgba(0,0,0,0.5)',
    },
    options.style || {},
  );

  /** 处理自定义react节点渲染 */
  const renderDom = (wrapper) => {
    isInitReact = true;
    originRoot = createRoot(wrapper);
    originRoot.render(<MaskCmp open={true} />);
    setTimeout(() => {
      isInitReact = false;
    }, 1000);
  };

  /** 创建遮罩层 */
  const createMask = () => {
    // 如果已经有,则移除旧节点
    let tempNode = document.getElementById(idKey);
    if (tempNode) {
      tempNode.remove();
    }

    // 创建新节点
    const div = document.createElement('div');

    // 设置固定定位
    div.id = idKey;
    Object.assign(div.style, baseStyle);

    // 阻止事件
    div.onclick = (event) => {
      event.preventDefault();
      event.stopPropagation();
    };

    document.body.appendChild(div);

    //处理react组件渲染挂载
    renderDom(div);
  };

  /** 启动观察者监听修改 */
  const startObserver = () => {
    /**实例化观察标签实例 */
    observer = new MutationObserver((mutations) => {
      if (isInitReact) {
        return;
      }

      let tempNode = document.getElementById(idKey);
      if (!tempNode) {
        const target = mutations[0].target;

        // 如果在body中删除节点,则重新创建,需要排除document.body
        if (target !== document.body) {
          target?.remove?.();
        }

        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        initMask();
        return;
      }

      mutations.forEach((mutation) => {
        // const target = mutation.target;
        const isOptCur = mutation.target.id === idKey;

        if (isOptCur && mutation.type === 'childList') {
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          initMask(); // 当前节点移除
        } else if (isOptCur && mutation.type === 'attributes') {
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          initMask(); // 处理属性修改
        }
      });
    });

    observer.observe(document.body, {
      attributes: true /**表示观察节点和子节点属性发生变化,触发回调 */,
      childList: true /**观察目标节点和子节点标签增删改的时候触发 */,
      subtree: true /**观察目标节点的整个子树 */,
      // characterData: true, // 观察文本内容变化
    });
  };

  /** 主逻辑 */
  const initMask = () => {
    createMask();
    startObserver();
  };

  initMask();

  /** 返回销毁函数 */
  return {
    destroy: () => {
      observer.disconnect();
      observer = null;
      originRoot?.unmount();
      originRoot = null;
    },
  };
};

  • MaskCmp.tsx
const MaskCmp = () => {
  return (
    <div
      style={{
        border: '1px solid gray',
        width: '100%',
        height: '100%',
      }}
    >
      <video
        style={{ width: '100%' }}
        autoPlay={true}
        tabIndex={-1}
        src="https://media.w3.org/2010/05/sintel/trailer.mp4"
      ></video>
    </div>
  );
};

export default MaskCmp;

参考资料