2022 年末了,react 拖拽组件和最牛的代码调试技巧一起学!
前言
最近刷到了利用 H5drag
、drop
api 进行拖拽组件实现的代码示例,于是想学习一下业界知名的一些拖拽组件。于是想从学习成本较低的react-sortable-hoc
开始看起。那么对于一个学习者而言,我们应该如何地去优雅地学习第三方库呢?
当然是「调试」啦。
调试
首先第一步,我们随便创建一个 react 项目,并且按照react-sortable-hoc
的最简单的案例编写后准备调试。
import {
SortableContainer,
SortableElement,
SortableHandle,
arrayMove,
} from 'react-sortable-hoc';
import { Component } from 'react';
const DragHandle = SortableHandle(() => (
));
const SortableItem = SortableElement(({ value }) => (
{value}
));
const MySortableContainer = SortableContainer(({ children }) => {
return {children}
;
});
export default class Sort extends Component {
state = {
items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6'],
};
onSortEnd = ({ oldIndex, newIndex }) => {
this.setState(({ items }) => ({
items: arrayMove(items, oldIndex, newIndex),
}));
};
render() {
const { items } = this.state;
return (
{items.map((value, index) => (
))}
);
}
}
比如说我们想看看SortableHandler
里面的具体实现,我们给它打个断点,并且创建一个 vscode debug 配置:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch", // 选择launch表示会同时启动debug client和debug server
"name": "Launch Chrome against localhost",
// 这里选择监听webpack dev server启动的地址
"url": "http://localhost:8080"
}
]
}
按F5
开启调试后我们进入SortableHandler
中,看到的却是经过打包后的产物:
这显然非常不利于去读懂代码。那么我们该如何将它变成我们能看得懂的源码呢?答案就是sourcemap
!
sourcemap
就是用于表示打包后代码和源码的映射关系。因此我们只需要开启 sourcemap 就可以进行 debug 的源码的映射。
我们将react-sortable-hoc
项目 clone 下来(这里只拉取一层 commit、一个 master 分支):
git clone --depth=1 --single-branch https://github.com/clauderic/react-sortable-hoc.git
我们可以发现整个项目是使用rollup
进行打包的,我们只需要配置一下 sourcemap 开启:
类似:
...
output: {
name: 'SortableHOC',
file: minify ? pkg["umd:main"].replace('.js', '.min.js') : pkg["umd:main"],
format: 'umd',
sourcemap: true,
...
},
然后执行npm run build
,将打包好的 dist 文件夹替换至node_modules/react-sortable-hoc/dist
目录下。接着在我们测试项目中将其引入路径改为:
import {
SortableContainer,
SortableElement,
SortableHandle,
arrayMove,
} from 'react-sortable-hoc/dist/react-sortable-hoc';
然后我们再来运行一下 debug 试试看:
瞧!这是不是非常熟悉呢?利用调试我们可以随时随地打断点,知道变量的运行时,读起源码来是不是非常轻松呢?
【注】有的小伙伴可能会发现在调试的时候,打开的源码文件是只读模式,这是为什么呢?
我们可以在 vscode 左侧的CALL STACK中找到当前文件映射到的目录。
如果是node_modules/react-sortable-hoc/src/.../xxx.js
,就证明你映射到的只是node_modules
中的路径,是无法更改的。
这时候,你可以点击该文件对应的.js.map
文件,将其中的../src/xxx.js
路径改成你克隆下来的react-sortable-hoc
的路径。这样的话,映射到的目录就是你本地的文件,就可以编辑啦!!~
我们修改过
node_modules
下的文件但又不想被覆盖,可以使用patch-package
这个包。
npx patch-package react-sortable-hoc
可以生成一个 diff 文件,上传至 GitHub 上,别人 clone 后只需要运行npx patch-package
即可将 diff 覆盖到node_modules
下
源码阅读
组件的初始化
我们首先来梳理一下示例代码的组件嵌套:
SortableContainer
>> SortableElement
>> SortableHandler
我们先从组件的初始化入手,从外到内一层一层解析:
SortableContainer
// WithSortableContainer.
// 注意这两个events不一样!!!!!
events = {
end: ['touchend', 'touchcancel', 'mouseup'],
move:['touchmove', 'mousemove'],
start:['touchstart', 'mousedown']
}
// Class部分
constructor(props) {
super(props);
const manager = new Manager();
this.manager = manager;
this.wrappedInstance = React.createRef();
this.sortableContextValue = {manager};
this.events = {
end: this.handleEnd,
move: this.handleMove,
start: this.handleStart,
};
}
componentDidMount() {
const {useWindowAsScrollContainer} = this.props;
const container = this.getContainer();
Promise.resolve(container).then((containerNode) => {
// ========== 获取本身node节点、document、window对象
this.container = containerNode;
this.document = this.container.ownerDocument || document;
const contentWindow =
this.props.contentWindow || this.document.defaultView || window;
this.contentWindow =
typeof contentWindow === 'function' ? contentWindow() : contentWindow;
// ========== 默认的滚动容器是本身
this.scrollContainer = useWindowAsScrollContainer
? this.document.scrollingElement || this.document.documentElement
: getScrollingParent(this.container) || this.container;
// ========== 绑定事件 兼容h5和移动端
Object.keys(this.events).forEach((key) =>
events[key].forEach((eventName) =>
this.container.addEventListener(eventName, this.events[key], false),
),
);
});
}
可以发现SortableContainer
来初始化的时候,获取了各种 dom 结构以及绑定好了事件。
除此之外,它 new 了一个Manager
作为总的拖拽管理中心。其主要功能如下:「注册并储存可拖拽的子节点」,「记录当前激活节点的 index」,「根据 index 进行 sort」:
// 总的结构如下:~~
// Manager {
// refs: {
// collection: [node {sortableInfo {index}}]
// },
// active: {index, collection}
// }
export default class Manager {
refs = {};
isActive() {
return this.active;
}
getActive() {
return this.refs[this.active.collection].find(
// eslint-disable-next-line eqeqeq
({ node }) => node.sortableInfo.index == this.active.index,
);
}
getOrderedRefs(collection = this.active.collection) {
return this.refs[collection].sort(sortByIndex);
}
... ...
}
function sortByIndex(
{
node: {
sortableInfo: { index: index1 },
},
},
{
node: {
sortableInfo: { index: index2 },
},
},
) {
return index1 - index2;
}
最后,它渲染函数是这样的:
render() {
return (
);
}
即通过Provider
将全局 Manager 对象传递给了子组件。
SortableElement
// WithSortableElement
componentDidMount() {
this.register();
}
register() {
const {collection, disabled, index} = this.props;
// 找到当前node节点
const node = reactDom.findDOMNode(this);
// sortableInfo结构
node.sortableInfo = {
collection,
disabled,
index,
manager: this.context.manager,
};
this.node = node;
this.ref = {node};
this.context.manager.add(collection, this.ref);
}
我们可以看到,其实SortableElement
的初始化只是将自身节点以及一些属性信息注册到了全局Manager
对象中。
SortableHandle
SortableHandle
的代码就更简单了,只是在自身 dom 上添加了一个sortableHandle
的标识,用于判断用户当前点击的节点是否是SortableHandle
。这部分逻辑我们在下面就可以看到~
事件触发
了解了各个组件的初始化流程之后,我们可以开始调试拖拽的整个过程的实现逻辑了~
首先我们要知道,所有的事件都是注册在SortableContainer
中的,因此我们只需要对其进行调试即可。
拖拽触发事件顺序如下图:
下面让我们来看一下各种事件的逻辑吧:
handleStart
handleStart = (event) => {
const {distance, shouldCancelStart} = this.props;
// 如果是右键或者是input等默认标签则不触发
if (event.button === 2 || shouldCancelStart(event)) {
return;
}
this.touched = true;
this.position = getPosition(event);
// 寻找被激活拖拽的子节点
// 条件:SortableElment 而且 当前没有别的激活节点
const node = closest(event.target, (el) => el.sortableInfo != null);
if (
node &&
node.sortableInfo &&
this.nodeIsChild(node) &&
!this.state.sorting
) {
const {useDragHandle} = this.props;
const {index, collection, disabled} = node.sortableInfo;
// ...
// 如果声明了useDragHandle但是没有激活drag Handler则不生效
if (useDragHandle && !closest(event.target, isSortableHandle)) {
return;
}
this.manager.active = {collection, index};
if (!distance) {
if (this.props.pressDelay === 0) {
this.handlePress(event);
} else {
this.pressTimer = setTimeout(
() => this.handlePress(event),
this.props.pressDelay,
);
}
}
}
};
在handleStart
的这个回调函数中,我们可以发现它主要做了一下事情:
-
- 从
e.target
向上寻找到可拖拽节点,并且记录其信息(index
等)
- 从
-
- 记录各种信息,比如设置
touched
为 true,设置当前激活节点
- 记录各种信息,比如设置
-
- 最后触发
handlePress
回调函数
- 最后触发
handlePress
handlePress = async (event) => {
const active = this.manager.getActive();
if (active) {
const {
axis,
getHelperDimensions,
helperClass,
hideSortableGhost,
updateBeforeSortStart,
onSortStart,
useWindowAsScrollContainer,
} = this.props;
const {node, collection} = active;
const {isKeySorting} = this.manager;
// ...
// 计算当前激活元素以及container的图形指标(长宽高、坐标、边距等)
// ...
const {index} = node.sortableInfo;
// ...
// 默认是body,即在body插入一个激活节点的克隆节点,并为其插入计算好的属性!!
this.initialOffset = getPosition(event); // 一开始点击时的初始偏移
this.helper = this.helperContainer.appendChild(cloneNode(node));
setInlineStyles(this.helper, {
boxSizing: 'border-box',
height: `${this.height}px`,
left: `${this.boundingClientRect.left - margin.left}px`,
pointerEvents: 'none',
position: 'fixed',
top: `${this.boundingClientRect.top - margin.top}px`,
width: `${this.width}px`,
});
// 计算激活节点可拖拽的距离
if (this.axis.x) {
this.minTranslate.x =
(useWindowAsScrollContainer ? 0 : containerBoundingRect.left) -
this.boundingClientRect.left -
this.width / 2;
this.maxTranslate.x =
(useWindowAsScrollContainer
? this.contentWindow.innerWidth
: containerBoundingRect.left + containerBoundingRect.width) -
this.boundingClientRect.left -
this.width / 2;
}
if (this.axis.y) {
this.minTranslate.y =
(useWindowAsScrollContainer ? 0 : containerBoundingRect.top) -
this.boundingClientRect.top -
this.height / 2;
this.maxTranslate.y =
(useWindowAsScrollContainer
? this.contentWindow.innerHeight
: containerBoundingRect.top + containerBoundingRect.height) -
this.boundingClientRect.top -
this.height / 2;
}
this.listenerNode = event.touches ? event.target : this.contentWindow;
events.move.forEach((eventName) =>
this.listenerNode.addEventListener(
eventName,
this.handleSortMove,
false,
),
);
events.end.forEach((eventName) =>
this.listenerNode.addEventListener(
eventName,
this.handleSortEnd,
false,
),
);
this.setState({
sorting: true,
sortingIndex: index,
});
};
注意看,这个函数有一个比较关键的思想:就是利用克隆节点来模拟正在拖拽的节点。计算并记录好所需要的图形指标并且赋值到新节点上,并且设置position:fixed
。
最后在绑定上move
事件的监听----handleSortMove
.
handleSortMove
// 注意,这里是move时候的event
handleSortMove = (event) => {
const {onSortMove} = this.props;
// Prevent scrolling on mobile
if (typeof event.preventDefault === 'function' && event.cancelable) {
event.preventDefault();
}
this.updateHelperPosition(event);
this.animateNodes();
this.autoscroll();
};
函数本身很简洁,首先是updateHelperPosition
。
updateHelperPosition
updateHelperPosition(event) {
const offset = getPosition(event);
const translate = {
x: offset.x - this.initialOffset.x,
y: offset.y - this.initialOffset.y,
};
// css translate3d
setTranslate3d(this.helper, translate);
}
updateHelperPosition
的代码经过清理后,核心就在于对克隆元素设置translate
,来模拟拖拽的过程。
其次就是最重要的animateNodes
函数了。
animateNodes() {
const nodes = this.manager.getOrderedRefs();
// ...
for (let i = 0, len = nodes.length; i < len; i++) {
const {node} = nodes[i];
const {index} = node.sortableInfo;
const width = node.offsetWidth;
const height = node.offsetHeight;
const offset = {
height: this.height > height ? height / 2 : this.height / 2,
width: this.width > width ? width / 2 : this.width / 2,
};
const translate = {
x: 0,
y: 0,
};
let {edgeOffset} = nodes[i];
// If we haven't cached the node's offsetTop / offsetLeft value
// getEdgeOffset获取当前元素基于页面的偏移值
if (!edgeOffset) {
edgeOffset = getEdgeOffset(node, this.container);
nodes[i].edgeOffset = edgeOffset;
}
// Get a reference to the next and previous node
const nextNode = i < nodes.length - 1 && nodes[i + 1];
const prevNode = i > 0 && nodes[i - 1];
// Also cache the next node's edge offset if needed.
// We need this for calculating the animation in a grid setup
if (nextNode && !nextNode.edgeOffset) {
nextNode.edgeOffset = getEdgeOffset(nextNode.node, this.container);
}
// If the node is the one we're currently animating, skip it
if (index === this.index) {
if (hideSortableGhost) {
/*
* With windowing libraries such as `react-virtualized`, the sortableGhost
* node may change while scrolling down and then back up (or vice-versa),
* so we need to update the reference to the new node just to be safe.
*/
this.sortableGhost = node;
setInlineStyles(node, {
opacity: 0,
visibility: 'hidden',
});
}
continue;
}
if (transitionDuration) {
setTransitionDuration(node, transitionDuration);
}
if ((index > this.index &&
// 拖拽下移:
// 激活元素偏移值 + (scroll) + 自身元素高度 >= 当前遍历元素的偏移值
sortingOffset.top + windowScrollDelta.top + offset.height >= edgeOffset.top))
{
translate.y = -(this.height + this.marginOffset.y);
this.newIndex = index;
} else if (
(index < this.index &&
// 拖拽上移:
// 激活元素偏移值 + (scroll) <= 当前遍历元素的偏移值 + 自身元素的高度
sortingOffset.top + windowScrollDelta.top <=
edgeOffset.top + offset.height)
) {
translate.y = this.height + this.marginOffset.y;
if (this.newIndex == null) {
this.newIndex = index;
}
}
setTranslate3d(node, translate);
nodes[i].translate = translate;
}
}
这里包含了拖拽排序最核心的节点移动逻辑。核心思想如下:
遍历所有sortableElement
,如果是当前激活节点,则把原有节点透明化。(因为有克隆节点了);如果不是,则判断激活节点的坐标以及当前遍历元素的坐标的大小,依此来进行translate3d
的动画。
handleSortEnd
最后,当拖拽结束后,触发handleSortEnd
。主要逻辑是做一些善后处理,清理各种事件监听器,全局 Manager 的变化,本身被拖拽元素恢复透明度等。。
handleSortEnd = (event) => {
const { hideSortableGhost, onSortEnd } = this.props;
const {
active: { collection },
isKeySorting,
} = this.manager;
const nodes = this.manager.getOrderedRefs();
// 清除绑定的事件监听器
if (this.listenerNode) {
events.move.forEach((eventName) =>
this.listenerNode.removeEventListener(
eventName,
this.handleSortMove,
),
);
events.end.forEach((eventName) =>
this.listenerNode.removeEventListener(
eventName,
this.handleSortEnd,
),
);
}
// Remove the helper from the DOM
this.helper.parentNode.removeChild(this.helper);
// 当前元素恢复透明度
if (hideSortableGhost && this.sortableGhost) {
setInlineStyles(this.sortableGhost, {
opacity: '',
visibility: '',
});
}
for (let i = 0, len = nodes.length; i < len; i++) {
// 清除节点的自定义属性
const node = nodes[i];
const el = node.node;
// Clear the cached offset/boundingClientRect
node.edgeOffset = null;
node.boundingClientRect = null;
// Remove the transforms / transitions
setTranslate3d(el, null);
setTransitionDuration(el, null);
node.translate = null;
}
// Update manager state
this.manager.active = null;
this.manager.isKeySorting = false;
this.setState({
sorting: false,
sortingIndex: null,
});
// 这里的newIndex和oldIndex指的是激活元素变化前后的索引
if (typeof onSortEnd === 'function') {
onSortEnd(
{
collection,
newIndex: this.newIndex,
oldIndex: this.index,
isKeySorting,
nodes,
},
event,
);
}
this.touched = false;
};
总结
到这里,整个react-sortable-hoc
实现的大致思想就全部介绍完毕啦。它并没有利用 h5 的drag
api,而是利用mousemove
、touchmove
之类的事件实现 h5 和移动端的兼容。利用 css3 的动画来实现 sort 效果。
但实现过程中也有一些缺点。
比如reactDom.findDomNode
api,react 并不推荐使用它来去获取 dom,可以换成ref
。
比如只能在react类组件中使用。
其他
觉得封装的比较好的工具函数用于学习记录:
- 判断是否可以滚动
function isScrollable(el) {
const computedStyle = window.getComputedStyle(el);
const overflowRegex = /(auto|scroll)/;
const properties = ['overflow', 'overflowX', 'overflowY'];
return properties.find((property) =>
overflowRegex.test(computedStyle[property]),
);
}
- 获取当前元素距离窗口的偏移值(也可以使用
elm.getBoundingClientRect()
)
export function getEdgeOffset(node, parent, offset = {left: 0, top: 0}) {
if (!node) {
return undefined;
}
// Get the actual offsetTop / offsetLeft value, no matter how deep the node is nested
const nodeOffset = {
left: offset.left + node.offsetLeft,
top: offset.top + node.offsetTop,
};
if (node.parentNode === parent) {
return nodeOffset;
}
return getEdgeOffset(node.parentNode, parent, nodeOffset);
}
- 移动数组内元素
export function arrayMove(array, from, to) {
array = array.slice();
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array;
}
- 过滤对象某些属性
export function omit(obj, keysToOmit) {
return Object.keys(obj).reduce((acc, key) => {
if (keysToOmit.indexOf(key) === -1) {
acc[key] = obj[key];
}
return acc;
}, {});
}
本文由mdnice多平台发布