项目介绍

之前的博客是基于hexo的,最近想在博客内集成一些工具,因此重新制作博客,这样也方便进行样式的定制。

距离vue3.0发布已经过去了一年了,vue3.x已经变得很成熟,因此直接使用vue3.x。个人对于Vite的了解不多,因此也就使用默认的webpack来打包。

平时使用typora来记Markdown格式的笔记,因此我优先选择了静态博客,将本地的md文件通过git上传到服务器目标文件夹下,并且直接渲染文件夹下的md文件即可。

在笔记分类上,

项目搭建

新建项目

使用vue-cli创建项目。

添加样式

在App.vue内引入字体和iconfont

@import url("https://fonts.font.im/css?family=Roboto:400,500,700"); //font
@import url("https://at.alicdn.com/t/font_3181020_137vc0q0r6ys.css");

在assets/themes文件夹下创建样式变量variable.scss,用来进行亮色/暗色模式的切换

body {
  @import "./github-markdown-css/github-markdown-light.css";
  --bg-primary: #f6f7f8;
  --bg-nav-primary: #ffffff;
  --bg-primary-transparent: rgba(246, 247, 248, 0.78);
  --color-font-primary: #000000;
  --color-font-hover: #ee87b4;
  --color-font-content: #404040;
  --color-shadow: 0 0 #0000;
  --color-bulb: #404040;
  --color-bolder: #999999;
}
body[theme="dark"] {
  @import "./github-markdown-css/github-markdown-dark.css";
  --bg-primary: #202124;
  --bg-nav-primary: #000000;
  --bg-primary-transparent: rgba(0, 0, 0, 0.78);
  --color-font-primary: #ffffff;
  --color-font-hover: #008357;
  --color-font-content: #ffffff;
  --color-shadow: 0 0 #fff;
  --color-bulb: #ffff00;
  --color-bolder: #202124;
}

修改路由

通过md文件的文件名来访问文章。

同时,这个文件名也是frontmatter中的name,作为文章的唯一ID。之后会具体提到这一点。

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/post/:name",
    name: "Post",
    component: Post,
  },
  {
    path: "/404",
    name: "PageNotFound",
    component: PageNotFound,
  },
];

制作导航栏

这一部分难度不大,只需要通过<router-link>标签来配置路由,剩下的只是一些小细节,例如:

  • 在移动端隐藏路由选项,并显示菜单按钮
  • 当下面向下滚动一部分距离时,将导航栏设置为半透明效果
  • 点击菜单栏,从左侧弹出菜单

最终效果:

具体代码:

css代码省略。

<template>
  <header :class="{ 'scrolled-nav': scrollNav }">
    <nav>
      <div class="logo" @click="jumpToHome">
        <img src="@/assets/logo.png" alt="" />
      </div>
      <div class="title" v-show="!mobile" @click="jumpToHome">flik's blog</div>
      <ul v-show="!mobile" class="navigation">
        <li>
          <router-link class="link" :to="{ name: 'Home' }">🏠Home</router-link>
        </li>
        <li><router-link class="link" :to="{}">📁Posts</router-link></li>
        <li><router-link class="link" :to="{}">💁‍♂️About</router-link></li>
      </ul>
      <div class="icon">
        <i
          @click="toggleMobileNav"
          v-show="mobile"
          class="iconfont icon-menu"
          :class="{ 'icon-active': mobileNav }"
        ></i>
      </div>

      <transition name="mobile-nav" class="dropdown-nav">
        <ul v-show="mobileNav">
          <div class="dropdown-nav-solid">
            <li>
              <router-link class="link" :to="{ name: 'Home' }"
                >🏠Home</router-link
              >
            </li>
            <li><router-link class="link" :to="{}">📁Posts</router-link></li>
            <li><router-link class="link" :to="{}">💁‍♂️About</router-link></li>
          </div>
          <div class="dropdown-nav-empty" @click="toggleMobileNav"></div>
        </ul>
        <!-- <div class="dropdown-nav-empty"></div> -->
      </transition>
    </nav>
  </header>
</template>
<script>
import { reactive, toRefs, onMounted, watch } from "vue";
import { useStore } from "vuex";

