写在前面:实现方法和element官网不一致,仅参考页面结构

需求分析:

1. 主体文档应该使用markdown编写,一个组件对应一个md文件,所以我们需要有在vue中导入md的功能。

2. 组件有不同的功能,需要提供一个演示框,这个演示框里面会放不同的组件功能展示,以及固定的查看源代码,复制代码功能。这个演示框应该是一个vue组件,所以需要有在md文件中导入vue组件的功能。

3、element右侧有一个目录,该目录需要自动提取md文件中的标题,需要跟着文档滚动而滚动,点击目录可以跳转到对应的标题

插件使用:

1、使用到vite-plugin-md vite的md插件,提供把md文件当做vue导入的能力,也可以在md文件中使用vue组件

2、prismjs 在代码展示的时候,提供代码高亮

3、markdown-it-anchor 自动提取md文件中的标题,实现右侧目录

路由设计:

在路由中引用的组件是一个md文件

文档主体:

每一个组件路由其实是一个md文件。要想在vue中正常解析.md需要下载一个vite插件。

npm i vite-plugin-md

代码高亮:

npm i prismjs

npm i vite-plugin-prismjs

vite.config.js配置如下:

右侧目录区:

npm i markdown-it-anchor

markdown-it-anchor插件可以用来实现右侧自适应目录,这个插件的作用是在vite-plugin-md插件生成vue组件时候,把h标签的内容作为它的id,这样就可以通过id跳转的方式从目录跳转到指定内容了。

vite.config.js配置如下:

右侧自适应目录需要拥有两个功能,一是点击目录页面滚动至对应内容,二是页面滚动时高亮对应目录

页面初始化时获取所有h2~h6标题内容渲染右侧目录,此时可知每个标题的对应id;

点击目录页面滚动至对应内容通过scrollTo方法实现即可;

页面滚动时高亮对应目录,需要在页面滚动时把所有标题按离视口的距离进行排序,过滤掉负数即可得到距离视口最近的标题元素id,根据id高亮对应目录


接下来就可以使用vue和md双向导入功能了。

vue导入md,直接导入作为组件就可以。

在md中使用vue组件的方法,md中可以用两种组件:

1. 全局组件 直接当html标签使用,可以直接解析

2. 局部组件,在md文件中导入使用,使用方式如下:

展示如下:

一共三个区域:

1. 展示区:通过slot,展示外部组件。

2. 控件区:查看源代码

3. 代码区:获取展示区传入的外部组件的代码,加上代码高亮展示

该组件(ShowCode)本身使用频繁,所以我们直接注册为全局组件,这样就可以直接在md文件中引入,而展示区的代码,则通过局部引入的方式,导入进行展示。

”隐藏源代码“使用sticky粘性定位,当代码区较长时也能展示在底部,方便使用。

每一个展示区,对应一个vue文件,这样控制粒度更加精细。

例:多个展示区时:


代码区:

在 Vite 的开发文档里有记载到,它支持在资源的末尾加上一个后缀来控制所引入资源的类型。比如可以通过 

import xx from 'xx?raw' 以字符串形式引入 xx 文件。

但是这种方式只能在开发环境得到支持,所以生产环境需要换成网络请求的方式。

使用网络请求方式需要把page文件夹复制一份到打包后的根路径,使用copy-dir插件实现

在原本的打包命令后加上 && node ./config/copyDocs.js,就会自动在打包后执行这个代码,node后面是代码所在相对路径

代码实现:

使用了Tailwind CSS原子类,前缀是tw-

 basic.vue:

