react-grid-layout 之核心代码分析与实践

2023-10-16 20:40:57 浏览数 (2)

1. 介绍

React Grid Layout 是一个用于构建可拖拽、可调整大小和自适应的网格布局的 React 组件库。通过简单易用的API,在 React 项目中能够快速构建复杂网格布局,轻松地创建可交互的网格布局,适用于构建面向用户的仪表盘、拖拽式页面布局等应用,提供良好的交互体验。通常用于自定义搭建页面中,例如我们公司用到自定义搭建工作台系统等等

React Grid Layou组件库的特点有:可拖拽、可调整大小,适应不同需求、自动适应支持响应式断点、设置组件的对齐方式和间距、支持自定义的组件和布局等等

本篇文章将带你了解如何使用 RGL(React Grid Layout),以及核心功能断点布局、网格布局、以及缩放、拖拽功能的代码实现。

2. 使用

下载 npm 包

代码语言:javascript复制
npm install react-grid-layout

引入 RGL(react-grid-layout)

代码语言:javascript复制
import GridLayout from "react-grid-layout";

设置初始化布局

代码语言:javascript复制
// 布局属性
const layout = [
  // i: 组件key值, x: 组件在x轴的坐标, y: 组件在y轴的坐标, w: 组件宽度, h: 组件高度
  // static: true,代表组件不能拖动
  { i: "a", x: 0, y: 0, w: 1, h: 3, static: true },  
  // minW/maxW 组件可以缩放的最大最小宽度
  { i: "b", x: 1, y: 0, w: 3, h: 2, minW: 2, maxW: 4 }, 
  { i: "c", x: 4, y: 0, w: 1, h: 2 }
];

return (
  <GridLayout
    className="layout"
    layout={layout} // 组件的布局参数配置
    cols={12} // 栅格列数配置,默认12列
    rowHeight={30} // 指定网格布局中每一行的高度, 这里设置为30px
    width={1200} // 设置容器的初始宽度
  >
    <div key="a" >组件A</div>
    <div key="b" >组件B</div>
    <div key="c" >组件C</div>
  </GridLayout>
)

效果图

3. 源码实现

3.1 断点布局实现

首先我们要了解什么是断点布局?

断点布局(Breakpoint layout)是一种响应式布局的设计方法,用于在不同的屏幕尺寸的显示和布局。

断点布局和网格布局不同点在于,断点布局需要根据不同屏幕大小的断点来设置不同的布局,例如下面代码,定义 lg、md、sm、xs 四个断点 ,并设置每一个断点对应的列数和布局。

代码语言:javascript复制
const MyGrid = () => {
  // 定义断点
  const breakpoints = { lg: 1200, md: 996, sm: 768, xs: 480 }; 
  // 定义断点对应的列数
  const cols = { lg: 12, md: 10, sm: 6, xs: 4 };

  // 定义不同断点下的布局
  const layouts = {
    lg: [
      { i: 'a', x: 0, y: 0, w: 6, h: 3 },
      { i: 'b', x: 6, y: 0, w: 6, h: 3 },
    ],
    md: [
      { i: 'a', x: 0, y: 0, w: 5, h: 3 },
      { i: 'b', x: 5, y: 0, w: 5, h: 3 },
    ],
    sm: [
      { i: 'a', x: 0, y: 0, w: 6, h: 3 },
      { i: 'b', x: 0, y: 3, w: 6, h: 3 },
    ],
    xs: [
      { i: 'a', x: 0, y: 0, w: 4, h: 3 },
      { i: 'b', x: 0, y: 3, w: 4, h: 3 },
    ],
  };

  return ( 
    <ResponsiveReactGridLayout 
      className="layout" 
      breakpoints={breakpoints} 
      cols={cols} 
      layouts={layouts} 
    > 
      <div key="a">Component A</div> 
      <div key="b">Component B</div>
    </ResponsiveReactGridLayout>
  );
};

断点布局实现的关键是获取并监听屏幕宽度的变化,这里使用了 resize-observer-polyfill 组件库,可以兼容旧浏览器实现元素大小的变化。首先我们创建一个 ResizeObserver 实例,在回调函数中获取目标元素的宽度,并通过 setState 更新。下面是获取屏幕宽度的主要代码:

代码语言:javascript复制
import ResizeObserver from 'resize-observer-polyfill';// 引入resize-observer-polyfill

this.resizeObserver = new ResizeObserver((entries) => {

  const node = this.elementRef.current // 获取当前元素节点

  if (node instanceof HTMLElement) {

    // 通过 resize-observer-polyfill 中的 api 获取当前元素的宽度
    const width = entries[0].contentRect.width 
    this.setState({width})
    
  }

})

