vc-align源码分析 -- ant-design-vue系列

vc-align源码分析

源码地址:https://github.com/vueComponent/ant-design-vue/tree/main/components/vc-align

1 基础代码

1.1 名词约定

需要对齐的节点叫source,对齐的目标叫target

1.2 props

提供了两个参数:

  1. align:对齐的配置
  2. target:一个函数,用于获取对齐的目标dom

1.3 主要逻辑

  1. 增加了一个dom,用来挂载source节点,同时拿到它的引用。
  2. 提供了一个方法align,在组件初始化/定位方式改变/对齐目标改变的时候,重新执行对齐方法。

代码如下:

javascript 复制代码
import { defineComponent, ref, onMounted, watch, PropType } from 'vue';
import { alignElement } from 'dom-align';
import { AlignType, TargetType } from './interface';

export default defineComponent({
	name: 'Align',
	props: {
		align: {
			type: Object as PropType<AlignType>,
			required: true
		},
		target: {
			type: [Object, Function] as PropType<TargetType>,
			required: true
		}
	},
	setup(props, { slots }) {
		const nodeRef = ref<HTMLElement | null>(null);

    /**
    * 用来对齐的方法
    */
		const align = () => {
			if (!nodeRef.value) return;

			const { align: latestAlign, target: latestTarget } = props;

			let result: any;
			let targetElement: HTMLElement | null = null;

			if (typeof latestTarget === 'function') {
				targetElement = latestTarget();
			}

			if (targetElement && targetElement.nodeType === Node.ELEMENT_NODE) {
        /**
        * 调用对齐的库方法
        */
				result = alignElement(nodeRef.value, targetElement, latestAlign);
			}
		};

		onMounted(() => {
			align();
		});

    /**
    * 监控对齐方式和target的改变,重新执行对齐
    */
		watch(
			() => [props.align, props.target],
			() => {
				align();
			},
			{ immediate: true, deep: true, flush: 'post' }
		);

		return () => {
			const child = slots.default?.();
			if (child) {
				return <div ref={nodeRef}>{child}</div>;
			}
			return null;
		};
	}
});

1.4 补充:dom-align 库

官方地址:https://yiminghe.me/dom-align/

1.4.1 基础用法
javascript 复制代码
import domAlign from 'dom-align';

// use domAlign
// sourceNode's initial style should be position:absolute;left:-9999px;top:-9999px;

const alignConfig = {
  points: ['tl', 'tr'],        
  offset: [10, 20],           
  targetOffset: ['30%','40%'], 
  overflow: { adjustX: true, adjustY: true },
};

domAlign(sourceNode, targetNode, alignConfig);
1.4.2 alignConfig对象的详细配置
Name Type Description
points String[2] source元素和targer元素的对齐方式,比如 ['tr', 'cc'],意思是source元素的右上角和target元素的中心对齐。点的取值可以是t, b, c, l, r。
offset Number[2] source元素的偏移量,offset[0] 是x轴,offset[1]是y轴。如果数组中包含了百分比,这个也是相对应source区域来说的。
targetOffset Number[2] 和上面一致,只不过都是针对target元素来说的。
overflow Object: { adjustX: boolean, adjustY: boolean, alwaysByViewport:boolean } 如果adjustX是true,那么如果source元素在x轴方向不可见,会自动调整位置。比如指定source元素在target右边,但是右边区域不足以放得下source,则会自动修改到做左边展示。adjustY同理。如果alwaysByViewport是true,那么当source不在视口中时,会自动调整。
useCssRight Boolean 是否使用css的right属性代替left属性去定位。
useCssBottom Boolean 是否使用css的bottom属性代替top属性去定位。
useCssTransform Boolean 是否使用css的transform属性代替 left/top/right/bottom来定位。

2 源码解析

2.1 可以优化的点

  1. 我们给source增加了一个div,用来获取引用,这个dom节点是不必要,可以去掉。
  2. 只监控了 对齐方式/target引用 的变化,没有监控sourcetarget大小的变化,需要在这些属性变化时,重新对齐。
  3. 需要监控窗口大小的变化,重新对齐。

2.2 实现

2.2.1 监控window变化

这个有resize事件,直接组册即可。

组件需要接受一个props,表示是否需要监控window变化。

javascript 复制代码
export const alignProps = {
  monitorWindowResize: Boolean,
};

代码如下,flush: post是为了保证页面已经渲染结束,可以拿到dom引用。

javascript 复制代码
/**
* 用来记录监控事件的id
*/
const winResizeRef = ref<{ remove: Function }>(null);

watch(
  () => props.monitorWindowResize,
  (monitorWindowResize) => {
    if (monitorWindowResize) {
     /**
	 * 需要监控window大小变化,但是以前没有注册过监控事件
	 */
      if (!winResizeRef.value) {
        winResizeRef.value = window.addEventListener('resize', forceAlign);
      }
    } else if (winResizeRef.value) {
     /**
	 * 如果不需要监控,但是已经监控过了,那就取消监控
	 */
      winResizeRef.value.remove();
      winResizeRef.value = null;
    }
  },
  { immediate: true, flush: 'post' }
);
2.2.2 监控source和target的变化
  • 需要手写一个监控的函数

