At the beginning

TypeScript has been around for years and is now being used by more and more people, and it will undoubtedly become more popular, but I haven’t used it yet because first of all on projects, and second of all BECAUSE I’m not sensitive to strong typing, so just looking at a document for a few minutes can get me distracted and delayed.

However, a lot of popular frameworks are starting to refactor in TypeScript, and a lot of sample code for articles is becoming TypeScript, so it’s awkward and you don’t get confused, so there’s no choice.

Since my current pain point is that I can’t read the source code, it’s probably more effective to understand TypeScript syntax in detail when I encounter it in the process of reading the source code rather than just reading the documentation. Next, I’ll read the BetterScroll source code while catching up on TypeScript.

BetterScroll is a scroll library for mobile devices. It uses pure JavaScript. Version 2.0 uses TypeScript.

For convenience, later TypeScript is abbreviated to TS and BetterScroll to BS.

The BS core functionality code is located in /packages/core/ folder and structured as follows:

The index.ts file is only used to expose the interface. We’ll start with bscroll. ts.

Entrance class

interface PluginCtor {
  pluginName: stringapplyOrder? : ApplyOrdernew (scroll: BScroll): any
}
Copy the code

The PluginCtor interface is used to define the structure of a value, which is then checked by the TS type checker. The PluginCtor interface is used to define and restrict the structure of a BS plug-in object. ApplyOrder = ApplyOrder; ApplyOrder = ApplyOrder;

export const enum ApplyOrder {
  Pre = 'pre',
  Post = 'post'
}
Copy the code

Enumerations support enumerations of numbers and strings. Enumerations of numbers also have the ability to increment themselves. Enumerations that are modified by const are called constant enumerations. The feature of constant enumerations is that they are deleted at compile time and are directly inlined to where they are used.

Going back to interfaces, interfaces can define interfaces for classes and instances. Here we have a new meaning that this is the interface defined for classes. Here we know that the BS plug-in body needs to be a class with two static attributes, the constructor input parameter is an instance of BS, and any stands for any type.

Following:

interface PluginsMap {
  [key: string] :boolean
}
Copy the code

[key: string] attributes are called index signatures, because TS performs additional attribute checks on object literals. That is, an attribute that is not defined in the interface is considered an error. One way to solve this problem is to add index signatures to the interface definition.

type ElementParam = HTMLElement | string
Copy the code

Type refers to a type alias, which is equivalent to giving a type an alias. It does not create a new type. It is a reference relationship, which is similar to the interface, but with some subtle differences.

| on behalf of the joint type, said a value can be one of several types.

export interface MountedBScrollHTMLElement extendsHTMLElement { isBScrollContainer? :boolean
}
Copy the code

Interfaces can be inherited, which copies members from one interface to another, increasing reusability.

export class BScrollConstructor<O = {} >extends EventEmitter {}
Copy the code


, <> is called a generic type, which can support multiple types without limiting any to a specific type. It also provides the possibility for extension and is more rigorous than using any. Just like (), <> is called with a type, which is received with parameters in <>.

function fn<T> (arg: T) :T {}
fn<Number> (1)
Copy the code

Both input and return parameters are of type Number, except <>. The T in the input parameter and the T in the return parameter can be interpreted as placeholders.

static plugins: PluginItem[] = []
Copy the code

[] represents an array type. There are two ways to define an array:

let list: number[] = [1.2.3]// 1. Element type []
let list: Array<number> = [1.2.3]// 2. Use Array generics, Array< element type >
Copy the code

So this means that we define an array of elements of type PluginItem.

The BS plugin calls the use method before new BS. Use is a static method of BS class:

class BS {
    static use(ctor: PluginCtor) {
        const name = ctor.pluginName
        // Plugin name check, plugin registered check...
        BScrollConstructor.pluginsMap[name] = true
        BScrollConstructor.plugins.push({
          name,
          applyOrder: ctor.applyOrder,
          ctor,
        })
        return BScrollConstructor
      }
}
Copy the code

The use method simply adds plug-ins to the plugins array.

class BS {
    constructor(el: ElementParam, options? : Options & O) {
        super([
            // Register the event name
        ])
    	const wrapper = getElement(el)// Get the element
    	this.options = new OptionsConstructor().merge(options).process()// Merge parameters
        if (!this.setContent(wrapper).valid) {
          return
        }
        this.hooks = new EventEmitter([
          // The registered hook name
        ])
    	this.init(wrapper)
  	}
}
Copy the code

Constructors do things like register events, get elements, merge parameters, check the environment and browser compatibility, and initialize the parameters. In order to distinguish between hooks, which are used by ordinary users, and which are used by plug-in developers.

The setContent function sets the content to be scrolled by BS. BS defaults to the first child of the Wrapper as content, which can also be specified with a configuration parameter.

