怎么用 MutationObserver 解决你的问题?

1,876 阅读13分钟

前言

MutationObserver 提供了 监听对 DOM 树所做更改 的能力,当对 目标根节点的 DOM 树 进行 添加移除改变元素任意属性 时都会触发其回调函数。

基于这个特性,在部分场景下能够让我们实现 无侵入式增强第三方库的能力,但也相信有不少人对它们的认识仅限于 "我看过""了解过",就是没有实打实的把它们应用起来,去解决一些实际的问题,这就导致了在出现某些问题需要快速解决时,就会出现要么没思路、要么解决方案过于繁琐等问题。

image.png

本文会列举两个实际场景(可能没有那么通用),以及如何使用这个 API 来解决问题的,希望对你有所启发。

由于篇幅有限,下文中不再单独介绍 MutationObserver API 的使用方法,若你之前不了解,可 点击此处 进行了解。

解决外部组件无法检测元素变化

场景描述

小 A 同学 在开发某个 H5 项目时,要求需要使用 内部自研的组件库(保证 UI 风格的一致性),但当他使用的 Collapse 折叠面板 组件时就发现了问题:

  • 折叠面板内容存在 动态内容 时,其内容区 高度无法自适应,并且超过内容区高度时还会被隐藏

【场景一】折叠面板内容为 Form 表单

触发表单校验提示时,由于触发了 表单校验文案提示,导致折叠面板 Header 部分向上偏移隐藏。

1.gif

【场景二】折叠面板内容为动态普通元素

折叠面板在 展开状态 下为 Content 内容区:

  • 添加元素 时,超出内容区部分被隐藏
  • 删除元素 内容时,内容区高度未自适应

1.gif

于是 小 A 同学 赶紧向对应开发组反馈该问题,得到的回答是:目前在整体重构升级,后续会改进

这言外之意不就是不能马上改进吗?(小 A 同学暗骂一句:真不知道当初是怎么验收通过的

image.png

一边是不能立刻解决问题,一边是不能影响开发、测试进程,那怎么办?

小 A 同学的尝试

小 A 同学 想着这个组件 高度无法自适应,必然是设定了固定的高度,确实在最终渲染的元素中发现 style="height: n px;" 这样的 行内样式,于是他就想通过权重覆盖的方式设置 height: auto !important;,可惜这是无用的,因为行内样式的值是组件内部通过 js 动态计算 并设置的。

常规解决方案

相信在此处应该有不少人会选择自己 重新实现一个,但是这样不好的是,你需要保证重新实现的折叠面板 UI 风格要统一,包括颜色、间距、字体等等,而且如果后续升级变动后产生变化,那这个也需要重新进行调整,很不合算。

image.png

那么还有什么方法可以 快速解决 这个问题呢?

不想重新实现,那么我们就可以看看它什么情况下是正常的,不难发现:

  • 初始化展示 是正常的
  • 当点击收起面板,在 重新打开面板 时是正常的

【方案一】使用动态的 key

初始化展示是正常的,那我们就可以把变化的部分作为折叠面板 动态的 Key,当内容发生变化时,我们通过改变这个 key 就可以使得 折叠面板组件更新时重新创建组件内容 达到目的,例如这里使用 data.length 作为 key 值:

1.gif

然而这样的方式只适用于 【场景二】折叠面板内容为动态普通元素,如果是针对场景 【场景一】折叠面板内容为 Form 表单 就不适用了。

比如当你点击 Submit 正常会触发表单校验,然后在表单项底部进行文案提示,当你加了动态的 key 就不会出现文案提示内容,原因很简单,当 key 变化后表单元素会 被重新创建,这意味着 上一次的表单状态会已经不存在了

这个方案缺点很明显了:

  • 频繁的删除、创建 目标元素及其所有的后代元素
  • 只针对【非表单校验场景】下有用

【方案二】触发 Collapse 的展开收起逻辑

前面我们说过 Collapse 折叠面板 折叠在展开时也是正常的,那么意味着在其组件内部计算高度的方法是和展开面板的动作是关联在一起的,只要触发它不就能够实现高度自适应了嘛。

正好该组件在 父组件 collapse 上有一个 value: Array 属性,它对应每个 collapse-panel 子面板 上对应的 value 的值,如果它们是符合的就会自动展开子面板。

于是可以写出这样内容:

<script>
const values = ref(["0"]);

const onSubmit = (e: any) => {
  values.value = []; // 收起
  nextTick(() => {
    values.value = ["0"];// 展开
  });
};
</script>

<template>
    <collapse v-model="values">
      <collapse-panel value="0">
       ...
      </collapse> 
    </collapse>  
</template>

效果如下:

1.gif

看着似乎可以了是吧!

42E90125.gif

因为缺点依旧很明显:

  • 页面会产生抖动,感知太明显
  • 触发展开/收起的代码不好复用,即便封装成函数,也得在特定环境调用,很麻烦
  • 频繁、快速 的触发动态元素的显示、隐藏时,不一定会触发组件内部展开/收起相关逻辑

MutationObserver + 自定义指令 — 无感知解决问题

现在问题本质其实就是,折叠面板中元素发生变动(新增/删除)时,:

  • 组件内部检测不到变化
  • 使用者也不知道什么时候该将把 组件内部动态设置的固定高度 height: n px,重写为 height: auto

MutationObserver 监听元素变化

实际上就是要监听折叠面板中元素的 增加/删除,而监测元素的变动就是 MutationObserver 的工作,于是可以很容易的写出:

let mutationObserver = null;
onMounted(() => {
  const target = document.querySelector(".collapse-panel__content");
  if (!target) return;

  mutationObserver = new MutationObserver(function (mutationsList) {
    console.log("【 MutationObserver 】some child nodes has been added or removed.");
    document.querySelector(".collapse-panel").style.height = "auto";
  });

  // 开启监听
  mutationObserver.observe(target, {
    subtree: true, // 监听以 target 为根节点的整个子树
    childList: true, // 监听 target 节点中发生的节点的新增与删除
  });
});

onBeforeUnmount(() => {
  mutationObserver && mutationObserver.disconnect();
});
</script>

演示效果如下:

1.gif

封装自定义指令

目前为止,已经可以实现 无感知自适应高度 了,就是 复用性太差,简单观察一下,基本上都是 和 dom 元素间的操作,于是我们可以封装一个 自定义指令 v-collapse:

export default {
  name: 'collapse',
  // 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode, prevVnode) {
    const target = el.querySelector(".collapse-panel__content");
    if (!target) return;
  
    const mutationObserver = new MutationObserver(function (mutationsList) {
      console.log("【 MutationObserver 】some child nodes has been added or removed.");
      el.querySelector(".collapse-panel").style.height = "auto";
    });
  
    // 开启监听
    mutationObserver.observe(target, {
      subtree: true, // 应用于整颗 子树
      childList: true, // 子节点的增、删变换
    });

    // 保留实例对象,便于后续销毁
    el.__mutation_observer__ = mutationObserver;
  },
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode, prevVnode) {
    const mutationObserver = el.__mutation_observer__;
    mutationObserver && mutationObserver.disconnect();
  },
};