export default {
  name: "Navigation",
  setup(props, context) {
    const $store = useStore();
    const toggleMobileNav = () => {
      state.mobileNav = !state.mobileNav;
    };
    const jumpToHome = () => {
      context.emit("jumpToHome", true);
    };
    const updateScroll = () => {
      const scrollPostion = window.scrollY;
      if (scrollPostion > 50) {
        state.scrollNav = true;
        //console.log(state.scrollNav);
      } else {
        state.scrollNav = true;
      }
    };
    onMounted(() => {
      let isMobile = $store.state.isMobile;
      console.log(isMobile);
      state.mobile = isMobile;

      //scorll监听
      window.addEventListener("scroll", updateScroll);
    });
    watch(
      () => $store.state.isMobile,
      (val, old) => {
        console.log(val, old);
        state.mobile = val;
        //state.mobileNav = val;
      }
    );
    const state = reactive({
      mobile: false,
      mobileNav: false,
      scrollNav: null,
      windowWidth: null,
      toggleMobileNav,
      jumpToHome,
    });

    return toRefs(state);
  },
};
</script>


Markdown解析

接下去就是编写核心功能:解析Markdown文件,渲染成html。

社区有许多markdown解析库,常见的有markdmarkdown-it,这里我使用markdown-it

若使用markdown-it,再搭配对应的markdown-it-loader即可。

不过由于这是自己的博客,我尽量少采用第三方库,因此使用raw-loader来当做markdown的容器,这样也可以定制更多我想要的功能。

安装Markdown依赖

npm install markdown-it --save-dev
npm install raw-loader --save-dev

读取文章信息

public下新建posts文件夹,存放md文件

在开始解析之前,我们还需要改造下md文件,在md文件中添加frontmatter

在每个文章的开头,我们需要在两个---符号中,以frontmatter格式记录该文章的一些基本信息,例如标题、描述、日期、分类等。例如:

---
title: vue3初体验—TodoList
desc: 最近开始学习vue3,记录一些新特性,并写一个小的demo。
date: 2022/1/02 03:16:10
name: dev-first-vue3-todolist
category: dev
---

之后使用graymatter,便可将这个frontmatter信息解析出,以供我们加载相应信息。

注意:name需要与md文件名相同,最好使用英文和‘-’分割符。name相当于文章的ID

随后,新建file.js,解析posts文件夹信息。核心代码如下:

import MarkdownIt from "markdown-it";
import matter from "gray-matter";

// 按照日期降序排序
let getPostRawList = function () {
  const context = require.context("./../posts", true, /\.md$/);
  const keys = context.keys();
  if (postCount == 0) {
    postCount = keys.length;
  }
  let postRawList = [];
  if (keys == 0) {
    return postRawList;
  }
  keys.forEach((key) => {
    const raw = context(key).default;
    postRawList.push(raw);
  });
  return postRawList.sort(sortPostByDate);
};

context用来获取post文件夹下的md文件上下文,通过key来读取文件信息。raw对象便是读取出的md文件,以String形式记录。

const raw = context(key).default;

其中,sortPostByDate根据文章的发布日期来降序排序。

let sortPostByDate = function (a, b) {
  // 降序
  return new Date(matter(b).data.date) - new Date(matter(a).data.date);
};

拿到md的字符串后,交给解析工具即可:

let getPostMd = function (name) {
  headerList.length = 0;
  const raw = getPostRawByName(name);
  if (!raw) {
    return null;
  }
  let slug = getSlugByName(name);
  let mda = require("markdown-it-anchor");
  let md = new MarkdownIt({
    highlight: function (str, lang) {
      if (lang && hljs.getLanguage(lang)) {
        try {
          return hljs.highlight(str, { language: lang }).value;
        } catch (__) {}
      }
      return ""; // use external default escaping
    },
  });
  md.use(mda.default, headerAnchorOpts);
  let result = md.render(matter(raw).content);
  let parsed = {
    markdown: result,
    slug: slug,
    headers: headerList,
  };
  return parsed;
};

核心的操作为:

let result = md.render(matter(raw).content);

需要注意的是,我在这里集成了markdown-it-anchor,用来提取出每篇文章中的标题列表,制作TOC(table of content)。

npm install --save-dev markdown-it-anchor
let headerAnchorOpts = {
  permalinkSpace: false,
  callback: headerCallback,
};
function headerCallback(item) {
  let header = {};
  header.type = Number(item.tag.substr(1, 1));
  header.id = item.attrs[0][1];
  header.name = decodeURIComponent(item.attrs[0][1]);
  header.left = (header.type - 1) * 20;
  headerList.push(header);
}

headerList便是这篇文章的标题列表。我们将它和文章解析结果一起返回。

渲染Markdown

在前面,我们已经获取到了文章解析后的结果,现在将其渲染在页面上。使用方法非常简单,指定一个markdown-body的类,使用v-html将结果渲染。

<div class="markdown-body" v-html="parsedMd"></div>

