JS原生实现对齐

172 阅读6分钟

最近考虑简单的做一套组件库,首先从DOM相关的操作开始,下面来实现将一个元素依附到目标元素具体的位置

需求分析

  • 可以将元素对齐到目标元素的外侧,内测以及中心位置,外侧和内测又具体分为左上,上,右上,右,右下,下,左下,左
  • 可以添加容器元素,如果元素要在目标元素外侧右边定位,而目标元素右侧宽度小于元素宽度,那么元素应自动翻转,定位到目标元素的左侧,如果左侧也无法无法满足,那么就强制移动元素
  • 对齐函数返回的结果中应该包括此次对齐中元素的坐标,目标元素的坐标,容器的坐标,以及是否发生过翻转,是否发生过强制移动等具体信息

分析实现

我们将对齐的位置概括为xx-yy的形式, xx表示x轴的对齐方式,可以分为

image.png

yy表示y轴的对齐方式,可以分为

image.png

那么通过组合,对齐方式包括ll-tt,ll-tb,ll-cc,ll-bt,ll-bb,lr-tt,lr-tb,lr-cc,lr-bt,lr-bb,cc-tt,cc-tb,cc-cc,cc-bt,cc-bb,rl-tt,rl-tb,rl-cc,rl-bt,rl-bb,rr-tt,rr-tb,rr-cc,rr-bt,rr-bb

image.png

我们可以通过分析,将两种动作分为两种,一种是x轴上的操作,一种是y轴上的操作,x轴和y轴又分两种情况,一种是位于中心的cc情况,一种是非cc情况

  1. 位于中心cc的情况,取x/y轴取宽度/高度的一半
  2. 非中心的情况, 分四种情况
第一个位置取值第二个位置取值处理情况
l/tl/t相对元素的坐标减宽度/高度
l/tr/b相对元素的坐标即可
r/bl/t相对元素的坐标+ 相对元素的宽度/高度 - 元素的宽度
l/tl/t相对元素的坐标即可

实现准备

实现相对位置,我们首先应该可以获取到当前元素的位置以及大小

interface Point {
    x: number; // x坐标
    y: number;// y坐标
}
interface Size {
    width: number,  //宽度
    height: number //高度
}
// 表示一个区域
interface Rect extends Point, Size { }

使用Rect来描述一个位置,我们先来定义获取元素相对位置以及大小的方法

/**
 * 获取指定元素的滚动距离
 * @param elem 要获取的元素或文档
 * @returns 返回当前元素滚动的距离, 如果元素不可滚动则返回(0,0)
 */
function getScroll(elem:HTMLELement | Document){
    if (elem.nodeType === 9) {
        const win = (elem as Document).defaultView;
        if("scrollX" in win){
            return {
                x: win.scrollX,
                y: win.scrollY
            }as Point
        }
        elem = (elem as Document).documentElement;  //兼容ie9
    }
    return {
        x: (elem as HTMLElement).scrollLeft,
        y: (elem as HTMLElement).scrollTop
    }as Point;
}
/**
 * 获取指定元素的区域
 * @param elem 要获取的元素或文档
 * @returns 返回元素实际占用区域(含内边距和边框,不含外边距), 如果元素不可见则返回空区域
 */
function getRect(elem: HTMLElement | Document){
    const doc: Document = elem.nodeType === 9 ? elem as Document : elem.ownerDocument;
    const html = doc.documentElement;
    const result = getScroll(doc) as IRect;
    if(elem.nodeType === 9){
        result.width = html.clientWidth;
        result.height = html.clientHeight;
    }else{
        const rect = (elem as Element).getBoundingClientRect();
        result.x = rect.left - html.clientLeft; //到左侧的距离 - 左边框宽度
        result.y = rect.top - html.clientTop;
        result.width = rect.width;
        result.height = rect.height;
    }
    return result;
}

定义设置元素位置以及大小的方法