为第三方库提供防水印篡改功能

无论项目中的水印是 自己实现的水印,还是 第三方库提供的水印,在实际使用的时候都加水印的防篡改。

如果是自己实现的水印我们自然可以按需要去添加、修改对应的防篡改逻辑,但如果是第三方库提供的水印,未必就提供这个功能,我们需要但又不能直接修改第三方库的代码,那么怎么办?

image.png

此时 MutationObserver 就能提供给我们 无侵入式增强第三方库的能力,下面用 Vant4 Watermark 水印组件来举例。

水印消失术

Vant4 提供了三种水印类型(文本、图片、Html),最终渲染的结构都如下: image.png

针对于这个结构,我们想要这个水印消失最常见的方式无非两种:

  • 将核心元素 删除
  • 将核心元素 隐藏
    • visibility: hidden
    • opacity: 0
    • display: none
    • transform: scale(0)
    • transform: translate(1000px, 1000px) 移动到可视区外
    • position: absolute 移动到可视区外
    • background-image 属性删除或置空

MutationObserver 防篡改

解决死循环

现在我们就需要针对与上面的方案,来进行防篡改了,看着方式挺多,但实际归类起来就两个:

  • 直接删除元素
    • 触发时,新增将删除元素新增
  • 修改元素属性
    • 触发时,将原始属性覆盖新的属性

按照这样的思路到底可行不可行呢?我们直接来看看例子。

