[BetterScroll 源码]事件派发流程
BScrollConstructor类是入口,其中创建了Scroller实例,管理滚动动作。
Scroller中创建以下类的实例:
- Behavior 管理手势动作,计算滚动方向,滚动距离,边界计算
- Translater 修改滚动元素的css transform属性的值
- Transition 用transition实现滑动动画效果
- ActionsHandler 监听用户触发的事件(点触,滑动)
- ScrollerActions
下面看一下scroll事件的派发流程(移动端)。
查看BetterScroll的文档,scroll事件通过以下代码监听:
// bs 是BScrollConstructor的实例
bs.on('scroll', (position) => {
console.log(position.x, position.y)
})
BScrollConstructor类继承自EventEmitter,EventEmitter是典型的订阅发布模式,通过on
监听,通过trigger
触发。
看一下EventEmitter里的关键代码:
var EventEmitter = /** @class */ (function () {
// 参数names是一个数组,数组中的项是字符串
function EventEmitter(names) {
this.events = {};
this.eventTypes = {};
// 把names中的项添加到eventTypes中
this.registerType(names);
}
// 注册事件type
EventEmitter.prototype.on = function (type, fn, context) {
if (context === void 0) {
context = this;
}
this.hasType(type);
// 每一种type创建一个数组,保存事件处理函数和作用域
if (!this.events[type]) {
this.events[type] = [];
}
this.events[type].push([fn, context]);
return this;
};
// 触发事件,执行事件处理函数
EventEmitter.prototype.trigger = function (type) {
var args = [];
for (var _i = 1; _i < arguments.length; _i++) {
args[_i - 1] = arguments[_i];
}
this.hasType(type);
var events = this.events[type];
if (!events) {
return;
}
var len = events.length;
var eventsCopy = __spreadArrays(events);
var ret;
for (var i = 0; i < len; i++) {
var event_1 = eventsCopy[i];
var fn = event_1[0],
context = event_1[1];
if (fn) {
ret = fn.apply(context, args);
if (ret === true) {
return ret;
}
}
}
};
EventEmitter.prototype.registerType = function (names) {
var _this = this;
names.forEach(function (type) {
_this.eventTypes[type] = type;
});
};
// 检查是type是否在eventTypes上存在,如果不存在打印错误
EventEmitter.prototype.hasType = function (type) {
var types = this.eventTypes;
var isType = types[type] === type;
if (!isType) {
warn("EventEmitter has used unknown event type: \"" + type +
"\", should be oneof [" +
("" + Object.keys(types).map(function (_) {
return JSON.stringify(_);
})) +
"]");
}
};
return EventEmitter;
}());
eventTypes
属性保存事件名字,events
属性保存事件处理函数及其作用域。
调用on
函数是向events
对象对应键的数组中添加事件处理函数和作用域;调用trigger
函数是把events
对象对应键的数组中保存的时间处理函数全部执行。
比如最开始那段注册scroll
事件的代码,执行之后,bs的eventTypes
和events
属性值可能是:
bs.eventTypes = {
//...
scroll: 'scroll'
};
bs.events = {
//...
scroll: [fn1, bs]
};
bs注册的scroll
事件是通过Scroller类的实例冒泡触发的,相关源代码如下:
// 事件冒泡
BScrollConstructor.prototype.eventBubbling = function () {
// scroller的钩子事件冒泡到 BScrollConstructor
// 原理是BScrollConstructor注册的钩子,通过bubbling函数scroller也同样注册,并在注册回调里触发BScrollConstructor的钩子
bubbling(this.scroller.hooks, this, [
this.eventTypes.beforeScrollStart,
this.eventTypes.scrollStart,
this.eventTypes.scroll,
this.eventTypes.scrollEnd,
this.eventTypes.scrollCancel,
this.eventTypes.touchEnd,
this.eventTypes.flick
]);
};
bubbling
函数的代码:
function bubbling(source, target, events) {
events.forEach(function (event) {
var sourceEvent;
var targetEvent;
if (typeof event === 'string') {
sourceEvent = targetEvent = event;
} else {
sourceEvent = event.source;
targetEvent = event.target;
}
source.on(sourceEvent, function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return target.trigger.apply(target, __spreadArrays([targetEvent], args));
});
});
}
上面代码中scroller.hooks
监听了scroll
事件,在事件回调里触发了bs监听的scroll
事件。
那么看看scroller.hooks
的scroll
事件是如何触发的。
下面是相关源代码:
//...
this.actions = new ScrollerActions(this.scrollBehaviorX, this.scrollBehaviorY, this.actionsHandler, this.animater, this.options);
//...
Scroller.prototype.bindActions = function () {
var _this = this;
var actions = this.actions;
bubbling(actions.hooks, this.hooks, [
// ...
{
source: actions.hooks.eventTypes.scroll,
target: this.hooks.eventTypes.scroll,
},
// ...
]);
// ...
};
从上面的源码看出,Scroller的scroll
钩子也是通过冒泡机制触发的,不过是通过ScrollerActions的实例冒泡触发的。
那么再看一下ScrollerActions的scroll
钩子是如何触发的。
下面只列出关键代码:
// this.actionsHandler 是 ActionsHandler类的实例
ScrollerActions.prototype.bindActionsHandler = function () {
var _this = this;
// [mouse|touch]start event
this.actionsHandler.hooks.on(this.actionsHandler.hooks.eventTypes.start, function (
e) {
if (!_this.enabled)
return true;
return _this.handleStart(e);
});
// [mouse|touch]move event 向ActionsHandler的move事件上注册处理函数
// ActionsHandler的move事件在内部是通过touchmove浏览器事件派发的
this.actionsHandler.hooks.on(this.actionsHandler.hooks.eventTypes.move, function (
_a) {
var deltaX = _a.deltaX,
deltaY = _a.deltaY,
e = _a.e;
if (!_this.enabled)
return true;
var _b = applyQuadrantTransformation(deltaX, deltaY, _this.options.quadrant),
transformateDeltaX = _b[0],
transformateDeltaY = _b[1];
var transformateDeltaData = {
deltaX: transformateDeltaX,
deltaY: transformateDeltaY,
};
_this.hooks.trigger(_this.hooks.eventTypes.coordinateTransformation,
transformateDeltaData);
return _this.handleMove(transformateDeltaData.deltaX,
transformateDeltaData.deltaY, e);
});
// ...
};
ScrollerActions.prototype.handleMove = function (deltaX, deltaY, e) {
// ...
if (this.contentMoved && positionChanged) {
this.animater.translate({
x: newX,
y: newY
});
// 派发scroll事件(这不是真正的浏览器scroll事件,是通过 requestAnimationFrame 函数模拟的)
this.dispatchScroll(timestamp);
}
};
ScrollerActions.prototype.dispatchScroll = function (timestamp) {
// dispatch scroll in interval time
if (timestamp - this.startTime > this.options.momentumLimitTime) {
// refresh time and starting position to initiate a momentum
this.startTime = timestamp;
this.scrollBehaviorX.updateStartPos();
this.scrollBehaviorY.updateStartPos();
if (this.options.probeType === 1 /* Throttle */ ) {
this.hooks.trigger(this.hooks.eventTypes.scroll, Object.assign(this.getCurrentPos(),{t:'123'}));
}
}
// dispatch scroll all the time
if (this.options.probeType > 1 /* Throttle */ ) {
this.hooks.trigger(this.hooks.eventTypes.scroll, Object.assign(this.getCurrentPos(),{t:'456'}));
}
};
从上面代码的调用顺序可以看出,最终是在ActionsHandler类的move
钩子中触发了ScrollerActions的scroll
钩子。
下面看一下ActionsHandler类的相关源码:
var ActionsHandler = /** @class */ (function () {
function ActionsHandler(wrapper, options) {
this.wrapper = wrapper;
this.options = options;
// 注册该模块支持的钩子函数
this.hooks = new EventEmitter([
'beforeStart',
'start',
'move',
'end',
'click',
]);
this.handleDOMEvents();
}
ActionsHandler.prototype.handleDOMEvents = function () {
var _a = this.options,
bindToWrapper = _a.bindToWrapper,
disableMouse = _a.disableMouse,
disableTouch = _a.disableTouch,
click = _a.click;
var wrapper = this.wrapper;
var target = bindToWrapper ? wrapper : window;
var wrapperEvents = [];
var targetEvents = [];
var shouldRegisterTouch = !disableTouch;
var shouldRegisterMouse = !disableMouse;
if (click) {
wrapperEvents.push({
name: 'click',
handler: this.click.bind(this),
capture: true,
});
}
if (shouldRegisterTouch) {
wrapperEvents.push({
name: 'touchstart',
handler: this.start.bind(this),
});
targetEvents.push({
name: 'touchmove',
handler: this.move.bind(this),
}, {
name: 'touchend',
handler: this.end.bind(this),
}, {
name: 'touchcancel',
handler: this.end.bind(this),
});
}
if (shouldRegisterMouse) {
wrapperEvents.push({
name: 'mousedown',
handler: this.start.bind(this),
});
targetEvents.push({
name: 'mousemove',
handler: this.move.bind(this),
}, {
name: 'mouseup',
handler: this.end.bind(this),
});
}
// EventRegister的实例上有两个属性
// wrapper -- dom元素
// events -- dom元素上注册的事件([{name,handler}])
this.wrapperEventRegister = new EventRegister(wrapper, wrapperEvents);
this.targetEventRegister = new EventRegister(target, targetEvents);
};
ActionsHandler.prototype.move = function (e) {
if (eventTypeMap[e.type] !== this.initiated) {
return;
}
this.beforeHandler(e, 'move');
var point = (e.touches ? e.touches[0] : e);
var deltaX = point.pageX - this.pointX; // 水平方向上的偏移量
var deltaY = point.pageY - this.pointY; // 垂直方向上的偏移量
this.pointX = point.pageX;
this.pointY = point.pageY;
// 派发move事件
if (this.hooks.trigger(this.hooks.eventTypes.move, {
deltaX: deltaX,
deltaY: deltaY,
e: e,
})) {
return;
}
// 当滑动的触摸点超出视口区域(滚动区域)时
// auto end when out of viewport
// 读取元素滚动条到元素左边的距离,即元素在x方向上滚动的距离
var scrollLeft = document.documentElement.scrollLeft ||
window.pageXOffset ||
document.body.scrollLeft;
// 获取元素在垂直方向上的滚动距离
var scrollTop = document.documentElement.scrollTop ||
window.pageYOffset ||
document.body.scrollTop;
var pX = this.pointX - scrollLeft;// 触摸点距离视口(滚动区域)左侧的距离
var pY = this.pointY - scrollTop; // 触摸点距离视口(滚动区域)顶部的距离
var autoEndDistance = this.options.autoEndDistance;// line 898
if (pX > document.documentElement.clientWidth - autoEndDistance ||
pY > document.documentElement.clientHeight - autoEndDistance ||
pX < autoEndDistance ||
pY < autoEndDistance) {
// 触发end事件(滑动结束)
this.end(e);
}
};
return ActionsHandler;
}());
handleDOMEvents
函数的作用是向wrapper
元素(初始化bs时传入的根元素)注册点击事件,手势事件和鼠标事件,我们重点关注touchmove
事件。
touchmove
事件的处理函数是move
函数,move
函数中触发了自身的move
钩子。
至此我们追踪到的scroll
事件的派发链条是:
wrapper元素的touchmove事件 --> ActionsHandler的move钩子 --> ScrollerActions的scroll钩子 --> Scroller的scroll钩子 --> BScrollConstructor的scroll自定义事件
但是还没有完。
上面的派发链条是当用户的手指没有离开设备屏幕时的派发链条,当用户在设备屏幕上快速滑动一段距离时,也会出现滚动,这个滚动过程也会派发scroll
事件。
下面看一下用户手指离开设备屏幕后的scroll
事件是如何派发的。
相关源代码:
Scroller.prototype.bindAnimater = function () {
var _this = this;
// reset position
this.animater.hooks.on(this.animater.hooks.eventTypes.end, function (pos) {
if (!_this.resetPosition(_this.options.bounceTime)) {
_this.animater.setPending(false);
_this.hooks.trigger(_this.hooks.eventTypes.scrollEnd, pos);
}
});
bubbling(this.animater.hooks, this.hooks, [
{
source: this.animater.hooks.eventTypes.move,
target: this.hooks.eventTypes.scroll,
},
{
source: this.animater.hooks.eventTypes.forceStop,
target: this.hooks.eventTypes.scrollEnd,
},
]);
};
从源代码可以看出Scroller的scroll
钩子也会通过animater的move
钩子触发的。
创建animater的相关代码:
function createAnimater(element, translater, options) {
var useTransition = options.useTransition;
var animaterOptions = {};
//probeType 决定是否派发 scroll 事件,对页面的性能有影响,尤其是在 useTransition 为 true 的模式下。
Object.defineProperty(animaterOptions, 'probeType', {
enumerable: true,
configurable: false,
get: function () {
return options.probeType;
},
});
if (useTransition) {
// 使用transition实现动画
return new Transition(element, translater, animaterOptions);
} else {
// 使用requestAnimationFrame实现动画
return new Animation(element, translater, animaterOptions);
}
}
// ...
this.animater = createAnimater(this.content, this.translater, this.options);
// ...
根据实现滚动动画效果的方式不同,创建animater的类也不同,假设是transition实现的滚动动画,那么animater实例就是由Transition类创建的。
看一下Transition类的源代码:
var Transition = /** @class */ (function (_super) {
__extends(Transition, _super);
function Transition() {
return _super !== null && _super.apply(this, arguments) || this;
}
// 用来派发事件,不是用来制作动画
Transition.prototype.startProbe = function (startPoint, endPoint) {
var _this = this;
var prePos = startPoint;
var probe = function () {
var pos = _this.translater.getComputedPosition();
if (isValidPostion(startPoint, endPoint, pos, prePos)) {
// 派发scroll事件
_this.hooks.trigger(_this.hooks.eventTypes.move, pos);
}
// call bs.stop() should not dispatch end hook again.
// forceStop hook will do this.
/* istanbul ignore if */
if (!_this.pending) {
if (_this.callStopWhenPending) {
_this.callStopWhenPending = false;
} else {
// transition ends should dispatch end hook.
_this.hooks.trigger(_this.hooks.eventTypes.end, pos);
}
}
prePos = pos;
if (_this.pending) {
_this.timer = requestAnimationFrame(probe);
}
};
// when manually(手动地) call bs.stop(), then bs.scrollTo()
// we should reset callStopWhenPending to dispatch end hook
if (this.callStopWhenPending) {
this.setCallStop(false);
}
cancelAnimationFrame(this.timer);
probe();
};
Transition.prototype.move = function (startPoint, endPoint, time, easingFn) {
this.setPending(time > 0);
this.transitionTimingFunction(easingFn);
this.transitionProperty();
this.transitionTime(time);
this.translate(endPoint);
//probeType 为3表示任何时候都派发 scroll 事件,包括调用 scrollTo 或者触发 momentum 滚动动画
var isRealtimeProbeType = this.options.probeType === 3 /* Realtime */ ;
if (time && isRealtimeProbeType) {
this.startProbe(startPoint, endPoint);
}
// if we change content's transformY in a tick
// such as: 0 -> 50px -> 0
// transitionend will not be triggered
// so we forceupdate by reflow
if (!time) {
this._reflow = this.content.offsetHeight;
if (isRealtimeProbeType) {
this.hooks.trigger(this.hooks.eventTypes.move, endPoint);
}
this.hooks.trigger(this.hooks.eventTypes.end, endPoint);
}
};
// ...
return Transition;
}(Base));
Transition类的move
钩子是在startProbe
函数中触发的。
我们知道transition实现的动画效果是通过修改元素的css属性值,然后浏览器根据css相关属性的配置添加的动画效果。
整个动画过程我们只能控制动画的开始和结束,中间过程是没有提供任何API接口的。
所以在startProbe
函数中是通过requestAnimationFrame
递归模拟的scroll
事件。
下面继续梳理整个事件派发的链条。
startProbe
函数是在move
函数中调用的,而Transition类的move
函数是在Scroller类的scrollTo
函数中调用的,Scroller类的scrollTo
函数是在其momentum
函数中调用的,而momentum
函数是在ScrollerActions类的scrollEnd
钩子里调用的:
Scroller.prototype.bindActions = function () {
var _this = this;
var actions = this.actions;
// ...
actions.hooks.on(actions.hooks.eventTypes.scrollEnd, function (pos, duration) {
var deltaX = Math.abs(pos.x - _this.scrollBehaviorX.startPos);
var deltaY = Math.abs(pos.y - _this.scrollBehaviorY.startPos);
if (_this.checkFlick(duration, deltaX, deltaY)) {
_this.animater.setForceStopped(false);
_this.hooks.trigger(_this.hooks.eventTypes.flick);
return;
}
if (_this.momentum(pos, duration)) {
_this.animater.setForceStopped(false);
return;
}
if (actions.contentMoved) {
_this.hooks.trigger(_this.hooks.eventTypes.scrollEnd, pos);
}
if (_this.animater.forceStopped) {
_this.animater.setForceStopped(false);
}
});
};
Scroller.prototype.momentum = function (pos, duration) {
// ...
// when x or y changed, do momentum animation now!
if (meta.newX !== pos.x || meta.newY !== pos.y) {
// change easing function when scroller goes out of the boundaries
if (meta.newX > this.scrollBehaviorX.minScrollPos ||
meta.newX < this.scrollBehaviorX.maxScrollPos ||
meta.newY > this.scrollBehaviorY.minScrollPos ||
meta.newY < this.scrollBehaviorY.maxScrollPos) {
meta.easing = ease.swipeBounce;
}
this.scrollTo(meta.newX, meta.newY, meta.time, meta.easing);
return true;
}
};
Scroller.prototype.scrollTo = function (x, y, time, easing, extraTransform) {
// ...
this.animater.move(startPoint, endPoint, time, easingFn);
};
继续追踪ScrollerActions类的scrollEnd
钩子:
ScrollerActions.prototype.bindActionsHandler = function () {
var _this = this;
// ...
// [mouse|touch]end event
this.actionsHandler.hooks.on(this.actionsHandler.hooks.eventTypes.end, function (e) {
if (!_this.enabled)
return true;
return _this.handleEnd(e);
});
// ...
};
ScrollerActions.prototype.handleEnd = function (e) {
if (this.hooks.trigger(this.hooks.eventTypes.beforeEnd, e)) {
return;
}
var currentPos = this.getCurrentPos();
this.scrollBehaviorX.updateDirection();
this.scrollBehaviorY.updateDirection();
if (this.hooks.trigger(this.hooks.eventTypes.end, e, currentPos)) {
return true;
}
currentPos = this.ensureIntegerPos(currentPos);
this.animater.translate(currentPos);
this.endTime = getNow();
var duration = this.endTime - this.startTime;
this.hooks.trigger(this.hooks.eventTypes.scrollEnd, currentPos, duration);
};
从源码可知,最终是ActionsHandler类的end
钩子触发了ScrollerActions类的scrollEnd
的钩子。
继续看ActionsHandler类的相关源码:
ActionsHandler.prototype.handleDOMEvents = function () {
var _a = this.options,
bindToWrapper = _a.bindToWrapper,
disableMouse = _a.disableMouse,
disableTouch = _a.disableTouch,
click = _a.click;
var wrapper = this.wrapper;
var target = bindToWrapper ? wrapper : window;
var wrapperEvents = [];
var targetEvents = [];
var shouldRegisterTouch = !disableTouch;
var shouldRegisterMouse = !disableMouse;
if (click) {
wrapperEvents.push({
name: 'click',
handler: this.click.bind(this),
capture: true,
});
}
if (shouldRegisterTouch) {
wrapperEvents.push({
name: 'touchstart',
handler: this.start.bind(this),
});
targetEvents.push({
name: 'touchmove',
handler: this.move.bind(this),
}, {
name: 'touchend',
handler: this.end.bind(this),
}, {
name: 'touchcancel',
handler: this.end.bind(this),
});
}
if (shouldRegisterMouse) {
wrapperEvents.push({
name: 'mousedown',
handler: this.start.bind(this),
});
targetEvents.push({
name: 'mousemove',
handler: this.move.bind(this),
}, {
name: 'mouseup',
handler: this.end.bind(this),
});
}
// EventRegister的实例上有两个属性
// wrapper -- dom元素
// events -- dom元素上注册的事件([{name,handler}])
this.wrapperEventRegister = new EventRegister(wrapper, wrapperEvents);
this.targetEventRegister = new EventRegister(target, targetEvents);
};
ActionsHandler.prototype.end = function (e) {
if (eventTypeMap[e.type] !== this.initiated) {
return;
}
this.setInitiated();
this.beforeHandler(e, 'end');
this.hooks.trigger(this.hooks.eventTypes.end, e);
};
最终是wrapper元素的touchend
事件触发了end
钩子。
用户的手指离开设备屏幕后,scroll
事件的派发链条是:
wrapper元素的touchend事件 --> ActionsHandler的end钩子 --> ScrollerActions的scrollEnd钩子 --> Scroller类的momentum函数 --> Scroller的scrollTo函数 --> Transition类的move函数 --> Transition类的startProbe函数 --> Transition类的move钩子 --> Scroller的scroll钩子 --> BScrollConstructor的scroll自定义事件