/**
 * 获取指定元素的定位父元素
 * @param elem 要获取的元素
 * @returns 要返回定位父元素
 */
export function offsetParent(elem: HTMLElement) {
    let result = elem;
    //一致向上寻找, position属性非 static 的,定位元素
    while ((result = result.offsetParent as HTMLElement) && result.nodeName !== "HTML" && getStyle(result, "position") === "static");
    return result || elem.ownerDocument.documentElement;
}

/**
 * 获取指定元素和其定位父元素的偏移距离
 * @param elem 要获取的元素
 * @returns 返回坐标
 */
export function getOffset(elem: HTMLElement): Point {
    const left = getStyle(elem, "left");
    const top = getStyle(elem, "top");
    if ((left && top && left !== 'auto' && top !== 'auto') || getStyle(elem, "position") !== "absolute") {
        return { x: parseFloat(left) || 0, y: parseFloat(top) || 0 };
    }
    //当 position 属性是 absolute 时,需要寻找定位父元素,并且根据定位父元素获取偏移距离
    const parent = offsetParent(elem);
    const rect = getRect(elem);
    if (parent.nodeName !== "HTML") {
        const rootRect = getRect(parent);
        rect.x -= rootRect.x;
        rect.y -= rootRect.y;
    }
    //位置减去当前 border 和 margin 宽度
    rect.x -= computeStyle(elem, "marginLeft") + computeStyle(parent, "borderLeftWidth");
    rect.y -= computeStyle(elem, "marginTop") + computeStyle(parent, "borderTopWidth");

    return rect;
}

/**
 * 设置指定元素的区域
 * @param elem 要设置的元素
 * @param value 要设置的内容区域(含内边距和边框, 不包含外边距)
 */
function setRect(elem: HTMLElement, value: Partial<Rect>){
    const style = elem.style;
    if(value.x != null || value.y != null){
        if(!/^(?:abs|fix)/.test(getStyle(elem, "position"))){  //如果当前元素位置非相对定位
            style.position = "relative";
        }
        const currentPosition = getRect(elem);
        const offset = getOffset(elem);
         if(value.x !== null){
            style.left = offset.x + value.x - currentPosition.x + "px";
        }
        if(value.y !== null){
            style.top = offset.y + value.y - currentPosition.y + "px";
        }
    }
     if(value.width != null || value.height != null){
        const boxSizing = getStyle(elem, "boxSizing") === "border-box";
        if(value.width != null) {
            style.width = value.width - (boxSizing ? 0 : computeStyle(elem, "borderLeftWidth", "paddingLeft", "paddingRight", "borderRightWidth")) + "px"; //如果boxSizing是 border-box时,宽度应减去边框和内边距
        }
        if(value.height != null){
            style.height = value.height - (boxSizing ? 0 : computeStyle(elem, "borderTopWidth", "paddingTop", "paddingBottom", "borderBottomWidth")) + "px";
        }
    }
}

实现

上面准备阶段实现了将一个元素设置到指定位置设置指定大小以及获取元素的位置和大小信息,下面就实现元素的对齐 我们将常用的对齐方式进一步封装

const knownAligns = {
    center: "cc-cc",
    leftTop: "ll-tb",
    left: "ll-cc",
    leftBottom: "ll-bt",
    rightBottom: "lr-bb",
    right: "rr-cc",
    rightTop: "rr-tb",
    topRight: "rl-tt",
    top: "cc-tt",
    topLeft: "lr-tt",
    bottomLeft: "lr-bb",
    bottom: "cc-bb",
    bottomRight: "rl-bb"
};

那么我们可选的对齐值为

/**
 * 表示对齐的位置。
 */
export type alignPos = keyof typeof knownAligns | alignResult["align"];

根据需求分析,对齐函数的返回结果中,我们需要包括元素的坐标,目标元素的坐标以及容器的坐标,以及是否发生过翻转,是否发生过强制移动等具体信息,我们首先定义返回结果的类型

/**
 * 表示对齐的结果。
 */
