《Sortable.js》 Code Reading

2025, May 11    

In the previous project when I was working for HoYoverse, there are some requirements for drag-and-drop sorting of elements, and I used Sortable.JS to implement it. In 2024.December In my personal kanban, I left a pending action item for myself to analyze the source code of Sortable.JS, which I finally got around to doing in 2025.May 😂 …

⬇️ try drag and drop the list example below ⬇️

Item 1.1
Item 2.1
Item 2.2
Item 3.1
Item 3.2
Item 3.3
Item 3.4
Item 2.3
Item 2.4
Item 1.2

This post is a brief analysis of the source code of Sortable.JS, which is a very popular library for drag-and-drop sorting.

Project Structure

Execute tree -L 2 src scripts entry to get the project structure:

src
├── Animation.js
├── BrowserInfo.js
├── EventDispatcher.js
├── PluginManager.js        // plugin management, which allows extending Sortable.JS with additional features
├── Sortable.js             // the core implementation of Sortable.JS, which contains the main logic for drag-and-drop 
└── utils.js
scripts
├── banner.js
├── build.js                // based build configuration
├── esm-build.js            // build configuration for ESM format, which is used for modern JavaScript modules
├── minify.js               // minification configuration
├── test-compat.js
├── test.js
└── umd-build.js            // build configuration for UMD format, which is used for compatibility with various module systems
entry
├── entry-complete.js           
├── entry-core.js
└── entry-defaults.js

Run the Project

  • Start a static service hosting proxy from the root directory: npx serve .
  • Open a new window and run the command npm run build:umd:watch to watch for real-time edits in the sortable file.

Configuration Items

When using this library, here are some core configuration options that enhances its utility:

  • filter: This option helps you specify elements within a draggable list that should not be draggable. It acts as a filter to exclude certain child elements from being impacted by the drag-and-drop functionality.
  • handle: With this option, you can define specific elements within the draggable item that can be used to initiate a drag operation. This is useful when you only want particular parts of an item to be draggable.
  • chosenClass: This is a CSS class applied to the element that is currently being dragged. It allows you to style the dragging item, helping in highlighting or differentiating it visually during the drag operation.
  • ghostClass: While an item is being dragged, a “ghost” placeholder remains in its original position. The ghostClass lets you apply styles to this placeholder, helping maintain layout consistency and providing visual cues during the drag-and-drop process.
  • store: This option is particularly useful for persistence. It can store an array of data-id attributes reflecting the order of elements post-action (e.g., after sorting). This helps track changes and maintain the sequence across sessions or page reloads.

Code Reading

Initialization

From an external perspective, initializing Sortable.js is straightforward; you just need to provide an elementId and the relevant configuration parameters.

new Sortable(document.getElementById('example1'), {
    animation: 150,
    ghostClass: 'blue-background-class'
});

From an internal perspective, within Sortable.js, the prototype property is established. The prototype primarily contains private functions, though a few public functions are also exposed.

/**
 * @class  Sortable
 * @param  {HTMLElement}  el
 * @param  {Object}       [options]
 */
function Sortable(el, options) {
  // ...
}

Sortable.prototype = /** @lends Sortable.prototype */ {
	constructor: Sortable,

  // 私有函数, 这里以 `_` 前缀进行标识 
	_isOutsideThisEl: function(target) {
		if (!this.el.contains(target) && target !== this.el) {
			lastTarget = null;
		}
	},
  
  _getDirection: function(evt, target) { ... },
  _delayedDragTouchMoveHandler: function (/** TouchEvent|PointerEvent **/e) { ... },
  _disableDelayedDrag: function () { ... },
  _disableDelayedDragEvents: function () { ... },

  // 公有函数 (目前这里只有个别)
  toArray: function () { ... },
  sort: function () { ... },
  save: function () { ... },
}

The construction process of the Sortable instance is shown below, which can be further divided into several steps:

  • check node type, only HTMLElement is supported
  • prepare the full default configuration items, which further merges the user-provided configuration options
  • prepare the event handlers for touch/pointer events.
