项目介绍
之前的博客是基于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解析库,常见的有markd
和markdown-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和更简单的更新博客内容。