整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          BetterScroll源碼閱讀順便學習TypeSc

          BetterScroll源碼閱讀順便學習TypeScript

          TypeScript已經出來很多年了,現在用的人也越來越多,毋庸置疑,它會越來越流行,但是我還沒有用過,因為首先是項目上不用,其次是我對強類型并不敏感,所以純粹的光看文檔看不了幾分鐘就心不在焉,一直就被耽擱了。

          但是,現在很多流行的框架都開始用TypeScript重構,很多文章的示例代碼也變成TypeScript,所以這就很尷尬了,你不會就看不懂,所以好了,沒得選了。

          既然目前我的痛點是看源碼看不懂,那不如就在看源碼的過程中遇到不懂的TypeScript語法再去詳細了解,這樣可能比單純看文檔更有效,接下來我將在閱讀BetterScroll源碼的同時惡補TypeScript

          BetterScroll是一個針對移動端的滾動庫,使用純JavaScript,2.0版本使用TypeScript進行了重構,通過插件化將功能進行了分離,核心只保留基本的滾動功能。

          方便起見,后續TypeScript縮寫為TSBetterScroll縮寫為BS

          BS的核心功能代碼在/packages/core/文件夾下,結構如下:

          index.ts文件只用來對外暴露接口,我們從BScroll.ts開始閱讀。

          入口類

          interface PluginCtor {
            pluginName: string
            applyOrder?: ApplyOrder
            new (scroll: BScroll): any
          }

          interface接口用來定義值的結構,之后TS的類型檢查器就會對值進行檢查,上面的PluginCtor接口用來對BS的插件對象結構進行定義及限制,意思為需要一個必填的字符串類型插件名稱pluginName?的意思為可選,可有可不有的ApplyOrder類型的調用位置,找到ApplyOrder的定義:

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

          enum的意思是枚舉,可以定義一些帶名字的常量,使用枚舉可以清晰的知道可選的選項是什么,枚舉支持數字枚舉和字符串枚舉,數字枚舉還有自增的功能,上述通過const來修飾的枚舉稱為常量枚舉,常量枚舉的特點是在編譯階段會被刪除而直接內聯到使用的地方。

          回到接口,interface可以為類和實例來定義接口,這里有個new意味著這是為類定義的接口,這里我們就可以知道BS的插件主體需要是一個類,且有兩個靜態屬性,構造函數入參是BS的實例,any代表任何類型。

          再往下:

          interface PluginsMap {
            [key: string]: boolean
          }

          這里同樣是個接口定義,[key: string]的屬性稱作索引簽名,因為TS會對對象字面量進行額外屬性檢查,即出現了接口里沒有定義的屬性時會認為是個錯誤,解決這個問題的其中一個方法就是在接口定義里增加索引簽名。

          type ElementParam=HTMLElement | string

          type意為類型別名,相當于給一個類型起了一個別名,不會新建類型,是一種引用關系,使用的時候和接口差不多,但是有一些細微差別。

          |代表聯合類型,表示一個值可以是幾種類型之一。

          export interface MountedBScrollHTMLElement extends HTMLElement {
            isBScrollContainer?: boolean
          }

          接口是可以繼承的,繼承能從一個接口里復制成員到另一個接口里,增加可重用性。

          export class BScrollConstructor<O={}> extends EventEmitter {}

          <o={}><>稱為泛型,即可以支持多種類型,不限制為具體的一種,為擴展提供了可能,也比使用any嚴謹,<>就像()一樣,調用的時候傳入類型,<>里的參數來接收,<>里的參數稱為類型變量,比如下面的泛型函數:

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

          表示入參和返回參數的類型都是Number,除了<>,入參里的T和返回參數類型的T可以理解為是占位符。

          static plugins: PluginItem[]=[]

          []代表數組類型,定義數組有兩種方式:

          let list: number[]=[1,2,3]// 1.元素類型后面跟上[]
          let list: Array<number>=[1,2,3]// 2.使用數組泛型,Array<元素類型>

          所以上面的意思是定義了一個元素類型是PluginItem的數組。

          BS使用插件需要在new BS之前調用use方法,useBS類的一個靜態方法:

          class BS {
              static use(ctor: PluginCtor) {
                  const name=ctor.pluginName
                  // 插件名稱檢查、插件是否已經注冊檢查...
                  BScrollConstructor.pluginsMap[name]=true
                  BScrollConstructor.plugins.push({
                    name,
                    applyOrder: ctor.applyOrder,
                    ctor,
                  })
                  return BScrollConstructor
                }
          }

          use方法就是簡單的把插件添加到plugins數組里。

          class BS {
              constructor(el: ElementParam, options?: Options & O) {
                  super([
                      //注冊的事件名稱
                  ])
                  const wrapper=getElement(el)// 獲取元素
                  this.options=new OptionsConstructor().merge(options).process()// 參數合并
                  if (!this.setContent(wrapper).valid) {
                    return
                  }
                  this.hooks=new EventEmitter([
                    // 注冊的鉤子名稱
                  ])
                  this.init(wrapper)
                }
          }

          構造函數做的事情是注冊事件,獲取元素,參數合并處理,參數處理里進行了環境檢測及瀏覽器兼容工作,以及進行初始化。BS本身繼承了事件對象,實例派發的叫事件,這里又創建了一個事件對象的實例hooks,在BS里為了區分叫做鉤子,普通用戶更關注事件,而插件開發一般要更關注鉤子。

          setContent函數的作用是設置BS要處理滾動的content,BS默認是將wrapper的第一個子元素作為content`,也可以通過配置參數來指定。

          class BS {
              private init(wrapper: MountedBScrollHTMLElement) {
                  this.wrapper=wrapper
                  // 創建一個滾動實例
                  this.scroller=new Scroller(wrapper, this.content, this.options)
                  // 事件轉發
                  this.eventBubbling()
                  // 自動失焦
                  this.handleAutoBlur()
                  // 啟用BS,并派發對應事件
                  this.enable()
                  // 屬性和方法代理
                  this.proxy(propertiesConfig)
                  // 實例化插件,遍歷BS類的plugins數組挨個進行實例化,并將插件實例以key:插件名,value:插件實例保存到BS實例的plugins對象上
                  this.applyPlugins()
                  // 調用scroller實例刷新方法,并派發刷新事件
                  this.refreshWithoutReset(this.content)
                  // 下面的用來設置初始滾動的位置
                  const { startX, startY }=this.options
                  const position={
                    x: startX,
                    y: startY,
                  }
                  if (
                    // 如果你的插件要修改初始滾動位置,那么可以監聽這個事件
                    this.hooks.trigger(this.hooks.eventTypes.beforeInitialScrollTo, position)
                  ) {
                    return
                  }
                  this.scroller.scrollTo(position.x, position.y)
                }
          }

          init方法里做了很多事情,一一來看:

          {
              private eventBubbling() {
                  bubbling(this.scroller.hooks, this, [
                    this.eventTypes.beforeScrollStart,
                    // 事件...
                  ])
                }
          }
          // 事件轉發
          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[]) {
                return target.trigger(targetEvent, ...args)
              })
            })
          }

          BS實例的構造函數里注冊了一系列事件,有些是scroller實例派發的,所以需要監聽scroller對應的事件來派發自己注冊的事件,相當于事件轉發。

          {
              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()
                      }
                    })
                  }
                }
          }

          配置項里有一個參數:autoBlur,如果設為true會監聽即將滾動的事件來將當前頁面上激活的元素(input、textarea)失去焦點,document.activeElement可以獲取文檔中當前獲得焦點的元素。

          另外這里出現了asTS支持的數據類型有:boolean、number、string、T[]|Array、元組、枚舉enum、任意any、空void、undefined、null、永不存在的值的類型never、非原始類型object,有時候你會確切的知道某個值是什么類型,可能會比TS更準確,那么可以通過as來指明它的類型,這稱作類型斷言,這樣TS就不再進行判斷了。

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

          插件會有一些自己的屬性和方法,proxy方法用來代理到BS實例,這樣可以直接通過BS的實例訪問,propertiesConfig的定義如下:

          export const propertiesConfig=[
            {
              sourceKey: 'scroller.scrollBehaviorX.currentPos',
              key: 'x'
            },
            // 其他屬性和方法...
          ]
          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)
          }

          通過defineProperty來定義屬性,需要注意的是sourceKey的格式都是需要能讓BS的實例this通過.能訪問到源屬性才行,比如這里的this.scroller.scrollBehaviorX.currentPos可以訪問到scroller實例的currentPos屬性,如果是一個插件的話,你的propertiesConfig需要這樣:

          {
              sourceKey: 'plugins.myPlugin.xxx',
              key: 'xxx'
            }

          pluginsBS實例上的一個屬性,這樣通過this.plugins.myPlugin.xxx就能訪問到你的源屬性,也就能夠直接通過this修改到源屬性的屬性值。所以setPropertygetProperty的邏輯也就很簡單了:

          const setProperty=(obj, key, value)=> {
               let keys=key.split('.')
              // 一級一級進行訪問
              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 (typeof obj !=='object' || !obj) return
            }
            const lastKey=keys.pop()
            if (typeof obj[lastKey]==='function') {
              return function () {
                return obj[lastKey].apply(obj, arguments)
              }
            } else {
              return obj[lastKey]
            }
          }

          獲取屬性時如果是函數的話要特殊處理,原因是如果你這么調用的話:

          let bs=new BS()
          bs.xxx()// 插件的方法

          xxx方法雖然是插件的方法,但是這樣調用的時候this是指向bs的,但是顯然,this應該指向這個插件實例才對,所以需要使用apply來指定上下文。

          除上述之外,BS實例還有幾個方法:

          class BS {
              // 重新計算,一般當DOM結構發生變化后需要手動調用
              refresh() {
                  // 調用setContent方法,調用scroller實例的刷新方法,派發相關事件
                }
          
              // 啟用BS
              enable() {
                  this.scroller.enable()
                  this.hooks.trigger(this.hooks.eventTypes.enable)
                  this.trigger(this.eventTypes.enable)
              }
          
              // 禁用BS
              disable() {
                  this.scroller.disable()
                  this.hooks.trigger(this.hooks.eventTypes.disable)
                  this.trigger(this.eventTypes.disable)
              }
          
              // 銷毀BS
              destroy() {
                  this.hooks.trigger(this.hooks.eventTypes.destroy)
                  this.trigger(this.eventTypes.destroy)
                  this.scroller.destroy()
              }
          
              // 注冊事件
              eventRegister(names: string[]) {
                  this.registerType(names)
              }
          }

          都很簡單,就不細說了,總的來說實例化BS時大致做的事情時參數處理、設置滾動元素、實例化滾動類,代理事件及方法,接下來看核心的滾動類/scroller/Scroller.ts

          滾動類

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

          上述為類定義了一個接口,scrollTo是實例的一個方法,定義了這個方法的入參及類型、返回參數。

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

          public關鍵字代表公開,public聲明的屬性或方法可以在類的外部使用,對應的private關鍵字代表私有的,即在類的外部不能訪問,比如:

          class S {
              public name: string,
              private age: number
          }
          let s=new S()
          s.name// 可以訪問
          s.age// 報錯

          另外還有一個關鍵字protected,聲明的變量不能在類的外部使用,但是可以在繼承它的子類的內部使用,所以這個關鍵字如果用在constructor上,那么這個類只能被繼承,自身不能被實例化。

          對于上面這個示例,它把成員的聲明和初始化合并在構造函數的參數里,稱作參數屬性:

          constructor(public wrapper: HTMLElement)
          class Scroller {
              constructor(
                  public wrapper: HTMLElement,
                  public content: HTMLElement,
                  options: BScrollOptions
              ) {
                  // 注冊事件
                  this.hooks=new EventEmitter([
                      // 事件... 
                  ])
                  // Behavior類主要用來存儲管理滾動時的一些狀態
                  this.scrollBehaviorX=new Behavior()
                  this.scrollBehaviorY=new Behavior()
                  // Translater用來獲取和設置css的transform的translate屬性
                  this.translater=new Translater()
                  // BS支持使用css3 transition和requestAnimationFrame兩種方式來做動畫,createAnimater會根據配置來創建對應類的實例
                  this.animater=createAnimater()
                  // ActionsHandler用來綁定dom事件
                  this.actionsHandler=new ActionsHandler()
                  // ScrollerActions用來做真正的滾動控制
                  this.actions=new ScrollerActions()
                  // 綁定手機的旋轉事件和窗口尺寸變化事件
                  this.resizeRegister=new EventRegister()
                  // 監聽content的transitionend事件
                  this.registerTransitionEnd()
                  // 監聽上述類的各種事件來執行各種操作
                  this.init()
              }
          }

          上面是Scroller類簡化后的構造函數,可以看到做了非常多的事情,new了一堆實例,這么多挨個打開看不出一會就得勸退,所以大致的知道每個類是做什么的后,我們來簡單思考一下,要能實現一個最基本的滾動大概要做一些什么事,首先肯定要先獲取一些基本信息,例如wrappercontent元素的尺寸信息,然后監聽事件,比如觸摸事件,然后判斷是否需要滾動,怎么滾動,最后進行滾動,根據這個思路我們來挨個看一下。

          初始信息計算

          獲取和計算尺寸信息的在new Behavior的時候,構造函數里會執行refresh方法,我們以scrollBehaviorY的情況來看:

          refresh(content: HTMLElement) {
              // size:height、position:top
              const { size, position }=this.options.rect
              const isWrapperStatic=  window.getComputedStyle(this.wrapper, null).position==='static'
              // wrapper的尺寸信息
              const wrapperRect=getRect(this.wrapper)
              // wrapper的高
              this.wrapperSize=wrapperRect[size]
              // 設置content元素,如果有變化則復位一些數據
              this.setContent(content)
              // content元素的尺寸信息
              const contentRect=getRect(this.content)
              // content元素的高
              this.contentSize=contentRect[size]
              // content距wrapper的距離
              this.relativeOffset=contentRect[position]
              // getRect方法里獲取普通元素信息用的是offset相關屬性,所以top是相對于offsetParent來說的,如果wrapper沒有定位那么content的offsetParent則還要在上層繼續查找,那么top就不是相對于wrapper的距離,需要減去wrapper的offsetTop
              if (isWrapperStatic) {
                  this.relativeOffset -=wrapperRect[position]
              }
              // 設置邊界,即可以滾動的最大和最小距離
              this.computeBoundary()
              // 設置默認滾動方向
              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,
              }
            }
          }

          看一下computeBoundary方法,這個方法主要獲取了能滾動的最大距離,也就是兩個邊界值:

          computeBoundary() {
              const boundary: Boundary={
                  minScrollPos: 0,// 可以理解為translateY的最小值
                  maxScrollPos: this.wrapperSize - this.contentSize,// 可以理解為translateY的最大值
              }
              // wrapper的高小于content的高,那么顯然是需要滾動的
              if (boundary.maxScrollPos < 0) {
                  // 因為content是相對于自身的位置進行偏移的,所以如果前面還有元素占了位置的話即使滾動了maxScrollPos的距離后還會有一部分是不可見的,需要繼續向上滾動relativeOffset的距離
                  boundary.maxScrollPos -=this.relativeOffset
                  // 這里屬實沒看懂,但是一般offsetTop為0的話這里也不影響
                  if (this.options.specifiedIndexAsContent===0) {
                      boundary.minScrollPos=-this.relativeOffset
                  }
              }
              this.minScrollPos=boundary.minScrollPos
              this.maxScrollPos=boundary.maxScrollPos
              // 判斷是否需要滾動
              this.hasScroll=this.options.scrollable && this.maxScrollPos < this.minScrollPos
              if (!this.hasScroll && this.minScrollPos < this.maxScrollPos) {
                  this.maxScrollPos=this.minScrollPos
                  this.contentSize=this.wrapperSize
              }
          }

          首先要搞明白的是滾動是作用在content元素上的,https://better-scroll.github.io/examples/#/core/specified-content,這個示例可以很清楚的看到,wrapper里非content的元素是不會動的。

          事件監聽處理

          接下來就是監聽事件,這個在ActionsHandler里,分pc和手機端綁定了鼠標和觸摸兩套事件,處理函數其實都是同一個,我們以觸摸事件來看,有start觸摸開始、move觸摸中、end觸摸結束三個事件處理函數。

          private start(e: TouchEvent) {
              // 鼠標相關事件的type為1,觸摸為2
              const _eventType=eventTypeMap[e.type]
              // 避免鼠標和觸摸事件同時作用?
              if (this.initiated && this.initiated !==_eventType) {
                return
              }
              // 設置initiated的值
              this.setInitiated(_eventType)
              // 如果檢查到配置了某些元素不需要響應滾動,這里直接返回
              if (tagExceptionFn(e.target, this.options.tagException)) {
                this.setInitiated()
                return
              }
              // 只允許鼠標左鍵單擊
              if (_eventType===EventType.Mouse && e.button !==MouseButton.Left) return
              // 這里根據配置來判斷是否要阻止冒泡和阻止默認事件
              this.beforeHandler(e, 'start')
              // 記錄觸摸開始的點距頁面的距離,pageX和pageY會包括頁面被卷去部分的長度
              let point=(e.touches ? e.touches[0] : e) as Touch
              this.pointX=point.pageX
              this.pointY=point.pageY
            }

          觸摸開始事件最主要的就是記錄一下觸摸點的位置。

          private move(e: TouchEvent) {
              let point=(e.touches ? e.touches[0] : e) as Touch
              // 計算觸摸移動的差值
              let deltaX=point.pageX - this.pointX
              let deltaY=point.pageY - this.pointY
              this.pointX=point.pageX
              this.pointY=point.pageY
              // 頁面被卷去的長度
              let scrollLeft=document.documentElement.scrollLeft ||
                window.pageXOffset ||
                document.body.scrollLeft
              let scrollTop=document.documentElement.scrollTop ||
                window.pageYOffset ||
                document.body.scrollTop
              // 當前觸摸的位置距離視口的位置,為什么不用clientX、clientY?
              let pX=this.pointX - scrollLeft
              let pY=this.pointY - scrollTop
              // 如果你快速滑動幅度過大的時候可能手指會滑出屏幕導致沒有觸發touchend事件,這里就是進行判斷,當你的手指位置距離邊界小于某個值時就自動調用end方法來結束本次滑動
              const autoEndDistance=this.options.autoEndDistance
              if (
                pX > document.documentElement.clientWidth - autoEndDistance ||
                pY > document.documentElement.clientHeight - autoEndDistance ||
                pX < autoEndDistance ||
                pY < autoEndDistance
              ) {
                this.end(e)
              }
            }

          觸摸中的方法主要做了兩件事,記錄和上次滑動的差值以及滿足條件自動結束滾動。

          private end(e: TouchEvent) {
              // 復位initiated的值,這樣move事件就不會再響應
              this.setInitiated()
              // 派發事件
              this.hooks.trigger(this.hooks.eventTypes.end, e)
            }

          滾動邏輯

          以上仍只是綁定了事件,還沒到滾動那一步,接下來看ScrollerActions,構造函數里調用了bindActionsHandler方法,這個方法里監聽了剛才actionsHandler里綁定的那些事件:

          private bindActionsHandler() {
              // [mouse|touch]觸摸開始事件
              this.actionsHandler.hooks.on(
                this.actionsHandler.hooks.eventTypes.start,
                (e: TouchEvent)=> {
                  if (!this.enabled) return true
                  return this.handleStart(e)
                }
              )
              // [mouse|touch]觸摸中事件
              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]觸摸結束事件
              this.actionsHandler.hooks.on(
                this.actionsHandler.hooks.eventTypes.end,
                (e: TouchEvent)=> {
                  if (!this.enabled) return true
                  return this.handleEnd(e)
                }
              )
            }

          接下來是上面三個事件對應的處理函數:

          private handleStart(e: TouchEvent) {
              // 獲取觸摸開始的時間戳
              const timestamp=getNow()
              this.moved=false
              this.startTime=timestamp
              // directionLockAction主要是用來做方向鎖定的,比如判斷某次滑動時應該進行水平滾動還是垂直滾動等,reset方法是復位鎖定的方向變量
              this.directionLockAction.reset()
              // start方法同樣也是做一些初始化或復位工作,包括滑動的距離、滑動方向
              this.scrollBehaviorX.start()
              this.scrollBehaviorY.start()
              // 強制結束上次滾動
              this.animater.doStop()
              // 復位滾動開始的位置
              this.scrollBehaviorX.resetStartPos()
              this.scrollBehaviorY.resetStartPos()
            }

          這個方法主要是做一系列的復位工作,畢竟是開啟一次新的滾動。

          private handleMove(deltaX: number, deltaY: number, e: TouchEvent) {
              // deltaX和deltaY記錄的是move事件每次觸發時和上一次的差值,getAbsDist方法是用來記錄當前和觸摸開始的絕對距離
              const absDistX=this.scrollBehaviorX.getAbsDist(deltaX)
              const absDistY=this.scrollBehaviorY.getAbsDist(deltaY)
              const timestamp=getNow()
              // 要么滑動距離大于閾值,要么在上次滑動結束后又立即滑動,否則不認為要進行滾動
              /**/
                  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
              }
              // 這里用來根據eventPassthrough配置項來判斷是否要進行鎖定,保留原生滾動
              // 如果本次檢測到你是進行水平滾動,那么水平方向上會進行鎖定,如果你這個配置設置的也是horizontal,這個方法會返回true,就相當于這次不進行模擬滾動而直接使用原生滾動,如果你傳的是vertical,就會調用e.preventDefault()來阻止原生滾動
              if (this.directionLockAction.checkMovingDirection(absDistX, absDistY, e)) {
                this.actionsHandler.setInitiated()
                return true
              }
              // 這個方法會把鎖定的那個方向的另外一個方向的delta值設為0,即另外那個方向不進行滾動
              const delta=this.directionLockAction.adjustDelta(deltaX, deltaY)
              // move方法做了兩件事,1是設置本次滑動的方向值,把右->左、下->上作為正向1,反之作為負向-1;2是調用阻尼方法,這個阻尼是啥意思呢,就是沒到邊界的話滑動的時候你能感覺到頁面是跟你的手指同步滑動的,阻尼之后你就會感覺到有阻力,頁面滑動變慢跟不上你的手指了:
              /**/
                  performDampingAlgorithm(delta: number, dampingFactor: number) {
                      // 滑動開始的位置加上本次滑動偏移量即當前滑動到的位置
                      let newPos=this.currentPos + delta
                      // 已經滑動到了邊界
                      if (newPos > this.minScrollPos || newPos < this.maxScrollPos) {
                        if (
                          (newPos > this.minScrollPos && this.options.bounces[0]) ||
                          (newPos < this.maxScrollPos && this.options.bounces[1])
                        ) {
                            // 阻尼原理很簡單,將本次滑動的距離乘一個小于1的小數就可以了
                          newPos=this.currentPos + delta * dampingFactor
                        } else {
                            // 如果配置關閉了阻尼效果,那么本次滑動就到頭了,滑不動了
                          newPos=          newPos > this.minScrollPos ? this.minScrollPos : this.maxScrollPos
                        }
                      }
                      return newPos
                    }
              /**/
              const newX=this.scrollBehaviorX.move(delta.deltaX)
              const newY=this.scrollBehaviorY.move(delta.deltaY)
              // 無論是使用css3 transition還是requestAnimationFrame做動畫,實際上改變的都是css的transform屬性的值,這里的translate最終調用的是上述this.translater實例的translate方法
              /**/
                  //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]
                          // x,y的值
                          const transformFnArg=point[key]
                          transformStyle.push(
                            `${transformFnName}(${transformFnArg}${transformFnArgUnit})`
                          )
                        }
                      })
                      this.hooks.trigger(
                        this.hooks.eventTypes.beforeTranslate,
                        transformStyle,
                        point
                      )
                      // 賦值
                      this.style[style.transform as any]=transformStyle.join(' ')
                      this.hooks.trigger(this.hooks.eventTypes.translate, point)
                    }
              /**/
              // 可以看到直接調用這個方法是沒有設置transition的值或是使用requestAnimationFrame來改變位移,所以是沒有動畫的,到這里content元素就已經會跟著你的觸摸進行滾動了
              this.animater.translate({
                x: newX,
                y: newY
              })
              // 這個方法主要是用來重置startTime的值以及根據probeType配置來判斷如何派發scroll事件
              /**/
                  private dispatchScroll(timestamp: number) {
                      // 每momentumLimitTime時間派發一次事件
                      if (timestamp - this.startTime > this.options.momentumLimitTime) {
                        // 刷新起始時間和位置,這個用來判斷是否要進行momentum動畫
                        this.startTime=timestamp
                        // updateStartPos會將元素當前滾動到的新位置作為起始位置startPos
                        this.scrollBehaviorX.updateStartPos()
                        this.scrollBehaviorY.updateStartPos()
                        if (this.options.probeType===Probe.Throttle) {
                          this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
                        }
                      }
                      // 實時派發事件
                      if (this.options.probeType > Probe.Throttle) {
                        this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
                      }
                    }
              /**/
              this.dispatchScroll(timestamp)
            }

          到這個函數內容就會跟著我們的觸摸開始滾動了,其實這樣就可以結束了,但是呢,還有兩件事要做,一是一般如果我們滑動一個東西,滑動較快的時候,即使手松開了物體也還會繼續滾動一會,不會你一松開它也立馬停下來,所以要判斷是否是快速滑動以及如何進行這個松開后的動量動畫;二是如果開啟了回彈動畫,這里需要判斷是否要回彈。

          動量動畫及回彈動畫

          先來看觸摸結束的處理函數:

          private handleEnd(e: TouchEvent) {
              if (this.hooks.trigger(this.hooks.eventTypes.beforeEnd, e)) {
                  return
              }
              // 調用scrollBehaviorX和scrollBehaviorY的同名方法來獲取當前currentPos的值
              const currentPos=this.getCurrentPos()
              // 更新本次的滾動方向
              this.scrollBehaviorX.updateDirection()
              this.scrollBehaviorY.updateDirection()
              if (this.hooks.trigger(this.hooks.eventTypes.end, e, currentPos)) {
                  return true
              }
              // 更新元素位置到結束觸摸點的位置
              this.animater.translate(currentPos)
              // 計算最后一次區間耗時
              this.endTime=getNow()
              const duration=this.endTime - this.startTime
              this.hooks.trigger(this.hooks.eventTypes.scrollEnd, currentPos, duration)
          }

          這個函數就派發了幾個事件,具體做了什么還要找到訂閱了這幾個事件的地方,那么就要回到Scroller.ts

          Scroller類構造函數最后的init方法里會執行一系列事件的訂閱,找到end事件的地方:

          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
                  }
                  // 判斷是否是點擊操作
                  if (!actions.moved) {
                      this.hooks.trigger(this.hooks.eventTypes.scrollCancel)
                      if (this.checkClick(e)) {
                          return true
                      }
                  }
                  // 這里這里,這個就是用來判斷是否越界及進行調整的方法
                  if (this.resetPosition(this.options.bounceTime, ease.bounce)) {
                      this.animater.setForceStopped(false)
                      return true
                  }
              }
          )

          resetPosition方法:

          resetPosition(time=0, easing=ease.bounce) {
              // checkInBoundary方法用來返回邊界值及是否剛好在邊界,具體邏輯看下面
              const {
                  position: x,
                  inBoundary: xInBoundary,
              }=this.scrollBehaviorX.checkInBoundary()
              const {
                  position: y,
                  inBoundary: yInBoundary,
              }=this.scrollBehaviorY.checkInBoundary()
              // 如果都剛好在邊界那么說明不需要回彈
              if (xInBoundary && yInBoundary) {
                  return false
              }
              // 超過邊界了那么就滾回去~(誒,你怎么罵人呢),scrollTo方法詳見下面
              this.scrollTo(x, y, time, easing)
              return true
          }
          
          /*scrollBehavior的相關方法*/
          checkInBoundary() {
              const position=this.adjustPosition(this.currentPos)
              // 如果邊界值和本次位置一樣那么說明剛好在邊界
              const inBoundary=position===this.getCurrentPos()
              return {
                  position,
                  inBoundary,
              }
          }
          
          // 越界調整位置
          adjustPosition(pos: number) {
              let roundPos=Math.round(pos)
              if (
                  !this.hasScroll &&
                  !this.hooks.trigger(this.hooks.eventTypes.ignoreHasScroll)
              ) {// 滿足條件返回最小滾動距離
                  roundPos=this.minScrollPos
              } else if (roundPos > this.minScrollPos) {// 越過最小滾動距離了則需要回彈到最小距離
                  roundPos=this.minScrollPos
              } else if (roundPos < this.maxScrollPos) {// 超過最大滾動距離了則需要回彈到最大距離
                  roundPos=this.maxScrollPos
              }
              return roundPos
          }
          /**/

          上述的最后就是調用scrollTo方法進行滾動,那么接下來就來看動畫相關的邏輯。

          scrollTo(
              x: number,
              y: number,
              time=0,
              easing=ease.bounce,
              extraTransform={
                  start: {},
                  end: {},
              }
          ) {
              // 根據是使用transition還是requestAnimationFrame來判斷是使用css cubic-bezier還是函數
              /*
              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()
              // 動畫開始位置
              const startPoint={
                  x: currentPos.x,
                  y: currentPos.y,
                  ...extraTransform.start,
              }
              // 動畫結束位置
              const endPoint={
                  x,
                  y,
                  ...extraTransform.end,
              }
              this.hooks.trigger(this.hooks.eventTypes.scrollTo, endPoint)
              // 起點終點相同當然就不需要動畫了
              if (isSamePoint(startPoint, endPoint)) return
              // 調用動畫方法
              this.animater.move(startPoint, endPoint, time, easingFn)
          }

          這個方法的最后終于調用了動畫的方法,因為支持兩種動畫方法,所以我們先來簡單思考一下這兩種的原理分別是什么。

          動畫

          使用css3的transition來做動畫是很簡單的,只要設置好過渡屬性transition的值,接下來改變transform的值自己就會應用動畫,transition是個簡寫屬性,包含四個屬性,一般來說我們主要設置它的transition-property(指定你要應用動畫的css屬性名稱,如transform,不設置則默認應用到所有可以應用的屬性)、transition-duration(過渡時間,必須要設置,不然為0沒有過渡)、transition-timing-function(動畫曲線)。

          使用requestAnimationFrame的話就需要自己來設置計算每次的位置了,配合一些常用的動畫曲線函數這個也是很簡單的,比如上述的函數,更多函數可訪問http://robertpenner.com/easing/:

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

          你只要把動畫已經進行了的時長和過渡時間的比例傳入,返回的值你再和本次動畫的距離相乘,即可得到此刻的位移。

          接下來看具體的實現,需要先說明的是這兩個類都繼承了一個基類,因為它們存在很多的共同操作。

          1.css3方式

          move(
              startPoint: TranslaterPoint,
              endPoint: TranslaterPoint,
              time: number,
              easingFn: string | EaseFn
          ) {
              // 設置一個pending變量,用來判斷當前是否正在動畫中
              this.setPending(time > 0)
              // 設置transition-timing-function屬性
              this.transitionTimingFunction(easingFn as string)
              // 設置transition-property的值為transform
              this.transitionProperty()
              // 設置transition-duration屬性
              this.transitionTime(time)
              // 調用上述提到過的this.translater的translate方法來設置元素的transform值
              this.translate(endPoint)
              // 如果時間不存在,那么在一個事件周期里里改變屬性值不會觸發transitionend事件,所以這里通過觸發回流強制更新
              if (!time) {
                  this._reflow=this.content.offsetHeight
                  this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
                  this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
              }
          }

          2.requestAnimationFrame方式

          move(
              startPoint: TranslaterPoint,
              endPoint: TranslaterPoint,
              time: number,
              easingFn: EaseFn | string
          ) {
              // time為0直接調用translate方法設置位置就可以了
              if (!time) {
                  this.translate(endPoint)
                  this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
                  this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
                  return
              }
              // 不為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
              // 動畫方法,會被requestAnimationFrame遞歸調用
              const step=()=> {
                  let now=getNow()
                  // 當前時間大于本次動畫結束的時間表示動畫結束了
                  if (now >=destTime) {
                      // 可能距目標值有一點小誤差,手動設置一下提高準確度
                      this.translate(endPoint)
                      this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
                      this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
                      return
                  }
                  // 時間耗時比例
                  now=(now - startTime) / duration
                  // 調用緩動函數
                  let easing=easingFn(now)
                  const newPoint={} as TranslaterPoint
                  Object.keys(endPoint).forEach((key)=> {
                      const startValue=startPoint[key]
                      const endValue=endPoint[key]
                      // 得到本次動畫的目標位置
                      newPoint[key]=(endValue - startValue) * easing + startValue
                  })
                  // 執行滾動
                  this.translate(newPoint)
                  if (this.pending) {
                      this.timer=requestAnimationFrame(step)
                  }
              }
              // 設置標志位
              this.setPending(true)
              // 基本操作,開始新的定時器或requestAnimationFrame時先做一次清除操作
              cancelAnimationFrame(this.timer)
              // 開始動畫
              step()
          }

          上面的代碼里都只有設置pendingtrue,而沒有重置為false的地方,聰明的你一定能想到肯定是通過事件訂閱在其他地方進行重置了,是的,讓我們回到Scroller.tsScroller類里面綁定了content元素的transitionend事件和訂閱了end事件:

          // 這是transitionend的處理函數
          private transitionEnd(e: TouchEvent) {
              if (e.target !==this.content || !this.animater.pending) {
                  return
              }
              const animater=this.animater as Transition
              // 刪除transition-duration的屬性值
              animater.transitionTime()
              // 這里也調用了resetPosition來進行邊界回彈,之前是在觸摸結束后的end事件調用了,因為直接調用translate方法時是不會觸發transitionend事件的,以及觸摸結束后可能會有回彈動畫,所以這里也需要調用
              if (!this.resetPosition(this.options.bounceTime, ease.bounce)) {
                  this.animater.setPending(false)
              }
          }
          this.animater.hooks.on(
              this.animater.hooks.eventTypes.end,
              (pos: TranslaterPoint)=> {
                  // 同上,邊界回彈
                  if (!this.resetPosition(this.options.bounceTime)) {
                      this.animater.setPending(false)
                      this.hooks.trigger(this.hooks.eventTypes.scrollEnd, pos)
                  }
              }
          )

          當然,上述邊界回彈的函數里最后動畫完成后又會觸發這兩個事件,就又走到了resetPosition的判斷邏輯,但是因為它們已經回彈完成在邊界上了,所以會直接返回false。

          回彈邏輯看完了,但是動量動畫還是沒看到,別急,上面說了一般是當你松開手指的時候才判斷是否要進行動量運動,所以回到上面的handleEnd方法,發現最后觸發了一個scrollEnd事件,在Scroller里找到訂閱該事件的處理函數:

          actions.hooks.on(
              actions.hooks.eventTypes.scrollEnd,
              (pos: TranslaterPoint, duration: number)=> {
                  // 這個duration=this.endTime - this.startTime,但是startTime在一次觸摸中每超過momentumLimitTime都會進行重置的,所以不是從手指觸摸到手指離開的總時間
                  // 最后這段時間片段滾動的距離
                  const deltaX=Math.abs(pos.x - this.scrollBehaviorX.startPos)
                  const deltaY=Math.abs(pos.y - this.scrollBehaviorY.startPos)
                  // 判斷是否是輕拂動作,應該是為插件服務的,這里不管
                  /**/
                  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
                  }
                  // 判斷是否進行momentum動畫
                  if (this.momentum(pos, duration)) {
                      this.animater.setForceStopped(false)
                      return
                  }
              }
          )
          private momentum(pos: TranslaterPoint, duration: number) {
              const meta={
                  time: 0,
                  easing: ease.swiper,
                  newX: pos.x,
                  newY: pos.y,
              }
              // 判斷是否滿足動量條件,滿足則計算動量數據,也就是最后要滾動到的位置,這個方法代碼較多,就不放出來了,反正做的事情時根據配置來判斷是否滿足動量條件,滿足再根據配置判斷是否在某個方向上允許回彈,最后再動用另一個方法momentum來計算動量數據,這個方法見下面
              const momentumX=this.scrollBehaviorX.end(duration)
              const momentumY=this.scrollBehaviorY.end(duration)
              // 做一下判斷
              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
              )
              // 位置變了,那么意味著要進行動量動畫
              if (meta.newX !==pos.x || meta.newY !==pos.y) {
                  this.scrollTo(meta.newX, meta.newY, meta.time, meta.easing)
                  return true
              }
          }
          // 計算動量數據
          private momentum(
              current: number,
              start: number,
              time: number,
              lowerMargin: number,
              upperMargin: number,
              wrapperSize: number,
              options=this.options
          ) {
              // 最后滑動的時間片段
              const distance=current - start
              // 最后滑動的速度
              const speed=Math.abs(distance) / time
              const { deceleration, swipeBounceTime, swipeTime }=options
              const momentumData={
                  // 目標位置計算方式:手指松開后元素最后的位置+額外距離
                  // deceleration代表減速度,默認值是0.0015,假如distance=15px,time=300ms,那么speed=0.05px/ms,則speed / deceleration=33,即從當前距離繼續滑動33px,你速度越快或deceleration設置的越小,滑動的越遠
                  destination: current + (speed / deceleration) * (distance < 0 ? -1 : 1),
                  duration: swipeTime,
                  rate: 15,
              }
              // 超過最大滑動距離
              if (momentumData.destination < lowerMargin) {
                  // 如果用戶配置允許該方向回彈,那么再次計算動量距離,為什么??否則最多只能滾動到最大距離
                  momentumData.destination=wrapperSize
                      ? Math.max(
                      lowerMargin - wrapperSize / 4,
                      lowerMargin - (wrapperSize / momentumData.rate) * speed
                  )
                  : lowerMargin
                  momentumData.duration=swipeBounceTime
              } else if (momentumData.destination > upperMargin) {// 超過最小滾動距離,同上
                  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
          }

          動量邏輯其實也很簡單,就是根據最后時刻的耗時和距離來進行一下判斷,再根據一定算法來計算動量數據也就是最終要滾動到的位置,然后滾過去。

          到這里,核心的滾動邏輯已經全部結束了,最后來看一下如何強制結束transition滾動,因為requestAnimationFrame結束很簡單,調用一下cancelAnimationFrame就可以了。

          doStop(): boolean {
              const pending=this.pending
              if (pending) {
                  // 復位標志位
                  this.setPending(false)
                  // 獲取content元素當前的translateX和translateY的值
                  const { x, y }=this.translater.getComputedPosition()
                  // 將transition-duration的值設為0
                  this.transitionTime()
                  // 設置到當前位置
                  this.translate({ x, y })
              }
              return pending
          }

          首先獲取到元素此刻的位置,然后刪除過渡時間,最后再修改目標值為此刻的位置,因為不修改,即使你把過渡時間改回0了過渡動畫仍然會繼續,此時你強制修改一下位置,它立馬就會結束。

          例行總結

          因為是第一次認真的閱讀一份源碼,所以可能會有很多問題,通篇就像在給這個源碼加注釋,而且因為是憑空閱讀并沒有通過運行代碼進行斷點調試,所以難免會存在錯誤。

          首先說說TypeScript,后半部分基本沒有再介紹過它,所以可以發現想要閱讀一份TypeScript代碼是并不難的,只要了解一些常用的語法基本就沒有障礙了,但是離自己能熟練的使用那還是存在很遠的距離,很多東西就是這樣,你可以看的懂,但是你自己寫就不會了,也沒啥捷徑,歸根結底還是要多用多思考。

          然后是BetterScroll,代碼總體來說還是比較清晰的,因為是插件化,所以事件機制是少不了的,優點是功能解耦,各部分獨立,缺點也顯而易見,首先是每個類都有自己的事件,很多事件還是同名的,所以很容易看著看著就暈了,其次是因為事件訂閱發布,很難清楚的理解事件流,所以這也是比如vue更提倡通過屬性來顯示傳遞和接收。

          總的來說,這個庫的核心滾動是一個很簡單的功能,自己實現什么都不考慮的話一百多行代碼可能也就夠了,但是并不妨礙可以將它擴展成一個功能強大的庫,這樣要考慮的事情就比較多了,首先要考慮到各種邊界情況,其次是要考慮兼容性,比如css樣式,可能還會遇到特定機型的bug,代碼如何組織也很重要,要盡量的復用,比如BetterScroll里兩種動畫方式就存在很多共同操作,那么就可以把這些提取到公共的父類里,又比如水平滾動和垂直滾動肯定也是大量代碼都是一樣的,所以也需要進行抽象提煉,因為設計成插件化,所以還要考慮插件的開發和集成,最后還需要完善的測試,所以一個優秀的開源項目都是不容易的。

          為啥沒有早點知道scrollIntoView!!!

          ### 引言

          身為一名Web前端開發者,你是否曾陷入過滾動頁面尋找某個特定元素的困境?亦或是想要精準定位某元素在視窗中央卻苦于找不到合適的解決方案?今天,我們要向你揭示一個鮮為人知卻又極其實用的Web API——`scrollIntoView`,它可以幫助我們在頁面中輕松定位任何元素,實現流暢自然的滾動效果。這篇文章將深度剖析`scrollIntoView`的用法及其背后的原理,從此,你再也不必為頁面滾動問題而煩惱!

          ### 一、scrollIntoView的基本用法

          **1.1 scrollIntoView是什么?**

          `scrollIntoView`是DOM元素的一個原生方法,它可以讓指定元素滾動到視窗可見區域,也就是讓目標元素盡可能地出現在瀏覽器窗口的可視范圍內。

          ```html

          <button id="scroll-to-me">點擊我,讓我進入視野</button>

          <script>

          const button=document.querySelector('#scroll-to-me');

          button.addEventListener('click', ()=> {

          button.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });

          });

          </script>

          ```

          **1.2 參數詳解**

          - `behavior`: 可選參數,決定滾動動畫的速度,可設為`'auto'`(默認,無動畫)或`'smooth'`(平滑滾動)。

          - `block`: 指定垂直滾動方式,可選值有`'start'`、`'center'`、`'end'`、`'nearest'`。默認值為`'auto'`,根據元素的位置自動選擇最佳滾動位置。

          - `inline`: 指定水平滾動方式,用法同`block`參數。

          ### 二、scrollIntoView的實際應用場景

          **2.1 頁面內的錨點導航**

          ```html

          <a href="#section2">跳轉到第二部分</a>

          <div id="section1">...</div>

          <div id="section2">這里是第二部分</div>

          <script>

          window.onload=()=> {

          const hash=window.location.hash;

          const targetElement=document.querySelector(hash);

          if (targetElement) {

          targetElement.scrollIntoView({ behavior: 'smooth' });

          }

          };

          </script>

          ```

          **2.2 自動滾動表單中的錯誤字段**

          ```html

          <form>

          <input type="text" id="field1" required>

          <input type="text" id="field2" required>

          <!-- 其他字段... -->

          <button type="submit" id="submit-btn">提交</button>

          </form>

          <script>

          const form=document.querySelector('form');

          const submitBtn=document.querySelector('#submit-btn');

          form.addEventListener('submit', (event)=> {

          event.preventDefault();

          let firstInvalidField=form.querySelector(':invalid');


          if (firstInvalidField) {

          firstInvalidField.scrollIntoView({ behavior: 'smooth' });

          }

          });

          </script>

          ```

          ### 三、高級用法與注意事項

          **3.1 平滑滾動與性能優化**

          使用`behavior: 'smooth'`時,瀏覽器會創建一個平滑滾動動畫,這可能導致CPU占用較高,特別是在移動端。因此,應合理評估滾動動畫對用戶體驗和性能的影響。

          **3.2 瀏覽器兼容性**

          `scrollIntoView`的選項參數在某些老版本瀏覽器中可能不受支持,因此在實際使用時需注意瀏覽器兼容性,必要時可通過polyfill庫或條件判斷來提供備選方案。

          ### 結語

          `scrollIntoView`這個隱藏的寶藏API無疑為Web開發者提供了極大便利,無論是實現頁面內部的錨點導航,還是聚焦表單錯誤提示,它都能輕松應對。下次當你面臨頁面滾動難題時,不妨試試`scrollIntoView`,相信它會讓你感嘆:“為啥沒有早點知道scrollIntoView!”如今,就讓我們一起擁抱這個強大的API,為用戶創造更流暢、更友好的瀏覽體驗吧!

          看個錨點定位的例子

          發現頁面唰的一些就到頂部了,快到我們懵逼了。。。

          開始解決

          scroll-behavior

          CSS屬性 scroll-behavior 為一個滾動框指定滾動行為,其他任何的滾動,例如那些由于用戶行為而產生的滾動,不受這個屬性的影響。在根元素中指定這個屬性時,它反而適用于視窗。

          scroll-behavior:smooth 寫在滾動容器元素上,可以讓容器的滾動變得平滑。

          在網頁默認滾動是在<html>標簽上,移動端大多數在<body>標簽上。

          我們可以這樣加:

          html, 
          body { scroll-behavior:smooth; }
          

          加了以后的效果如下:

          這是錄制的GIF圖,效果沒那么好。 大家可以動手試一下,滑動體驗非常不錯。

          缺點

          兼容性不夠好

          當然我們可以通過js來做個類似

          Element.scrollIntoView() 方法

          DOM元素的scrollIntoView() 方法讓當前的元素滾動到瀏覽器窗口的可視區域內,通過觸發滾動容器的定位實現。

          DOM元素的scrollIntoView()方法 是原生JS 兼容到IE6,兼容性非常好。

          參數如下

          {
           behavior: "auto" | "instant" | "smooth", // 默認 auto
           block: "start" | "center" | "end" | "nearest", // 默認 center
           inline: "start" | "center" | "end" | "nearest", // 默認 nearest
          }
          

          解釋一下這三個參數:

          1. behavior 表示滾動方式。auto 表示使用當前元素的 scroll-behavior 樣式。instant 和 smooth 表示 直接滾到底 和 使用平滑滾動。
          2. block 表示塊級元素排列方向要滾動到的位置。對于默認的 writing-mode: horizontal-tb 來說,就是豎直方向。start 表示將視口的頂部和元素頂部對齊;center 表示將視口的中間和元素的中間對齊;end 表示將視口的底部和元素底部對齊;nearest 表示就近對齊。
          3. inline 表示行內元素排列方向要滾動到的位置。對于默認的 writing-mode: horizontal-tb 來說,就是水平方向。其值與 block 類似。

          用法:

          html:

          <div class="wrap">
           <div onClick="onScrollIntoView()">點擊讓黑色塊到頂部</div>
           <ul class="body">
           <li>1</li>
           <li>2</li>
           <li id="box">我是黑色</li>
           <li>3</li>
           <li>4</li>
           </ul>
          </div>
          

          js:

          function onScrollIntoView () {
           var element=document.getElementById("box");
           element.scrollIntoView({behavior: "smooth"});
          }
          

          效果:

          這回大家再也不用害怕做錨點定位啦。

          最后我們在說一個關于頁面滾動問題吧,那就是 返回頂部 功能實現

          返回頂部 功能實現

          我們常用定時器 setInterval 來不斷減去高度。

          如:當前距離頂部 1000, 我們每10毫秒減50,

          var timer=setInterval(function() { // 定時器 每10毫秒執行一次
           // 頂部距離 document.body.scrollTop=1000 
           var speed=50 // 返回頂部速度 
           document.body.scrollTop=document.body.scrollTop - speed
           if (document.body.scrollTop===0) { // 返回到達頂部后, 銷毀定時器
           clearInterval(timer)
           }
          }, 10)
          

          效果:

          大家會發現,頁面返回是滾動起來很干。 沒10毫秒減50. 很平均,在交互上效果并不好。

          借鑒上面 scroll-behavior:smooth 的交互效果。 緩動的返回頂部。

          改一下計算方式:1000/2=500, 500/2=250, 250/2=...... 這樣滑動起來是不是就平滑了呢?

          換算成公式:開始位置=開始位置 + (結束位置 - 開始位置) / 速度

          document.body.scrollTop=1000 + (0 - 1000) / 2

          公式太煩了還是上代碼吧:

          var onTop=function (a, b, c, d) {
           if (a==b || typeof a !='number') {
           return
           }
           b=b || 0
           c=c || 2
           
           var speed=function () {
           a=a + (b - b) / c
           
           if (a < 1) {
           d(b, true)
           return
           }
           d(a, false)
           requestAnimationFrame(speed)
           }
           speed()
          }
          
          • a 開始位置
          • b 結束位置
          • c 速度
          • d 位置回調,d(當前的位置值, 否動畫結束)


          調用:

          var target=document.body.scrollTop ? document.body : document.documentElement
          onTop(target.scrollTop, 0, 4, function (value) {
           target.scrollTop=value
          })
          

          效果:

          Ps: gif錄制效果不好,大家可以動手寫一下DEMO


          主站蜘蛛池模板: 精品国产精品久久一区免费式| 国产日韩高清一区二区三区| 国产乱人伦精品一区二区在线观看 | 国产日韩精品一区二区在线观看| 3d动漫精品啪啪一区二区免费 | 亚洲国产高清在线精品一区| 亚洲国产精品第一区二区三区 | 久久精品国产第一区二区三区| 亚洲国产激情一区二区三区| 国产一区二区精品久久岳| 日韩一区二区三区精品| 日本精品视频一区二区三区| 精品乱码一区二区三区在线| 无码日韩AV一区二区三区| 欧美成人aaa片一区国产精品| 中文字幕日韩一区二区三区不卡| 97一区二区三区四区久久| 韩国精品一区视频在线播放| A国产一区二区免费入口| 中文人妻av高清一区二区| 国产一区二区三区乱码网站| 日韩精品一区在线| 无码人妻精品一区二区三18禁| 精品国产高清自在线一区二区三区 | jizz免费一区二区三区| 精品亚洲一区二区三区在线观看| 无码人妻一区二区三区免费看| 日本韩国一区二区三区| 国产精品无码一区二区在线观 | 一区二区免费视频| 无码人妻精品一区二区蜜桃 | 在线成人综合色一区| 一区二区不卡久久精品| 国产精品无码一区二区三区电影| 色偷偷av一区二区三区| 无码一区二区三区中文字幕| 肉色超薄丝袜脚交一区二区| 国产一区二区三区在线电影| 亚洲日本va午夜中文字幕一区| 高清无码一区二区在线观看吞精| 日韩人妻无码一区二区三区久久|