function Sortable(el, options) {
	// ZY_NOTE: only handles when el == DOM element type
	if (!(el && el.nodeType && el.nodeType === 1)) {
		throw `Sortable: \`el\` must be an HTMLElement`;
	}

	this.el = el;
	this.options = options = Object.assign({}, options);

	// Export instance
	el[expando] = this;

    // ZY_Note: the default configuration for sortable instance;
	let defaults = {
		group: null,
		sort: true,
		disabled: false,
		...
	};

	PluginManager.initializePlugins(this, el, defaults);

	// Set default options
	for (let name in defaults) {
		!(name in options) && (options[name] = defaults[name]);
	}

	_prepareGroup(options);

	// ZY_Note: using Object.bind to enhance the context for all private method, ensure 
    // all the binding 
	for (let fn in this) {
		if (fn.charAt(0) === '_' && typeof this[fn] === 'function') {
			this[fn] = this[fn].bind(this);
		}
	}

	// Bind events
	// ZY_Note: supporting multiple types of input:
	// touch events: https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Using_Touch_Events
	// pinter events: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
	if (options.supportPointer) {
		on(el, 'pointerdown', this._onTapStart);
	} else {
		on(el, 'mousedown', this._onTapStart);
		on(el, 'touchstart', this._onTapStart);
	}

	if (this.nativeDraggable) {
		on(el, 'dragover', this);
		on(el, 'dragenter', this);
	}

	sortables.push(this.el);

	// Restore sorting
	options.store &&
		options.store.get &&
		this.sort(options.store.get(this) || []);

	// Add animation state manager
	Object.assign(this, AnimationStateManager());
}

Gesture Trigger

Regardless of whether the input comes from a mouse, gesture, or touch hardware, all entry calls are ultimately routed to the _onTapStart: function (/** Event or TouchEvent */evt) handler for processing. Before the gesture is officially recognized and the drag logic begins execution, the following steps are performed:

  • Filter logic handling: if the currently dragged element matches with the filter selector, the current gesture will be aborted.
if (filter) {
  filter = filter.split(',').some(function (criteria) {
    criteria = closest(originalTarget, criteria.trim(), el, false);

    if (criteria) {
      _dispatchEvent({
        sortable: _this,
        rootEl: criteria,
        name: 'filter',
        targetEl: target,
        fromEl: el,
        toEl: el
      });
      pluginEvent('filter', _this, { evt });
      return true;
    }
  });

  if (filter) {
    preventOnFilter && evt.preventDefault();
    return; // cancel dnd
  }
}
  • Handle logic processing: if the currently dragged element does not match a handle selector and a handle selector has been explicitly set, the current gesture will be aborted.
// ZY_NOTE: 在设置handle 后,发现不属于当前handle class
if (options.handle && !closest(originalTarget, options.handle, el, false)) {
  return;
}

Special attention is given to the use of the closest() function in utils.js: it is used to query the currently clicked element and the nearest ancestor that matches a specified CSS selector. By default, the selector is set to ‘>*’, which means the function will select the current element itself.

function closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx, includeCTX) {
	if (el) {
		ctx = ctx || document;

		do {
			if (
				selector != null &&
				(
					selector[0] === '>' ?
					el.parentNode === ctx && matches(el, selector) :
					matches(el, selector)
				) ||
				includeCTX && el === ctx
			) {
				return el;
			}

			if (el === ctx) break;
			/* jshint boss:true */
		} while (el = getParentOrHost(el));
	}

	return null;
}
  • Immediately after that, the private function _prepareDragStart is called. This function calculates the current gesture position and sets the draggable attribute on the dragElement, enabling it to become draggable. At the same time, _dragStarted is invoked to highlight the current dragElement and create the placeholder object.
if (!FireFox && _this.nativeDraggable) {
  dragEl.draggable = true;
}
  • The process of creating the placeholder object can be referenced in the _appendGhost: function () method. After the placeholder is inserted, its position will be continuously updated in response to the dragover event handled by handleEvent.

After understanding the event triggering flow described above, we now turn our attention to the handling details that occur after the dragover event is triggered.

Dragging Process

All dragover events are further handled by the private _onDragOver function in Sortable.js. The following section provides a detailed explanation of the implementation during the drag process:

// ZY_NOTE: 获取目标元素的位置信息
targetRect = getRect(target);