class BS {
    private init(wrapper: MountedBScrollHTMLElement) {
        this.wrapper = wrapper
        // Create a scroll instance
        this.scroller = new Scroller(wrapper, this.content, this.options)
        // Event forwarding
        this.eventBubbling()
        // Automatic out of focus
        this.handleAutoBlur()
        // Enable BS and send corresponding events
        this.enable()
        // Property and method proxies
        this.proxy(propertiesConfig)
        // Instantiate the plugins one by one by traversing the plugins array of the BS class, and save the plugins to the plugins object of the BS instance with key: name of the plug-in, value: plug-in instance
        this.applyPlugins()
        // Call the scroller instance refresh method and issue the refresh event
        this.refreshWithoutReset(this.content)
        // Set the initial scrolling position
        const { startX, startY } = this.options
        const position = {
          x: startX,
          y: startY,
        }
        if (
          // If your plugin wants to change the initial scroll position, it can listen for this event
          this.hooks.trigger(this.hooks.eventTypes.beforeInitialScrollTo, position)
        ) {
          return
        }
        this.scroller.scrollTo(position.x, position.y)
      }
}
Copy the code

The init method does a lot of things.

{
    private eventBubbling() {
        bubbling(this.scroller.hooks, this[this.eventTypes.beforeScrollStart,
          / / event...])}}// Event forwarding
export function bubbling(source,target,events) {
  events.forEach(event= > {
    let sourceEvent
    let targetEvent
    if (typeof event === 'string') {
      sourceEvent = targetEvent = event
    } else {
      sourceEvent = event.source
      targetEvent = event.target
    }
    source.on(sourceEvent, function(. args:any[]) {
      returntarget.trigger(targetEvent, ... args) }) }) }Copy the code

A series of events are registered in the constructor of BS instance, some of which are distributed by SCroller instance, so it is necessary to listen on the events corresponding to SCroller to distribute its registered events, which is equivalent to event forwarding.

{
    private handleAutoBlur() {
        if (this.options.autoBlur) {
          this.on(this.eventTypes.beforeScrollStart, () = > {
            let activeElement = document.activeElement as HTMLElement
            if (
              activeElement &&
              (activeElement.tagName === 'INPUT' ||
                activeElement.tagName === 'TEXTAREA')
            ) {
              activeElement.blur()
            }
          })
        }
      }
}
Copy the code

The configuration item has a single parameter: autoBlur. If set to true, it listens for events that are about to roll to take active elements (input, Textarea) out of focus on the current page. Document. activeElement gets the current focus element in the document.

In addition, as appears here, and TS supports the following data types: Boolean, number, string, T [] | Array, tuples, enumeration or arbitrary any enum, empty void, undefined, null, and the type of the value of never, never the original type object, sometimes you will know exactly what is a value type, If it is more accurate than TS, it is possible to specify its type by as, which is called a type assertion, so that TS is no longer judging.

{
    proxy(propertiesConfig: PropertyConfig[]) {
        propertiesConfig.forEach(({ key, sourceKey }) = > {
          propertiesProxy(this, sourceKey, key)
        })
      }
}
Copy the code

The plugin will have its own properties and methods. The proxy method is used to proxy the BS instance, which can be directly accessed through the BS instance. The definition of propertiesConfig is as follows:

export const propertiesConfig = [
  {
    sourceKey: 'scroller.scrollBehaviorX.currentPos'.key: 'x'
  },
  // Other attributes and methods...
]
Copy the code
export function propertiesProxy(target,sourceKey,key) {
  sharedPropertyDefinition.get = function proxyGetter() {
    return getProperty(this, sourceKey)
  }
  sharedPropertyDefinition.set = function proxySetter(val) {
    setProperty(this, sourceKey, val)
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
Copy the code

Define attributes via defineProperty. Note that the sourceKey format is always required to allow instance this of BS to pass. Can access to the source properties, such as the enclosing here scroller. ScrollBehaviorX. CurrentPos can access to the scroller instance currentPos attribute, if is a plug-in, so we need your propertiesConfig:

{
    sourceKey: 'plugins.myPlugin.xxx'.key: 'xxx'
  }
Copy the code

Plugins is a BS instance attributes, so by this. Plugins. MyPlugin. XXX can access to your source attribute, can also directly through this modification to the source property of attribute values. So the logic of setProperty and getProperty is simple:

const setProperty = (obj, key, value) = > {
 	let keys = key.split('. ')
    // Level by level visits
    for(let i = 0; i < keys.length - 1; i++) {
        let tmp = keys[i]
        if(! obj[tmp]){ obj[tmp] = {} } obj = obj[tmp] } obj[keys.pop()] = value }const getProperty = (obj,key) = > {
  const keys = key.split('. ')
  for (let i = 0; i < keys.length - 1; i++) {
    obj = obj[keys[i]]
    if (typeofobj ! = ='object'| |! obj)return
  }
  const lastKey = keys.pop()
  if (typeof obj[lastKey] === 'function') {
    return function () {
      return obj[lastKey].apply(obj, arguments)}}else {
    return obj[lastKey]
  }
}
Copy the code

Getting attributes is special if it’s a function, because if you call it this way:

let bs = new BS()
bs.xxx()// Plugin methods
Copy the code

The XXX method is a plug-in method, but this is called to point to bs, but obviously this should point to the plug-in instance, so use apply to specify the context.

In addition to the above, there are several methods for BS instances:

class BS {
    // Recalculate, usually when the DOM structure changes need to be called manually
    refresh() {
        // Call the setContent method, call the scroller instance refresh method, and send related events
      }

    / / BS
    enable() {
        this.scroller.enable()
        this.hooks.trigger(this.hooks.eventTypes.enable)
        this.trigger(this.eventTypes.enable)
    }

    / / disable BS
    disable() {
        this.scroller.disable()
        this.hooks.trigger(this.hooks.eventTypes.disable)
        this.trigger(this.eventTypes.disable)
    }

    BS / / destroyed
    destroy() {
        this.hooks.trigger(this.hooks.eventTypes.destroy)
        this.trigger(this.eventTypes.destroy)
        this.scroller.destroy()
    }
    
    // Register events
    eventRegister(names: string[]) {
        this.registerType(names)
    }
}
Copy the code

I won’t go into details. In general, instantiating BS involves parameter handling, setting the scroll element, instantiating the scroll class, proxy events, and methods. Let’s look at the core scroller class/scroller.ts.

Scroll to the class

export interface ExposedAPI {
  scrollTo(
    x: number.y: number, time? :number, easing? : EaseItem, extraTransform? : {start: object; end: object }
  ): void
}
Copy the code

The above defines an interface for the class, and scrollTo is a method of the instance that defines the method’s input parameters, type, and return parameters.

export default class Scroller implements ExposedAPI {
    constructor(
        public wrapper: HTMLElement,
        public content: HTMLElement,
        options: BScrollOptions
      ){}}Copy the code

The public keyword stands for public. Properties or methods declared by public can be used outside the class. The corresponding private keyword stands for private, that is, cannot be accessed outside the class.

class S {
    public name: string.private age: number
}
let s = new S()
s.name// It can be accessed
s.age/ / an error
Copy the code

There is also the keyword protected. A declared variable cannot be used outside of a class, but can be used inside the subclasses that inherit it. If used with constructor, the class can only be inherited, not instantiated itself.

For the example above, it combines the declaration and initialization of the member into the constructor’s parameters, called parameter properties:

constructor(public wrapper: HTMLElement)
Copy the code
class Scroller {
	constructor(
        public wrapper: HTMLElement,
        public content: HTMLElement,
        options: BScrollOptions
    ) {
        // Register events
        this.hooks = new EventEmitter([
            / / event...
        ])
        // The Behavior class is used to store and manage the scrolling state
        this.scrollBehaviorX = new Behavior()
        this.scrollBehaviorY = new Behavior()
        // Translater is used to get and set the translate attribute of the CSS transform
        this.translater = new Translater()
        CreateAnimater creates an instance of the cSS3 Transition and requestAnimationFrame classes according to the configuration
        this.animater = createAnimater()
        ActionsHandler is used to bind DOM events
        this.actionsHandler = new ActionsHandler()
        // ScrollerActions for actual scrolling control
        this.actions = new ScrollerActions()
        // Bind the phone's rotation event and window size change event
        this.resizeRegister = new EventRegister()
        // Listen for the transitionEnd event of content
        this.registerTransitionEnd()
        // Listen for various events of the above class to perform various operations
        this.init()
    }
}
Copy the code

Above is the simplified constructor of the Scroller class. You can see that it does a lot of things. There are a lot of new instances. Get some basic information, such as the size of the Wrapper and Content elements, then listen for events, such as touch events, determine if and how to scroll, and then scroll.

Initial information calculation

The constructor executes a refresh method on the new Behavior to obtain and calculate the size information. For example, the Behavior of scrollBehaviorY:

refresh(content: HTMLElement) {
    // size: height, position: top
    const { size, position } = this.options.rect
    const isWrapperStatic =
          window.getComputedStyle(this.wrapper, null).position === 'static'
    // Size information for wrapper
    const wrapperRect = getRect(this.wrapper)
    / / wrapper
    this.wrapperSize = wrapperRect[size]
    // Set the content element and reset some data if it changes
    this.setContent(content)
    // Content specifies the size of the element
    const contentRect = getRect(this.content)
    // The height of the content element
    this.contentSize = contentRect[size]
    // The distance between content and wrapper
    this.relativeOffset = contentRect[position]
    // Top is relative to offsetParent. If the wrapper is not located then the offsetParent of content needs to be searched at the top. Then top is not the distance to the wrapper and the offsetTop of the wrapper needs to be subtracted
    if (isWrapperStatic) {
        this.relativeOffset -= wrapperRect[position]
    }
	// Set boundaries, i.e., the maximum and minimum distance that can be rolled
    this.computeBoundary()
	// Set the default scroll direction
    this.setDirection(Direction.Default)
}

export function getRect(el: HTMLElement) :DOMRect {
  if (el instanceof (window as any).SVGElement) {
    let rect = el.getBoundingClientRect()
    return {
      top: rect.top,
      left: rect.left,
      width: rect.width,
      height: rect.height,
    }
  } else {
    return {
      top: el.offsetTop,
      left: el.offsetLeft,
      width: el.offsetWidth,
      height: el.offsetHeight,
    }
  }
}
Copy the code

Take a look at the computeBoundary method. This method mainly obtains the maximum distance that can be rolled, namely two boundary values:

computeBoundary() {
    const boundary: Boundary = {
        minScrollPos: 0.// can be interpreted as the minimum value of translateY
        maxScrollPos: this.wrapperSize - this.contentSize,// can be interpreted as the maximum of translateY
    }
    // The wrapper height is less than the content height, so obviously scrolling is required
    if (boundary.maxScrollPos < 0) {
        // Because the content is offset with respect to its own position, some of the preceding elements are invisible even after scrolling the maxScrollPos distance, so scroll up the distance within the relativeOffset
        boundary.maxScrollPos -= this.relativeOffset
        // If offsetTop is 0, it doesn't matter
        if (this.options.specifiedIndexAsContent === 0) {
            boundary.minScrollPos = -this.relativeOffset
        }
    }
    this.minScrollPos = boundary.minScrollPos
    this.maxScrollPos = boundary.maxScrollPos
    // Determine whether to scroll
    this.hasScroll =
        this.options.scrollable && this.maxScrollPos < this.minScrollPos
    if (!this.hasScroll && this.minScrollPos < this.maxScrollPos) {
        this.maxScrollPos = this.minScrollPos
        this.contentSize = this.wrapperSize
    }
}
Copy the code

The first thing to understand is the scroll is effect on the content element, better – scroll. Making. IO/examples / # /… This example makes it clear that non-content elements in the Wrapper are not moved.

Event listening processing

In ActionsHandler, there are two sets of events, mouse and touch, which are bound to both PC and mobile phone. In fact, the processing functions are the same. In terms of touch events, there are three event processing functions: start touch, Move touch and end touch.

private start(e: TouchEvent) {
    // Mouse-related events have type 1 and touch 2
    const _eventType = eventTypeMap[e.type]
	// Avoid both mouse and touch events.
    if (this.initiated && this.initiated ! == _eventType) {return
    }
    // Set the initiated value
    this.setInitiated(_eventType)
	// If it is found that some elements are configured that do not require response scrolling, it is returned directly
    if (tagExceptionFn(e.target, this.options.tagException)) {
      this.setInitiated()
      return
    }
	// Only left mouse click is allowed
    if(_eventType === EventType.Mouse && e.button ! == MouseButton.Left)return
	// It depends on the configuration to prevent bubbling and default events
    this.beforeHandler(e, 'start')
	// Record the distance from the touch point to the page. PageX and pageY will include the length of the part of the page that is rolled out
    let point = (e.touches ? e.touches[0] : e) as Touch
    this.pointX = point.pageX
    this.pointY = point.pageY
  }
Copy the code

The most important thing about the touch start event is to record the position of the touch point.

private move(e: TouchEvent) {
    let point = (e.touches ? e.touches[0] : e) as Touch
    // Calculate the difference of touch movement
    let deltaX = point.pageX - this.pointX
    let deltaY = point.pageY - this.pointY
    this.pointX = point.pageX
    this.pointY = point.pageY
	// The length of the page to be rolled out
    let scrollLeft =
      document.documentElement.scrollLeft ||
      window.pageXOffset ||
      document.body.scrollLeft
    let scrollTop =
      document.documentElement.scrollTop ||
      window.pageYOffset ||
      document.body.scrollTop
	// Why not use clientX and clientY when the current touch position is away from the viewport position?
    let pX = this.pointX - scrollLeft
    let pY = this.pointY - scrollTop
	// If you swipe too fast, your finger may slide off the screen and not trigger the TouchEnd event. If your finger position is less than a certain value, the end method is automatically called to end the slide
    const autoEndDistance = this.options.autoEndDistance
    if (
      pX > document.documentElement.clientWidth - autoEndDistance ||
      pY > document.documentElement.clientHeight - autoEndDistance ||
      pX < autoEndDistance ||
      pY < autoEndDistance
    ) {
      this.end(e)
    }
  }
Copy the code

The method in touch does two main things, recording the difference from the last slide and automatically ending the scroll if the condition is met.

private end(e: TouchEvent) {
    // Reset the initiated value so that the move event does not respond again
    this.setInitiated()
    // Send events
    this.hooks.trigger(this.hooks.eventTypes.end, e)
  }
Copy the code

Scroll to logic

The constructor calls the bindActionsHandler method, which listens for the events bound to the actionsHandler:

private bindActionsHandler() {
    / / / mouse | touch] touch start event
    this.actionsHandler.hooks.on(
      this.actionsHandler.hooks.eventTypes.start,
      (e: TouchEvent) = > {
        if (!this.enabled) return true
        return this.handleStart(e)
      }
    )
    / / / mouse | touch] in touch events
    this.actionsHandler.hooks.on(
      this.actionsHandler.hooks.eventTypes.move,
      ({ deltaX, deltaY, e}) = > {
        if (!this.enabled) return true
        return this.handleMove(deltaX, deltaY, e)
      }
    )
    / / / mouse | touch] touch the end of the event
    this.actionsHandler.hooks.on(
      this.actionsHandler.hooks.eventTypes.end,
      (e: TouchEvent) = > {
        if (!this.enabled) return true
        return this.handleEnd(e)
      }
    )
  }