现在我们知道了如何获取元素的宽度,当我们缩放视图窗口时,需要判断目前视图窗口的宽度处于哪个断点范围内,这时候我们用到的方法是 onWidthChange,该方法会监听每一次宽度变化,根据新的窗口宽度和断点信息,重新计算网格布局,并更新组件状态。其中 getBreakpointFromWidth 方法根据当前屏幕宽度,返回设置的断点。getColsFromBreakpoint 方法根据断点,返回当前的布局。下面的核心代码实现:

代码语言:javascript复制
// 判断断点是否变化
if (
  lastBreakpoint !== newBreakpoint ||
  prevProps.breakpoints !== breakpoints ||
  prevProps.cols !== cols
) {
  // 如果下一个布局中没有当前断点,则保留当前布局
  if (!(lastBreakpoint in newLayouts))
    newLayouts[lastBreakpoint] = cloneLayout(this.state.layout);

  // 根据现有布局和新的断点查找或生成布局
  let layout = findOrGenerateResponsiveLayout(
    newLayouts,
    breakpoints,
    newBreakpoint,
    lastBreakpoint,
    newCols,
    compactType
  );

  // 根据子元素和初始布局生成新的布局
  layout = synchronizeLayoutWithChildren(
    layout,
    this.props.children,
    newCols,
    compactType,
    this.props.allowOverlap
  );

  // 存储新布局。
  newLayouts[newBreakpoint] = layout;

  this.setState({
    breakpoint: newBreakpoint,
    layout: layout,
    cols: newCols
  }); // 存入当前新的断点数据
}

插入:这里我们是使用了 resize-observer-polyfill 组件库中的 api 来监听屏幕宽高变化,我们还可以使用 css 中的 @media 来实现宽高变化带来的样式改变。另外还有 js 的原生方法 window.innerWidth 获取屏幕的宽高并通过 window.addEventListener 监听宽度的变化。

3.2 网格布局实现

什么是网格布局?

网格布局是一种用于创建网格化布局的 CSS 布局模块。它允许开发者将一个元素的内容划分为行和列,形成一个灵活且强大的布局系统。

在 RGL(React Grid Layout)中,创建一个网络布局做了三件事:

1、渲染子组件 child,包括子组件元素的定位、占比、宽高等

2、合并类名和样式

3、绑定缩放和拖拽事件

根据设置的 x,y 坐标计算子组件到顶部和左边的距离分别为 left,top,和子组件的宽度和高度。温馨提示,在后面的代码实现过程中会有许多 x,y 和 left,top 的转换,下面的这张图方便我们理解:

实现代码如下:

代码语言:javascript复制
render(): ReactNode {
  const {
    x,
    y,
    w,
    h,
    isDraggable,
    isResizable,
    droppingPosition,
    useCSSTransforms
  } = this.props;

  // 定位
  const pos = calcGridItemPosition(
    this.getPositionParams(),
    x,
    y,
    w,
    h,
    this.state
  );
  const child = React.Children.only(this.props.children);

  // 创建子元素。我们克隆现有的元素,但修改它的className和样式。
  let newChild = React.cloneElement(child, {
    ref: this.elementRef,
    className: clsx(
      "react-grid-item",
      child.props.className,
      this.props.className,
      {
        static: this.props.static,
        resizing: Boolean(this.state.resizing),
        "react-draggable": isDraggable,
        "react-draggable-dragging": Boolean(this.state.dragging),
        dropping: Boolean(droppingPosition),
        cssTransforms: useCSSTransforms
      }
    ),
    // 我们可以设置子元素的宽度和高度,但我们不能设置位置。
    style: {
      ...this.props.style,
      ...child.props.style,
      ...this.createStyle(pos)
    }
  });

  // 绑定缩放事件
  newChild = this.mixinResizable(newChild, pos, isResizable);

  // 绑定拖拽事件
  newChild = this.mixinDraggable(newChild, isDraggable);

  return newChild;
}

子组件渲染

通过 children.map 遍历执行 processGridItem 方法,在 processGridItem 方法中将每一个 child 的 key 作为 id 设置布局项并且把要设置的布局属性和回调函数传递到 组件。

calcGridItemPosition - 定位

当我们要知道子组件的定位时,需要计算子组件到顶部和左边的距离和子组件的宽高,实现代码如下:

代码语言:javascript复制
export function calcGridItemPosition() {
  const { margin, containerPadding, rowHeight } = positionParams;
  const colWidth = calcGridColWidth(positionParams);
  const out = {};

  // 缩放态计算宽高
  if (state && state.resizing) {
    out.width = Math.round(state.resizing.width);
    out.height = Math.round(state.resizing.height);
  }
  // 否则,按网格单位计算。
  else {
    out.width = calcGridItemWHPx(w, colWidth, margin[0]);
    out.height = calcGridItemWHPx(h, rowHeight, margin[1]);
  }

  // 拖动态计算top、left
  if (state && state.dragging) {
    out.top = Math.round(state.dragging.top);
    out.left = Math.round(state.dragging.left);
  }
  // 否则,按网格单位计算。
  else {
    out.top = Math.round((rowHeight   margin[1]) * y   containerPadding[1]);
    out.left = Math.round((colWidth   margin[0]) * x   containerPadding[0]);
  }

  return out;
}

在上面的代码中,我们看到在网格单位计算中用到了 calcGridColWidth、calcGridItemWHPx 方法, calcGridColWidth 用于计算每一列的宽度,calcGridItemWHPx 用于计算整个网络布局的宽高。下面分别详细介绍:

计算每一列的宽度

根据 positionParams 属性中的 margin, containerPadding, containerWidth, cols 等,计算网格中每一列的宽度:

(容器宽度-所有列的内、外边距)/列数

如下图所示:

calcGridColWidth 方法代码如下:

代码语言:javascript复制
export function calcGridColWidth(positionParams: PositionParams): number {
  const { margin, containerPadding, containerWidth, cols } = positionParams;
  return (
    (containerWidth - margin[0] * (cols - 1) - containerPadding[0] * 2) / cols
  );
}
计算网格项目宽高

网格项目的大小 = 所有子组件 child 实际占的大小 子组件 child 之间的边距大小

代码语言:javascript复制
export function calcGridItemWHPx(
  // 子组件 child 的宽或高 w/h
  gridUnits: number, 
  // 每个网格单位在像素上实际的大小,也就是上面 calcGridColWidth 计算的每一列宽度
  colOrRowSize: number, 
  // 子组件 child 之间的间距
  marginPx: number
): number {
  // 0 * Infinity === NaN, which causes problems with resize contraints
  if (!Number.isFinite(gridUnits)) return gridUnits;
  return Math.round(
    colOrRowSize * gridUnits   Math.max(0, gridUnits - 1) * marginPx
  );
}

合并样式

克隆当前的子组件 child 合并 className 和样式,合并类名使用了 clsx。clsx 是一个用于动态生成 CSS 类名的工具,使得合并和处理类名变得更加简单和灵活。

代码语言:javascript复制
const child = React.Children.only(this.props.children);

// 通过克隆现有的元素创建为新的子元素,并修改它的 className 和样式。
let newChild = React.cloneElement(child, {
  ref: this.elementRef,
  className: clsx(
    "react-grid-item",
    child.props.className,
    this.props.className,
    {
      static: this.props.static,
      resizing: Boolean(this.state.resizing),
      "react-draggable": isDraggable,
      "react-draggable-dragging": Boolean(this.state.dragging),
      dropping: Boolean(droppingPosition),
      cssTransforms: useCSSTransforms
    }
  ),
  // 我们可以设置子元素的宽度和高度
  style: {
    ...this.props.style,
    ...child.props.style,
    ...this.createStyle(pos)
  }
});

// 绑定缩放功能。默认是可缩放,用户也可设置为不可缩放
newChild = this.mixinResizable(newChild, pos, isResizable);

// 绑定拖拽功能。默认是可拖拽,用户也可设置为不可拖拽
newChild = this.mixinDraggable(newChild, isDraggable);

在上面这段代码中,我们克隆后的新元素都调用 mixinResizable、mixinDraggable 方法,分别用来执行可缩放和拖拽功能的。下面具体讲讲如何实现

3.3 拖拽功能实现

拖拽功能函数 mixinDraggable,核心用到了 react-draggable 拖拽组件。在 DraggableCore 组件中传入的属性主要有 onDragStart、onDrag、onDragStop 事件等等,代码如下:

代码语言:javascript复制
mixinDraggable(
  child: ReactElement<any>,
  isDraggable: boolean
): ReactElement<any> {
  return (
    <DraggableCore
      disabled={!isDraggable} // 是否支持拖拽
      onStart={this.onDragStart} // 开始拖拽触发的事件
      onDrag={this.onDrag} // 拖拽过程中一直触发的事件
      onStop={this.onDragStop} // 拖拽结束时触发的事件
      handle={this.props.handle} // 上一级组件传入的回调函数
      cancel={
        ".react-resizable-handle"  
        (this.props.cancel ? ","   this.props.cancel : "")
      }
      scale={this.props.transformScale}
      nodeRef={this.elementRef}
    >
      {child}
    </DraggableCore>
  );
}