这里需要一个新的接口:ResizeObserver https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver

使用这个接口,可以监听一个DOM节点的变化,这种变化包括但不仅限于:

  1. 某个节点的出现和隐藏
  2. 某个节点的大小变化

我们用它来观察指定的元素,如果元素变化,执行指定的回调。

javascript 复制代码
export function monitorResize(element: HTMLElement, callback: Function) {
  /**
   * 1 初始化一个观察器
   * onResize 是元素变化后的回调
   */
  const resizeObserver = new ResizeObserver(onResize);

  /**
   * 2 观察指定的DOM元素 element
   */
  if (element) {
    resizeObserver.observe(element);
  }
  
  // ......

  /**
   * 3 返回一个函数,用于取消观察
   */
  return () => {
    resizeObserver.disconnect();
  };
}

每次都用当前大小和上次的大小比较,如果不一致,执行callback回调。

javascript 复制代码
export function monitorResize(element: HTMLElement, callback: Function) {
  
  // ......
  
  let prevWidth: number = null;
  let prevHeight: number = null;

  /**
   * 4 当元素大小变化时,调用用户传入的 callback 方法
   */
  function onResize([{ target }]: ResizeObserverEntry[]) {
    if (!document.documentElement.contains(target)) return;
    const { width, height } = target.getBoundingClientRect();
    const fixedWidth = Math.floor(width);
    const fixedHeight = Math.floor(height);

    if (prevWidth !== fixedWidth || prevHeight !== fixedHeight) {
      // https://webkit.org/blog/9997/resizeobserver-in-webkit/
      Promise.resolve().then(() => {
        callback({ width: fixedWidth, height: fixedHeight });
      });
    }

    prevWidth = fixedWidth;
    prevHeight = fixedHeight;
  }
}
  • 在页面挂载的时候,注册监控事件;在页面属性更新的时候(比如source或者target变化时),需要清除旧的事件,注册新的事件
javascript 复制代码
onMounted(() => {
  nextTick(() => {
    /**
    * goAlign 用来维护监控事件,同时执行对齐方法
    * 实现在下面。
    */
    goAlign();
  });
});

 onUpdated(() => {
   nextTick(() => {
     goAlign();
   });
 });

因为要清除旧的事件,所以需要需要保存 注册方法返回的 resizeObserver.disconnect(),方便执行清除的时候调用;同时记录下来当前引用的dom节点,来判断是否需要注册新的监听事件。

javascript 复制代码
interface MonitorRef {
  element?: HTMLElement; // 当前`dom`节点的引用
  cancel: () => void;    // 监控事件的取消方法
}

// Listen for target updated
const targetResizeMonitor = ref<MonitorRef>({
  cancel: () => {},
});
// Listen for source updated
const sourceResizeMonitor = ref<MonitorRef>({
  cancel: () => {},
});

goAlign()的实现

javascript 复制代码
const goAlign = () => {
  const target = props.target;
  const element = getElement(target);
  const point = getPoint(target);

  /**
  * onMounted 的时候,必定执行;onUpdated 的时候,只有source的引用变了才会执行
  * 清除旧的监听事件,注册新的
  */ 
  if (nodeRef.value !== sourceResizeMonitor.value.element) {
    sourceResizeMonitor.value.cancel();
    sourceResizeMonitor.value.element = nodeRef.value;
    sourceResizeMonitor.value.cancel = monitorResize(nodeRef.value, forceAlign);
  }

  /**
  * 如果缓存的target和当前的target不一致,或者对齐方式不一致,就执行对齐方法
  * 同时如果target变了,清除旧的监听事件,注册新的
  */
  if (
    cacheRef.value.element !== element ||
    !isSamePoint(cacheRef.value.point, point) ||
    !isEqual(cacheRef.value.align, props.align)
  ) {
    forceAlign();

    // Add resize observer
    if (resizeMonitor.value.element !== element) {
      resizeMonitor.value.cancel();
      resizeMonitor.value.element = element;
      resizeMonitor.value.cancel = monitorResize(element, forceAlign);
    }
  }
};
2.2.3 重写对齐的方法

因为我们监控了元素大小的变化,触发频率很高,也就是说对齐方法执行的频率也会非常高。

所以需要一个方法,这个方法需要实现类似防抖的功能。源码是使用useBuffer实现的,我们先看一下这个方法。

javascript 复制代码
export const alignProps = {
  monitorBufferTime: Number,
};

/**
* 返回了一个强制执行的方法和一个取消执行的方法
*/
const [forceAlign, cancelForceAlign] = useBuffer(
  () => {
  	// ...... 对齐的方法
  },
  computed(() => props.monitorBufferTime),
);
  • useBuffer的实现
javascript 复制代码
/**
 * 这个函数设计用于控制一个基于时间缓冲的触发逻辑,确保在一定时间间隔内(由buffer参数指定)
 * 即使多次尝试触发,也只有一次实际执行callback的机会,除非通过强制执行(force参数为true)来绕过这个缓冲逻辑。
 *
 * 提供了执行的方法和取消执行的方法
 */
