目录
第一章 实现效果
第二章 锚点组件分析
2.1 功能分析
2.2 核心点
第三章 源代码
3.1 数据格式
3.2 代码分析
3.2.1 tab栏以及内容页面
3.2.2 逻辑
第四章 遇到的问题
第一章 实现效果
第二章 锚点组件分析
2.1 功能分析
- tab栏以及切换涉及逻辑
- 点击tab切换同时页面需要将对应的栏的内容滚动到顶部
- 滚动页面到对应栏的内容时tab栏也需要同时变化
- (小编需要做的完整需求是所有的内容都是配置生成的,这里只是其中的一部分,需要tab栏与内容除了一一对应,其他内容的多少都是不确定的,只能通过数据先做渲染),还需要知道每一个tab对应的内容距离顶部的距离(计算获取)
2.2 核心点
- 自定义tab
- addEventListener(添加监听事件:这里需要用到的是scroll滚动事件)、removeEventListener(移除监听事件)
- element.scrollIntoView(将列表滚动到特定的dom项上)
- scrollTop(获取或设置元素的垂直滚动条位置)
第三章 源代码
3.1 数据格式
- 完整数据格式(注意:用到的是guideJson.guideInfo下的数据,如果有想要的可以从该处获取,也可以根据上面展开的数据格式造数据也可)
https://download.csdn.net/download/qq_45796592/89865179?spm=1001.2014.3001.5503
3.2 代码分析
3.2.1 tab栏以及内容页面
// 定位锚点的tab栏
<div class="preview_container">
<div class="preview_anchor">
<ul class="anchor_ul">
<li
class="anchor_li"
v-for="item in guideJson.guideInfo" // 遍历数据,渲染tab
:key="item.id"
@click="handleClickAnchor(item.id)" // 点击tab实现滚动的方法
:style="{
color: currentAnchor === item.id ? 'red' : '',
borderRight: currentAnchor === item.id ? '4px solid red' : ''
}"
>
{{ item.title }}
</li>
</ul>
</div>
<div class="preview_wrapper" ref="preview_wrapper">
// 这块内容是可以自定义的,如果大家没有数据跟着改造就好,也可以弄个空白页占高
<div
v-for="item in guideJson.guideInfo"
:key="item.id"
:id="'dom-' + item.id" // 这里很重要,由于小编的id是通过uuid生成,首字母是以数字开头,这种定义是不符合规范的,有可能还获取不到对应的dom,所以这里小编添加了前缀(也可自定义)
class="previw_item"
>
<div class="sub_title">{{ item.title }}</div> // 标题
<div v-if="item.type === 'text'"> // 下面就是根据不同的类别展示不同的组件对应的页面了
<TextPreview :state="item" />
</div>
<div v-else-if="item.type === 'list'">
<ListPreview :state="item" />
</div>
<div v-else-if="item.type === 'table'">
<TablePreview :state="item" />
</div>
<div v-else-if="item.type === 'image'">
<ImagePreview :state="item" />
</div>
<div v-else-if="item.type === 'file'">
<FilePreview :state="item" />
</div>
<div v-else-if="item.type === 'link'">
<LinkPreview :state="item" />
</div>
<div v-else-if="item.type === 'required_materialsList'">
<RequiredFilePreview :state="item" />
</div>
<div v-else-if="item.type === 'result_materialsList'">
<ResultFilePreview :state="item" />
</div>
</div>
</div>
</div>
// 样式
.preview_container {
margin: 0 auto;
width: 1200px;
height: 100%;
.preview_anchor {
text-align: right;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
position: fixed;
transform: translateX(-150px);
top: 389px;
.anchor_ul {
.anchor_li {
padding: 4px 16px;
max-width: 150px;
border-right: 2px solid rgba(0, 0, 0, 0.06);
cursor: pointer;
}
}
}
.preview_wrapper {
height: calc(100% - 251px);
overflow: auto;
.previw_item {
.sub_title {
width: 100%;
height: 32px;
font-size: 16px;
font-weight: bold;
color: #3d3d3d;
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
margin-top: 24px;
}
}
}
}
@media (max-width: 1200px) {
.preview_anchor {
display: none;
}
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
3.2.2 逻辑
const currentAnchor = ref('') // 点击的当前tab
const preview_wrapper = ref(null) // preview_wrapper 定义dom元素
const staticHeight = ref(0) // 滚动盒子preview_wrapper距离顶部的高度
let onScrollFunction = null // 初始化方法为null
// ===== scroll 滚动带动 tab 切换的逻辑 =====
// 页面首次进来时执行的逻辑
onMounted(async () => {
query.value = route.query // 获取路由的参数
await thingApi.guideById({ id: query.value.id }).then((res) => { // 接口请请求,为了获取数据,大家用的时候可以直接根据前面的图造数据就行
guideJson.value = res ? JSON.parse(res.guideJson) : {} // 数据复制,大家针对自己造的数据进行除了
console.log('预览数据', guideJson.value)
const { guideInfo } = guideJson.value // 获取到我们需要的数据
currentAnchor.value = guideInfo[0].id
nextTick(() => {
const wrapper = preview_wrapper.value // dom元素赋值
staticHeight.value = wrapper.offsetTop // 距离元素最近的一个具有定位的祖宗元素,没有定义则是body
genHeadingsOffset(wrapper) // 首次进来初始化,获取每一个tab对应的dom距离顶部的距离(有优化空间,最后给出)
// 滚动逻辑函数,赋值 控制是用一个滚动函数,方能移除
onScrollFunction = function onScroll(e) {
const target = e.target
const offsetTop = target.scrollTop
const offsetList = Object.keys(offsetMap.value).map((item) => +item)
for (let i = 0; i < offsetList.length; i++) { // 从第一个元素往后遍历
if (offsetTop + staticHeight.value <= offsetList[i]) { // 如果滚动的高度+静态不变的高度小于某个元素的高度,获取对应元素赋值,带动tab切换,结束遍历(效果有缺陷,大家后续自己看效果)
const activeId = `#${offsetMap.value[offsetList[i]]}`
const findItem = anchors.value.find((item) => item.href === activeId)
if (findItem) {
currentAnchor.value = findItem.href.split('#dom-').join('')
}
break
}
}
}
wrapper?.addEventListener && wrapper.addEventListener('scroll', onScrollFunction)
})
})
})
// 监听 getContainer 获取容器的滚动事件,更新当前的锚点信息
const offsetMap = ref({}) // 每一个dom节点对应顶部的高度
const headingsEl = ref([]) // 每一个dom节点
const anchors = ref([]) // 动态保存每一个id对应的dom元素名称(注意要与写样式时的id一致)
const genHeadingsOffset = (wrapper) => {
nextTick(() => {
const { guideInfo } = guideJson.value
anchors.value = guideInfo.map((item) => { // map遍历保存id
return {
href: `#dom-${item.id}` // id命名
}
})
const headingsElCache = [] // 缓存每一个dom节点
anchors.value.forEach((item, index) => {
headingsElCache[index] = wrapper.querySelector(item.href)
})
const offsetMapCache = {} // 缓存每一个dom节点对应顶部的高度
headingsElCache.forEach((head) => {
offsetMapCache[head.offsetTop] = head.id
})
offsetMap.value = offsetMapCache
headingsEl.value = headingsElCache
})
}
// =========== onScrollFunction方法优化方案 ============
// onScrollFunction = function onScroll(e) {
// Object.keys(offsetMap.value).length ? offsetMap.value : genHeadingsOffset(wrapper) // 针对首次没有初始化数据时初始化数据,有了数据之后不在执行函数逻辑直接赋值
// const target = e.target
// const offsetTop = target.scrollTop
// const offsetList = Object.keys(offsetMap.value).map((item) => +item)
// for (let i = offsetList.length; i > 0; i--) { // 从最后一个元素往前遍历
// if (offsetTop + staticHeight.value > offsetList[i]) { // 如果滚动的高度+静态不变的高度大于某个元素的高度,获取对应元素赋值,带动tab切换(相比前面的方法,效果更合理),结束遍历
// const activeId = `#${offsetMap.value[offsetList[i]]}`
// const findItem = anchors.value.find((item) => item.href === activeId)
// if (findItem) {
// currentAnchor.value = findItem.href.split('#dom-').join('')
// }
// break
// }
// }
// }
// ============= 点击tab 切换 滚动的逻辑 ===================
//点击tab切换的逻辑
const handleClickAnchor = async (e) => {
// 先移除滚动监听(由于我们在onMounted已经注册添加过滚动事件了,如果不移除就会造成事件叠加的问题,以及我们的本意是想点击直接切换tab,由于前面的滚动函数还存在,还是会有滚动带动tab切换的效果)
const wrapper = preview_wrapper.value
wrapper.removeEventListener('scroll', onScrollFunction)
currentAnchor.value = e
// 滑动
const element = document.querySelector(`#dom-${e}`)
// 确定element.scrollIntoView滚动完全完成后再开启滚动监听,否则提前触发滚动逻辑也会有滚动带动tab切换的现象
scrollIntoViewWithListener(element, { behavior: 'smooth' }).then(() => {
// 添加延迟二次确定
setTimeout(() => {
// 执行完成后添加滚动监听
wrapper.addEventListener('scroll', onScrollFunction)
}, 500)
})
}
// 确定element.scrollIntoView滚动完全完成
function scrollIntoViewWithListener(element, scrollOptions) {
return new Promise((resolve) => {
// 使用IntersectionObserver来检测滚动是否真正发生
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
// 元素滚动到视口中时,停止观察并调用resolve
observer.unobserve(element)
resolve()
}
},
{
// 这些选项可以根据需要进行调整
root: null,
threshold: 0
}
)
// 开始观察元素
observer.observe(element)
// 滚动到指定元素
element.scrollIntoView(scrollOptions)
})
}
// 最后注意移除滚动事件
onBeforeUnmount(() => {
const wrapper = preview_wrapper.value
wrapper.removeEventListener('scroll', onScrollFunction)
})
第四章 遇到的问题
- 使用了addEventListener添加事件后removeEventListener移除不掉。解决原理小编在该文章。
js基础:addEventListener与removeEventListener使用时,涉及的问题(包括事件捕获、冒泡,removeEventListener不生效问题)-CSDN博客
- 点击切换时没有移除事件以及使用scrollIntoView滚动到指定节点期间就添加了滚动事件。解决方法小编在代码中已添加说明。