用 ES6 寫全屏滾動外掛

L小庸發表於2018-05-06

這篇文章將介紹如何使用原生 JS (主要使用 ES6 語法)實現全屏滾動外掛,相容 IE 10+、手機觸屏,Mac 觸控板優化,支援自定義頁面動畫,壓縮後 gzip 檔案只有 2.15KB(包括了 CSS 檔案)。完整原始碼在這 pure_full_page,點這檢視 demo

1)前面的話

現在已經有很多全屏滾動外掛了,比如著名的 fullPage,那為什麼還要自己造輪子呢?

現有輪子有以下問題:

  • 首先,最大的問題是最流行的幾個外掛都依賴 jQuery,這意味著在使用 React 或者 Vue 的專案中使用他們是一件十分蛋疼的事:我只需要一個全屏滾動功能,卻還需要把 jQuery 引入,有種殺雞使用宰牛刀的感覺;
  • 其次,現有的很多全屏滾動外掛功能往往都十分豐富,這在前幾年是優勢,但現在(2018-5)可以看作是劣勢:前端開發已經發生了很大變化,其中很重要的一個變化是 ES6 原生支援模組化開發,模組化開發最大的特點是一個模組最好只專注做好一件事,然後再拼成一個完整的系統,從這個角度看,大而全的外掛有悖模組化開發的原則。

對比之下,通過原生語言造輪子有以下好處:

  • 使用原生語言編寫的外掛,自身不會受依賴的外掛的使用場景而影響自身的使用(現在依賴 jQuery 的外掛非常不適合開發單頁面應用),所以使用上更加靈活;
  • 搭配模組化開發,使用原生語言開發的外掛可以只專注一個功能,所以程式碼量可以很少;
  • 最後,隨著 JS/CSS/HTML 的發展以及瀏覽器不斷迭代更新,現在使用原生語言編寫外掛的開發成本越來越低,那為什麼不呢?

2)實現原理及程式碼架構

2.1 實現原理

實現原理見下圖:容器及容器內的頁面取當前可視區高度,同時容器的父級元素 overflow 屬性值設為 hidden,通過更改容器 top 值實現全屏滾動效果。

全屏滾動實現原理

2.2 程式碼架構

程式碼編寫的思路是通過 class 定義全屏滾動類,使用時通過 new PureFullPage().init() 使用。

/**
 * 全屏滾動類
 */
class PureFullPage {
  // 建構函式
  constructor() {}

  // 原型方法
  methods() {}

  // 初始化函式
  init() {}
}
複製程式碼

3)html 結構

鑑於上述實現原理,對於 html 的結構有特定要求,如下:頁面容器為 #pureFullPageContainer,所有的頁面為其直接子元素,這裡為了方便,直接取 body 為其直接父元素。

<body>
  <div id="pureFullPageContainer">
    <div class="page"></div>
    <div class="page"></div>
    <div class="page"></div>
  </div>
</body>
複製程式碼

4)css 設定

首先,容器及容器內的頁面取當前可視區高度,為每次切換都顯示一個完整的頁面做準備;

第二,容器的父級元素(此處是 bodyoverflow 屬性值定為 hidden,這樣可以保證每次只會顯示一個頁面,其他頁面被隱藏。

經過上述設定,對容器 top 值,每次更改一個可視區高度的距離,便實現了頁面間的切換,部分程式碼如下:

body {
  /* body 為容器直接的父元素 */
  overflow: hidden;
}

#pureFullPage {
  /* 只有當 position 的值不是 static 時,top 值才有效 */
  position: relative;
  /* 設定初始值 */
  top: 0;
}
.page {
  /* 此處不能為 100vh,後面詳述 */
  /* 其父元素,也就是 #pureFullPage 的高度,通過 js 動態設定*/
  height: 100%;
}
複製程式碼