调用之前提到的getPostMd的方法。

入参为路由值,也就是frontmatter中的name,同时也是文章的文件名。

    let getParsedMd = function () {
      let name = router.currentRoute.value.params.name;
      let parsed = getPostMd(name);
      // 未找到
      if (!parsed.markdown) {
        router.replace({
          path: "/404",
        });
        return;
      }
      state.parsedMd = parsed.markdown;
      state.slug = parsed.slug;
      state.headers = parsed.headers;
    };

这样,文章便渲染至页面上了。

加载文章列表

需要获取所有文章列表,并按照类型分类。

简单的做法为,获取所有的文章解析结果,提取所有的frontmatter。

循环加载frontmatter即可。

// 获取文章slug列表
let getSlugList = function () {
  const postRawList = getPostRawList();
  let slugList = [];
  postRawList.forEach((raw) => {
    slugList.push(matter(raw).data);
  });
  return slugList;
};
const getList = () => {
  let list = getSlugList();
  state.postList = list;
};

创建PostList.vue组件,传入获取到的postList

<template>
  <div class="list">
    <post-list-item
      v-for="(slug, index) of list"
      :key="index"
      :post="slug"
      @click="jumpToPost(slug.name)"
    />
  </div>
</template>
  setup(props, context) {
    let postList = computed(() => props.list);
    const jumpToPost = (name) => {
      context.emit("clickPost", name);
    };
    const state = reactive({
      postList,
      jumpToPost,
    });
    return toRefs(state);
  },

实现的效果如下:

自定义滚动条

  • ::-webkit-scrollbar — 整个滚动条.
  • ::-webkit-scrollbar-button — 滚动条上的按钮 (上下箭头).
  • ::-webkit-scrollbar-thumb — 滚动条上的滚动滑块.
  • ::-webkit-scrollbar-track — 滚动条轨道.
  • ::-webkit-scrollbar-track-piece — 滚动条没有滑块的轨道部分.
  • ::-webkit-scrollbar-corner — 当同时有垂直滚动条和水平滚动条时交汇的部分.
  • ::-webkit-resizer — 某些元素的corner部分的部分样式(例:textarea的可拖动按钮).

交互式文章目录树(TOC):

在文章详情页面左侧,可以生成一个文章目录树。该文章目录树具有如下功能:

  • 点击对应标题,文章将自动滚动至相应位置
  • 监听当前浏览内容的标题,并动态更新至对应标题

最终效果如图所示:

解析标题

在开始之前,需要安装markdown-it-anchor,该插件可以为markdown内所有的标题设置锚点。

npm install markdown-it-anchor --save-dev

安装后,将其配置在之前的markdown-it对象上。

let mda = require("markdown-it-anchor");
md.use(mda.default, headerAnchorOpts);

每一个标题解析后的结果如下:

可以看见,每一个标题标签都设置2个属性:

  • tabIndex:相对于父级标题的次序。由于我没有设置,因此均为-1
  • id:默认为标题的内容经过url编码后的结果

其中,配置选项headerAnchorOpts的内容如下。headerCallback为每个标题解析后的回调函数,入参为上图标题解析后的结果对象。该回调函数将每一个结果转换为一个目录树里的标题对象,并添加进全局的标题列表(headerList)对象中。最后,将全局标题列表对象返回至页面中渲染即可。

let headerAnchorOpts = {
  permalinkSpace: false,
  callback: headerCallback,
};

function headerCallback(item) {
  let header = {};
  header.type = Number(item.tag.substr(1, 1));
  header.id = item.attrs[0][1];
  header.name = decodeURIComponent(item.attrs[0][1]);
  header.left = (header.type - 1) * 20;
  headerList.push(header);
}

渲染文章目录树

拿到处理过的标题列表后,即可开始渲染。

创建文章目录树组件(markdownSider)。具体代码如下:

<template>
  <div class="sider" ref="sider" v-if="headers.length">
    <div v-for="(item, index) of headers" :key="item.id">
      <div
        :class="[
          'sider-item',
          currentHeaderIndex === index ? 'sider-item-seleced' : '',
        ]"
        :style="'margin-left:' + item.left + 'px'"
        @click="clickItem(item, index)"
      >
        <p class="sider-item-text">{{ item.name }}</p>
      </div>
    </div>
  </div>
</template>

点击跳转至对应标题

为每一个标题item设置点击事件clickItem,点击后跳转至对应的标题。

let clickItem = (item, index) => {
  state.currentHeaderIndex = index;
  context.emit("clickItem", { item, index });
};
<MarkdownSider
   :key="new Date()"
   :headerList="headers"
   @clickItem="handleClickSider"
