目录
第一章 实现效果
第二章 锚点组件分析
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滚动到指定节点期间就添加了滚动事件。解决方法小编在代码中已添加说明。





