Notice:

  • 容器的 position 屬性值需要設定為 relative,因為 top 只有在 position 屬性值不為 static 時才有效;

  • 頁面高度需設定為當前可視區高度,但不能直接設定為 100vh,因為 safari 手機瀏覽器把位址列算進去計算 100vh,但位址列下面的不應該算做“可視區”,畢竟實際上是“看不見”的區域。這會導致 100vh 對應的畫素值比 document.documentElement.clientHeight 獲取的畫素值大。這樣在切換 top 值時就不是全屏切換了,實際上,這種情況下切換的高度小於頁面的高度。

  • 解決 safari 手機瀏覽器可視區高度問題:既然通過 js 獲取的 document.documentElement.clientHeight 值是符合預期的可視區高度(不包括頂部位址列和底部工具欄),那就將該值通過 js 設定為容器的高度,同時,容器內的頁面高度設定為 100%,這樣就可以保證容器及頁面的高度和切換 top 值相同了,也就保證了全屏切換。

// 虛擬碼
'#pureFullPage'.style.height = document.documentElement.clientHeight + 'px';
複製程式碼

5)監控滾動/滑動事件

這裡的滾動/滑動事件包括滑鼠滾動、觸控板滑動以及手機螢幕上下滑動。

5.1 PC 端

PC 端主要解決的問題是獲取滑鼠滾動或觸控板滑動方向,觸控板上下滑動和滑鼠滾動繫結的是同一個事件:

  • firefox 是 DOMMouseScroll 事件,對應的滾輪資訊(向前滾還是向後滾)儲存在 detail 屬性中,向前滾,這個屬性值是 3 的倍數,反之,是 -3 的倍數;
  • firefox 之外的其他瀏覽器是 mousewheel 事件,對應的滾輪資訊儲存在 wheelDelta 屬性中,向前滾,這個屬性值是 -120 的倍數,反之, 120 的倍數。

macOS 如此,windows 相反?

所以,可以通過 detailwheelDelta 的值判斷滑鼠的滾動方向,進而控制頁面是向上還是向下滾動。在這裡我們只關心正負,不關心具體值的大小,為了便於使用,下面基於這兩個事件封裝了一個函式:如果滑鼠往前滾動,返回負數,反之,返回正數,程式碼如下:

// 滑鼠滾輪事件
getWheelDelta(event) {
  if (event.wheelDelta) {
    return event.wheelDelta;
  } else {
    // 相容火狐
    return -event.detail;
  }
},
複製程式碼

有了滾動事件,就可以據此編寫頁面向上或者向下滾動的回撥函式了,如下:

// 滑鼠滾動邏輯(全屏滾動關鍵邏輯)
scrollMouse(event) {
  let delta = utils.getWheelDelta(event);
  // delta < 0,滑鼠往前滾動,頁面向下滾動
  if (delta < 0) {
    this.goDown();
  } else {
    this.goUp();
  }
}
複製程式碼

goDowngoUp 是頁面滾動的邏輯程式碼,需要特別說明的是必須 判斷滾動邊界,保證容器中顯示的始終是頁面內容

  • 上邊界容易確定,為 1 個頁面(也即可視區)的高度,即如果容器當前的上外邊框距離整個頁面頂部的距離(這裡此值正是容器的 offsetTop 值的絕對值,因為它父元素的 offsetTop 值都是 0)大於等於當前可視區高度時,才允許向上滾動,不然,就證明上面已經沒有頁面了,不允許繼續向上滾動;
  • 下邊界為 n - 2(n 表示全屏滾動的頁面數) 個可視區的高度,當容器的 offsetTop 值的絕對值小於等於 n - 2 個可視區的高度時,表示還可以向下滾動一個頁面。

具體程式碼如下:

goUp() {
  // 只有頁面頂部還有頁面時頁面向上滾動
  if (-this.container.offsetTop >= this.viewHeight) {
    // 重新指定當前頁面距檢視頂部的距離 currentPosition,實現全屏滾動,
    // currentPosition 為負值,越大表示超出頂部部分越少
    this.currentPosition = this.currentPosition + this.viewHeight;

    this.turnPage(this.currentPosition);
  }
}
goDown() {
  // 只有頁面底部還有頁面時頁面向下滾動
  if (-this.container.offsetTop <= this.viewHeight * (this.pagesNum - 2)) {
    // 重新指定當前頁面距檢視頂部的距離 currentPosition,實現全屏滾動,
    // currentPosition 為負值,越小表示超出頂部部分越多
    this.currentPosition = this.currentPosition - this.viewHeight;

    this.turnPage(this.currentPosition);
  }
}
複製程式碼

最後新增滾動事件:

// 滑鼠滾輪監聽,火狐滑鼠滾動事件不同其他
if (navigator.userAgent.toLowerCase().indexOf('firefox') === -1) {
  document.addEventListener('mousewheel', scrollMouse);
} else {
  document.addEventListener('DOMMouseScroll', scrollMouse);
}
複製程式碼

5.2 移動端

移動端需要判斷是向上還是向下滑動,可以結合 touchstart(手指開始接觸螢幕時觸發) 和 touchend(手指離開螢幕時觸發) 兩個事件實現判斷:分別獲取兩個事件開始觸發時的 pageY 值,如果觸控結束時的 pageY 大於觸控開始時的 pageY,表示手指向下滑動,對應頁面向上滾動,反之亦然。

此處我們需要觸控事件跟蹤觸控的屬性:

  • touches:當前跟蹤的觸控操作的 Touch 物件的陣列,用於獲取觸控開始時的 pageY 值;
  • changeTouches:自上次觸控以來發生了改變的 Touch 物件的陣列,用於獲取觸控觸控結束時的 pageY 值。

相關程式碼如下:

// 手指接觸螢幕
document.addEventListener('touchstart', event => {
  this.startY = event.touches[0].pageY;
});
//手指離開螢幕
document.addEventListener('touchend', event => {
  let endY = event.changedTouches[0].pageY;
  if (endY - this.startY < 0) {
    // 手指向上滑動,對應頁面向下滾動
    this.goDown();
  } else {
    // 手指向下滑動,對應頁面向上滾動
    this.goUp();
  }
});
複製程式碼

為了避免下拉重新整理,可以阻止 touchmove 事件的預設行為:

// 阻止 touchmove 下拉重新整理
document.addEventListener('touchmove', event => {
  event.preventDefault();
});
複製程式碼

6)PC 端滾動事件效能優化

6.1 防抖函式和截流函式介紹

優化主要從兩方便入手:

  • 更改頁面大小時,通過防抖動(debounce)函式限制 resize 事件觸發頻率;
  • 滾動/滑動事件觸發時,通過截流(throttle)函式限制滾動/滑動事件觸發頻率。

既然都是限制觸發頻率(都通過定時器實現),那這兩者有什麼區別?

首先,防抖動函式工作時,如果在指定的延遲時間內,某個事件連續觸發,那麼繫結在這個事件上的回撥函式永遠不會觸發,只有在延遲時間內,這個事件沒再觸發,對應的回撥函式才會執行。防抖動函式非常適合改變視窗大小這一事件,這也符合 拖動到位以後再觸發事件,如果一直拖個不停,始終不觸發事件 這一直覺。

而截流函式是在延遲時間內,繫結到事件上的回撥函式能且只能觸發一次,這和截流函式不同,即便是在延遲時間內連續觸發事件,也不會阻止在延遲時間內有一個回撥函式執行。並且截流函式允許我們指定回撥函式是在延遲時間開始時還是結束時執行。

鑑於截流函式的上述兩個特性,尤其適合優化滾動/滑動事件:

  • 可以限制頻率;
  • 不會因為滾動/滑動事件太靈敏(在延遲時間內不斷觸發)導致註冊在事件上的回撥函式無法執行;
  • 可以設定在延遲時間開始時觸發回撥函式,從而避免使用者感到操作之後的短暫延時。

