这篇文章将介绍如何使用原生 JS (主要使用 ES6 语法)实现全屏滚动插件,兼容 IE 10+、手机触屏,Mac 触摸板优化,支持自定义页面动画,压缩后 gzip 文件只有 2.15KB。完整源码在这 pure-full-page,点这查看 demo。javascript
如今已经有不少全屏滚动插件了,好比著名的 fullPage,那为何还要本身造轮子呢?css
现有轮子有如下问题:html
对比之下,经过原生语言造轮子有如下好处:前端
实现原理见下图:容器及容器内的页面取当前可视区高度,同时容器的父级元素 overflow
属性值设为 hidden
,经过更改容器 top
值实现全屏滚动效果。java
代码编写的思路是经过 class 定义全屏滚动类,使用时经过 new PureFullPage().init()
使用。git
/** * 全屏滚动类 */ class PureFullPage { // 构造函数 constructor() {} // 原型方法 methods() {} // 初始化函数 init() {} }
鉴于上述实现原理,对于 html 的结构有特定要求,以下:页面容器为 #pureFullPageContainer
,全部的页面为其直接子元素,这里为了方便,直接取 body
为其直接父元素。github
<body> <div id="pureFullPageContainer"> <div class="page"></div> <div class="page"></div> <div class="page"></div> </div> </body>
首先,容器及容器内的页面取当前可视区高度,为每次切换都显示一个完整的页面作准备;windows
第二,容器的父级元素(此处是 body
) overflow
属性值定为 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
值时就不是全屏切换了,实际上,这种状况下切换的高度小于页面的高度。document.documentElement.clientHeight
值是符合预期的可视区高度(不包括顶部地址栏和底部工具栏),那就将该值经过 js 设置为容器的高度,同时,容器内的页面高度设置为 100%
,这样就能够保证容器及页面的高度和切换 top
值相同了,也就保证了全屏切换。// 伪代码 '#pureFullPage'.style.height = document.documentElement.clientHeight + 'px';
这里的滚动/滑动事件包括鼠标滚动、触摸板滑动以及手机屏幕上下滑动。
PC 端主要解决的问题是获取鼠标滚动或触摸板滑动方向,触摸板上下滑动和鼠标滚动绑定的是同一个事件:
DOMMouseScroll
事件,对应的滚轮信息(向前滚仍是向后滚)存储在 detail
属性中,向前滚,这个属性值是 3 的倍数,反之,是 -3 的倍数;mousewheel
事件,对应的滚轮信息存储在 wheelDelta
属性中,向前滚,这个属性值是 -120 的倍数,反之, 120 的倍数。macOS 如此,windows 相反?
因此,能够经过 detail
或 wheelDelta
的值判断鼠标的滚动方向,进而控制页面是向上仍是向下滚动。在这里咱们只关心正负,不关心具体值的大小,为了便于使用,下面基于这两个事件封装了一个函数:若是鼠标往前滚动,返回负数,反之,返回正数,代码以下:
// 鼠标滚轮事件 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(); } }
goDown
、goUp
是页面滚动的逻辑代码,须要特别说明的是必须 判断滚动边界,保证容器中显示的始终是页面内容:
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); }
移动端须要判断是向上仍是向下滑动,能够结合 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(); });
优化主要从两方便入手:
resize
事件触发频率;既然都是限制触发频率(都经过定时器实现),那这二者有什么区别?
首先,防抖动函数工做时,若是在指定的延迟时间内,某个事件连续触发,那么绑定在这个事件上的回调函数永远不会触发,只有在延迟时间内,这个事件没再触发,对应的回调函数才会执行。防抖动函数很是适合改变窗口大小这一事件,这也符合 拖动到位之后再触发事件,若是一直拖个不停,始终不触发事件 这一直觉。
而截流函数是在延迟时间内,绑定到事件上的回调函数能且只能触发一次,这和截流函数不一样,即使是在延迟时间内连续触发事件,也不会阻止在延迟时间内有一个回调函数执行。而且截流函数容许咱们指定回调函数是在延迟时间开始时仍是结束时执行。
鉴于截流函数的上述两个特性,尤为适合优化滚动/滑动事件:
这里不介绍防抖动函数和截流函数的实现原理,感兴趣的能够看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。
经过上述说明,咱们已经知道截流函数能够经过限定滚动事件触发频率提高性能,同时,设置在延迟时间开始阶段当即调用滚动事件的回调函数并不会牺牲用户体验。
截流函数上文已经定义好,使用起来就很简单了:
// 设置截流函数 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 实例。
为了简化 html 结构,导航按钮经过 js 建立。这里的难点在于如何实现点击不一样按钮实现对应页面的跳转并更新对应按钮的样式。
解决的思路是:
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'); }); }); }
得当的自定义参数能够增长插件的灵活性。
参数经过构造函数传入,并经过 Object.assign()
进行参数合并:
constructor(options) { // 默认配置 const defaultOptions = { isShowNav: true, delay: 150, definePages: () => {}, }; // 合并自定义配置 this.options = Object.assign(defaultOptions, options); }
浏览器窗口尺寸改变的时候,须要从新获取可视区、页面元素高度,并从新肯定容器当前的 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));
这里的兼容性主要指两个方面:一是不一样浏览器对同一行为定义了不一样 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, }); } },
由于本插件只兼容到 IE10,因此不打算对事件作兼容处理,毕竟
IE9 都支持
addEventListener
了。
在 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 的回答