阐述 Vue Router 源码中 `scrollBehavior` 选项的实现,以及它如何控制页面滚动行为。

各位靓仔靓女,今天咱们聊聊 Vue Router 里的一个神奇小精灵:scrollBehavior。它就像个贴心的管家,负责在你切换路由的时候,帮你把页面滚动到指定的位置,让你的用户体验丝滑流畅,告别手动滚动的尴尬。

大家好!现在开始咱们的 Vue Router scrollBehavior 源码探秘之旅!

一、scrollBehavior 是个啥?它的使命是啥?

简单来说,scrollBehavior 是 Vue Router 提供的一个配置选项,允许你自定义路由切换时的页面滚动行为。默认情况下,每次路由切换,浏览器都会尝试恢复到之前的滚动位置。但有时候,我们希望页面滚动到顶部,或者滚动到特定的元素位置,或者干脆保持原样。这时候,scrollBehavior 就派上用场了。

它的使命就是:控制路由切换时的页面滚动位置,提升用户体验。

二、scrollBehavior 的配置方式:三种姿势任你选

scrollBehavior 可以是一个函数,这个函数接收两个参数,返回一个描述滚动位置的对象。这个函数会在每次路由切换后被调用。

const router = new VueRouter({
  routes: [...],
  scrollBehavior (to, from, savedPosition) {
    // to: 即将要进入的目标路由对象
    // from: 当前导航正要离开的路由对象
    // savedPosition: 只有在使用 popstate 导航时才可用 (比如点击浏览器的后退/前进按钮)。

    // 返回期望滚动到的位置
    // 可以是以下形式:
    // - { x: number, y: number }
    // - { selector: string }
    // - { x: number, y: number, behavior: 'auto' | 'smooth' }
    // - 异步返回 Promise<PositionResult>
  }
})

让我们仔细看看这三个参数:

  • to: 目标路由对象,包含了你将要导航到的路由的所有信息,比如路径、参数、查询参数等等。
  • from: 当前路由对象,包含了你当前所在路由的所有信息。
  • savedPosition: 这个参数只有在使用 popstate 导航时才可用。啥是 popstate 导航呢?简单来说,就是用户点击浏览器的前进/后退按钮时触发的导航。这个 savedPosition 包含了上一次页面的滚动位置,你可以利用它来实现“记忆滚动位置”的功能。

返回值类型:

  1. { x: number, y: number }: 直接指定滚动条的横纵坐标。比如 { x: 0, y: 0 } 表示滚动到页面顶部。
  2. { selector: string }: 指定一个 CSS 选择器,页面会滚动到该选择器对应的元素的位置。比如 { selector: '#app' } 表示滚动到 id 为 app 的元素。
  3. { x: number, y: number, behavior: 'auto' | 'smooth' }: 指定滚动条的横纵坐标,并指定滚动行为。behavior 可以是 'auto' (立即滚动) 或者 'smooth' (平滑滚动)。
  4. Promise<PositionResult>: 异步返回一个描述滚动位置的对象。这个允许你执行一些异步操作,比如等待某个元素加载完成,然后再滚动到指定位置。

三、源码剖析:scrollBehavior 是如何工作的?