這裡不介紹防抖動函式和截流函式的實現原理,感興趣的可以看Throttling and Debouncing in JavaScript,下面是實現的程式碼:

// 防抖動函式,method 回撥函式,context 上下文,event 傳入的時間,delay 延遲函式
debounce(method, context, event, delay) {
  clearTimeout(method.tId);
  method.tId = setTimeout(() => {
    method.call(context, event);
  }, delay);
},

// 截流函式,method 回撥函式,context 上下文,delay 延遲函式,
// 這裡沒有提供是在延遲時間開始還是結束的時候執行回撥函式的選項,
// 直接在延遲時間開始的時候執行回撥
throttle(method, context, delay) {
  let wait = false;
  return function() {
    if (!wait) {
      method.apply(context, arguments);
      wait = true;
      setTimeout(() => {
        wait = false;
      }, delay);
    }
  };
},
複製程式碼

《JavaScript 高階程式設計 - 第三版》 22.33.3 節中介紹的 throttle 函式和此處定義的不同,高程中定義的 throttle 函式對應此處的 debounce 函式,但網上大多數文章都和高程中的不同,比如 lodash 中定義的 debounce

6.2 改造 PC 端滾動事件

通過上述說明,我們已經知道截流函式可以通過限定滾動事件觸發頻率提升效能,同時,設定在延遲時間開始階段立即呼叫滾動事件的回撥函式並不會犧牲使用者體驗。

截流函式上文已經定義好,使用起來就很簡單了:

// 設定截流函式
let handleMouseWheel = utils.throttle(this.scrollMouse, this, this.DELAY, true);

// 滑鼠滾輪監聽,火狐滑鼠滾動事件不同其他
if (navigator.userAgent.toLowerCase().indexOf('firefox') === -1) {
  document.addEventListener('mousewheel', handleMouseWheel);
} else {
  document.addEventListener('DOMMouseScroll', handleMouseWheel);
}
複製程式碼

上面這部分程式碼是寫在 class 的 init 方法中,所以截流函式的上下文(context)傳入的是 this,表示當前 class 例項。

7)其他

7.1 導航按鈕

為了簡化 html 結構,導航按鈕通過 js 建立。這裡的難點在於如何實現點選不同按鈕實現對應頁面的跳轉並更新對應按鈕的樣式

解決的思路是:

  • 頁面跳轉:頁面個數和導航按鈕的個數一致,所以點選第 i 個按鈕也就是跳轉到第 i 個頁面,而第 i 個頁面對應的容器 top 值恰好是 -(i * this.viewHeight)
  • 更改樣式:更改樣式即先刪除所有按鈕的選中樣式,然後給當前點選的按鈕新增選中樣式。
// 建立右側點式導航
createNav() {
  const nav = document.createElement('div');
  nav.className = 'nav';
  this.container.appendChild(nav);
  // 有幾頁,顯示幾個點
  for (let i = 0; i < this.pagesNum; i++) {
    nav.innerHTML += '<p class="nav-dot"><span></span></p>';
  }
  const navDots = document.querySelectorAll('.nav-dot');
  this.navDots = Array.prototype.slice.call(navDots);
  // 新增初始樣式
  this.navDots[0].classList.add('active');
  // 新增點式導航點選事件
  this.navDots.forEach((el, i) => {
    el.addEventListener('click', event => {
      // 頁面跳轉
      this.currentPosition = -(i * this.viewHeight);
      this.turnPage(this.currentPosition);
      // 更改樣式
      this.navDots.forEach(el => {
        utils.deleteClassName(el, 'active');
      });
      event.target.classList.add('active');
    });
  });
}
複製程式碼

7.2 自定義引數

得當的自定義引數可以增加外掛的靈活性。

引數通過建構函式傳入,並通過 Object.assign() 進行引數合併:

constructor(options) {
  // 預設配置
  const defaultOptions = {
    isShowNav: true,
    delay: 150,
    definePages: () => {},
  };
  // 合併自定義配置
  this.options = Object.assign(defaultOptions, options);
}
複製程式碼

7.3 視窗尺寸改變時更新資料

瀏覽器視窗尺寸改變的時候,需要重新獲取可視區、頁面元素高度,並重新確定容器當前的 top 值。

同時,為了避免不必要的效能開支,這裡使用了防抖動函式。

// window resize 時重新獲取位置
getNewPosition() {
  this.viewHeight = document.documentElement.clientHeight;
  this.container.style.height = this.viewHeight + 'px';
  let activeNavIndex;
  this.navDots.forEach((e, i) => {
    if (e.classList.contains('active')) {
      activeNavIndex = i;
    }
  });
  this.currentPosition = -(activeNavIndex * this.viewHeight);
  this.turnPage(this.currentPosition);
}

handleWindowResize(event) {
  // 設定防抖動函式
  utils.debounce(this.getNewPosition, this, event, this.DELAY);
}

// 視窗尺寸變化時重置位置
window.addEventListener('resize', this.handleWindowResize.bind(this));
複製程式碼

7.4 相容性

這裡的相容性主要指兩個方面:一是不同瀏覽器對同一行為定義了不同 API,比如上文提到的獲取滑鼠滾動資訊的 API Firefox 和其他瀏覽器不一樣;第二點就是 ES6 新語法、新 API 的相容處理。

對於 class、箭頭函式這類新語法的轉換,通過 babel 就可完成,鑑於本外掛程式碼量很小,都處於可控的狀態,並沒有引入 babel 提供的 polyfill 方案,因為新 API 只有 Object.assign() 需要做相容處理,單獨寫個 polyfill 就好,如下:

// polyfill Object.assign
polyfill() {
  if (typeof Object.assign != 'function') {
    Object.defineProperty(Object, 'assign', {
      value: function assign(target, varArgs) {
        if (target == null) {
          throw new TypeError('Cannot convert undefined or null to object');
        }
        let to = Object(target);
        for (let index = 1; index < arguments.length; index++) {
          let nextSource = arguments[index];
          if (nextSource != null) {
            for (let nextKey in nextSource) {
              if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
                to[nextKey] = nextSource[nextKey];
              }
            }
          }
        }
        return to;
      },
      writable: true,
      configurable: true,
    });
  }
},
複製程式碼

引用自:MDN-Object.assign()

因為本外掛只相容到 IE10,所以不打算對事件做相容處理,畢竟 IE9 都支援 addEventListener 了。

7.5 通過惰性載入進一步優化效能

在 5.1 中寫的 getWheelDelta 函式每次執行都需要檢測是否支援 event.wheelDelta,實際上,瀏覽器只需在第一次載入時檢測,如果支援,接下來都會支援,再做檢測是沒必要的。

並且這個檢測在頁面的生命週期中會執行很多次,這種情況下可以通過 惰性載入 技巧進行優化,如下:

getWheelDelta(event) {
  if (event.wheelDelta) {
    // 第一次呼叫之後惰性載入,無需再做檢測
    this.getWheelDelta = event => event.wheelDelta;
    // 第一次呼叫使用
    return event.wheelDelta;
  } else {
    // 相容火狐
    this.getWheelDelta = event => -event.detail;
    return -event.detail;
  }
},
複製程式碼

完整原始碼在這 pure_full_page,點這檢視 demo

參考資料

純 JS 全屏滾動 / 整屏翻頁
Throttling and Debouncing in JavaScript
Debouncing and Throttling Explained Through Examples
JavaScript Debounce Function
Simple throttle in js
Simple throttle in js - jsfiddle
Viewport height is taller than the visible part of the document in some mobile browsers
MDN-Object.assign()
Babel 編譯出來還是 ES 6?難道只能上 polyfill?- Henry 的回答

相關文章