Copy the code

Here are the handlers for the above three events:

private handleStart(e: TouchEvent) {
    // Get the timestamp when the touch starts
    const timestamp = getNow()
    this.moved = false
    this.startTime = timestamp
    // directionLockAction is mainly used for direction locking, such as determining whether a slide should be horizontal or vertical, etc. The reset method is used to reset the lock direction variable
    this.directionLockAction.reset()
	// The start method also does some initialization or reset work, including the sliding distance and sliding direction
    this.scrollBehaviorX.start()
    this.scrollBehaviorY.start()
	// Force the last scroll to end
    this.animater.doStop()
	// Reset the position where the scroll began
    this.scrollBehaviorX.resetStartPos()
    this.scrollBehaviorY.resetStartPos()
  }
Copy the code

This method is basically a series of resets, after all, to start a new scroll.

private handleMove(deltaX: number, deltaY: number, e: TouchEvent) {
    DeltaX and deltaY record the difference between each move event and the last one. GetAbsDist is used to record the absolute distance between the current event and the start of the touch
    const absDistX = this.scrollBehaviorX.getAbsDist(deltaX)
    const absDistY = this.scrollBehaviorY.getAbsDist(deltaY)
    const timestamp = getNow()
	// Either the slide distance is greater than the threshold, or the slide immediately after the last slide, otherwise the roll is not considered to be started
    / * * /
    	private checkMomentum(absDistX: number, absDistY: number, timestamp: number) {
            return (
              timestamp - this.endTime > this.options.momentumLimitTime &&
              absDistY < this.options.momentumLimitDistance &&
              absDistX < this.options.momentumLimitDistance
            )
          }
    / * * /
    if (this.checkMomentum(absDistX, absDistY, timestamp)) {
      return true
    }
    // This is used to determine whether to lock based on the eventPassthrough configuration item
    // This method will return true if you set the horizontal scroll to horizontal. If you set the horizontal scroll to vertical, this method will return true if you set the horizontal scroll to vertical. E.preventdefault () is called to prevent native scrolling
    if (this.directionLockAction.checkMovingDirection(absDistX, absDistY, e)) {
      this.actionsHandler.setInitiated()
      return true
    }
	// This method sets the delta value of the locked direction in the other direction to 0, that is, the other direction does not scroll
    const delta = this.directionLockAction.adjustDelta(deltaX, deltaY)
    // The move method does two things: 1 sets the direction of the slide, right -> left, down -> up as positive 1, and vice versa as negative -1. 2 is to call the damping method, which means that when you slide without reaching the boundary, you can feel the page sliding synchronously with your finger. After damping, you will feel the drag, and the page will slow down to keep up with your finger:
    / * * /
    	performDampingAlgorithm(delta: number, dampingFactor: number) {
    		// The starting position of the slide plus the offset of the slide is the current position of the slide
            let newPos = this.currentPos + delta
            // It has slid to the edge
            if (newPos > this.minScrollPos || newPos < this.maxScrollPos) {
              if (
                (newPos > this.minScrollPos && this.options.bounces[0]) ||
                (newPos < this.maxScrollPos && this.options.bounces[1]) {// The damping principle is simple: multiply the sliding distance by a decimal less than 1
                newPos = this.currentPos + delta * dampingFactor
              } else {
              	// If the configuration turns off the damping effect, then the slide will end
                newPos =
                  newPos > this.minScrollPos ? this.minScrollPos : this.maxScrollPos
              }
            }
            return newPos
          }
    / * * /
    const newX = this.scrollBehaviorX.move(delta.deltaX)
    const newY = this.scrollBehaviorY.move(delta.deltaY)
	// Whether we animate cSS3 Transition or requestAnimationFrame, we're actually changing the transform property of the CSS. Translate here ends up calling the translate method of the this.translater instance above
    / * * /
    	//point:{x:10,y:10}
    	translate(point: TranslaterPoint) {
            let transformStyle = [] as string[]
            Object.keys(point).forEach((key) = > {
              if(! translaterMetaData[key]) {return
              }
              // translateX/translateY
              const transformFnName = translaterMetaData[key][0]
              if (transformFnName) {
              	// px
                const transformFnArgUnit = translaterMetaData[key][1]
                // the value of x and y
                const transformFnArg = point[key]
                transformStyle.push(
                  `${transformFnName}(${transformFnArg}${transformFnArgUnit}) `)}})this.hooks.trigger(
              this.hooks.eventTypes.beforeTranslate,
              transformStyle,
              point
            )
            / / assignment
            this.style[style.transform as any] = transformStyle.join(' ')
            this.hooks.trigger(this.hooks.eventTypes.translate, point)
          }
    / * * /
    // You can see that calling this method directly does not set the transition value or use requestAnimationFrame to change the displacement, so there is no animation, at this point the Content element will already scroll with your touch
    this.animater.translate({
      x: newX,
      y: newY
    })
	// This method is mainly used to reset the startTime value and determine how to send scroll events according to the probeType configuration
    / * * /
    	private dispatchScroll(timestamp: number) {
            // Issue one event per momentumLimitTime
            if (timestamp - this.startTime > this.options.momentumLimitTime) {
              // Refresh the start time and position to determine whether to animate momentum
              this.startTime = timestamp
              // updateStartPos will use the new position to which the element is currently scrolled as startPos
              this.scrollBehaviorX.updateStartPos()
              this.scrollBehaviorY.updateStartPos()
              if (this.options.probeType === Probe.Throttle) {
                this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
              }
            }
            // Send events in real time
            if (this.options.probeType > Probe.Throttle) {
              this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
            }
          }
    / * * /
    this.dispatchScroll(timestamp)
  }
Copy the code

This is where the content starts scrolling with our touch, and we’re done, but there are two things to do, one is that normally if you slide something, if you slide it really fast, it keeps scrolling for a while even if you let go of it, it doesn’t stop as soon as you release it, So determine if it’s a fast slide and how to animate the momentum after the release; Second, if the rebound animation is enabled, it is necessary to determine whether to rebound.

Momentum animation and rebound animation

Let’s start with the end-of-touch handler:

private handleEnd(e: TouchEvent) {
    if (this.hooks.trigger(this.hooks.eventTypes.beforeEnd, e)) {
        return
    }
    // Call the scrollBehaviorX and scrollBehaviorY methods of the same name to get the current currentPos value
    const currentPos = this.getCurrentPos()
    // Update the scrolling direction of this time
    this.scrollBehaviorX.updateDirection()
    this.scrollBehaviorY.updateDirection()
    if (this.hooks.trigger(this.hooks.eventTypes.end, e, currentPos)) {
        return true
    }
    // Update the element position to the position of the end touch point
    this.animater.translate(currentPos)
    // Calculate the last interval time
    this.endTime = getNow()
    const duration = this.endTime - this.startTime
    this.hooks.trigger(this.hooks.eventTypes.scrollEnd, currentPos, duration)
}
Copy the code

This function just sends out a couple of events, and what it does is it finds the places that subscribe to those events, so it goes back to scroller.ts,

The init method at the end of the Scroller constructor executes a subscription to a series of events, where the end event is found:

actions.hooks.on(
    actions.hooks.eventTypes.end,
    (e: TouchEvent, pos: TranslaterPoint) = > {
        this.hooks.trigger(this.hooks.eventTypes.touchEnd, pos)
        if (this.hooks.trigger(this.hooks.eventTypes.end, pos)) {
            return true
        }
        // Check if it is a click operation
        if(! actions.moved) {this.hooks.trigger(this.hooks.eventTypes.scrollCancel)
            if (this.checkClick(e)) {
                return true}}// Here, here, this is the method used to determine whether the boundary is crossed and adjust
        if (this.resetPosition(this.options.bounceTime, ease.bounce)) {
            this.animater.setForceStopped(false)
            return true}})Copy the code

See the resetPosition method:

resetPosition(time = 0, easing = ease.bounce) {
    // The checkInBoundary method is used to return the value of the boundary and whether it happens to be within the boundary
    const {
        position: x,
        inBoundary: xInBoundary,
    } = this.scrollBehaviorX.checkInBoundary()
    const {
        position: y,
        inBoundary: yInBoundary,
    } = this.scrollBehaviorY.checkInBoundary()
    // If both are at the boundary, then there is no need to bounce back
    if (xInBoundary && yInBoundary) {
        return false
    }
	// The scrollTo method is described below
    this.scrollTo(x, y, time, easing)
    return true
}

/*scrollBehavior */
checkInBoundary() {
    const position = this.adjustPosition(this.currentPos)
    // If the boundary value is the same as this position, it means that the boundary value is exactly the same
    const inBoundary = position === this.getCurrentPos()
    return {
        position,
        inBoundary,
    }
}

// Out of bounds adjust position
adjustPosition(pos: number) {
    let roundPos = Math.round(pos)
    if(!this.hasScroll &&
        !this.hooks.trigger(this.hooks.eventTypes.ignoreHasScroll)
    ) {// The minimum roll distance is returned
        roundPos = this.minScrollPos
    } else if (roundPos > this.minScrollPos) {// After the minimum roll distance, you need to bounce back to the minimum roll distance
        roundPos = this.minScrollPos
    } else if (roundPos < this.maxScrollPos) {// If the roll distance exceeds the maximum, you need to bounce back to the maximum
        roundPos = this.maxScrollPos
    }
    return roundPos
}
/ * * /
Copy the code

The last thing to do is call the scrollTo method to scroll, so let’s look at the animation logic.

scrollTo(
    x: number,
    y: number,
    time = 0,
    easing = ease.bounce,
    extraTransform = {
        start: {},
        end: {},
    }
) {
    // Determine whether to use CSS cubic- Bezier or function based on whether to use Transition or requestAnimationFrame
    /* bounce: {style: 'cubic-bezier(0.165, 0.84, 0.44, 1)', fn: function(t: number) {return 1 - --t * t * t * t}} */
    const easingFn = this.options.useTransition ? easing.style : easing.fn
    const currentPos = this.getCurrentPos()
	// Animation start position
    const startPoint = {
        x: currentPos.x,
        y: currentPos.y, ... extraTransform.start, }// Animation end position
    constendPoint = { x, y, ... extraTransform.end, }this.hooks.trigger(this.hooks.eventTypes.scrollTo, endPoint)
	// If the starting point is the same as the ending point, no animation is needed
    if (isSamePoint(startPoint, endPoint)) return
	// Call the animation method
    this.animater.move(startPoint, endPoint, time, easingFn)
}
Copy the code

This method finally calls the animation method, because two animation methods are supported, so let’s briefly think about the principle of the two.

animation

It’s easy to animate cSS3’s transition property. You set the transition property, and then change the transform property to animate itself. Transition is a shorthand property. Transition-property specifies the name of the CSS property to which you want to apply the animation, such as transform. If you don’t set it, it will be applied to all available properties by default. Transition-duration must be set. Otherwise 0, no transition), transition-timing-function (animation curve).