要理解 scrollBehavior 的工作原理,我们需要深入 Vue Router 的源码。这里我们关注的核心代码片段位于 src/util/scroll.jssrc/history/base.js 中。

  1. src/util/scroll.js: 包含一些辅助函数,用于获取当前页面的滚动位置,以及执行滚动操作。

    // src/util/scroll.js
    
    const supportsPushState =
      inBrowser &&
      (function () {
        const ua = window.navigator.userAgent
    
        // detect android 4.0+
        const isAndroid = ua.indexOf('Android') > -1
        const isNewerAndroid = ua.indexOf('Chrome') > -1
        if (isAndroid && !isNewerAndroid) {
          return false
        }
    
        return window.history && typeof window.history.pushState === 'function'
      })()
    
    function getScrollPosition (): { x: number, y: number } {
      const supportPageOffset = window.pageXOffset !== undefined
      const x = supportPageOffset
        ? window.pageXOffset
        : document.documentElement.scrollLeft
      const y = supportPageOffset
        ? window.pageYOffset
        : document.documentElement.scrollTop
      return {
        x: x,
        y: y
      }
    }
    
    function getElementPosition (el: Element): { x: number, y: number } {
      const docRect = document.documentElement.getBoundingClientRect()
      const elRect = el.getBoundingClientRect()
      return {
        x: elRect.left - docRect.left,
        y: elRect.top - docRect.top
      }
    }
    
    function isValidPosition (obj: any): boolean {
      return typeof obj === 'object' &&
        typeof obj.x === 'number' &&
        typeof obj.y === 'number'
    }
    
    function scrollLeft (x: number): void {
      window.scrollTo(x, getScrollPosition().y)
    }
    
    function scrollTop (y: number): void {
      window.scrollTo(getScrollPosition().x, y)
    }
    
    function scrollTo (x: number, y: number): void {
      window.scrollTo(x, y)
    }
    
    export {
      supportsPushState,
      getScrollPosition,
      getElementPosition,
      isValidPosition,
      scrollLeft,
      scrollTop,
      scrollTo
    }
    
    • getScrollPosition(): 获取当前页面的滚动位置 (x, y)。
    • getElementPosition(el): 获取元素相对于文档的坐标。
    • scrollTo(x, y): 滚动到指定的坐标。
  2. src/history/base.js: 这是 Vue Router 历史模式的基础类,负责处理路由切换和滚动行为。核心代码如下:

    // src/history/base.js
    
    export class History {
      constructor (router: Router, base: ?string) {
        this.router = router
        this.base = normalizeBase(base)
        this.current = START
    
        // installed by Router.use(VueRouter)
        this.pending = null
        this.ready = false
        this.readyCbs = []
        this.readyErrorCbs = []
        this.errorCbs = []
      }
    
      listen (cb: Function) {
        this.cb = cb
      }
    
      onReady (cb: Function, errorCb: ?Function) {
        if (this.ready) {
          cb()
        } else {
          this.readyCbs.push(cb)
          if (errorCb) {
            this.readyErrorCbs.push(errorCb)
          }
        }
      }
    
      onError (cb: Function) {
        this.errorCbs.push(cb)
      }
    
      transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
        let route
        try {
          route = this.router.match(location, this.current)
        } catch (e) {
          this.errorCbs.forEach(cb => {
            cb(e)
          })
          // Exception should still be thrown after executing the error callbacks.
          throw e
        }
    
        const prev = this.current
        this.confirmTransition(
          route,
          () => {
            this.updateRoute(route)
            onComplete && onComplete(route)
            this.ensureURL()
    
            // 调用 scrollBehavior
            if (this.router.options.scrollBehavior) {
              this.handleScroll(route, prev, savedPosition) // 调用 handleScroll
            }
    
            if (this.pending) {
              this.pending = null
            }
          },
          err => {
            if (onAbort) {
              onAbort(err)
            }
            if (err && typeof err === 'object' && err.isCancelled) {
              return
            }
            this.ensureURL(true)
          }
        )
      }
    
      updateRoute (route: Route) {
        const prev = this.current
        this.current = route
        this.cb && this.cb(route)
        this.router.afterHooks.forEach(hook => {
          hook && hook(route, prev)
        })
      }
    
      handleScroll (to: Route, from: Route, isPop?: boolean) {
        const router = this.router
        if (!router.app) {
          return
        }
    
        const behavior = router.options.scrollBehavior
        if (!behavior) {
          return
        }
    
        if (isPop && savedPosition) {
          return setTimeout(() => {
            this.scrollToPosition(savedPosition)
          }, supportsPushState ? 0 : 1)
        }
    
        let position = false
        try {
          position = behavior.call(router, to, from, savedPosition)
        } catch (e) {
          return console.error(e)
        }
    
        // 异步处理
        if (!position) {
          return
        }
    
        if (typeof position.then === 'function') {
          position.then(position => {
            this.scrollToPosition(position)
          }).catch(err => {
            console.log(err)
          })
        } else {
          this.scrollToPosition(position)
        }
      }
    
      scrollToPosition (position) {
        if (!position) return
        if (typeof position.selector === 'string') {
          const el = document.querySelector(position.selector)
          if (el) {
            let offset = getElementPosition(el)
            scrollTo(offset.x, offset.y)
          }
        } else if (isValidPosition(position)) {
          scrollTo(position.x, position.y)
        }
      }
    }
    
    • transitionTo(location, onComplete, onAbort): 这个方法负责处理路由切换的核心逻辑。它首先匹配路由,然后确认过渡,最后调用 updateRoute 更新当前路由。在 updateRoute 之后,会调用 handleScroll 来处理滚动行为。
    • handleScroll(to, from, isPop): 这个方法是 scrollBehavior 的核心执行者。它首先判断是否需要处理滚动,然后调用用户配置的 scrollBehavior 函数,获取期望的滚动位置。如果 scrollBehavior 返回的是一个 Promise,它会等待 Promise resolve 后再进行滚动。
    • scrollToPosition(position): 这个方法接收一个描述滚动位置的对象,然后根据对象的类型 ( { x, y }{ selector } ) 调用相应的滚动方法。

流程总结:

  1. 路由切换触发 transitionTo 方法。
  2. transitionTo 方法调用 handleScroll 方法。
  3. handleScroll 方法调用用户配置的 scrollBehavior 函数,获取期望的滚动位置。
  4. handleScroll 方法调用 scrollToPosition 方法。
  5. scrollToPosition 方法根据滚动位置的类型,调用 scrollTogetElementPosition 方法来执行滚动操作。

