仿写一个简易auto-animate

仿写一个auto-animate

视频教程: www.bilibili.com/list/476962...

代码地址:github.com/chenyuan-ne...

auto-animate可以自动给所配置元素的直接子元素的新增、删除、移动加上动画,使用非常简便,下面是如何仿写一个简单的此库

思路

auto-animate的实现思路是利用 MutationObserver监听元素的变动,获取到位置变动(新增、删除、继续存在)的元素element,然后根据之前保存的每个元素的旧的位置信息,结合当前此元素的位置信息,计算出transform,利用element.animate重放动画,从而产生动画效果;在处理删除操作时需要把被删除的元素重新插入回子元素list中,重放它的消失动画

前序知识

MutationObserver: 监听DOM的变动, 可以监听元素的变动, 包括新增、删除已经元素的属性变动,使用方式可以参考下mutation-observer

xml 复制代码
<div contentEditable id="elem">Click and <b>edit</b>, please</div>

<script>
let observer = new MutationObserver(mutationRecords => {
  console.log(mutationRecords); // console.log(the changes)
});

observer.observe(elem, {
  childList: true, // 监听直接子元素
  subtree: true, // 监听所有的子元素
});
</script>

ResizeObserver: 监听元素的尺寸变动

element.animate: 创建一个动画

实现

环境搭建

使用rsbuild创建一个react-ts项目,然后安装auto-animate, 用于和自行实现的版本进行效果对比 首先是使用方式相同,入参如下:

ts 复制代码
export const autoAnimate = (
  parent: HTMLElement,
  options: {
    duration: number;  // 动画持续时间
    easing:  'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | string; // 动画缓动函数
  },
) => {
//  to be implemented
}

参考auto-animate的doc部分的代码,搭建一个测试页面

实现

主体结构

主体逻辑分三步:

  1. 获取到parent的position,如果为static,则设置为relative, 因为动画实现需要用到绝对定位,所以要个parent设置relative
  2. 处理parent和其直接子元素,记录当前位置信息,并监听resize
  3. mutation监听parent的子元素的变动
tsx 复制代码
export const autoAnimate = (
  parent,
  options,
) => {
  if(getComputedStyle(parent).position === "static") {
    parent.style.position = "relative";
  }
    // 处理parent元素和parent的直接子元素
   addCallbacks(parent, [
    // 初始化操作,保存当前位置信息
    (element) => {
      coordinateMap.set(element, getCoordinate(element));
    },
  ]);
  // 监听parent的子元素的变动, 使用MutationObserver
  mutation.observe(parent, {
    childList: true,
  });
}

const addCallbacks = (
  element: Element,
  callbacks: Array<(element: Element) => void>,
) => {
  callbacks.forEach((callback) => {
    callback(element);
  });
  for (let i = 0; i < element.children.length; i++) {
    const child = element.children[i];
    if (child) {
      callbacks.forEach((callback) => {
        callback(child);
      });
    }
  }
};

const getCoordinate = (element: Element) => {
  const coordinates = element.getBoundingClientRect();
  let p = element.parentElement;
  let x = 0;
  let y = 0;
  // 因为元素的坐标是相对于父元素的, 所以需要获取父元素的scrollLeft和scrollTop; getBoundingClientRect获取的是元素相对于视口的坐标
  while (p) {
    if (p.scrollLeft || p.scrollTop) {
      x = p.scrollLeft;
      y = p.scrollTop;
      break;
    }
    p = p.parentElement;
  }
  return {
    top: coordinates.top + y,
    left: coordinates.left + x,
    width: coordinates.width,
    height: coordinates.height,
  };
};

  const resize = new ResizeObserver((entries) => {
    entries.forEach((entry) => {
      updatePos(entry.target);
    });
  });
mutation具体实现

mutation实现逻辑是需要先获取到变动的元素,然后判断是新增、删除、还是移动,然后根据变动类型, 分别处理新增、删除、移动三种情况

tsx 复制代码
  const animate = (element: Element) => {
    const isMounted = element.isConnected;
    const preExist = coordinateMap.get(element);

    if (preExist && isMounted) {
      remain(element);
    } else if (!isMounted && preExist) {
      remove(element);
    } else {
      add(element);
    }
  };

  const getElements = (mutations: MutationRecord[]) => {
    return mutations.reduce((acc: Set<Element> | false, mutation) => {
      // 说明此次mutation是因为为了重放删除元素动画,重新插入了被删除的元素导致的,所以不需要处理
      if (!acc) return false;

      if (mutation.target instanceof Element) {
        if (!acc.has(mutation.target)) {
          acc.add(mutation.target);
          for (let i = 0; i < mutation.target.children.length; i++) {
            const child = mutation.target.children[i];
            if (!child) continue;
            // 说明这次mutation是由于将删除的元素插入回去导致的,所以不需要处理
            if (DELETE in child) {
              return false;
            }
            if (child) {
              acc.add(child);
            }
          }
        }
        if (mutation.removedNodes.length) {
          for (let i = 0; i < mutation.removedNodes.length; i++) {
            const node = mutation.removedNodes[i];
            if (DELETE in node) return false;
            if (node instanceof Element) {
              acc.add(node);
              // 用于展示删除时的动画效果,根据sibling方便把已被删除的元素插入回原来的位置
              siblingsMap.set(node, [
                mutation.previousSibling,
                mutation.nextSibling,
              ]);
            }
          }
        }
      }
      return acc;
    }, new Set<Element>());
  };

  const mutation = new MutationObserver((mutations) => {
    const elements = getElements(mutations);
    if (!elements) return;
    for (const element of elements) {
      animate(element);
    }
  });