RequestAnimationFrame allows you to set the position of each time you calculate it. This is also easy to use with some common animation curve functions, such as the above function. More functions can be found at robertpenner.com/easing/ :

function(t: number) {
    return 1 - --t * t * t * t
}
Copy the code

You just pass in the ratio of how long the animation has taken to the transition time, and you multiply the returned value by the distance of the animation to get the current displacement.

Looking at the implementation, it is important to note that both classes inherit from a base class because they have many operations in common.

1. The range of ways

move(startPoint: TranslaterPoint, endPoint: TranslaterPoint, time: number, easingFn: string | EaseFn) {
    // Set a pending variable to determine whether the animation is currently in progress
    this.setPending(time > 0)
    // Set the transition-timing-function property
    this.transitionTimingFunction(easingFn as string)
    // Set transition-property to transform
    this.transitionProperty()
    // Set the transition-duration property
    this.transitionTime(time)
    // Call the aforementioned this.translater translate method to set the element's transform value
    this.translate(endPoint)
	// If the time does not exist, changing the attribute value within an event cycle does not trigger the transitionEnd event, so the update is forced by triggering backflow
    if(! time) {this._reflow = this.content.offsetHeight
        this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
        this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
    }
}
Copy the code

2. RequestAnimationFrame way

move(startPoint: TranslaterPoint, endPoint: TranslaterPoint, time: number, easingFn: EaseFn | string) {
    // When time is 0, call translate directly to set the location
    if(! time) {this.translate(endPoint)
        this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
        this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
        return
    }
    // do not animate without 0
    this.animate(startPoint, endPoint, time, easingFn as EaseFn)
}