const observe = (selector:string) => {
  // nextTick 是为了避免组件首次挂载时会触发监听回调
  nextTick(() => {
    const el = document.querySelector(selector);
    const parent = el?.parentElement!;

    const mutationObserver = new MutationObserver(function (mutationList) {
      for (const mutation of mutationList) {
        // 元素 删除/新增
        if (mutation.type === "childList") {
          console.log(
            "A child node has been added or removed.",
            mutation.removedNodes
          );
        } else if (mutation.type === "attributes") {
          // 元素 style 属性值被修改 
          console.log(
            `The 【${mutation.attributeName}】 attribute was modified.`,
            mutation,
            mutation.oldValue
          );
          
          // 使用旧的 style 覆盖新的 style
          mutation.target.style = mutation.oldValue;
        }
      }
    });

    // 开启监听
    mutationObserver.observe(parent, {
      subtree: true, // 应用于整颗 子树
      childList: true, // 子节点的增、删变换
      attributes: true, // 检测属性变化
      attributeFilter: ["style"], // style 相关属性变化
      attributeOldValue: true, // 记录变化之前的属性值
    });
  });
};

onMounted(() => {
  observe(".van-watermark");
});

1.gif

显然,当我们尝试把对应的 background-image 属性给关闭时,会发现与 style 相关的日志不止输出了一次,其实这里发生了 死循环

  • 因为外部编辑 style 触发监听回调执行时,而我们又在回调中通过 mutation.target.style = mutation.oldValue 使用 旧的 style 覆盖 新的 style,于是产生了循环

同样的,如果如果是 删除元素 的时候,我们再去将被删除的元素重新添加回去,也会产生 死循环

image.png

那这么看来分两类情况还是太多,处理起来也更繁杂,直接把情况 归一化:

  • 无论是 删除元素 还是 编辑元素 style 属性,我们直接让对应的 水印组件重新渲染 就好了,而让组件触发重新渲染,只需要给它定义一个 动态的 key 即可

统一了最终处理方式之后,还是要解决 死循环 的问题,其实也好解决,我们只需要在回调中处理和目标元素相关操作时先调用 mutationObserver.disconnect()监听断开,处理完成后重新 恢复监听 即可。

封装 hooks

核心问题解决了,实际逻辑就好写了,但是为了 复用性 这里将逻辑抽离出去,封装成一个 hooks

// useMutatWatermark.ts

import { nextTick, reactive, onMounted, onBeforeUnmount } from "vue";

export default function useMutatWatermark(seletors: string[]) {
  const keys: number[] = reactive([]);
  const mutationObservers: MutationObserver[] = [];

  // 挂载时,开启监听
  onMounted(() => {
    // 遍历监听传入的多个元素
    seletors.forEach((seletor, index) => {
      // 初始化 key
      keys.push(performance.now());

      // 监听元素
      observer(seletor, index);
    });
  });

  // 开启监听
  const observer = (seletor: string, index: number) => {
    const target: HTMLElement | null = document.querySelector(seletor);
    if (!target) return;

    // 获取目标元素父元素作为监听目标,目的是到希望外部传入的元素被删除能够被监听到
    const parent = target?.parentNode!;

    // 实例化
    const mutationObserver = new MutationObserver(function (mutationList) {
      // 先关闭监听,避免死循环
      mutationObserver.disconnect();

      // 修改 key 值,目的是让外部组件重新渲染
      keys[index] = performance.now();

      // 修改完成后,重新开启监听
      nextTick(() => observer(seletor, index));
    });

    // 开启监听,这里不能使用 nextTick,可能会导致死循环
    setTimeout(() => {
      mutationObserver.observe(parent, {
        subtree: true, // 应用于整颗 子树
        childList: true, // 子节点的增、删变换
        attributes: true, // 检测属性变化
        attributeFilter: ["style"], // style 相关属性变化
        attributeOldValue: true, // 记录变化之前的属性值
      });
    });

    // 缓存实例对象,便于后续关闭监听
    mutationObservers.push(mutationObserver);
  };

  // 卸载时,关闭监听
  onBeforeUnmount(() => {
    mutationObservers.forEach((item) => item.disconnect());
  });

  return keys;
}

具体实现效果,如下:

<script setup lang="ts">
import useMutatWatermark from "@/hooks/useMutatWatermark";
const mutatKeys = useMutatWatermark(['.van-watermark']);
</script>

<template>
  <van-watermark :width="150" opacity="1" :key="mutatKeys[0]">
    <template #content>
      <div
        style="background: linear-gradient(45deg, #000 0, #000 50%, #fff 50%)"
      >
        <p style="mix-blend-mode: difference; color: #fff">Vant watermark</p>
      </div>
    </template>
  </van-watermark>
</template>

2.gif

解决目标元素被删除引发的异常

上面看着是不是又没问题了!

那是因为我们还没尝试删除传入的目标元素,也就是删除 useMutatWatermark([...]) 返回 mutatKeys 使用的元素,如上面的 van-watermark 组件

