講講吸頂效果與react-sticky

songjp發表於2019-05-12

前言

之前專案裡的頭部導航需要實現吸頂效果,一開始是自己實現,發現效果總是差那麼一點,當時急著實現功能找來了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.jsContainer.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,topfps,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 ,減少了引數配置程式碼和對兩種模式stackedrelative的支援。著實簡易,同時改變了元件呼叫形式,採用了render-props

4. 總結

本文源於之前工作急於完成任務而留下的一個小坑,所幸現在填上了。react-sticky在github上1926個star,本身卻並不複雜,通過閱讀這樣一個經受住開源考驗的小庫也能學到不少東西。

5. 討論

歡迎討論~

參考資料

react-sticky
CSS Position Sticky - How It Really Works!
An Introduction to Hardware Acceleration with CSS Animations
render-props

相關文章