vc-align源码分析
源码地址:https://github.com/vueComponent/ant-design-vue/tree/main/components/vc-align
1 基础代码
1.1 名词约定
需要对齐的节点叫source
,对齐的目标叫target
。
1.2 props
提供了两个参数:
align
:对齐的配置target
:一个函数,用于获取对齐的目标dom
1.3 主要逻辑
- 增加了一个
dom
,用来挂载source
节点,同时拿到它的引用。 - 提供了一个方法
align
,在组件初始化/定位方式改变/对齐目标改变的时候,重新执行对齐方法。
代码如下:
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 基础用法
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 可以优化的点
- 我们给
source
增加了一个div
,用来获取引用,这个dom
节点是不必要,可以去掉。 - 只监控了 对齐方式/
target
引用 的变化,没有监控source
和target
大小的变化,需要在这些属性变化时,重新对齐。 - 需要监控窗口大小的变化,重新对齐。
2.2 实现
2.2.1 监控window变化
这个有resize
事件,直接组册即可。
组件需要接受一个props
,表示是否需要监控window
变化。
export const alignProps = {
monitorWindowResize: Boolean,
};
代码如下,flush: post
是为了保证页面已经渲染结束,可以拿到dom
引用。
/**
* 用来记录监控事件的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节点的变化,这种变化包括但不仅限于:
- 某个节点的出现和隐藏
- 某个节点的大小变化
我们用它来观察指定的元素,如果元素变化,执行指定的回调。
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
回调。
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变化时),需要清除旧的事件,注册新的事件
onMounted(() => {
nextTick(() => {
/**
* goAlign 用来维护监控事件,同时执行对齐方法
* 实现在下面。
*/
goAlign();
});
});
onUpdated(() => {
nextTick(() => {
goAlign();
});
});
因为要清除旧的事件,所以需要需要保存 注册方法返回的 resizeObserver.disconnect()
,方便执行清除的时候调用;同时记录下来当前引用的dom
节点,来判断是否需要注册新的监听事件。
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()
的实现
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
实现的,我们先看一下这个方法。
export const alignProps = {
monitorBufferTime: Number,
};
/**
* 返回了一个强制执行的方法和一个取消执行的方法
*/
const [forceAlign, cancelForceAlign] = useBuffer(
() => {
// ...... 对齐的方法
},
computed(() => props.monitorBufferTime),
);
- useBuffer的实现
/**
* 这个函数设计用于控制一个基于时间缓冲的触发逻辑,确保在一定时间间隔内(由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
的实现如下:
- 不在回调过程中:直接设置定时
- 如果是强制触发:取消旧的定时,设置新的定时
- 在回调过程中:取消旧的定时,设置新的定时
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
时间结束后,会执行对齐函数。
- 对齐的方法
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
,在复制的时候重写了他的属性。
return () => {
const child = slots?.default();
if (child) {
return cloneElement(child[0], { ref: nodeRef }, true, true);
}
return null;
};
看一下这个函数的实现。调用了vue
的cloneVNode
方法,把{ ref: nodeRef }
加入到虚拟节点的属性中。
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
包裹,同时也拿到了它的引用进行定位。