共计 13330 个字符,预计需要花费 34 分钟才能阅读完成。
2022 年末了,react 拖拽组件和最牛的代码调试技巧一起学!
前言
最近刷到了利用 H5drag
、drop
api 进行拖拽组件实现的代码示例,于是想学习一下业界出名的一些拖拽组件。于是想从学习老本较低的 react-sortable-hoc
开始看起。那么对于一个学习者而言,咱们应该如何地去优雅地学习第三方库呢?
当然是 <span style=”color:#d2568c;font-weight:bold;”>「调试」</span> 啦。
调试
首先第一步,咱们轻易创立一个 react 我的项目,并且依照 react-sortable-hoc
的最简略的案例编写后筹备调试。
import {
SortableContainer,
SortableElement,
SortableHandle,
arrayMove,
} from 'react-sortable-hoc';
import {Component} from 'react';
const DragHandle = SortableHandle(() => (<span></span>));
const SortableItem = SortableElement(({value}) => (
<li>
<DragHandle />
{value}
</li>
));
const MySortableContainer = SortableContainer(({children}) => {return <ul>{children}</ul>;
});
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 (<MySortableContainer onSortEnd={this.onSortEnd} useDragHandle>
{items.map((value, index) => (<SortableItem key={`item-${value}`} index={index} value={value} />
))}
</MySortableContainer>
);
}
}
比如说咱们想看看 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 左侧的 <span style=”color:#d2568c;font-weight:bold;”>CALL STACK</span> 中找到以后文件映射到的目录。
如果是 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
咱们先从组件的初始化动手,从外到内一层一层解析:
<span style=”color:#2b4490″>SortableContainer</span>
// 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
作为总的拖拽管理中心。其次要性能如下:<span style=”color:#d2568c”>「注册并贮存可拖拽的子节点」</span>,<span style=”color:#d2568c”>「记录以后激活节点的 index」</span>,<span style=”color:#d2568c”>「依据 index 进行 sort」</span>:
// 总的构造如下:~~
// 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 (<SortableContext.Provider value={this.sortableContextValue}>
<WrappedComponent {...omit(this.props, omittedProps)} />
</SortableContext.Provider>
);
}
即通过 Provider
将全局 Manager 对象传递给了子组件。
<span style=”color:#2b4490″>SortableElement</span>
// 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
对象中。
<span style=”color:#2b4490″>SortableHandle</span>
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 多平台公布