onDragStart - 开始拖拽

在开始拖拽事件中,做了以下事情:

  • 获取当前拖拽元素
  • 获取最近祖先元素中含有定位属性元素
  • 获取以上两种元素的定位信息

首先如何获取当前拖拽元素?在 DraggableCore 组件中的回调函数提供了一个包含拖拽事件相关信息的回调数据对象叫作 ReactDraggableCallbackData,里面的属性包含当前被拖拽的元素节点 node。

第二步如何获取最近祖先元素中含有定位属性元素?在原生 js 中有个 HTMLElement.offsetParent 属性,通过 node.offsetParent 可以获取父级含有定位属性元素

最后通过 DOM 方法中的 getBoundingClientRect 分别获取它们的定位信息对当前元素计算最新定位

具体代码如下:

代码语言:javascript复制
onDragStart: (Event, ReactDraggableCallbackData) => void = (e, { node }) => {
  const { onDragStart, transformScale } = this.props;
  if (!onDragStart) return;

  const newPosition: PartialPosition = { top: 0, left: 0 };

  // offsetParent: 获取指定元素的最近的祖先元素中含有定位属性(position 不为 static)的元素。
  const { offsetParent } = node;
  if (!offsetParent) return;
  
  // getBoundingClientRect: 获取指定元素的大小和位置信息
  const parentRect = offsetParent.getBoundingClientRect();
  const clientRect = node.getBoundingClientRect();
  const cLeft = clientRect.left / transformScale;
  const pLeft = parentRect.left / transformScale;
  const cTop = clientRect.top / transformScale;
  const pTop = parentRect.top / transformScale;
  newPosition.left = cLeft - pLeft   offsetParent.scrollLeft;
  newPosition.top = cTop - pTop   offsetParent.scrollTop;
  this.setState({ dragging: newPosition }); // 当前拖拽元素最新定位信息

  const { x, y } = calcXY(
    this.getPositionParams(),
    newPosition.top,
    newPosition.left,
    this.props.w,
    this.props.h
  );

  return onDragStart.call(this, this.props.i, x, y, {
    e,
    node,
    newPosition
  });
};

onDrag - 拖拽中

在拖拽的过程中,为了确保元素不超出边界,我们要实时计算拖拽元素是否超出网格,通过计算底部边界 - bottomBoundary 确保元素不会超出其偏移父元素的底部边界;通过计算右侧边界 - rightBoundary 确保元素不会超出其偏移父元素的右侧边界。具体计算步骤如下:

  • 计算底部边界 bottomBoundary:偏移父元素的可见高度减去元素的高度、上下边距之和
  • 计算右侧边界 rightBoundary:容器的宽度减去元素的宽度、左右边距之和
  • 通过 clamp 函数计算将 top、left 的值限制在 0-bottomBoundary、0-rightBoundary

主要代码实现如下:

代码语言:javascript复制
onDrag = () => {
  ...
  const positionParams = this.getPositionParams();

  // 边界计算; 保证项目在网格保持在网格内
  if (isBounded) {
    const { offsetParent } = node;

    if (offsetParent) {
      const { margin, rowHeight } = this.props;
      const bottomBoundary =
        offsetParent.clientHeight - calcGridItemWHPx(h, rowHeight, margin[1]);
      // 将 top 的值设置在 0 到 bottomBoundary 之间
      top = clamp(top, 0, bottomBoundary); 

      const colWidth = calcGridColWidth(positionParams);
      const rightBoundary =
        containerWidth - calcGridItemWHPx(w, colWidth, margin[0]);
      left = clamp(left, 0, rightBoundary);
    }
  }

 ...
}

// utils.js
export function clamp(
num: number,
lowerBound: number,
upperBound: number
): number {
  return Math.max(Math.min(num, upperBound), lowerBound);
}

onDragStop - 拖拽结束

记录下拖拽后新位置,把拖拽计算的 top、left 等定位信息通过 calcXY 函数计算新的位置为 x,y 并保存下来。

代码语言:javascript复制
onDragStop: (Event, ReactDraggableCallbackData) => void = (e, { node }) => {
  ...
  const newPosition: PartialPosition = { top, left };
  this.setState({ dragging: null }); // 表示拖拽结束

  const { x, y } = calcXY(this.getPositionParams(), top, left, w, h);

  return onDragStop.call(this, i, x, y, {
    e,
    node,
    newPosition
  });
};