<template>
  <div class="page-content">
    <v-sidebar tabName="components" />
    <el-scrollbar
      ref="containerRef"
      class="tw-flex-1 tw-pr-[10px]"
      height="100%"
      @scroll.self="handleScroll"
    >
      <router-view></router-view>
    </el-scrollbar>
    <div class="cus-anchor cus-anchor--vertical tw-min-w-[150px]">
      <div class="cus-anchor__marker" ref="markerRef"></div>
      <div class="cus-anchor__list">
        <div
          class="cus-anchor__item"
          v-for="item in catalogueData"
          :key="item.id"
        >
          <a
            @click.prevent
            :id="`a-${item.id}`"
            class="cus-anchor__link"
            :class="{ is_active: curId === item.id }"
            :href="`#${item.id}`"
            @click="(e) => handleClick(item.id, e)"
            >{{ item.text }}</a
          >
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import VSidebar from "./VSidebar.vue";
import { nextTick, onMounted, ref,onBeforeUnmount } from "vue";
import bus from '../bus/index'
const containerRef = ref(null)
bus.on('afterEach',()=>{
  nextTick(()=>{
      containerRef?.value?.setScrollTop(0);
      markerRef.value.style.opacity = 0;
      markerRef.value.style.top = "8px";
      curId.value = null;
      containerRef.value.update();
      getElements();
      nextTick(() => {
        containerRef.value.handleScroll();
      });
  })
})
onBeforeUnmount(()=>{
   bus.off('afterEach');
})
const catalogueData = ref([]);

const markerRef = ref(null);
const curId = ref();
const stopChange = ref(false);
onMounted(() => {
  nextTick(() => {
    containerRef.value.setScrollTop(0);
    getElements();
    nextTick(() => {
      containerRef.value.handleScroll();
    });
  });
});
// 获取离视口最近的标题元素id
const getTopMore = (els) => {
  const data = els
    .map((item) => ({
      offsetTop: item.getBoundingClientRect().top,
      text: item.innerText,
      id: item.id,
    }))
    .filter((item) => item.offsetTop > 0);
  const minElement = data.sort((a, b) => a.offsetTop - b.offsetTop)[0];
  return minElement?.id;
};
// 滚动时根据离视口最近的标题元素id高亮对应目录
const handleScroll = () => {
  if (stopChange.value) return;
  const hElement = document.querySelectorAll("h2,h3,h4,h5,h6");
  const topEle = getTopMore([...hElement]);
  if (curId.value !== topEle && topEle) {
    curId.value = topEle;
    markerRef.value.style.opacity = 1;
    markerRef.value.style.top =
      document.getElementById(`a-${curId.value}`).getBoundingClientRect().top -
      64 +
      "px";
  }
};
// 获取所有标题元素
const getElements = () => {
  const hElement = document.querySelectorAll("h2,h3,h4,h5,h6");
  catalogueData.value = [...hElement].map((item) => {
    return {
      text: item.innerText,
      id: item.id,
    };
  });
};
const handleClick = (anchor, e) => {
  stopChange.value = true;
  curId.value = anchor;
  markerRef.value.style.opacity = 1;
  markerRef.value.style.top = e.target.getBoundingClientRect().top - 64 + "px";
  containerRef.value.scrollTo({
    top: document.getElementById(anchor).offsetTop,
    behavior: "smooth",
  });
  setTimeout(() => {
    stopChange.value = false;
  }, 1000);
};
</script>
<style lang="less" scoped>
.page-content {
  display: flex;
  height: calc(100% - 54px);
  overflow: scroll;
}
.cus-anchor {
  position: relative;
  .cus-anchor__marker {
    width: 4px;
    height: 14px;
    top: 8px;
    left: 0;
    transition:
      top 0.25s ease-in-out,
      opacity 0.25s;
    position: absolute;
    border-radius: 4px;
    background-color: #45d4d5;
    opacity: 0;
    z-index: 0;
  }
  .cus-anchor__list {
    padding-left: 14px;
    .cus-anchor__item {
      overflow: hidden;
      display: flex;
      flex-direction: column;
      .cus-anchor__link {
        display: inline-block;
        font-size: 12px;
        line-height: 22px;
        padding: 4px 0;
        color: #909399;
        transition: color 0.3s;
        white-space: nowrap;
        text-decoration: none;
        text-overflow: ellipsis;
        overflow: hidden;
        max-width: 100%;
        outline: none;
        cursor: pointer;
      }
      .is_active {
        color: #45d4d5;
      }
    }
  }
}
</style>

 ShowCode.vue:

<template>
  <div
    class="tw-my-[10px] tw-border tw-border-[#dcdfe6] tw-box-border tw-rounded"
  >
    <div class="tw-box-border tw-p-[15px]">
      <slot>
        <el-button>测试一下</el-button>
      </slot>
    </div>
    <div
      class="tw-h-[35px] tw-border-t tw-border-[#dcdfe6] tw-flex tw-items-center tw-justify-end tw-p-[5px]"
    >
      <el-tooltip
        :show-arrow="false"
        :content="isShow ? '隐藏源代码' : '查看源代码'"
        effect="dark"
        placement="bottom-end"
      >
        <el-icon
          class="tw-cursor-pointer tw-mx-[5px]"
          @click="openAndCloseCode"
        >
          <Reading />
        </el-icon>
      </el-tooltip>
    </div>
    <transition
      name="slide"
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
    >
      <div v-show="isShow" class="tw-box-border tw-relative">
        <pre
          style="margin: 0"
        ><code class="language-js line-numbers" style="padding-top: 0;">{{ sourceCode }}</code></pre>
        <div
          class="tw-border-t tw-border-[#dcdfe6] tw-mt-[-1px] tw-bg-[#fff] tw-z-10 tw-text-[#909399] hover:tw-text-[#409eff] tw-flex tw-justify-center tw-items-center tw-h-[44px] tw-cursor-pointer tw-sticky tw-bottom-0"
          @click="openAndCloseCode"
        >
          <el-icon>
            <CaretTop />
          </el-icon>
          <span class="tw-ml-[10px]">隐藏源代码</span>
        </div>
      </div>
    </transition>
  </div>
</template>
<script setup>
import { nextTick, onMounted, ref, useAttrs } from "vue";
import { Reading, CaretTop } from "@element-plus/icons-vue";
import Prism from "prismjs";

const { showpath } = useAttrs();
const sourceCode = ref();
onMounted(async () => {
  const isDev = import.meta.env.MODE === "development";
  let path = showpath || "button/baseComp";
  if (isDev) {
    /* @vite-ignore */
    const data = await import(/* @vite-ignore */`/examples/src/page/${path}.vue?raw`);
    sourceCode.value = data.default;
  } else {
    sourceCode.value = await fetch(`./page/${path}.vue`).then((res) =>
      res.text()
    );
  }
  await nextTick(() => {
    Prism.highlightAll();
  });
});

const isShow = ref(false);
// 展开控制
const openAndCloseCode = () => {
  isShow.value = !isShow.value;
};
const beforeLeave = (el) => {
  // 给元素设置过渡效果
  el.style.transition = "0.3s height ease-in-out";
  // 高度变化时,让其内容隐藏
  el.style.overflow = "hidden";
};
const leave = (el) => {
  el.style.height = "auto";
  // 设置高度为具体的值
  el.style.height = window.getComputedStyle(el).height;
  // 强制浏览器回流,否则浏览器会合并两次元素的高度更改
  el.offsetHeight;
  el.style.height = "0px";
};
const afterLeave = (el) => {
  // 收尾工作,展示完过渡效果之后,设为原来的值
  el.style.transition = "";
  el.style.overflow = "visible";
};
const beforeEnter = (el) => {
  // 给元素设置过渡效果
  el.style.transition = "0.3s height ease-in-out";
  // 高度变化时,让其内容隐藏
  el.style.overflow = "hidden";
};
const enter = (el) => {
  el.style.height = "auto";
  // 保存元素原来的高度
  const endWidth = window.getComputedStyle(el).height;
  el.style.height = "0px";
  // 强制浏览器回流,否则浏览器会合并两次元素的高度更改
  el.offsetHeight;
  el.style.height = endWidth;
};
const afterEnter = (el) => {
  // 收尾工作,展示完过渡效果之后,设为原来的值
  el.style.transition = "";
  el.style.overflow = "visible";
};
</script>

Logo

永洪科技,致力于打造全球领先的数据技术厂商,具备从数据应用方案咨询、BI、AIGC智能分析、数字孪生、数据资产、数据治理、数据实施的端到端大数据价值服务能力。

更多推荐