目标是实现一个导航条组件,包含的功能:
- 元素到达页面顶部时吸顶
- 按钮对应楼层到达页面顶部时,高亮该按钮
- 借助vue-awosome-swiper,实现导航条可拉动,高亮时自动滚动过去
- 导航条可以配置展开更多按钮,展开更多时导航条不可拉动,选取对应楼层后重新恢复原有状态

按钮是通过锚链接来跟楼层对应的,该导航遵循两种数据格式
一种为强关联,即按钮与楼层数据在一个字段里,需用开发者为其创建关联id
另外一种为数据映射,即按钮数据与对应楼层是通过配置里的floor_id来产生映射的
{
"config": {
/* 第一种数据格式,数据跟楼层按钮强制关联,不写floor_id */
"navList_1": [
{
"nav_btn": "苹果",
"list": [
"楼层内容1",
"楼层内容2",
"楼层内容3"
]
},{
"nav_btn": "oppo",
"list": [
"楼层内容1",
"楼层内容2",
"楼层内容3"
]
}
],
/* 第二种数据格式,会把楼层id写在配置里,视图层读取,让导航条跟楼层对应 */
"navList_2": [
{
"nav_btn": "楼层测试1",
"floor_id": "test_1" // 对应下面的test楼层的floor_id
},{
"nav_btn": "楼层测试2",
"floor_id": "test_2" // 对应下面的demo楼层的floor_id
}
],
"test": {
"floor_id": "test_1",
"content": ["随便写点东西1", "随便写点东西2"]
}
"demo": {
"floor_id": "test_2",
"content": ["随便写点东西1", "随便写点东西2"]
}
}
}
这篇文章主要会做三件事
- 开发 scrollFn 单例,将其加到Vue的原型属性上,也就是Vue.prototype.$scrollFn,该单例作为单一状态管理,所有需要绑定滚动事件的元素都可以通过此方法快速实现
- 开发 v-fixed 指令,该指令用于元素到达页面顶部时,添加类名来实现
position: fixed
定位,而删除类名的条件则根据指定 滚动类型 来做处理 - 开发 scroll-nav 组件,也就是上面最终的效果
对到 滚动类型 ,这里个人根据已有经验分为两种
- 交替类型 - 当某一个元素得到信号时,原来的元素信号消失,比如页面有多个吸顶导航条,则导航条应该是交替吸顶的,而不是全都依旧保持着吸顶状态
- 区间滚动类型 - 根据起始元素的 offsetTop 到 另一个元素的 offsetTop + clientHeight,形成一个区间,当滚动至该区间时,订阅者得到信号,离开时得到取消信号
该文章除了基础的vue知识,还需要用稍微了解过设计模式,因为需要使用到单例模式及观察者模式,文章中的代码注释足够健全,所以不会在篇幅中过多的再次解释,具体查看代码注释即可
scrollFn 具体逻辑实现代码
scrollFn 是导航的核心功能,作用是根据订阅者的类型,在满足条件时发送通知执行函数。
import { G_SCROLL_FIXED, G_SCROLL_LIGHTHEIGHT, G_SCROLL_IN_SECTION } from './type.js';
/**
* @name [ScorllFn单例]
* @class ScrollFn
* @author [yose]
*/
class ScrollFn {
constructor () {
this.scrollTop = 0; // 当前滚动高度
this.isTicking = false; // 节流锁
this.subscribes = {}; // 所有订阅类型存储对象
this._itemFlag = {}; // 根据类型,均分配一个对象来缓存上一次高亮节点
this.htmlHeight = 0; // 浏览器滚动高度
this.documentHeight = document.documentElement.clientHeight; // 可见区域高度
this._oldHtmlHgt = 0; // 浏览器滚动高度标记,用于判断文档是否发生回流
}
/**
* @methods addSubscribes
* @param {HtmlElement/Object} elm
* @param {String} type
* @param {Function} fn
* @memberof ScrollFn
* @description 添加订阅者
*/
addSubscribes (elm, type, fn) {
let obj = this._createObj(elm, fn);
if (this.subscribes[type]) {
this.subscribes[type].push(obj);
} else {
this.subscribes[type] = [obj];
this._itemFlag[type] = {};
}
/* 将订阅者根据其 offsetTop 值进行从小到大排序 */
this.subscribes[type].sort((a, b) => a.offsetTop - b.offsetTop);
}
/**
* @methods _createObj
* @param {HtmlElement/Object} element
* @param {Function} fn
* @memberof ScrollFn
* @description 完善订阅者对象内容,根据不同订阅类型返回完善后的对象
*/
_createObj (element, fn) {
let obj = {};
/* 传入的是object类型
* 必须带有 elm 和 lastFloor 属性
* {HtmlElement} elm 用于获取offsetTop值的节点
* {HtmlElement} lastFloor 用于获取height的节点
* 两个节点形成一个滑动距离响应区间,进入与离开区间内都会更改signal信号
*/
if (element.constructor === Object) {
let elm = element.elm;
let lastFloor = element.lastFloor;
obj = {
elm,
signal: 0,
offsetTop: ~~elm.offsetTop - 2,
lastFloor,
lastFloorOffsetTop: lastFloor.offsetTop >> 0,
lastFloorHeight: lastFloor.clientHeight >> 0,
fn
};
} else {
/* 单纯elm对象
* 只需要获取 offsetTop 值
* 根据 offsetTop 值进行替换,超过其 offsetTop 值的signal置1,其余为0
*/
obj = {
elm: element,
signal: 0,
offsetTop: ~~element.offsetTop - 2,
fn
};
}
return obj;
}
/**
* @methods getSubscribesType
* @param {String} type
* @memberof ScrollFn
* @description 获取订阅者类型,由于部分类型采用哈希值分组,这个方法用于去除哈希得到正确的滚动类型
*/
getSubscribesType (type) {
let result = type.replace(/(_*\d*)$/g, '');
return result;
}
/**
* @methods unsubscribe
* @param {Object} observer
* @memberof ScrollFn
* @description 取消订阅,传入完善后的订阅者对象,暂时没有使用场景,暂留该函数待后续需要的时候再完善
*/
// unSubscribes (observer) {
//
// }
/**
* @methods _notice
* @param {String} type
* @memberof ScrollFn
* @description 发送通知,当某一类型的订阅者数组中有信号置换会执行该方法
*/
_notice (type) {
this.subscribes[type].forEach(item => {
item.fn(item.signal);
});
}
/**
* @methods hashCode
* @param {String} str
* @returns
* @memberof ScrollFn
* @description 产生一个hash值,只有数字,规则和java的hashcode规则相同
*/
hashCode (str) {
let h = 0;
let len = str.length;
let t = 2147483648;
for (let i = 0; i < len; i++) {
h = 31 * h + str.charCodeAt(i);
if (h > 2147483647) h %= t; // java 整型溢出则取模
}
return h;
}
/**
* @methods randomWord
* @param {Number} min 任意长度最小位(固定位数)
* @param {Number} max 任意长度最大位
* @memberof ScrollFn
* @returns 产生任意长度随机字母数字组合
*/
randomWord (min, max) {
let str = '';
let range = min;
let arr = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
range = Math.round(Math.random() * (max - min)) + min;
for (let i = 0; i < range; i++) {
let pos = Math.round(Math.random() * (arr.length - 1));
str += arr[pos];
}
return str;
}
/**
* @methods createHash
* @returns {Number} hashcode
* @memberof ScrollFn
* @description 生成唯一Number类型哈希值,当订阅者属于区间类型时需要带上 _hash 的形式,比如 G_SCROLL_LIGHTHEIGHT_2059310118
*/
createHash () {
let timestamp = (new Date()).valueOf();
let myRandom = this.randomWord(6, 10);
return this.hashCode(myRandom + timestamp.toString());
}
/**
* @methods isSubscribeActive
* @memberof ScrollFn
* @description 滑动事件主逻辑,根据不同订阅者执行不同滚动类型函数,判断订阅者是否发生信号置换
*/
isSubscribeActive () {
let groups = Object.keys(this.subscribes);
groups.forEach((type) => {
this.subscribes[type].forEach((item, index, arr) => {
let scrollType = this.getSubscribesType(type); // 滚动类型
let nextItemOST = arr[index + 1] ? arr[index + 1].offsetTop : 0; // 下一个订阅者的 offsetTop
let itemOST = item.offsetTop; // 当前订阅者的 offsetTop
let scrollTop = this.scrollTop; // 当前滚动高度值
let isLastItem = (index === arr.length - 1); // 是否为最后一个订阅者
switch (scrollType) {
case G_SCROLL_FIXED:
this._replaceType(item, itemOST, nextItemOST, isLastItem, scrollTop, type, index);
break;
case G_SCROLL_LIGHTHEIGHT:
this._replaceType(item, itemOST, nextItemOST, isLastItem, scrollTop, type, index);
this._setLastItemActive(type, item, isLastItem);
break;
case G_SCROLL_IN_SECTION:
this._inSectionType(item, itemOST, item.lastFloorOffsetTop + item.lastFloorHeight, scrollTop, type);
break;
default:
break;
}
});
});
}
/**
* @methods _setLastItemActive
* @memberof ScrollFn
* @param {String} type 订阅者类型
* @param {Object} item 订阅者对象
* @param {Boolean} isLastItem 是否为最后一个订阅者
* @description 页面置底,高亮类型订阅者最后一个置为高亮信号(无需关注是否处于视觉层内)
*/
_setLastItemActive (type, item, isLastItem) {
let isArriveBtm = (this.scrollTop >= (this.htmlHeight - this.documentHeight) >> 0);
if (!isArriveBtm || !isLastItem) return;
item.signal = 1;
this._itemFlag[type] = item;
this._notice(type);
}
/**
* @methods _replaceType
* @memberof ScrollFn
* @param {Object} item 订阅者对象
* @param {Number} itemOST 订阅者对象的offsetTop
* @param {Object} nextItemOST 下一个订阅者对象,用于是否仍处于激活状态判断
* @param {Boolean} isLastItem 是否为最后一个订阅者
* @param {Number} scrollTop 当前窗口滚动的 scrollTop 值
* @param {String} type 订阅者类型
* @param {Number} index 该订阅者在其数组中的索引值
* @description 订阅者属于替换类型执行该事件,根据订阅者的 offsetTop 进行交替信号
* 这里隐藏一个问题,会把吸顶的楼层做个标记,不会重新获取该订阅者的offsetTop值,也就是进入页面时处于吸顶的订阅者 offsetTop 不会更新
* 但暂时想不出能触发此bug的场景,所以暂时认为是安全的
*/
_replaceType (item, itemOST, nextItemOST, isLastItem, scrollTop, type, index) {
// 超过HtmlElement的offsetTop
if (scrollTop >= itemOST && (scrollTop < nextItemOST || isLastItem) && this._itemFlag[type] !== item) {
this._itemFlag[type].signal = 0;
this._itemFlag[type] = item;
item.signal = 1;
this._notice(type);
}
// 低于HtmlElement的offsetTop
if (scrollTop < itemOST && item.signal === 1) {
item.signal = 0;
this._notice(type);
if (!index) this._itemFlag[type] = {};
}
}
/**
* @methods _inSectionType
* @memberof ScrollFn
* @param {Object} item 订阅者对象
* @param {Number} itemOST 订阅者对象的offsetTop
* @param {Number} lastFloorHeight 订阅者最后一个HtmlElement楼层对象的高度,也可以非最后一个,形成滚动区间即可
* @param {Number} scrollTop 当前窗口滚动的 scrollTop 值
* @param {String} type 订阅者类型
* @param {Number} index 该订阅者在其数组中的索引值
* @description 订阅者属于滚动区间类型执行该事件,进入与离开区间内都会更改signal信号
*/
_inSectionType (item, itemOST, lastFloorHeight, scrollTop, type, index) {
// 处于滚动区间
if (scrollTop >= itemOST && scrollTop < lastFloorHeight && this._itemFlag[type] !== item) {
this._itemFlag[type].signal = 0;
this._itemFlag[type] = item;
item.signal = 1;
this._notice(type);
}
// 离开滚动区间
if ((scrollTop < itemOST || scrollTop > lastFloorHeight) && item.signal === 1) {
item.signal = 0;
this._notice(type);
if (!index) this._itemFlag[type] = {};
}
}
/**
* @methods _inSectionType
* @memberof ScrollFn
* @description 文档高度是否发生发生变化
*/
_isHtmlReflow () {
this.htmlHeight = document.documentElement.scrollHeight;
if (this.htmlHeight !== this._oldHtmlHgt) {
this._oldHtmlHgt = this.htmlHeight;
this._resetSubscribes();
}
}
/**
* @methods _resetSubscribes
* @memberof ScrollFn
* @description 重置所有监听者数据
*/
_resetSubscribes () {
let types = Object.keys(this.subscribes);
let subscribes = this.subscribes;
types.forEach(type => {
subscribes[type].forEach(item => {
// 有吸顶信号不去重写offsetTop,否则此时为0,将导致永远无法取消吸顶
if (!item.signal) item['offsetTop'] = (item.elm.offsetTop >> 0) - 2;
// 如果类型区域监听,重新获取最后楼层高度
if (this.getSubscribesType(type) === G_SCROLL_IN_SECTION) {
item.lastFloorOffsetTop = item.lastFloor.offsetTop >> 0;
item.lastFloorHeight = (item.lastFloor.clientHeight - 10) >> 0;
}
});
});
}
/**
* @methods windowScrollFun
* @memberof ScrollFn
* @description 滚动事件
*/
windowScrollFun () {
this.scrollTop = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop;
this._isHtmlReflow();
this.isSubscribeActive();
this.isTicking = false;
}
/**
* @methods throttling
* @memberof ScrollFn
* @description 节流,提供外部调用,来执行滚动函数
*/
throttling () {
if (!this.isTicking) {
requestAnimationFrame(this.windowScrollFun.bind(this));
this.isTicking = true;
}
}
}
export default ScrollFn;
创建 pluging.js ,用于注册插件
// pluging.js 作为插件导出,并挂载到Vue原型属性上
import ScrollFn from './index.js';
/**
* @name [创建ScorllFn单例]
* @author [yose]
* @returns ScorllFn
*/
export default {
install (Vue) {
Vue.prototype.$scrollFn = (function () {
let instance;
return function () {
if (!instance) {
instance = new ScrollFn();
window.addEventListener('scroll', instance.throttling.bind(instance));
}
return instance;
};
})();
}
};
v-fixexd 指令,作用与元素到达页面顶部时添加置顶类名。可对到吸顶导航,只置顶不取消肯定是不可行的,所以需要对其中vnode进行查询,如果存在高亮滚动导航条,则作为滚动区间类型处理,否则为交替类型
/**
* @name [fixed指令]
* @author [yose]
* ---
* 不传值,则默认吸顶状态添加common-bar类名
* 否则吸顶状态添加传入类名
*/
import { G_SCROLL_FIXED, G_SCROLL_IN_SECTION } from '@/mod/util/scroll/h5/1.0/type.js';
Vue.directive('fixed', {
bind: function (elm, binding, vnode) {
let className = binding.value || 'common-bar';
let tagFlag = false;
Vue.prototype.$nextTick().then(() => {
// 存在scroll-tap子组件,则吸顶判断为区间类型
tagFlag = vnode.children.some(item => {
let tag = item.componentOptions ? item.componentOptions.tag : false;
if (tag && tag === 'scroll-tap') return true;
});
let instance = Vue.prototype.$scrollFn();
let fn = (signal) => {
signal ? elm.classList.add(className) : elm.classList.remove(className);
};
// 是否存在scroll-tap子组件 ? 区间滚动类型 : 交替类型
tagFlag ? inSectionTypeFn(instance, elm, fn) : instance.addSubscribes(elm, G_SCROLL_FIXED, fn);
});
}
});
/**
* @mtehods inSectionTypeFn
* @param {Object} instance
* @param {HtmlElement} elm 绑定指令的元素,这里指的是吸顶导航条的包裹层
* @param {Function} fn
* @description 根据 elm ,找到swiper-wrapper的最后一个子元素, 通过其 href 获取到对应的 htmlElement,创建成 {elm, lastFloor}
*/
function inSectionTypeFn (instance, elm, fn) {
let lastChild = elm.querySelector('.swiper-wrapper').lastElementChild;
let lastFloor = document.querySelector(lastChild.getAttribute('href'));
let hash = instance.createHash();
instance.addSubscribes({ elm, lastFloor }, `${G_SCROLL_IN_SECTION}_${hash}`, fn);
}
这里需要注意使用Vue.prototype.$nextTick().then()
,不能单纯将函数放进$nextTick里处理,因为这个时候Dom节点还没被渲染出来
scroll-nav,它需要对两种不同数据进行处理,有对应floor_id的,不去自行添加floor_id,否则组件内部自动给对应的楼层数据添加id
虽然设计模式里,数据应该自上而下,不应该离散,但是根据真实业务场景,运营产品使用的时候没有这种概念,曾经还有需求是楼层导航里部分按钮是跳转链接的,情况很多,因此选择数据分散,依靠映射来处理
导航中接收四个数据,除了navList接受的导航条数据为必须,其余均按照真实场景进行选择
<div class="fix-bar-wrap" v-fixed>
<h3 class="g-hd">{{fashion.title}}</h3>
<scroll-nav
:navList="bookNavList"
:isShowBtn="true"
:options="{slidesPerView : 5}"
floorId="nav_comment_">
</scroll-nav>
</div>
// scrollNav.vue 这里为了方便查看,改成单文件组件的形式
<template>
<div class="nav-top" ref="scrollTapElm">
<swiper ref="swiperNav" :options="swiperOption" :class="lockSwiper">
<a class="swiper-slide" v-for="(item, index) of realNavList" :key="index"
:data-fql-stat="`${realStat[index]}`" :href="`${floorSaveID[index]}`">
<slot name="nav" :nav="item">{{item}}</slot>
</a>
</swiper>
<slot name="navBtn" v-if="isShowBtn">
<div ref="showMoreNavBtn" class="toggle-btn"></div>
<p class="tips">请选择楼层</p>
</slot>
</div>
</template>
<script>
import { swiper } from 'vue-awesome-swiper';
import { G_SCROLL_LIGHTHEIGHT } from '@/mod/util/scroll/h5/1.0/type.js';
export default {
name: 'scrollNav',
components: {
swiper
},
props: {
/* 导航条数据 */
navList: {
type: Array,
required: true,
default: () => {
return [];
}
},
/* swiper属性 */
options: {
type: Object,
default: () => {
return {};
}
},
floorId: '', // 是否需要内部帮助生成楼层ID
isShowBtn: false // 是否为可展示更多导航条
},
computed: {
/* 固定swiper不可拖动 */
lockSwiper () {
if (this.navList.length <= 1) return 'swiper-no-swiping';
},
/* 获取对应swiper实例 */
swiper () {
return this.$refs.swiperNav.swiper;
},
/* 合并swiper属性 */
swiperOption () {
return Object.assign({slidesPerView: 'auto'}, this.options);
}
},
data () {
return {
realNavList: [], // 去除无效楼层后的navList
realStat: [], // 生成hot-tag上报字段
floorSaveID: [], // 对应楼层ID储存数组
floorSaveArr: [], // 对应楼层HtmlElement储存数组
floorNum: 0, // 当前激活楼层
lockFlag: false // 标记展开状态,锁定swiper
};
},
methods: {
/**
* @methods getFloors
* @description 获取对应楼层 ID 写入数组缓存
*/
getFloors () {
// 不需要内部生成楼层id,item自带floor_id字段
if (!this.floorId) {
this.floorSaveID = this.navList.map((item) => {
return `#${item.floor_id}`;
});
}
if (this.floorId) {
let [i, len] = [0, this.navList.length];
for (i; i < len; i++) {
this.floorSaveID.push(`#${this.floorId}${i}`);
}
}
},
/**
* @methods saveFloors
* @description 去除无效按钮,并暴露在控制台上
*/
saveFloors () {
this.floorSaveID.forEach((item, i) => {
let elm = document.querySelector(item);
if (elm) {
this.floorSaveArr.push(elm);
} else {
delete this.floorSaveID[i];
console.log(`${item} is not found`);
}
});
},
/**
* @methods resetNavList
* @description 重置按钮数据,获取有效按钮
*/
resetNavList () {
// 抽离原navList中的有效按钮
this.realNavList = this.navList.filter((item, i) => {
if (this.floorSaveID[i]) return item;
});
// 去除无效楼层id
this.floorSaveID = this.floorSaveID.filter(Boolean);
// 有效按钮生成hot-tag上报字段
this.realStat = this.floorSaveID.map(item => {
let str = item.toLocaleUpperCase();
return str.replace('#', 'NAV_BTN_');
});
},
/**
* @methods judgeBtnFlex
* @description 判断按钮是否需要设置为flex: 1
*/
judgeBtnFlex () {
this.$nextTick(function () {
let lastBtnLeft = this.swiper.slidesGrid[this.swiper.slidesGrid.length - 1];
let lastBtnWidth = this.swiper.slidesSizesGrid[this.swiper.slidesSizesGrid.length - 1];
if (this.swiper.slides.length && lastBtnLeft + lastBtnWidth <= this.swiper.width) {
this.swiper.lockSwipes();
let slides = Array.prototype.slice.apply(this.swiper.slides);
slides.map(item => {
item.classList.add('fx1');
});
// 有展开更多按钮,删除按钮并把空间释放出来
if (this.isShowBtn) {
this.isShowBtn = false;
this.$refs.scrollTapElm.style.paddingRight = 0;
}
}
});
},
/**
* @methods lightHeight
* @description 按钮高亮事件
*/
lightHeight () {
this.$nextTick(function () {
let _this = this;
let instance = Vue.prototype.$scrollFn();
let params = Array.prototype.slice.call(this.swiper.slides);
let hash = instance.createHash();
// 初始化第一个按钮高亮
params[0].classList.add('on');
this.floorSaveArr.forEach((item, index, arr) => {
let _index = index;
// 区间类型,需要以G_SCROLL_LIGHTHEIGHT开头设置滚动类型,使用_hash方式进行分组
instance.addSubscribes(item, `${G_SCROLL_LIGHTHEIGHT}_${hash}`, function (signal) {
if (signal && _this.floorNum !== _index) {
params[_this.floorNum].classList.remove('on');
params[_index].classList.add('on');
_this.floorNum = _index;
_this.swiper.slideTo(_index - 1);
}
});
});
});
},
/**
* @methods toggleSwiper
* @description 导航条点击事件
*/
toggleSwiper () {
if (!this.isShowBtn) return;
let showMoreBtn = this.$refs.showMoreNavBtn;
let scrollTapElm = this.$refs.scrollTapElm;
let swiperWrapper = this.swiper.wrapper[0];
// 展开更多按钮事件
showMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
scrollTapElm.classList.toggle('open');
if (!this.lockFlag) {
this.lockFlag = true;
this.swiper.setWrapperTranslate(0);
this.swiper.lockSwipes();
this.swiper.activeIndex = 0;
} else {
this.recoverSwiper();
}
});
// 导航条点击收起更多事件
swiperWrapper.addEventListener('click', (e) => {
e.stopPropagation();
if (this.lockFlag) {
this.swiper.update(false);
scrollTapElm.classList.remove('open');
this.recoverSwiper();
}
});
},
/**
* @methods recoverSwiper
* @description 收起,还原最初状态,并滚动swiper
*/
recoverSwiper () {
this.lockFlag = false;
this.swiper.unlockSwipes();
this.swiper.update(true);
this.swiper.slideTo(this.floorNum - 1, 0, false);
},
init () {
this.getFloors();
this.saveFloors();
this.resetNavList();
this.lightHeight();
this.judgeBtnFlex();
this.toggleSwiper();
}
},
mounted () {
this.init();
}
};
</script>
<style>
@import "~@/inc/sales/style/mixin/fn.less";
.toggle-btn{
position: absolute;
z-index: 2;
top: 0;
right: 0;
}
.tips{
position: absolute;
z-index: 1;
top: 0;
width: 100%;
padding-left: 36px;
opacity: 0;
box-sizing: border-box;
pointer-events: none;
}
.open{
.swiper-container{
position: absolute;
width: 100%;
}
/deep/.swiper-wrapper{
-webkit-box-lines: multiple;
flex-wrap: wrap;
}
.tips{opacity: 1;}
}
</style>
这里说一下为什么需要附上哈希,因为导航条存在吸顶与吸底,吸顶的导航条多数情况下只是响应页面某一个模块,但是吸底一般为整体页面所有模块的锚点,这个时候彼此数值存在交集。如果只有一组,那么在触发信号的时候会快速的连续置换满足条件的订阅者,这样就导致滚动的每一刻,所有滚动区间类型的订阅者都反复收到信号执行函数,因此需要将他们进行分组,带上哈希,前面为固定固定类型字段,后面带上哈希,形成多个分组
网友评论