拖拽过程中的阴影是如何实现?

在实际使用拖拽功能时,会有当前拖动元素的阴影站位,如下图11号元素:

如何实现拖拽过程中的阴影?

在我们使用 GRL 渲染子元素时可以添加拖动时的类名例如.droppable-element,并给类目设置样式

代码语言:javascript复制
.droppable-element {

  ...

  background: #fdd;
}

此外我们回顾一下上面子组件渲染的时候,有一个合并样式,其中合并 className 里有一项是:

代码语言:javascript复制
"react-draggable-dragging": Boolean(this.state.dragging)

// .css
.react-grid-item.react-draggable-dragging {
  transition: none; // 取消了被拖拽元素上的过渡效果。RGL 默认会添加过渡动画效果来实现平滑的移动效果
  z-index: 3; // 保证拖拽元素在顶部,不被其他元素覆盖
  will-change: transform; // 提示浏览器被拖拽元素将要发生的变化,可以优化动画性能
}

3.4 缩放功能实现

缩放功能需要计算约束缩放的最大最小宽高,并且在可缩放功能用到了 react-resizable 组件。在 Resizable 组件中 传入 minConstraints、maxConstraints 可缩放的最小和最大宽高。

代码如下:

代码语言:javascript复制
mixinResizable() {
  const positionParams = this.getPositionParams();

  // 计算最大宽度,不能超过窗口的宽度
  const maxWidth = calcGridItemPosition(
    positionParams,
    0,
    0,
    cols - x,
    0
  ).width;

  // 约束最大最小的宽度
  const mins = calcGridItemPosition(positionParams, 0, 0, minW, minH);
  const maxes = calcGridItemPosition(positionParams, 0, 0, maxW, maxH);
  // 计算可以缩放的最小宽高
  const minConstraints = [mins.width, mins.height];
  // 计算可以缩放的最大宽高
  const maxConstraints = [
    Math.min(maxes.width, maxWidth),
    Math.min(maxes.height, Infinity)
  ];
  
  return (
    <Resizable
      // 是否可缩放
      draggableOpts={{
        disabled: !isResizable
      }}
      className={isResizable ? undefined : "react-resizable-hide"}
      width={position.width}
      height={position.height}
      minConstraints={minConstraints}
      maxConstraints={maxConstraints}
      onResizeStop={this.onResizeStop}
      onResizeStart={this.onResizeStart}
      onResize={this.onResize}
      transformScale={transformScale}
      resizeHandles={resizeHandles}
      handle={resizeHandle}
    >
      {child}
    </Resizable>
  );
}

从上面的代码中我们还看到在 Resizable 组件中调用了一些拖拽事件例如:onResizeStart、onResizeStop、onResize 分别用于处理调整大小开始时、结束时、过程中触发的事件。都共同调用了 onResizeHandler 方法,下面我们来看下 onResizeHandler 函数:

onResizeHandler 函数用来更新组件的宽度和高度,调整组件的位置和边界,重新计算并更新布局,发送请求或触发其他副作用。

代码语言:javascript复制
onResizeHandler() {
  const handler = this.props[handlerName];
  if (!handler) return;
  const { cols, x, y, i, maxH, minH } = this.props;
  let { minW, maxW } = this.props;

  // 得到新的XY,给定像素值中的高度和宽度,计算网格单位。
  let { w, h } = calcWH(
    this.getPositionParams(),
    size.width,
    size.height,
    x,
    y
  );

  // minW应该至少是1 (TODO propTypes验证?)
  minW = Math.max(minW, 1);

  // maxW应该最多为(cols - x)
  maxW = Math.min(maxW, cols - x);

  // 最小/最大限制
  w = clamp(w, minW, maxW);
  h = clamp(h, minH, maxH);

  this.setState({ resizing: handlerName === "onResizeStop" ? null : size });

  handler.call(this, i, w, h, { e, node, size });
}

4. 总结

通过对 React-grid-layout 源码的学习,我们对它的使用也会更得心应手,这篇文章主要对组件元素的定位、拖拽、缩放功能的源码实现做了详细的介绍。在我们具体应用过程中还有很多值得我们深入思考的,例如通过对元素的拖拽实现吸附效果、拖拽的动画等等期待下一次的介绍!

5. 参考文献

https://github.com/react-grid-layout/react-grid-layout https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_grid_layout

0 人点赞