export interface alignResult extends Rect {

    /**
     * 目标区域。
     */
    target: Rect;

    /**
     * 容器区域。
     */
    container: Rect;

    /**
     * 对齐方式。
     */
    align: "ll-tt" | "ll-tb" | "ll-cc" | "ll-bt" | "ll-bb" | "lr-tt" | "lr-tb" | "lr-cc" | "lr-bt" | "lr-bb" | "cc-tt" | "cc-tb" | "cc-cc" | "cc-bt" | "cc-bb" | "rl-tt" | "rl-tb" | "rl-cc" | "rl-bt" | "rl-bb" | "rr-tt" | "rr-tb" | "rr-cc" | "rr-bt" | "rr-bb";

    /**
     * 是否水平翻转了位置。
     */
    rotateX?: boolean;

    /**
     * 是否垂直翻转了位置。
     */
    rotateY?: boolean;

    /**
     * 是否调整了水平位置。
     */
    transformX?: boolean;

    /**
     * 是否调整了垂直位置。
     */
    transformY?: boolean;
}

最终的实现如下

/**
 * 将元素对齐到其他节点或区域
 * @param elem 要定位的元素
 * @param target 对其的目标节点或区域
 * @param align 对其的位置
 * @param margin 要定位元素的外边框
 * @param container 容器节点区域,定位超出容器后会自动调整,如果为null则不自动调整
 * @param containerPadding 容器的内边距
*/
function pin(
    elem: HTMLElement, 
    target: Document | HTMLElement | Rect, 
    align: alignPos = "bottomLeft", 
    margin = 0,
    container: Document | HTMLElement | Rect | null = scrollParent(elem),  //默认使用第一个可滚动的父元素做容器
    containerPadding= 10) {

    const result = getRect(elem) as alignResult;
    result.align = align = (knownAligns[align as keyof typeof knownAligns] || align) as alignResult["align"];
    result.target = target = (target as Document | HTMLElement).nodeType ? getRect(target as Document | HTMLElement) : target as Rect;
    //如果存在则更新容器位置信息
    if(container){
        result.container = container = (container as Document | HTMLElement).nodeType ? getRect(container as Document | HTMLElement) : container as Rect;
        container.x += containerPadding;
        container.y += containerPadding;
        container.width -= containerPadding * 2;
        container.height -= containerPadding * 2;
    }


    const proc = (x: "x" | "y", width: "width" | "height", offset: number, center: boolean, left: boolean, right: boolean) => {
        if(container && result[width] > container[width]){

        }

        let value = (target as Rect)[x] + (center 
            ? ((target as Rect)[width] - result[width]) / 2 + offset
            : left 
                ? right ? offset : -result[width] - offset 
                : (target as Rect)[width] + (right ? offset : -result[width] - offset));
        
        // 检测是否超出容器
        if(container){
            const leftBound = container[x];
            const rightBound = leftBound + container[width] - result[width];
            if((center || !right) && value < leftBound || (center || right) && value > rightBound){
                if(!center){
                    const route = "rotate" + x.toUpperCase();
                    //如果未发生过当前类型的翻转,那么执行翻转
                    if(!result[route]){
                        result[route] = true;
                        proc(x, width, offset, center, !left, !right);
                        return;
                    }
                }

                //翻转后仍然超出位置,那么移动位置实现
                result["transform"+x.toUpperCase()] = true;
                value = value < leftBound ? leftBound : rightBound;
            }
           
        }
        
        result[x] = value;
    };
    proc("x", "width", margin, align.charAt(1) === "c", align.charAt(0) === "l", align.charAt(1) === "r");
    proc("y", "height", margin, align.charAt(4) === "c", align.charAt(3) === "t", align.charAt(4) === "b");
    setRect(elem, { x: result.x, y: result.y });
    return result;
}

实现效果

1654046233765 00_00_00-00_00_30.gif

参考内容

Tealui 打造小而精的专业前端组件库