let direction = 0,
  targetBeforeFirstSwap,
  differentLevel = dragEl.parentNode !== el,
  differentRowCol = !_dragElInRowColumn(dragEl.animated && dragEl.toRect || dragRect, target.animated && target.toRect || targetRect, vertical),
  side1 = vertical ? 'top' : 'left',
  scrolledPastTop = isScrolledPast(target, 'top', 'top') || isScrolledPast(dragEl, 'top', 'top'),
  scrollBefore = scrolledPastTop ? scrolledPastTop.scrollTop : void 0;

if (lastTarget !== target) {
  targetBeforeFirstSwap = targetRect[side1];
  pastFirstInvertThresh = false;
  isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
let sibling;
if (direction !== 0) {
  // Check if target is beside dragEl in respective direction (ignoring hidden elements)
  let dragIndex = index(dragEl);
  do {
    dragIndex -= direction;
    sibling = parentEl.children[dragIndex];
  } while (sibling && (css(sibling, 'display') === 'none' || sibling === ghostEl));
}
// If dragEl is already beside target: Do not insert
if (direction === 0 || sibling === target) {
  return completed(false);
}
lastTarget = target;
lastDirection = direction;
let nextSibling = target.nextElementSibling,
  after = false;
after = direction === 1;

// ZY_NOTE: 通过onMove 判断是否应该进行移动
let moveVector = onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, after);
if (moveVector !== false) {
  if (moveVector === 1 || moveVector === -1) {
    after = moveVector === 1;
  }
  _silent = true;
  setTimeout(_unsilent, 30);
  capture();
  // ZY_NOTE: 判断是否需要进行append 或者位置插入
  if (after && !nextSibling) {
    // ZY_NOTE: 如果是向后插入,且后面没有sibling,那么直接改为append;
    el.appendChild(dragEl);
  } else {
    // ZY_NOTE: 其他情况下,直接使用insertBefore 插入当前元素 / 或者改变当前元素的位置。
    target.parentNode.insertBefore(dragEl, after ? nextSibling : target);
  }

  // ZY_NOTE:chrome 游览器滑动效果兼容逻辑
  if (scrolledPastTop) {
    scrollBy(scrolledPastTop, 0, scrollBefore - scrolledPastTop.scrollTop);
  }
  parentEl = dragEl.parentNode; 

  // must be done before animation
  if (targetBeforeFirstSwap !== undefined && !isCircumstantialInvert) {
    targetMoveDistance = Math.abs(targetBeforeFirstSwap - getRect(target)[side1]);
  }
  changed();
  return completed(true);
}

Conclusion

As a lightweight JavaScript library for creating sortable lists with drag-and-drop functionality, Sortable.js is highly efficient and extensible in handling drag-and-drop operations. When analyzing the source code of Sortable.js, there are several noteworthy aspects that are worth studying and learning from:

  • Maintaining a comprehensive set of event: The library uses custom events to allow users to hook into key interactions, such as drag start, dragging, and drag end. This approach enhances code flexibility and testability. Plugin-Events also serve as the core mechanism for interaction with plugins by default. Each time a pluginEvent is triggered, it may be handled by corresponding prototype chain functions within the plugin system.
pluginEvent(eventName, sortable, evt) {
  this.eventCanceled = false;
  evt.cancel = () => {
    this.eventCanceled = true;
  };
  const eventNameGlobal = eventName + 'Global';
  plugins.forEach(plugin => {
    if (!sortable[plugin.pluginName]) return;
    // Fire global events if it exists in this sortable
    if (
      sortable[plugin.pluginName][eventNameGlobal]
    ) {
      sortable[plugin.pluginName][eventNameGlobal]({ sortable, ...evt });
    }

    // Only fire plugin event if plugin is enabled in this sortable,
    // and plugin has event defined
    if (
      sortable.options[plugin.pluginName] &&
      sortable[plugin.pluginName][eventName]
    ) {
      sortable[plugin.pluginName][eventName]({ sortable, ...evt });
    }
  });
}

To understand how the above code works, you can take a quick glimpse at one of the default plugins provided by the library, such as sortablejs/plugins/swap.js(link).

  • Cross-browser compatibility: The library is designed to work across different browsers, including support for touch events on mobile devices.
  • Extensibility: Sortable.js is built with extensibility in mind, allowing developers to create custom plugins to add additional features or modify existing behavior. The PluginManager class is responsible for managing these plugins, enabling developers to easily integrate new functionalities into the core library.

References

TOC