四、实战演练:几种常见的 scrollBehavior 用法

  1. 滚动到页面顶部:

    scrollBehavior (to, from, savedPosition) {
      return { x: 0, y: 0 }
    }

    这个是最简单的用法,每次路由切换都会滚动到页面顶部。

  2. 记忆滚动位置:

    scrollBehavior (to, from, savedPosition) {
      if (savedPosition) {
        return savedPosition
      } else {
        return { x: 0, y: 0 }
      }
    }

    这个用法会尝试恢复之前的滚动位置 (如果存在),否则滚动到页面顶部。

  3. 滚动到指定元素:

    scrollBehavior (to, from, savedPosition) {
      if (to.hash) {
        return {
          selector: to.hash,
          behavior: 'smooth',
        }
      } else {
        return { x: 0, y: 0 }
      }
    }

    这个用法会根据路由的 hash 值滚动到对应的元素。比如,如果路由是 /about#team,它会滚动到 id 为 team 的元素。

  4. 异步滚动:

    scrollBehavior (to, from, savedPosition) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve({ x: 0, y: 100 })
        }, 500)
      })
    }

    这个用法会在 500 毫秒后滚动到 x: 0, y: 100 的位置。

  5. 根据 meta 信息动态滚动

    // 路由配置
    const routes = [
      {
        path: '/page1',
        component: Page1,
        meta: { scrollToTop: true }
      },
      {
        path: '/page2',
        component: Page2,
        meta: { scrollToElement: '#section2' }
      }
    ]
    
    // scrollBehavior 函数
    scrollBehavior (to, from, savedPosition) {
      if (to.meta.scrollToTop) {
        return { x: 0, y: 0 }
      }
    
      if (to.meta.scrollToElement) {
        return { selector: to.meta.scrollToElement, behavior: 'smooth' }
      }
    
      return savedPosition || { x: 0, y: 0 }
    }

    这种方式可以根据路由元信息动态地设置滚动行为,非常灵活。

五、注意事项:

  • scrollBehavior 函数是在路由切换执行的,所以页面可能已经渲染完毕。
  • 如果你的页面使用了异步组件,可能需要在 scrollBehavior 函数中使用 Promise 来等待组件加载完成。
  • savedPosition 只在使用 popstate 导航时可用。
  • 如果你的 scrollBehavior 函数返回 nullundefined,则不会执行滚动操作。
  • behavior: 'smooth' 属性的兼容性可能存在问题,需要根据你的目标浏览器进行测试。

六、总结:

scrollBehavior 是 Vue Router 提供的一个非常强大的工具,可以让你轻松控制路由切换时的页面滚动行为。通过深入理解 scrollBehavior 的配置方式和源码实现,你可以更好地利用它来提升用户体验。希望今天的讲解能够帮助你更好地掌握这个神奇的小精灵,让你的 Vue 应用更加出色!

最后的彩蛋:一个高级用法示例

假设你的应用有一个侧边栏,点击侧边栏的链接会切换到不同的内容区域。你希望在切换内容区域时,页面滚动到对应的标题位置,并且使用平滑滚动效果。同时,你也希望记住用户的滚动位置,以便在点击浏览器的后退/前进按钮时能够恢复之前的滚动状态。

<template>
  <div class="app">
    <aside>
      <ul>
        <li><router-link to="/section1">Section 1</router-link></li>
        <li><router-link to="/section2">Section 2</router-link></li>
        <li><router-link to="/section3">Section 3</router-link></li>
      </ul>
    </aside>
    <main>
      <section id="section1">
        <h2>Section 1</h2>
        <p>...</p>
      </section>
      <section id="section2">
        <h2>Section 2</h2>
        <p>...</p>
      </section>
      <section id="section3">
        <h2>Section 3</h2>
        <p>...</p>
      </section>
    </main>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
.app {
  display: flex;
}

aside {
  width: 200px;
  padding: 20px;
}

main {
  flex: 1;
  padding: 20px;
}

section {
  margin-bottom: 50px;
}
</style>
// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'

Vue.use(VueRouter)

const routes = [
  { path: '/section1', component: App },
  { path: '/section2', component: App },
  { path: '/section3', component: App }
]

const router = new VueRouter({
  routes,
  mode: 'history',
  scrollBehavior (to, from, savedPosition) {
    if (to.hash) {
      return {
        selector: to.hash,
        behavior: 'smooth'
      }
    }

    if (savedPosition) {
      return savedPosition
    }

    return { x: 0, y: 0 }
  }
})

export default router

这个例子结合了 hash 锚点滚动和记忆滚动位置的功能,可以提供非常流畅的用户体验。

希望这个彩蛋能让你对 scrollBehavior 的应用有更深入的理解!

好了,今天的讲座就到这里。希望大家有所收获,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注