add

新增的逻辑最简单, 只需要获取到新增元素的坐标,然后设置动画即可

tsx 复制代码
  const add = (element: Element) => {
    const newCoordinate = getCoordinate(element);
    coordinateMap.set(element, newCoordinate);
    let animation: Animation;
    animation = element.animate(
      [
        { transform: "scale(.98)", opacity: 0 },
        { transform: "scale(0.98)", opacity: 0, offset: 0.5 },
        { transform: "scale(1)", opacity: 1 },
      ],
      {
        duration: options.duration * 1.5,
        easing: "ease-in",
      }
    );
    animateMap.set(element, animation);
    animation.play();
  };
remove

删除需要从siblingsMap获取到被删除元素的兄弟元素[pre,next],然后根据pre和next是否存在,判断被删除元素是插入到pre后面还是next前面并将被删除的元素重新插入,然后设置动画,并且监听动画执行,在动画结束后进行cleanup,将重新插入的元素从siblingsMap、coodinateMap中删除

tsx 复制代码
  const remove = (element: Element) => {
    if (!siblingsMap.has(element) || !coordinateMap.has(element)) return;
    element[DELETE] = true;
    const [pre, next] = siblingsMap.get(element)!;
    if (next?.parentNode && next.parentNode instanceof Element) {
      next.parentNode.insertBefore(element, next);
    } else if (pre?.parentNode && pre.parentNode instanceof Element) {
      pre.parentNode.appendChild(element);
    } else {
      parent.appendChild(element);
    }
    const oldCoordinate = coordinateMap.get(element)!;
    const { width, height } = oldCoordinate;
    let offsetParent: Element | null = element.parentElement;

    const parentStyles = getComputedStyle(offsetParent);
    const parentCoords =
      coordinateMap.get(offsetParent) || getCoordinate(offsetParent);

    const top =
      Math.round(oldCoordinate.top - parentCoords.top) -
      raw(parentStyles.borderTopWidth);
    const left =
      Math.round(oldCoordinate.left - parentCoords.left) -
      raw(parentStyles.borderLeftWidth);

    const styleReset: Partial<CSSStyleDeclaration> = {
      position: "absolute",
      top: `${top}px`,
      left: `${left}px`,
      width: `${width}px`,
      height: `${height}px`,
      margin: "0",
      pointerEvents: "none",
      transformOrigin: "center",
      zIndex: "100",
    };

    Object.assign((element as HTMLElement).style, styleReset);
    const animation = element.animate(
      [
        {
          transform: "scale(1)",
          opacity: 1,
        },
        {
          transform: "scale(.98)",
          opacity: 0,
        },
      ],
      { duration: options.duration, easing: "ease-out" }
    );
    animateMap.set(element, animation);
    animation.play();
    // 动画结束后删除元素
    animation.addEventListener("finish", () => {
      element.remove();
      coordinateMap.delete(element);
      siblingsMap.delete(element);
    });
  };
remain

remain的逻辑是获取到元素的旧坐标和新的坐标,然后计算出坐标的移动和宽高的变化并设置动画,动画结束后,将元素的坐标信息从coodinateMap中删除

tsx 复制代码
  const remain = (element: Element) => {
    const oldCoordinate = coordinateMap.get(element);
    const newCoordinate = getCoordinate(element);
    let animation: Animation;

    if (!oldCoordinate) return;
    const deltaX = oldCoordinate.left - newCoordinate.left;
    const deltaY = oldCoordinate.top - newCoordinate.top;

    const start = {
      transform: `translate(${deltaX}px, ${deltaY}px)`,
      height: `${oldCoordinate.height}px`,
      width: `${oldCoordinate.width}px`,
    };
    const end = {
      transform: "translate(0, 0)",
      height: `${newCoordinate.height}px`,
      width: `${newCoordinate.width}px`,
    };

    animation = element.animate([start, end], options);
    coordinateMap.set(element, newCoordinate);
    animation.play();
  };

实现效果

左边是自行实现的版本,右边是auto-animate的版本,可以看到大体的动画功能出来了,但是在动效生成的时候元素有宽高的跳变,需要处理

