前言
之前專案裡的頭部導航需要實現吸頂效果,一開始是自己實現,發現效果總是差那麼一點,當時急著實現功能找來了react-sticky這個庫,現在有空便想著徹底琢磨透這個吸頂的問題。
1. 粘性定位
吸頂效果自然會想到position:sticky
, 這屬性網上相關資料也很多,大家可以自行查閱。就提一點與我最初預想不一樣的地方:
示例1. 符合我的預期,正常吸頂
// html
<body>
<div class="sticky">123</div>
</body>
// css
body {
height: 2000px;
}
div.sticky {
position: sticky;
top:0px;
}
複製程式碼
示例2. 不符合我的預期 不能吸頂
// html
<body>
<div class='sticky-container'>
<div class="sticky">123</div>
</div>
</body>
// css
body {
height: 2000px;
}
div.sticky-contaienr {
height: 1000px; // 除非加上這段程式碼才會有一定的吸頂效果
}
div.sticky {
position: sticky;
top:0px;
}
複製程式碼
我以為只要加上了 position:sticky
,設定了 top
的值就能吸頂,不管其他的元素如何,剛好也是我需要的效果,如示例1一樣。
但是其實對於 position:sticky
而言,它的活動範圍只能在父元素內,滾動超過父元素的話,它一樣不能吸頂。示例2中,.sticky-container
的高度和 .sticky
的高度一致,滾動就沒有吸頂效果。 給 .sticky-container
設定個 1000px
的高度,那 .sticky
就能在那 1000px
的滾動中吸頂。
當然 sticky
這樣設計是為了實現更為複雜的效果。
附上一份參考資料 CSS Position Sticky - How It Really Works!
2. react-sticky
2.1 使用
// React使用
<StickyContainer style={{height: 2000}}>
<Sticky>
{({style}) => {
return <div style={style}>123 </div> // 需要吸頂的元素
}}
</Sticky>
其它內容
</StickyContainer>
// 對應生成的Dom
<div style='height: 2000px;'> // sticky-container
<div> // parent
<div style='padding-bottom: 0px;'></div> // placeholder
<div>123 </div> // 吸頂元素
</div>
其它內容
</div>
複製程式碼
2.2 疑惑
看上面的React程式碼及對應生成的dom結構,發現Sticky
生成了一個巢狀div結構,把我們真正需要吸頂的元素給包裹了一層:
<div> // parent
<div style='padding-bottom: 0px;'></div> // placeholder
<div>123 </div> // 吸頂元素
</div>
複製程式碼
一開始我是有些疑惑的,這個庫為什麼要這樣實現,不能生成下面的結構嘛?減去div1
,div2
?
<div style='height: 2000px;'>
<div>123 </div>
其它內容
</div>
複製程式碼
於是我先不管別人的程式碼,本地寫demo,思考著如何實現吸頂效果,才慢慢理解到react-sticky
的設計。
2.3 解惑
吸頂,即當 頁面滾動的距離
超過 吸頂元素距離文件(而非瀏覽器視窗)頂部的高度
時,則吸頂元素進行吸頂,否則吸頂元素變為正常的文件流定位。
因此當然可以在第一次
滾動前,通過吸頂元素(之後會用sticky代替)sticky.getBoundingClientRect().top
獲取元素距離html文件頂部的距離假設為htmlTop
,之所以強調在第一次滾動前是因為,只有第一次滾動前代表的是距離html文件頂部的距離,之後有滾動了就只能代表距離瀏覽器視窗頂部的距離。
通過document.documentElement.scrollTop
獲取頁面滾動距離假設為scrollTop
,每次滾動事件觸發時計算scrollTop - htmlTop
,大於0則將sticky元素的position
設為 fixed
,否則恢復為原來的定位方式。
這樣是能正常吸頂的,但是會有個問題,由於sticky
變為fixed
脫離文件流,導致文件內容缺少一塊。想象下:
div1
div2
div3
1,2,3三個div,假如突然2變為fixed了,那麼會變成:
div1
div3 div2
複製程式碼
即吸頂的之後,div3的內容會被div2遮擋住。
所以檢視剛剛react-sticky
生成的dom中,吸頂元素會有個兄弟元素placeholder
。有了placeholder之後即使吸頂元素fixed
了脫離文件流,也有placeholder佔據它的位置:
div1
placeholder div2
div3
複製程式碼
同時由於給吸頂元素新增了兄弟元素,那麼最好的處理方式是再加個parent
把兩個元素包裹起來,這樣不容易被別的元素影響也不容易影響別的元素(我猜的)。
2.4 原始碼
它的實現也很簡單,就Sticky.js
和Container.js
兩個檔案,稍微講下。程式碼不貼上了點開這裡看 Container.js, Sticky.js。
- 首先繫結一批事件:
resize
,scroll
,touchstart
,touchmove
,touchend
,pageshow
,load
。 - 通過觀察者模式,當以上這些事件觸發時,將
Container
的位置資訊傳遞到Sticky
元件上。 Sticky
元件再通過計算位置資訊判斷是否需要fixed
定位。
其實也就是這樣,當然它還支援了relative
,stacked
兩種模式,因此程式碼更復雜些。看看從中我們能學到什麼:
- 用到了
raf
庫控制動畫,它是對requestAnimationFrame
做了相容性處理 - 使用到
Context
,以及 觀察者模式 - 居然需要監聽那麼多種事件(反正是我的話就只會加個scroll)
- 用
React.cloneElement
來建立元素,以致於最終使用Sticky
元件用起來感覺有些不尋常。 disableHardwareAcceleration
屬性用於關閉動畫的硬體加速,實質上是決定是否設定transform:"translateZ(0)";
對最後一個知識點感興趣,老是說用transform
能啟動硬體加速,動畫更流暢,真的假的?於是又去找了資料,An Introduction to Hardware Acceleration with CSS Animations。本地測試chrome的performance
發現用left,top
,fps,gpu,frames
等一片綠色柱狀線條,用 transform
則只有零星幾條綠色柱狀線條。感覺有道理。
3. 來個簡易版
理解完原始碼自己寫(抄)一個,只實現最簡單的吸頂功能:
import React, { Component } from 'react'
const events = [
'resize',
'scroll',
'touchstart',
'touchmove',
'touchend',
'pageshow',
'load'
]
const hardwareAcceleration = {transform: 'translateZ(0)'}
class EasyReactSticky extends Component {
constructor (props) {
super(props)
this.placeholder = React.createRef()
this.container = React.createRef()
this.state = {
style: {},
placeholderHeight: 0
}
this.rafHandle = null
this.handleEvent = this.handleEvent.bind(this)
}
componentDidMount () {
events.forEach(event =>
window.addEventListener(event, this.handleEvent)
)
}
componentWillUnmount () {
if (this.rafHandle) {
raf.cancel(this.rafHandle)
this.rafHandle = null
}
events.forEach(event =>
window.removeEventListener(event, this.handleEvent)
)
}
handleEvent () {
this.rafHandle = raf(() => {
const {top, height} = this.container.current.getBoundingClientRect()
// 由於container只包裹著placeholder和吸頂元素,且container的定位屬性不會改變
// 因此container.getBoundingClientRect().top大於0則吸頂元素處於正常文件流
// 小於0則吸頂元素進行fixed定位,同時placeholder撐開吸頂元素原有的空間
const {width} = this.placeholder.current.getBoundingClientRect()
if (top > 0) {
this.setState({
style: {
...hardwareAcceleration
},
placeholderHeight: 0
})
} else {
this.setState({
style: {
position: 'fixed',
top: '0',
width,
...hardwareAcceleration
},
placeholderHeight: height
})
}
})
}
render () {
const {style, placeholderHeight} = this.state
return (
<div ref={this.container}>
<div style={{height: placeholderHeight}} ref={this.placeholder} />
{this.props.content(style)}
</div>
)
}
}
//使用
<EasyReactSticky content={style => {
return <div style={style}>this is EasyReactSticky</div>
}} />
複製程式碼
顯然,大部分程式碼借鑑 react-sticky
,減少了引數配置程式碼和對兩種模式stacked
和relative
的支援。著實簡易,同時改變了元件呼叫形式,採用了render-props
。
4. 總結
本文源於之前工作急於完成任務而留下的一個小坑,所幸現在填上了。react-sticky
在github上1926
個star,本身卻並不複雜,通過閱讀這樣一個經受住開源考驗的小庫也能學到不少東西。
5. 討論
- 如果給一個用
top,left
改變來做動畫的元素,例如An Introduction to Hardware Acceleration with CSS Animations中的第一個例子,新增transform:translateZ(0)
,那樣會有硬體加速嘛?(我測試的結果像是沒有,依舊很多painting)
歡迎討論~
參考資料
react-sticky
CSS Position Sticky - How It Really Works!
An Introduction to Hardware Acceleration with CSS Animations
render-props