private animate(startPoint: TranslaterPoint, endPoint: TranslaterPoint, duration: number, easingFn: EaseFn) {
    let startTime = getNow()
    const destTime = startTime + duration
    // Animation method, which is recursively called by requestAnimationFrame
    const step = () = > {
        let now = getNow()
        // If the current time is greater than the end time of the animation, the animation is over
        if (now >= destTime) {
            // There may be a small error from the target value, set it manually to improve accuracy
            this.translate(endPoint)
            this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
            this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
            return
        }
		// Time time ratio
        now = (now - startTime) / duration
        // Call the buffer function
        let easing = easingFn(now)
        const newPoint = {} as TranslaterPoint
        Object.keys(endPoint).forEach((key) = > {
            const startValue = startPoint[key]
            const endValue = endPoint[key]
            // Get the target position of this animation
            newPoint[key] = (endValue - startValue) * easing + startValue
        })
        // Perform the scroll
        this.translate(newPoint)
        if (this.pending) {
            this.timer = requestAnimationFrame(step)
        }
    }
	// Set the flag bit
    this.setPending(true)
    // The basic operation is to clean up a new timer or requestAnimationFrame
    cancelAnimationFrame(this.timer)
    // Start animation
    step()
}
Copy the code

The above code only sets pending to true, but does not reset it to false. You are smart enough to know that it must be reset somewhere else by event subscription. Yes, let’s go back to Scroll.ts, The Scroller class binds the transitionEnd event to the content element and subscribes to the end event:

// This is the transitionEnd handler
private transitionEnd(e: TouchEvent) {
    if(e.target ! = =this.content || !this.animater.pending) {
        return
    }
    const animater = this.animater as Transition
    // Delete the transition-duration property
    animater.transitionTime()
	// resetPosition is also called to perform bounding bounceback, which was called in the end event after the touch, since the translate method does not trigger the transitionEnd event, and there may be a bounceback animation after the touch
    if (!this.resetPosition(this.options.bounceTime, ease.bounce)) {
        this.animater.setPending(false)}}Copy the code
this.animater.hooks.on(
    this.animater.hooks.eventTypes.end,
    (pos: TranslaterPoint) = > {
        // Same as above, boundary bounces back
        if (!this.resetPosition(this.options.bounceTime)) {
            this.animater.setPending(false)
            this.hooks.trigger(this.hooks.eventTypes.scrollEnd, pos)
        }
    }
)
Copy the code

Of course, the bounding bounce-back function above will trigger these two events again after the animation is completed, and the judgment logic of resetPosition will be repeated, but since they are already bounce-back on the boundary, it will return false directly.

Return to the handleEnd method above and find that a scrollEnd event is triggered. Find the handler that subscribed to this event in the Scroller:

actions.hooks.on(
    actions.hooks.eventTypes.scrollEnd,
    (pos: TranslaterPoint, duration: number) = > {
        // This duration=this.endTime - this.startTime, but startTime is reset every time it exceeds momentumLimitTime in a touch, so it's not the total time from finger touch to finger departure
        // The scrolling distance of the last time segment
        const deltaX = Math.abs(pos.x - this.scrollBehaviorX.startPos)
        const deltaY = Math.abs(pos.y - this.scrollBehaviorY.startPos)
		// Determine if it is a flick, it should be for the plugin service, no matter here
        / * * /
        private checkFlick(duration: number, deltaX: number, deltaY: number) {
            const flickMinMovingDistance = 1 // distinguish flick from click
            if (
                this.hooks.events.flick.length > 1 &&
                duration < this.options.flickLimitTime &&
                deltaX < this.options.flickLimitDistance &&
                deltaY < this.options.flickLimitDistance &&
                (deltaY > flickMinMovingDistance || deltaX > flickMinMovingDistance)
            ) {
                return true}}/ * * /
        if (this.checkFlick(duration, deltaX, deltaY)) {
            this.animater.setForceStopped(false)
            this.hooks.trigger(this.hooks.eventTypes.flick)
            return
        }
		// Determine whether to animate momentum
        if (this.momentum(pos, duration)) {
            this.animater.setForceStopped(false)
            return}})Copy the code