/>
const handleClickSider = function (e) {
  state.currentHeaderIndex = e.index;
  scrollToAnchor(e.item.id);
};
const scrollToAnchor = (anchorName) => {
  if (anchorName) {
    // 找到锚点
    let anchorElement = document.getElementById(anchorName);
    // 如果对应id的锚点存在,就跳转到锚点
    if (anchorElement) {
      const topOfElement =
        window.pageYOffset +
        anchorElement.getBoundingClientRect().top -
        headerOffset;
      window.scroll({ top: topOfElement, behavior: "smooth" });
      // 点击侧边栏时,取消scroll监听
      // todo
    }
  }
};

在scrollToAnchor方法中,使用到了window.scroll函数。该函数根据入参,调整页面滚动的距离。

在上面解析标题的步骤中,文章中的每一个标题已经设置了id。为什么不使用window.scrollIntoView来直接跳转到该标题呢?

若你尝试使用window.scrollIntoView,会发现该api有效,且页面跳转到了对应标题位置。但是由于我们的导航栏的布局为fixed,因此不计入页面高度中。如下图,当我们点击“在线课程”按钮时,页面跳转的距离正确,但标题被导航栏遮挡。因此,只能尝试换个api,使用window.scroll。

核心的操作是计算window.scroll函数入参的top属性值。

通过window.pageYOffset获取到页面在Y轴方向已滚动的距离,再通过getBoundingClientRect().top获取目标标题距离页面顶部高度,二者相加。由于导航栏是fixed布局,还需要减去导航栏的高度(headerOffset)。这样,页面需要滚动的距离值(top)便计算完成了。

点击后,可以看见页面滚动至正确的位置。

监听标题的改变

我们还需要监听当前浏览内容的标题,当标题改变后,动态更新目录树选中的标题item。

在组件的onMounted生命函数内,设置监听函数。

window.addEventListener("scroll", scrollEventListen, false);

由于监听页面滚动是一个开销极大的举动,我们可以使用window.requestAnimationFrame实现节流。其中,detectFirstHeader函数用来监听页面顶部的标题的改变。

该函数的实现思路为:获取页面所有的标题对象,判断该标题是否是顶部的标题。若页面不存在顶部标题,或者该标题的高度大于顶部标题高度,则该标题为顶部标题,将该标题在文章目录树中对应的item设置为选中状态。

为了增加细节,可以将文章目录树也设置滚动效果。当页面滚动时,文章目录树也相应滚动,使得选中的item一直在该文章目录树的偏中心的位置,滚动的值为scrollHeight。该值需要结合每个item的高度和文章目录树组件的高度来计算,这里不展开讲。

let ticking = false;

let scrollEventListen = () => {
      if (!ticking) {
        window.requestAnimationFrame(function () {
          detectFirstHeader();
          ticking = false;
        });
        ticking = true;
      }
    };
    const detectFirstHeader = () => {
      let scrollTop = document.documentElement.scrollTop;
      let topHeader = null;
      let topHeaderIndex = 0;
      let headerList = document.querySelectorAll("h1,h2,h3,h4,h5,h6");
      for (let i = 0; i < headerList.length; i++) {
        let header = headerList[i];
        if (header.offsetTop > scrollTop + headerOffset + 5) {
          continue;
        }
        if (!topHeader) {
          topHeader = header;
          topHeaderIndex = i;
        } else if (header.offsetTop >= topHeader.offsetTop) {
          topHeader = header;
          topHeaderIndex = i;
        }
      }
      if (topHeader && state.currentHeaderIndex !== topHeaderIndex) {
        state.currentHeaderIndex = topHeaderIndex;
        state.currentId = topHeader.getAttribute("id");

        if (topHeaderIndex > 6) {
          let scrollHeight = (topHeaderIndex - 12) * 60;
          let siderBar = sider.value;
          siderBar.scroll({ top: scrollHeight, behavior: "smooth" });
        }
      }
    };

总结

这个博客项目做下来,基本上完成了预设的目标。但它更像是一个普通的Vue应用,而不是静态博客。

由于对于webpack知识的欠缺,没有实现文章开头的目标,即在打包后动态更改Markdown的内容来更新博客。显然,实现这个目标,需要使用其他方法,例如将项目文件上传至服务器后,由服务器来打包渲染。

因此,这个博客项目的参考意义不大。下一步,我可能会使用Nuxt.js来重新搭建博客项目,以实现更好的SEO和更简单的更新博客内容。