export default (callback: () => boolean, buffer: ComputedRef<number>) => {
  let called = false;
  let timeout = null;

  function cancelTrigger() {
    clearTimeout(timeout);
  }

  function trigger(force?: boolean) {
   // ......
  }

  return [
    trigger,
    () => {
      called = false;
      cancelTrigger();
    },
  ];
};

执行方法trigger的实现如下:

  1. 不在回调过程中:直接设置定时
  2. 如果是强制触发:取消旧的定时,设置新的定时
  3. 在回调过程中:取消旧的定时,设置新的定时
javascript 复制代码
function trigger(force?: boolean) {
  // 如果不在回调过程中 || 强制触发,则
  if (!called || force === true) {
    // 执行一遍callback,如果返回了false,就不需要延迟
    if (callback() === false) {
      // Not delay since callback cancelled self
      return;
    }

    called = true;
    // 取消上次的定时,重新定时
    cancelTrigger();
    timeout = setTimeout(() => {
      called = false;
    }, buffer.value);
  } else {
    // 在回调过程中:取消上次的定时,重新定时
    cancelTrigger();
    timeout = setTimeout(() => {
      called = false;
      trigger();
    }, buffer.value);
  }
}

buffer时间结束后,会执行对齐函数。

  • 对齐的方法
javascript 复制代码
const cacheRef = ref<{ element?: HTMLElement; point?: TargetPoint; align?: AlignType }>({});
const nodeRef = ref();
const [forceAlign, cancelForceAlign] = useBuffer(
  () => {
    const {
      disabled: latestDisabled,
      target: latestTarget,
      align: latestAlign,
      onAlign: latestOnAlign,
    } = props;
    if (!latestDisabled && latestTarget && nodeRef.value) {
      const source = nodeRef.value;

      /**
      * 获取了目标元素或者对齐点。
      */
      let result: AlignResult;
      const element = getElement(latestTarget);
      const point = getPoint(latestTarget);

      /**
      * 缓存目标元素的信息和对齐方式
      */
      cacheRef.value.element = element;
      cacheRef.value.point = point;
      cacheRef.value.align = latestAlign;

      // 🚁 IE浏览器在元素对齐后会失去焦点,所以需要在对齐后重新聚焦
      /**
      * 记录了当前文档中的活动元素(activeElement),以便在对齐操作后恢复焦点
      */
      const { activeElement } = document;
      // 只有元素可见才需要对齐
      if (element && isVisible(element)) {
        result = alignElement(source, element, latestAlign);
      } else if (point) {
        result = alignPoint(source, point, latestAlign);
      }
      restoreFocus(activeElement, source);

      /**
      * 如果调用者需要在对齐后做一些事情,就执行props传进来的回调方法
      */
      if (latestOnAlign && result) {
        latestOnAlign(source, result);
      }

      return true;
    }

    return false;
  },
  computed(() => props.monitorBufferTime),
);

target节点为啥要缓存下来?

onUpdated中,调用了goAlign()props中的target是一个函数,可能对于同一个target节点,引用发生变化(调用者每次都给target一个新的函数),引起不必要的重新对齐操作。

2.2.4 给插槽元素增加ref引用

这里的实现比较简单,先看代码。主要逻辑就是cloneElement,在复制的时候重写了他的属性。

javascript 复制代码
return () => {
  const child = slots?.default();
  if (child) {
    return cloneElement(child[0], { ref: nodeRef }, true, true);
  }
  return null;
};

看一下这个函数的实现。调用了vuecloneVNode方法,把{ ref: nodeRef }加入到虚拟节点的属性中。

javascript 复制代码
import { cloneVNode } from 'vue';

export function cloneElement<T, U>(
  vnode: VNode<T, U> | VNode<T, U>[],
  nodeProps: Record<string, any> &
    Omit<VNodeProps, 'ref'> & { ref?: VNodeProps['ref'] | RefObject } = {},
  override = true,
  mergeRef = false,
): VNode<T, U> {
  let ele = vnode;
  if (Array.isArray(vnode)) {
    ele = filterEmpty(vnode)[0];
  }
  if (!ele) {
    return null;
  }
  const node = cloneVNode(ele as VNode<T, U>, nodeProps as any, mergeRef);

  // cloneVNode内部是合并属性,这里改成覆盖属性
  node.props = (override ? { ...node.props, ...nodeProps } : node.props) as any;
  return node;
}

3 效果演示

3.1 resize变化

当窗口大小变化时,对自适应对齐方式。以纵向为例。

3.2 source 和target大小变化

分别修改二者大小,都可以重新触发对齐操作。

3.3 插槽引用

source节点没有增加一个div包裹,同时也拿到了它的引用进行定位。

相关推荐
zhougl9962 小时前
html处理Base文件流
linux·前端·html
花花鱼2 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_2 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo3 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
TDengine (老段)4 小时前
TDengine 中的关联查询
大数据·javascript·网络·物联网·时序数据库·tdengine·iotdb
杉之5 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端5 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡5 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木6 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法