private momentum(pos: TranslaterPoint, duration: number) {
    const meta = {
        time: 0.easing: ease.swiper,
        newX: pos.x,
        newY: pos.y,
    }
    // Determine whether the momentum condition is satisfied, then calculate the momentum data, that is, the final position to scroll to, anyway, according to the configuration to determine whether the momentum condition is satisfied, then according to the configuration to determine whether to allow springback in a certain direction, Finally, momentum is used to calculate momentum data, as shown below
    const momentumX = this.scrollBehaviorX.end(duration)
    const momentumY = this.scrollBehaviorY.end(duration)
	// Make a judgment
    meta.newX = isUndef(momentumX.destination)
        ? meta.newX
    : (momentumX.destination as number)
    meta.newY = isUndef(momentumY.destination)
        ? meta.newY
    : (momentumY.destination as number)
    meta.time = Math.max(
        momentumX.duration as number,
        momentumY.duration as number
    )
    // The position changes, which means momentum animation
    if(meta.newX ! == pos.x || meta.newY ! == pos.y) {this.scrollTo(meta.newX, meta.newY, meta.time, meta.easing)
        return true}}Copy the code
// Calculate momentum data
private momentum(
    current: number,
    start: number,
    time: number,
    lowerMargin: number,
    upperMargin: number,
    wrapperSize: number,
    options = this.options
) {
    // The time segment of the last slide
    const distance = current - start
    // The speed of the last slide
    const speed = Math.abs(distance) / time
    const { deceleration, swipeBounceTime, swipeTime } = options
    const momentumData = {
        // Target position calculation: the last position of the element after the finger is released + extra distance
        // Deceleration -- Distance = 15px, time = 300ms, speed = 0.05px/ms, speed/deceleration = 33. Down 33px from the current distance, the faster you go or the smaller the activity, the farther you'll go
        destination: current + (speed / deceleration) * (distance < 0 ? -1 : 1),
        duration: swipeTime,
        rate: 15,}// The maximum sliding distance is exceeded
    if (momentumData.destination < lowerMargin) {
        // If the user configuration allows this direction to bounce back, then the momentum distance is calculated again. Why? Otherwise, you can only scroll to the maximum distance
        momentumData.destination = wrapperSize
            ? Math.max(
            lowerMargin - wrapperSize / 4,
            lowerMargin - (wrapperSize / momentumData.rate) * speed
        )
        : lowerMargin
        momentumData.duration = swipeBounceTime
    } else if (momentumData.destination > upperMargin) {// Over the minimum roll distance, as above
        momentumData.destination = wrapperSize
            ? Math.min(
            upperMargin + wrapperSize / 4,
            upperMargin + (wrapperSize / momentumData.rate) * speed
        )
        : upperMargin
        momentumData.duration = swipeBounceTime
    }
    momentumData.destination = Math.round(momentumData.destination)
    return momentumData
}
Copy the code

Momentum logic is also very simple, which is to make a judgment according to the time and distance at the last moment, and then calculate the momentum data according to a certain algorithm, that is, the final position to roll to, and then roll over.

At this point, the core scrolling logic is complete. Finally, let’s look at how to force the transition scroll to end, because requestAnimationFrame is easy to end by calling cancelAnimationFrame.

doStop(): boolean {
    const pending = this.pending
    if (pending) {
        // Reset flag bit
        this.setPending(false)
        // Get the current translateX and translateY values of the Content element
        const { x, y } = this.translater.getComputedPosition()
        // Set transition-duration to 0
        this.transitionTime()
        // Set to the current position
        this.translate({ x, y })
    }
    return pending
}
Copy the code

First get the current position of the element, then delete the transition time, and finally change the target value to the current position, because if you don’t change the transition time, even if you change the transition time back to 0, the transition animation will continue, and if you force the position to change, it will end immediately.

Conclusion routine

Because it is the first time to seriously read a source code, so there may be a lot of problems, as in the entire source code to annotate, and because it is read in thin air and did not run the code for breakpoint debugging, so there will inevitably be errors.

We haven’t talked about TypeScript in the second half of this section, so it’s easy to read TypeScript code. It’s easy to understand some common syntax, but it’s still a long way from being able to use it. But you can’t write it yourself. There are no shortcuts. It all comes down to thinking more.

Then BetterScroll, code in general is relatively clear, because is the plug-in, and so is little not event mechanism, advantages of decoupling function, independence, each part defect is obvious, the first is each class has its own event, many events or of the same name, so it’s easy to watch them dizzy, the second is released event subscription, It is difficult to understand the flow of events clearly, so it is also the case that VUE, for example, favors attributes to show delivery and reception.

Rolling in general, the core of the library is a very simple function, achieve what all don’t consider themselves more than one hundred lines of code may be enough, but does not prevent it can be expanded into a powerful library, so much more thing to consider is, must first consider the various boundary conditions, the second is to consider the compatibility, such as CSS styles, May also have a specific type of bug, how to organize the code is also very important, try to reuse, such as BetterScroll two animation way there are many common operations, you can take these to the public in the parent class, and such as vertical and horizontal scroll scroll is certainly a lot of code are the same, so also need to be abstract, A good open source project is not easy because it is designed as a plug-in, so there is also the need to consider the development and integration of plug-ins, and finally the need for perfect testing.