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>
// WithSortableElementcomponentDidMount() { 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时候的eventhandleSortMove = (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多平台公布