原生JS实现全屏滚动插件:原理、代码与性能优化

作者:缓存笔记时间:2026-10-22 13:59:25浏览:593

本文详细介绍如何使用原生JS(ES6语法)从零开发一个轻量级全屏滚动插件。文章解析了基于容器top值切换的实现原理,涵盖PC端鼠标滚轮与Mac触摸板事件的统一处理,以及移动端touch事件的滑动逻辑。重点讲解了针对Safari可视区高度差异的解决方案,并通过防抖与截流技术优化滚动性能,最终实现一个兼容IE10+、代码压缩后仅2k的纯JS全屏滚动组件。

引言与插件概述

本文旨在演示如何利用原生 JavaScript(主要基于 ES6 语法)构建一个全屏滚动插件。该方案兼容 IE 10 及以上版本,支持手机触屏操作及 Mac 触摸板优化,并允许自定义页面动画效果。经过 gzip 压缩后,文件体积仅约 2k。完整源代码托管于 pure_full_page 仓库,点击此处可查看在线演示。

1)前面的话

目前市场上已存在多款成熟的全屏滚动插件,例如广受欢迎的 fullPage。那么,为何还要选择自行开发?

现有的主流插件存在以下主要问题:

  1. 首要问题在于,最流行的几个插件均依赖 jQuery。这意味着在 React 或 Vue 等现代前端框架项目中引入它们显得极为繁琐:用户仅需全屏滚动功能,却被迫引入庞大的 jQuery 库,这种“杀鸡用牛刀”的方式显然不够优雅。
  2. 其次,现有全屏滚动插件功能丰富,在2018-5年可视为劣势:前端开发已发生很大变化,ES6 原生支持模块化开发,其最大特点是一个模块最好只专注做好一件事,再拼成完整系统,大而全插件有悖模块化开发原则。

相比之下,使用原生语言自行开发插件具有以下优势:

  1. 原生编写的插件不受依赖库使用场景的制约(例如,依赖 jQuery 的插件在单页面应用开发中极不友好),因此使用方式更加灵活。
  2. 配合模块化开发理念,原生插件可专注于单一功能,从而保持代码精简。
  3. 最后,随着 JS、CSS 和 HTML 标准的演进以及浏览器的持续迭代,使用原生语言开发插件的成本日益降低,这为自行开发提供了良好契机。

实现原理与代码架构

2)实现原理及代码架构

2.1 实现原理

实现原理如图所示:容器及其内部页面获取当前可视区高度,同时将容器的父级元素 overflow 属性设置为 hidden。通过动态更改容器的 top 值,即可实现全屏滚动效果。

引言与插件概述

2.2 代码架构

代码编写思路是通过 class 定义全屏滚动类,使用时通过 new PureFullPage().init() 进行初始化调用。


/**
 * 全屏滚动类
 */
class PureFullPage {
 // 构造函数
 constructor() {}

 // 原型方法
 methods() {}

 // 初始化函数
 init() {}
}

HTML结构与CSS设置

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 设置

首先,容器及容器内的页面需获取当前可视区高度,为每次切换时显示完整页面做准备。

其次,将容器的父级元素(此处为 body)的 overflow 属性值设为 hidden,以确保每次仅显示一个页面,其余页面被隐藏。

经过上述设置,每次更改容器的 top 值一个可视区高度距离,即可实现页面间的切换。部分核心代码如下:


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

#pureFullPage {
 /* 只有当 position 的值不是 static 时,top 值才有效 */
 position: relative;
 /* 设置初始值 */
 top: 0;
}
.page {
 /* 此处不能为 100vh,后面详述 */
 /* 其父元素,也就是 #pureFullPage 的高度,通过 js 动态设置*/
 height: 100%;
}

注意:

容器的 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 端主要解决获取鼠标滚动或触摸板滑动方向的问题。触摸板上下滑动与鼠标滚动绑定的是同一个事件:

  1. Firefox 浏览器使用 DOMMouseScroll 事件,滚轮信息(向前或向后)存储在 detail 属性中。向前滚动时,该属性值为 3 的倍数;向后滚动时,为 -3 的倍数。
  2. 除 Firefox 外的其他浏览器使用 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 是页面滚动的逻辑代码。需要特别说明的是,必须判断滚动边界,以保证容器中始终显示页面内容:

  1. 上边界容易确定,即 1 个页面(也即可视区)的高度。如果容器当前的上外边框距离整个页面顶部的距离(此值正是容器 offsetTop 值的绝对值,因其父元素的 offsetTop 值均为 0)大于等于当前可视区高度,则允许向上滚动;否则,证明上方已无页面,不允许继续向上滚动。
  2. 下边界为 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,表示手指向下滑动,对应页面向上滚动;反之亦然。

此处我们需要关注触摸事件跟踪触摸的属性:

  1. touches:当前跟踪的触摸操作的 Touch 对象数组,用于获取触摸开始时的 pageY 值。
  2. changedTouches:自上次触摸以来发生了改变的 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();
});

PC端滚动性能优化

6)PC 端滚动事件性能优化

6.1 防抖函数和截流函数介绍

优化主要从两方面入手:

  1. 更改页面大小时,通过防抖动(debounce)函数限制 resize 事件触发频率。
  2. 滚动/滑动事件触发时,通过截流(throttle)函数限制滚动/滑动事件触发频率。

既然两者都通过定时器实现以限制触发频率,那它们有何区别?

首先,防抖动函数工作时,如果在指定的延迟时间内某个事件连续触发,那么绑定在该事件上的回调函数永远不会触发;只有在延迟时间内该事件不再触发,对应的回调函数才会执行。防抖动函数非常适合改变窗口大小这一事件,这符合“拖动到位以后再触发事件,如果一直拖个不停,始终不触发事件”的直觉。

而截流函数是在延迟时间内,绑定到事件上的回调函数能且只能触发一次。这与防抖动不同,即便在延迟时间内连续触发事件,也不会阻止在延迟时间内有一个回调函数执行。并且截流函数允许我们指定回调函数是在延迟时间开始时还是结束时执行。

鉴于截流函数的上述两个特性,其特别适合优化滚动/滑动事件:

  1. 可以限制频率。
  2. 不会因为滚动/滑动事件太灵敏(在延迟时间内不断触发)导致注册在事件上的回调函数无法执行。
  3. 可以设置在延迟时间开始时触发回调函数,从而避免用户感到操作后的短暂延时。

这里不介绍防抖动函数和截流函数的实现原理,感兴趣的读者可参考 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 延迟函数,
// immediate 传入 true 表示在 delay 开始时执行回调函数
throttle(method, context, delay, immediate) {
 return function() {
  const args = arguments;
  const later = () => {
   method.tID = null;
   if (!immediate) {
    method.apply(context, args);
   }
  };
  const callNow = immediate && !method.tID;
  clearTimeout(method.tID);
  method.tID = setTimeout(later, delay);
  if (callNow) {
   method.apply(context, args);
  }
 };
},

《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 创建。这里的难点在于如何实现点击不同按钮实现对应页面的跳转并更新对应按钮的样式。

解决思路如下:

  1. 页面跳转:页面个数与导航按钮个数一致,因此点击第 i 个按钮即跳转到第 i 个页面。而第 i 个页面对应的容器 top 值恰好是 -(i * this.viewHeight)。
  2. 更改样式:更改样式即先删除所有按钮的选中样式,然后给当前点击的按钮添加选中样式。

// 创建右侧点式导航
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;
 }
},

参考资料

纯 JS 全屏滚动 / 整屏翻页

Throttling and Debouncing in JavaScript

Debouncing and Throttling Explained Through Examples

JavaScript Debounce Function

Viewport height is taller than the visible part of the document in some mobile browsers

MDN-Object.assign()

Babel 编译出来还是 ES 6?难道只能上 polyfill?- Henry 的回答

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章