您好,登錄后才能下訂單哦!
這篇文章將為大家詳細講解有關怎么在 Vue中實現一個吸頂錨點組件,文章內容質量較高,因此小編分享給大家做個參考,希望大家閱讀完這篇文章后對相關知識有一定的了解。
拆分功能點
現在我們已經明確需求了,接下來我們總結一下這個需求有哪些功能點:
按鈕組要有吸頂效果
點擊按鈕要有錨點定位功能
滾動內容區需要找到對應的按鈕并高亮
吸頂組件
要做一個吸頂效果最簡單的方式是將 css 的 position 屬性設置為 sticky, 這樣就實現粘性布局。
.sticky-container { position: sticky; top: 0px; }
上面的示例僅僅用了兩行 css 的代碼就實現了粘性布局,但由于 IE 瀏覽器完全不支持粘性布局,而我的項目又需要支持一部分的 IE 瀏覽器,所以就需要手動去實現這樣一個功能。
MDN 官方對粘性布局的解釋是這樣的,粘性布局元素默認是相對定位的,當粘性元素超出父元素的指定值(如 `top` 、`left` 等),例如上面的示例,當元素粘性元素改為固定定位。關于父級元素 MDN 描述的不是很精確,這里的父級元素指的是父級滾動元素,如果沒有父級滾動元素則將 `body` 元素作為父級元素。
既然需要自己實現一個吸頂的效果,思考到其他頁面可能也會使用的吸頂的功能,所以決定將其單獨抽離成一個通用組件。首先我們知道粘性布局是對父級滾動元素定位,所以我們要先找到父級滾動元素,這個功能我們可以通過兩種方式實現,一種是向上查找,一種是通過 props 傳遞一個唯一標識的 css 選擇器。
我覺得其他項目可能也會遇到這個功能,所以我定義組件 盡量向著開源靠攏,所以我這里同時支持兩種方案。首先我們要實現一個查找父級滾動元素的功能,如何判斷一個元素是滾動元素呢?很簡單判斷其 `overflow` 是否是 `auto` 或者 `scroll`。
// util.js 文件 // 判斷一個元素是否是滾動元素 const scrollList = ['auto', 'scroll'] export function hasScrollElement(el, direction = 'vertical') { if (!el) return const style = window.getComputedStyle(el) if (direction === 'vertical') { return scrollList.includes(style.overflowY) } else if (direction === 'horizontal') { return scrollList.includes(style.overflowX) } } // 獲取第一個滾動元素 export function getFirstScrollElement(el, direction = 'vertical') { if (!el) return if (hasScrollElement(el, direction)) { return el } else { return getFirstScrollElement(el && el.parentElement, direction) } }
這里說下實現吸頂效果所需要的一些基礎知識:
fixed 定位是相對于瀏覽器的可視區進行定位,這意味著即使頁面滾動,它還是會固定在相同的位置
offsetTop 是一個只讀的屬性,它返回當前元素相對于距離它最近的父級定位元素頂部的距離。
scrollTop 屬性可以獲取或設置一個元素的內容垂直滾動的像素值,`scrollTop` 表示這個元素達到父級滾動元素頂部的距離。
<template> <div class="cpt-sticky" :class="fixedClass" :> <slot></slot> </div> </template> <script> export default { props: { top: Number, parent: String, zIndex: Number }, data() { return { fixedClass: '', scrollElement: null } }, mounted() { this.initScrollElement() }, destroyed() { this.removeScrollEvent() }, methods: { handleScroll() { const scrollOffsetTop = this.$el.offsetTop - this.top if (this.scrollElement.scrollTop >= scrollOffsetTop) { this.fixedClass = 'top-fixed' } else { this.fixedClass = '' } }, initScrollElement() { const element = document.querySelector(this.parent) if (element) { this.removeScrollEvent() this.scrollElement = element this.scrollElement.addEventListener('scroll', this.handleScroll) } }, removeScrollEvent() { if (this.scrollElement) { this.scrollElement.removeEventListener('scroll', this.handleScroll) } } } } </script> <style lang="scss"> .cpt-sticky { .top-fixed { position: fixed; width: 100%; background: #fff; } } </style>
就像上面的示例代碼一樣,短短幾十行就實現了一個吸頂組件,不過它實現了吸頂的功能,但是還有一些缺陷。
在慢速滾動頁面,吸頂組件在固定與非固定的時候有明顯的卡頓現象。
由于我的需求有一些是需要做錨點定位功能,但是直接用錨點定位會改變路由所以改為了滾動定位(后面會細說)。但是由于吸頂組件在 `fixed` 之后會脫離文檔流,導致定位的元素會有一部分(吸頂組件高度 )被卡在吸頂組件下方。就像下面這張圖的效果,右邊的錨點定位2區域的標題被隱藏了。
這些問題也很好解決,使用一個和吸頂組件相同大小的占位元素,當吸頂組件脫離文檔流之后,占位元素插入吸頂組件原來的 DOM 位置中,然后順便帶上一些小優化。由于占位元素需要和組件高度一致,所以必須要保證 `slot` 插槽中的 DOM 元素已經被加載完成,另外放在 slot 元素中可能發生變更,所以我在吸頂狀態變更之前獲取其高度。
<template> <div class="cpt-sticky"> <div class="sticky-container" :class="fixedClass" :> <slot></slot> </div> <div v-if="showPlaceholder" class="sticky-placeholder" :></div> </div> </template> <script> import { getFirstScrollElement } from 'util.js' export default { props: { top: { type: Number, default: 0 }, zIndex: { type: Number, default: 0 }, parent: { type: String, default: '' } }, data() { return { isMounted: false, fixedClass: '', offsetHeight: 0, scrollElement: null, showPlaceholder: false } }, mounted() { this.isMounted = true this.initScrollElement() }, watch: { parent: { immediate: true, handler: 'getScrollElement' }, fixedClass(v) { if (v && !this.offsetHeight) { this.offsetHeight = this.$el.offsetHeight } this.showPlaceholder = !!v } }, destroyed() { this.removeScrollEvent() }, methods: { handleScroll(e) { const scrollOffsetTop = this.$el.offsetTop - this.top if (this.scrollElement.scrollTop >= scrollOffsetTop) { this.fixedClass = 'top-fixed' } else { this.fixedClass = '' } }, initScrollElement() { if (!this.isMounted) return const parent = this.parent let element = null if (parent) { element = document.querySelector(parent) if (element === this.scrollElement) return } else if (this.$el) { element = getFirstScrollElement(this.$el) } if (element) { this.removeScrollEvent() this.scrollElement = element this.scrollElement.addEventListener('scroll', this.handleScroll) } }, removeScrollEvent() { if (this.scrollElement) { this.scrollElement.removeEventListener('scroll', this.handleScroll) } } } } </script> <style lang="scss"> .cpt-sticky { .top-fixed { position: fixed; width: 100%; background: #fff; } } </style>
錨點定位
網頁中經常會有用到錨點定位的場景,例如百度知道的目錄,我目前知道有三種方式可以實現這種功能。
使用 a 標簽定位
使用 js 定位
使用 a 標簽定位
先說說 a 標簽定位,這是一種最常用的定位方式。它有兩種實現方式,一種是通過 herf 屬性鏈接的指定元素的 id。另一種是添加一個 a 標簽,再將 href 屬性鏈接到這個 a 標簽的 name 屬性。
<a href="#view1">按鈕1</a> <a href="#view2">按鈕1</a> ... <div id="view1">視圖1</div> <div><a name="view2">視圖2</a></div>
這種定位方式很簡單,它支持任意標簽定位。不過它也存在一些問題,例如如果滾動區內有固定或絕對定位,會出現遮罩問題,還有瞬間滾動到頂部,交互不是很好,當然這些都可以通過 css 解決。但最主要問題是,a 標簽定位會改變路由的 hash,如果有相應的路由的話會進行路由跳轉。
通過 js 模擬錨點定位
通過 js 去操作元素的 `scrollTop` 等屬性,使其滾動到父級滾動元素指定的位置,就能實現定位效果。這里簡單提一下 `scrollIntoView()` 這個方法,根據MDN 的描述,`Element.scrollIntoView()` 方法讓當前的元素滾動到瀏覽器窗口的可視區域內。`scrollIntoView()` 還支持動畫的選項,通過 `behavior` 設置,不過遺憾的是它遇到固定定位也會出現遮蓋的問題,所以最終選擇手動去擼碼,不過 `scrollIntoView()` 倒是很適合做回到頂部這種功能。
首先我們需要讓按鈕和滾動區內容元素建立對應關系,在按鈕的值中放入對應的內容區元素的 css 選擇器,根據點擊按鈕的值找到對應的元素。所以計算規則是這個元素距離滾動區的高度加上這個元素上邊距的高度(我在內容區加了外邊距,我希望顯示它),減去滾動區距離可視區的高度(我的頁面沒有定位,所以 offsetTop 對應可視區),再減去按鈕組件的高度,就可以得出需要滾動的位置。
<template> <div class="cpt-anchor"> <el-radio-group v-model="selector" size="mini" @change="handleMenuChange"> <el-radio-button v-for="menu in menus" :key="menu.value" :label="menu.value"> {{ menu.label }} </el-radio-button> </el-radio-group> </div> </template> <script> // 添加緩動函數 import { tween } from 'shifty' // 類似 lodash.get 但處理了 null 類型 import { get as _get } from 'noshjs' import { getFirstScrollElement } from 'util.js' export default { props: { // 滾動區距離可視區頂部的高度 top: { type: Number, default: 0 }, menus: { type: Array, default: [] } }, data() { return { selector: '' } }, watch: { menus: { immediate: true, handler(list) { this.selector = _get(list, [0, 'value'], '') } } }, methods: { handleMenuChange(selector) { const scrollElement = document.querySelector(select) const rootScrollElement = getFirstScrollElement(scrollElement) if (scrollElement && rootScrollElement) { const offsetTop = scrollElement.offsetTop + scrollElement.clientTop const offsetHeight = _get(this.$el, ['parentElement', 'offsetHeight'], 0) const top = offsetTop - this.top - offsetHeight // 做一個緩動處理 tween({ from: { x: rootScrollElement.scrollTop }, to: { x: top }, duration: 500, easing: 'easeOutQuint', step: ({ x }) => { rootScrollElement.scrollTop = x } }).then(({ x }) => { rootScrollElement.scrollTop = x }) } } } } </script>
錨點與視圖聯動
接下來我們來看看最后一個功能,當用戶滾動內容區時,高亮距離按鈕組件最近的那個元素所對應的按鈕。這個功能我可以看成是目錄導航,當我們查看不同內容時高亮對應的目錄。
這個功能如何實現呢,我們來分析一下,當查看不同內容時會滾動屏幕,所以我們要給按鈕的父級滾動元素綁定 `scroll` 事件。判斷當前滾動區距離按鈕最近的元素,我們需要在這個元素上添加與按鈕中的值對應的 css 選擇器。當內容區發生滾動時根據按鈕獲取內容區中所有的元素,然后將滾動區元素的 `scrollTop` 減去按鈕元素的高度,即得出按鈕下方的滾動高度,然后再遍歷這些元素頭部和尾部是否包含了這個滾動高度,然后找到這個元素對應的按鈕。
上面的結論已經可以完成,但存在一些問題,先說第一個問題導致按鈕導航失效,只導航到下一個按鈕邊結束。這個問題不一定會所有人都遇到,之所以我會遇到這個問題,是因為我用了 `Element` 的 `Radio` 組件,要高亮的時候變更了 v-model 的值導致。而點擊按鈕時會觸發滾動,就會和聯動高亮的事件沖突了,所以用一個 `isScroll` 變量標記當前是否是錨點定位狀態,定位狀態不觸發滾動操作。
<template> <div class="cpt-anchor"> <el-radio-group v-model="selector" size="mini" @change="handleMenuChange"> <el-radio-button v-for="menu in menus" :key="menu.value" :label="menu.value"> {{ menu.label }} </el-radio-button> </el-radio-group> </div> </template> <script> import { tween } from 'shifty' import { get as _get } from 'noshjs' import { getFirstScrollElement } from 'util.js' import TabMenus from 'components/tab-menus.vue' export default { props: { top: { type: Number, default: 0 }, menus: { type: Array, default: [] }, parent: { type: String, default: '' } }, data() { return { menu: '', isScroll: true, isMounted: false, scrollTop: 0, anchorChange: false, rootScrollElement: '' } }, mounted() { this.isMounted = true this.getScrollElement() }, watch: { parent: { immediate: true, handler: 'getScrollElement' }, menus: { immediate: true, handler(list) { this.menu = _get(list, [0, 'prop'], '') } }, scrollTop(v) { if (this.anchorChange) { // 切換按鈕會滾動視圖,$nextTick 之后按鈕值改變了,但滾動可能還沒有結束,所以需要打個標記。 this.isScroll = true } } }, methods: { handleMenuChange(select) { this.isScroll = false this.anchorChange = false // 滾動高度等于元素距離可視區頭部高度減去元素自身高度與元素上邊框高度以及滾動區距離可視區頭部的高度。 const scrollElement = document.querySelector(select) if (scrollElement && this.rootScrollElement) { const offsetTop = scrollElement.offsetTop + scrollElement.clientTop const offsetHeight = _get( this.$el, ['parentElement', 'offsetHeight'], 0 ) const top = offsetTop - this.top - offsetHeight // 做一個緩動處理 tween({ from: { x: this.rootScrollElement.scrollTop }, to: { x: top }, duration: 500, easing: 'easeOutQuint', step: ({ x }) => { this.rootScrollElement.scrollTop = x } }).then(({ x }) => { this.rootScrollElement.scrollTop = x }) this.$nextTick(() => { this.anchorChange = true }) } }, getScrollElement() { if (!this.isMounted) return // 如果沒有傳入 parent 默認取第一個父級滾動元素 const parent = this.parent let element = null if (parent) { element = document.querySelector(parent) // mount 之后 rootScrollElement 可能已經存在了,如果和上次一樣就不做任何操作。 if (element === this.rootScrollElement) return } else if (this.$el) { element = getFirstScrollElement(this.$el.parentElement) } if (element) { this.removeScrollEvent() this.rootScrollElement = element this.rootScrollElement.addEventListener('scroll', this.handleScroll) } }, removeScrollEvent() { if (this.rootScrollElement) { this.rootScrollElement.removeEventListener('scroll', this.handleScroll) } }, handleScroll(event) { const scrollTop = this.rootScrollElement.scrollTop this.scrollTop = scrollTop if (!this.isScroll) return const { data, top } = this const offsetHeight = _get(this.$el, ['parentElement', 'offsetHeight'], 0) const scrollList = [] data.forEach(item => { const element = document.querySelector(item.prop) if (element) { const top = element.offsetTop const rect = { top: top + element.clientTop - top - offsetHeight, bottom: top + element.offsetHeight - top - offsetHeight } scrollList.push(rect) } }) // 遍歷按鈕元素的 top 和 bottom,查看當前滾動在那個元素的區間內。 scrollList.some((it, index) => { if (index && scrollTop >= it.top && top < it.bottom) { const menu = _get(data, [index, 'prop'], '') if (menu) this.menu = menu return true } else { // 當小于最小高度時,就等于最小高度 if (scrollTop >= 0 && scrollTop < it.bottom) { const menu = _get(data, [index, 'prop'], '') if (menu) this.menu = menu return true } } }) } } } </script> <style lang="scss"> .cpt-anchor { padding-top: 4px; .cpt-tab-menus { margin: 0; .el-radio-button { margin-left: 10px; .el-radio-button__inner { border: none; border-radius: 5px 5px 0 0; border-bottom: 2px solid #e4e7ed; background-color: #f6f6f8; font-size: 16px; &:hover { border-bottom: 2px solid #409eff; } } &.is-active { .el-radio-button__inner { color: #fff; border: none; border-radius: 5px 5px 0 0; background-color: #409eff; border-bottom: 2px solid #409eff; box-shadow: none; } } } } } </style>
吸頂錨點組件
最后將上面兩個組件合并到一起就是我們所需要的吸頂錨點組件了。
<template> <div class="cpt-sticky-anchor"> <sticky :top="top" :z-index="zIndex"> <sticky-menu :top="top" :data="menus" :parent="parent"></sticky-menu> </sticky> // 滾動區內容存放位置 <slot></slot> </div> </template> <script> import Sticky from './sticky.vue' import StickyMenu from './menu.vue' export default { // 這里簡寫了,因為上面已經有了。 props: { top, menus, parent, zIndex, offsetHeight }, components: { Sticky, StickyMenu } } </script>
使用示例
<template> <div class="page-demo"> ... 其他內容 <sticky-anchor menus="menus" parent=".page-demo"> <ul> <li class="button-1">視圖一</li> <li class="button-2">視圖二</li> </ul> </sticky-anchor> </div> </template> <script> import StickyAnchor from 'components/sticky-anchor.vue' export default { data() { return { menus: [ { label: '按鈕一', value: '.button-1' }, { label: '按鈕二', value: '.button-2' } ] } }, components: { StickyAnchor } } </script>
Vue具體輕量級框架、簡單易學、雙向數據綁定、組件化、數據和結構的分離、虛擬DOM、運行速度快等優勢,Vue中頁面使用的是局部刷新,不用每次跳轉頁面都要請求所有數據和dom,可以大大提升訪問速度和用戶體驗。
關于怎么在 Vue中實現一個吸頂錨點組件就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。