|
react-grid-layout 之核心代码分析与实践
378 1. 介绍React Grid Layout 是一个用于构建可拖拽、可调整大小和自适应的网格布局的 React 组件库。通过简单易用的API,在 React 项目中能够快速构建复杂网格布局,轻松地创建可交互的网格布局,适用于构建面向用户的仪表盘、拖拽式页面布局等应用,提供良好的交互体验。通常用于自定义搭建页面中,例如我们公司用到自定义搭建工作台系统等等React Grid Layou组件库的特点有:可拖拽、可调整大小,适应不同需求、自动适应支持响应式断点、设置组件的对齐方式和间距、支持自定义的组件和布局等等本篇文章将带你了解如何使用 RGL(React Grid Layout),以及核心功能断点布局、网格布局、以及缩放、拖拽功能的代码实现。2. 使用下载 npm 包npm install react-grid-layout引入 RGL(react-grid-layout)import GridLayout from "react-grid-layout";设置初始化布局// 布局属性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 ( 组件A 组件B 组件C )效果图3. 源码实现3.1 断点布局实现首先我们要了解什么是断点布局?断点布局(Breakpoint layout)是一种响应式布局的设计方法,用于在不同的屏幕尺寸的显示和布局。断点布局和网格布局不同点在于,断点布局需要根据不同屏幕大小的断点来设置不同的布局,例如下面代码,定义 lg、md、sm、xs 四个断点 ,并设置每一个断点对应的列数和布局。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 ( Component A Component B );};断点布局实现的关键是获取并监听屏幕宽度的变化,这里使用了 resize-observer-polyfill 组件库,可以兼容旧浏览器实现元素大小的变化。首先我们创建一个 ResizeObserver 实例,在回调函数中获取目标元素的宽度,并通过 setState 更新。下面是获取屏幕宽度的主要代码:import ResizeObserver from 'resize-observer-polyfill';// 引入resize-observer-polyfillthis.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 方法根据断点,返回当前的布局。下面的核心代码实现:// 判断断点是否变化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 的转换,下面的这张图方便我们理解:实现代码如下: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 - 定位当我们要知道子组件的定位时,需要计算子组件到顶部和左边的距离和子组件的宽高,实现代码如下: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 方法代码如下:export function calcGridColWidth(positionParams: ositionParams): number { const { margin, containerPadding, containerWidth, cols } = positionParams; return ( (containerWidth - margin[0] * (cols - 1) - containerPadding[0] * 2) / cols );}计算网格项目宽高网格项目的大小 = 所有子组件 child 实际占的大小 + 子组件 child 之间的边距大小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 类名的工具,使得合并和处理类名变得更加简单和灵活。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 事件等等,代码如下:mixinDraggable( child: ReactElement, isDraggable: boolean): ReactElement { return ( {child} );}onDragStart - 开始拖拽在开始拖拽事件中,做了以下事情:获取当前拖拽元素获取最近祖先元素中含有定位属性元素获取以上两种元素的定位信息首先如何获取当前拖拽元素?在 DraggableCore 组件中的回调函数提供了一个包含拖拽事件相关信息的回调数据对象叫作 ReactDraggableCallbackData,里面的属性包含当前被拖拽的元素节点 node。第二步如何获取最近祖先元素中含有定位属性元素?在原生 js 中有个 HTMLElement.offsetParent 属性,通过 node.offsetParent 可以获取父级含有定位属性元素最后通过 DOM 方法中的 getBoundingClientRect 分别获取它们的定位信息对当前元素计算最新定位具体代码如下:onDragStart: (Event, ReactDraggableCallbackData) => void = (e, { node }) => { const { onDragStart, transformScale } = this.props; if (!onDragStart) return; const newPosition: artialPosition = { 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主要代码实现如下: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.jsexport function clamp(num: number,lowerBound: number,upperBound: number): number { return Math.max(Math.min(num, upperBound), lowerBound);}onDragStop - 拖拽结束记录下拖拽后新位置,把拖拽计算的 top、left 等定位信息通过 calcXY 函数计算新的位置为 x,y 并保存下来。onDragStop: (Event, ReactDraggableCallbackData) => void = (e, { node }) => { ... const newPosition: artialPosition = { 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,并给类目设置样式.droppable-element { ... background: #fdd;}此外我们回顾一下上面子组件渲染的时候,有一个合并样式,其中合并 className 里有一项是:"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 可缩放的最小和最大宽高。代码如下: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 ( {child} );}从上面的代码中我们还看到在 Resizable 组件中调用了一些拖拽事件例如:onResizeStart、onResizeStop、onResize 分别用于处理调整大小开始时、结束时、过程中触发的事件。都共同调用了 onResizeHandler 方法,下面我们来看下 onResizeHandler 函数:onResizeHandler 函数用来更新组件的宽度和高度,调整组件的位置和边界,重新计算并更新布局,发送请求或触发其他副作用。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-layouthttps://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_grid_layout,Base 杭州,一个富有激情和技术匠心精神的成长型团队。前端团队,一个年轻富有激情和创造力的前端团队。团队现有 80 余个前端小伙伴,平均年龄 27 岁,近 4 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、智能化平台、性能体验、云端应用、数据分析、错误监控及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com
|
|