那我们尝试删除一下看看!

3.gif

image.png

好家伙,直接报错了!

70229D8E.png

看这报错还是很容易理解的,梳理下大致流程:

  • 首先 删除目标元素 时,触发了 MutationObserver 的回调,回调中修改 目标元素的 key,由于其是响应式的,所以此时会触发 vue 的更新流程
  • 更新过程中由于 key 不同,于是会将 VNode 卸载,将 新的 VNode 挂载,值得注意的是 VNode 会持有与其对应的 真实 DOM 的引用
  • 从上面图可以看出 新节点挂载 时是通过 parent.insertBefore(child, anchor || null) 实现的,而异常信息提示这里的 parent = null

实际上这个问题,不是 Vue 的,因为在 JavaScript 中的表现就是如此,我们来看如下的例子:

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="parent">
      这是父节点。
      <p id="child">这是子节点。</p>
    </div>

    <script>
      // 获取子元素
      var child = document.getElementById("child");

      // 在 DOM 中删除子元素,但此时子元素的引用还在 child 变量中
      parent.parentNode.removeChild(child);

      // 通过 child 变量访问父元素会得到 null
      console.log(child.parentNode); // 输出: null
    </script>
  </body>
</html>

那这是为啥呢?

因为在 JavaScript 中,如果你已经从文档中 删除目标元素,那么它与 页面 DOM 的连接就被切断了,包括它的 父元素,此时去访问它的父元素就只能获得一个 空引用 null

7048B3DC.jpg

实际上也好解决,我们判断一下如果 当前删除的是目标元素,就不去改变对应的 key 值了,直接刷新页面(也算是让水印组件重新渲染),此时只需要在 observer 方法中加入判断即可,如下:

import { nextTick, reactive, onMounted, onBeforeUnmount } from "vue";

export default function useMutatWatermark(seletors: string[]) {
  const keys: number[] = reactive([]);
  const mutationObservers: MutationObserver[] = [];

  // 挂载时,开启监听
  onMounted(() => {
    // 遍历监听传入的多个元素
    seletors.forEach((seletor, index) => {
      // 初始化 key
      keys.push(performance.now());

      // 监听元素
      observer(seletor, index);
    });
  });

  // 开启监听
  const observer = (seletor: string, index: number) => {
    const target: HTMLElement | null = document.querySelector(seletor);
    if (!target) return;

    // 获取目标元素父元素作为监听目标,目的是到希望外部传入的元素被删除能够被监听到
    const parent = target?.parentNode!;

    // 实例化
    const mutationObserver = new MutationObserver(function (mutationList) {
      // 先关闭监听,避免死循环
      mutationObserver.disconnect();

      for (const mutation of mutationList) {
        // 如果命中此处,意味着外部传入的目标元素被删除,此时直接刷新页面
        if (
          mutation.type == "childList" && 
          mutation.removedNodes[0]
        ) {
          const nodeEl = mutation.removedNodes[0] as HTMLElement;
          const seletorStr = seletor.slice(1);
          const isTargetEl = nodeEl.classList.contains(seletorStr) || nodeEl.id === seletorStr;

          if(isTargetEl){
            window.location.reload();
            return;// 终止后续逻辑
          }
        }
      }

      // 修改 key 值,目的是让外部组件重新渲染
      keys[index] = performance.now();

      // 修改完成后,重新开启监听
      nextTick(() => observer(seletor, index));
    });

    // 开启监听,这里不能使用 nextTick,可能会导致死循环
    setTimeout(() => {
      mutationObserver.observe(parent, {
        subtree: true, // 应用于整颗 子树
        childList: true, // 子节点的增、删变换
        attributes: true, // 检测属性变化
        attributeFilter: ["style"], // style 相关属性变化
      });
    });

    // 缓存实例对象,便于后续关闭监听
    mutationObservers.push(mutationObserver);
  };

  // 卸载时,关闭监听
  onBeforeUnmount(() => {
    mutationObservers.forEach((item) => item.disconnect());
  });

  return keys;
}

效果如下:

3.gif

最后

从上面的例子来看,MutationObserver 具有足够强大的功能,但需要在使用要注意回调中的条件判断,否则容易导致 死循环

当你需要在某个 DOM 变化进行某些操作时,不能直接就将其当做 "万能药",更应该考虑常规处理方案是不是可以很好地解决问题,如果不行再去考虑要不要使用,选择不同场景的最优解才是正道。

7055CF06.gif