用 ES6 寫全屏滾動插件

這篇文章將介紹如何使用原生 JS (主要使用 ES6 語法)實現全屏滾動插件,兼容 IE 10+、手機觸屏,Mac 觸摸板優化,支持自定義頁面動畫,壓縮後 gzip 文件只有 2.15KB(包括了 CSS 文件)。完整源碼在這 pure-full-page,點這查看 demojavascript

1)前面的話

如今已經有不少全屏滾動插件了,好比著名的 fullPage,那爲何還要本身造輪子呢?css

現有輪子有如下問題:html

  • 首先,最大的問題是最流行的幾個插件都依賴 jQuery,這意味着在使用 React 或者 Vue 的項目中使用他們是一件十分蛋疼的事:我只須要一個全屏滾動功能,卻還須要把 jQuery 引入,有種殺雞使用宰牛刀的感受;
  • 其次,現有的不少全屏滾動插件功能每每都十分豐富,這在前幾年是優點,但如今(2018-5)能夠看做是劣勢:前端開發已經發生了很大變化,其中很重要的一個變化是 ES6 原生支持模塊化開發,模塊化開發最大的特色是一個模塊最好只專一作好一件事,而後再拼成一個完整的系統,從這個角度看,大而全的插件有悖模塊化開發的原則。

對比之下,經過原生語言造輪子有如下好處:前端

  • 使用原生語言編寫的插件,自身不會受依賴的插件的使用場景而影響自身的使用(如今依賴 jQuery 的插件很是不適合開發單頁面應用),因此使用上更加靈活;
  • 搭配模塊化開發,使用原生語言開發的插件能夠只專一一個功能,因此代碼量能夠不多;
  • 最後,隨着 JS/CSS/HTML 的發展以及瀏覽器不斷迭代更新,如今使用原生語言編寫插件的開發成本愈來愈低,那爲何不呢?

2)實現原理及代碼架構

2.1 實現原理

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

全屏滾動實現原理

2.2 代碼架構

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

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

  // 原型方法
  methods() {}

  // 初始化函數
  init() {}
}
複製代碼

3)html 結構

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

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

4)css 設置

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

第二,容器的父級元素(此處是 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 的回答

相關文章
相關標籤/搜索