处理宽高跳变

宽高跳变是因为在remain的时候,设置了元素的宽高同时还设置了easing函数,导致元素的宽高也在动画的计算范围内,所有有跳变,在宽高相同的时候不设置宽高可处理此问题

tsx 复制代码
const remain = (element: Element) => {
    // ...
    const start = {
      transform: `translate(${deltaX}px, ${deltaY}px)`,
      height: `${oldCoordinate.height}px`,
      width: `${oldCoordinate.width}px`,
    };
    const end = {
      transform: "translate(0, 0)",
      height: `${newCoordinate.height}px`,
      width: `${newCoordinate.width}px`,
    };

    if (start.height === end.height) {
      delete start.height;
      delete end.height;
    }
    if (start.width === end.width) {
      delete start.width;
      delete end.width;
    }

    animation = element.animate([start, end], options);
    // ...
}

处理后效果如下,添加时的跳变没有了,但是在添加时外部的边框会有跳变,删除元素时也有跳变,需要进一步处理:

处理外部边框跳变和删除时元素跳变

出现这种跳变的原因是元素的box-sizing为content-box, 用getBoundingClientRect获取的元素的宽高是包含padding和border的,需要处理这种情况

tsx 复制代码
const getTransitionSize = (
  element: Element,
  oldCoordinate: Coodinate,
  newCoordinate: Coodinate
) => {
  let oldWidth = oldCoordinate.width;
  let oldHeight = oldCoordinate.height;
  let newWidth = newCoordinate.width;
  let newHeight = newCoordinate.height;

  const styles = getComputedStyle(element);
  const isContentBox = styles.getPropertyValue("box-sizing") === "content-box";
  if (isContentBox) {
    const deltaY =
      raw(styles.paddingTop) +
      raw(styles.paddingBottom) +
      raw(styles.borderTopWidth) +
      raw(styles.borderBottomWidth);
    const deltaX =
      raw(styles.paddingLeft) +
      raw(styles.paddingRight) +
      raw(styles.borderRightWidth) +
      raw(styles.borderLeftWidth);
    oldWidth = oldWidth - deltaX;
    oldHeight = oldHeight - deltaY;
    newWidth = newWidth - deltaX;
    newHeight = newHeight - deltaY;
  }
  return {
    oldWidth,
    oldHeight,
    newWidth,
    newHeight,
  };
};


const remain = (element: Element) => {
    // ...
    const { oldWidth, oldHeight, newWidth, newHeight } = getTransitionSize(element, oldCoordinate, getCoordinate(element));

    const { oldWidth, oldHeight, newWidth, newHeight } =   getTransitionSize(element, oldCoordinate, newCoordinate);
    const deltaX = oldCoordinate.left - newCoordinate.left;
    const deltaY = oldCoordinate.top - newCoordinate.top;

    const start = {
      transform: `translate(${deltaX}px, ${deltaY}px)`,
      height: `${oldHeight}px`,
      width: `${oldWidth}px`,
    };
    const end = {
      transform: "translate(0, 0)",
      height: `${newHeight}px`,
      width: `${newWidth}px`,
    };
  // ...

const remove = (element: Element) => {
  // ...
  const oldCoordinate = coordinateMap.get(element)!;
  const { oldWidth:width, oldHeight:height } = getTransitionSize(element, oldCoordinate, getCoordinate(element));
    // ...
}

最终效果如下,可以发现基本和auto-animate的效果一致:

总结

auto-animate的实现思路是利用 MutationObserver监听元素的变动,获取到位置变动(新增、删除、移动)的元素element,然后根据之前保存的每个元素的旧的位置信息,结合当前此元素的位置信息,计算出transform,利用element.animate重放动画,从而产生动画效果;在处理删除操作时需要把被删除的元素重新插入回子元素list中,重放它的消失动画

此版实现的是最简单的版本,有很多细节没有处理,如父元素有scroll的场景等,有兴趣的可以继续完善

相关推荐
珹洺34 分钟前
JSP技术入门指南【一】利用IDEA从零开始搭建你的第一个JSP系统
java·开发语言·前端·html·intellij-idea·jsp
2401_878454534 小时前
Themeleaf复用功能
前端·学习
葡萄城技术团队5 小时前
基于前端技术的QR码API开发实战:从原理到部署
前端
八了个戒7 小时前
「数据可视化 D3系列」入门第三章:深入理解 Update-Enter-Exit 模式
开发语言·前端·javascript·数据可视化
noravinsc7 小时前
html页面打开后中文乱码
前端·html
小满zs8 小时前
React-router v7 第四章(路由传参)
前端·react.js
小陈同学呦8 小时前
聊聊双列瀑布流
前端·javascript·面试
键指江湖8 小时前
React 在组件间共享状态
前端·javascript·react.js
诸葛亮的芭蕉扇9 小时前
D3路网图技术文档
前端·javascript·vue.js·microsoft