一. 需求和方案
前段时间想给自己搭建一个技术博客,并希望它满足如下几点:
- 高定制性,样式全部自己写;
- 支持 markdown 格式来书写文章;
- 利用宽屏空间,文章支持分栏展示;
- 支持 CI/CD,提交了直接构建和部署;
- 免费。
基于第 1 点需求,就绕过了已有的 Hexo 等现成工具。我自己算半个设计师,希望可以打造一个具备个人风格的站点,所以页面结构和样式都计划自己手撸,也算是属于自己的小任性吧。
技术选型这块毫不犹豫地选用 Vue3 + Vite,因为使用 Vite 走 dev 构建实在爽快,加上博客面向的受众也是前端开发,一般都使用的新版主流浏览器,不需要操心兼容性的问题(当然,对不达标的浏览器用户依旧要有引导界面)。
对 Vue3 感兴趣的读者可以查阅我的《Vue3源码解析》掘金专栏。
第 4~5 的需求点很好解决 —— 通过 GitLab 的 CI/CD 能力,对提交到 GitLab 仓库的项目进行自动构建,接着自动部署到免费的 Github Page 上。
这么做可以顺便薅到一个好处 —— 单个免费的 Github 仓库是有空间限制的,而我们会把构建前的资源留在 GitLab 仓库,只部署构建后的资源到 Github 仓库,从而减少空间焦虑。
比较头疼的是第 2~3 的需求点,其中第 3 点的「利用宽屏空间,文章支持分栏展示」是我个人倾向 —— 现代大部分显示器都是宽屏,如果页面左右留白过多,技术文章阅读起来是很低效的。我希望可以实现如下的排版:
这样可以将较长的代码块一分为二,减少需要滚动页面查阅代码的问题。
在目前市面上支持 markdown 的 Vite 插件中,vite-plugin-markdown 应该是最主流的一个,但它存在如下问题:
- 不支持
ˋˋˋ
生成的代码块(code block); - 样式定制性差,生成的 block 无法加上自定义类名;
- 无法支持数学表达式(因为计划给博客开一个算法板块,有表达式的书写需求);
- 无法支持分栏展示 blocks(例如左栏展示两个 blocks,右栏展示三个 blocks)。
于是决定自行动手,封装了一个 vite-plugin-markdown-to-component 插件,它的 markdown 转换示例:
[toc]
# t1
## t1-2 {.t2}
p1
# t2
ˋˋˋjs {.c1}
var a = 1;
var b = 2;
ˋˋˋ
^^^ {.wrapper-class}
> wrapped-block-1
wrapped-block-2
^^^
ˋˋˋjs {.c2 data-c=hello}
var c = 1;
var d = 2;
ˋˋˋ
$$KaTeX-formula^2$$
## t2-1
### t2-1-1
复制代码
上述代码可转换为 HTML:
<ul class="toc-container">
<li class="level-1">t1</li>
<li class="level-2">t1-2</li>
<li class="level-1">t2</li>
<li class="level-2">t2-1</li>
<li class="level-3">t2-1-1</li>
</ul>
<h1>t1</h1>
<h2 class="t2">t2</h2>
<p>p1</p>
<h1>t2</h1>
<pre class="c1 language-js"><code class="language-js" v-pre="true"><span class="token keyword">var</span> a <span class="token operator">=</span> <span class="token number">1</span><span class="token punctuation">;</span>
<span class="token keyword">var</span> b <span class="token operator">=</span> <span class="token number">2</span><span class="token punctuation">;</span>
</code></pre>
<div class="wrapper-class">
<blockquote>
<p>wrapped-block-1</p>
</blockquote>
<p>wrapped-block-2</p>
</div>
<pre class="c2 language-js" data-c="hello"><code class="language-js" v-pre="true"><span class="token keyword">var</span> c <span class="token operator">=</span> <span class="token number">1</span><span class="token punctuation">;</span>
<span class="token keyword">var</span> d <span class="token operator">=</span> <span class="token number">2</span><span class="token punctuation">;</span>
</code></pre>
<p><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>K</mi><mi>a</mi><mi>T</mi><mi>e</mi><mi>X</mi><mo>−</mo><mi>f</mi><mi>o</mi><mi>r</mi><mi>m</mi><mi>u</mi><mi>l</mi><msup><mi>a</mi><mn>2</mn></msup></mrow><annotation encoding="application/x-tex">KaTeX-formula^2</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7667em;vertical-align:-0.0833em;"></span><span class="mord mathnormal" style="margin-right:0.07153em;">K</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.13889em;">T</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.07847em;">X</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1.0085em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal" style="margin-right:0.02778em;">or</span><span class="mord mathnormal">m</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord"><span class="mord mathnormal">a</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8141em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span></span></p>
<h2>t2-1</h2>
<h3>t2-1-1</h3>
复制代码
其中的大花括号 {.className1 attrs}
是来自 markdown-it-attrs 的语法,它会将花括号里的自定义属性,附加到 markdown block 转换后对应的 HTML DOM 上。
另外 ^^^
是自创的语法,被其包裹住的多个 blocks,构建后会被一个 div
元素包住,我们只需对最外层的 div
加上一个含 float: left/right
属性的样式名,就能轻松实现分栏的能力。
最后,为了让 code blocks 和数学表达式正常展示,还需给页面加上对应的样式文件(在后文会介绍)。
我的博客最终部署在 devazine.github.io,仅供读者参考。
二. 起步,搭载项目架构
2.1 新建 GitLab 项目
首先在 GitLab 上创建一个私人的空项目,然后点击 Clone
按钮选择在 IDE 中打开:
这样我们便能直接在 IDE 上开发和提交改动了。
请确保项目保持 Private 私有,后续建立 CI/CD 配置文件时会填上 Github 的认证 token,要避免泄漏。
2.2 创建 Vue3 + Vite 项目内容
在项目根目录执行 npm init vue3-blog
,会通过我的脚手架下载博客的基础框架内容。
下载完毕后,执行 npm install
安装依赖,再执行 npm run dev
,便可以通过访问 http://localhost:3001/ 查看博客效果:
为了方便后续读者自行扩展,本项目的结构尽可能简洁,可以说只实现了基础功能和组件。
其结构大致如下:
src
├─assets // 存放静态资源
├─pages // 存放各页面组件、脚本、样式
├─components // 存放公共组件
├─js // 存放公共脚本模块
└─scss // 存放公共样式
index.html // 首页入口文件
vite.config.js // vite 配置文件
package.json
复制代码
我们接下来会介绍其中一些重点。
三. 项目架构介绍
3.1 vite.config.js
根目录下的 vite.config.js
为 Vite 的配置文件,我们在 resolve.alias
中定义了一些路径别名:
/** vite.config.js **/
export default defineConfig(({ mode }) => {
return {
resolve: {
alias: [{
find: '@',
replacement: dirname
}, {
find: '@src',
replacement: path.resolve(dirname, 'src')
},
{
find: '@assets',
replacement: path.resolve(dirname, 'src/assets')
},
{
find: '@pages',
replacement: path.resolve(dirname, 'src/pages')
},
{
find: '@components',
replacement: path.resolve(dirname, 'src/components')
},
{
find: '@stores',
replacement: path.resolve(dirname, 'src/stores')
}
]
},
// 略...
}
})
复制代码
这意味着我们可以通过访问别名的形式来访问对应路径,书写起来更便捷:
// 脚本示例
import { router } from '@src/js/router';
// 样式示例
.banner-wrap{
background: url(@assets/index/banner_bg.jpeg);
}
复制代码
另外通过 css.preprocessorOptions.sass.additionalData
配置,我们把 sass 自定义变量模块(./src/scss/variables.scss
)应用到全局,在其它的 sass 模块中可以直接使用该模块内定义的变量:
/** vite.config.js **/
export default defineConfig(({ mode }) => {
return {
resolve: {
// 略...
},
css: {
preprocessorOptions: {
sass: {
additionalData: '@import "@src/scss/variables.scss"'
}
}
}
}
})
复制代码
最后就是前文提及的 vite-plugin-markdown-to-component 插件的应用了:
/** vite.config.js **/
import mdPlugin from 'vite-plugin-markdown-to-component'
const plugins = [
vue(),
mdPlugin.plugin({}), // 应用该插件
// 略...
];
复制代码
应用后,我们在模块内 import('xxx.md')
时,该插件会将 markdown 内容转换为 Vue 组件,非常方便。
3.2 文章模块
3.2.1 文章模块介绍
在本项目 demo 中,文章以 markdown 的格式存放在 ./src/pages/post/markdowns
下,在路由匹配到对应文章时,异步加载对应的 markdown 模块即可:
/** ./src/pages/post/js/config.js **/
const getMdComponentWithPromise = (promise, callback) => {
return defineAsyncComponent(() =>
promise.then(m => {
callback && callback({
toc: transTocToDom(getTocFromModule(m)) // markdown 模块会返回 toc 信息
});
return m;
})
);
}
const getArticle1 = () => {
return new Promise((resolve) => {
resolve(import('../markdowns/1.md')); // 异步加载文章模块
})
}
const getArticle2 = () => {
return new Promise((resolve) => {
resolve(import('../markdowns/2.md')); // 异步加载文章模块
})
}
export const articleList = [
{ title: '我的第一篇文章', path: '/post/1/' },
{ title: '我的第二篇文章', path: '/post/2/' },
];
const asyncMdComponents = [
(callback) => getMdComponentWithPromise(getArticle1(), callback),
(callback) => getMdComponentWithPromise(getArticle2(), callback),
];
复制代码
模块的使用在 src/pages/post/components/Post.vue
中(下方代码为精简版):
<template>
<component :is="view"></component>
</template>
<script>
import { getAsyncMdComponent, articleList } from '../js/config'
export default {
setup() {
const route = useRoute();
watch(() => route.params,
params => {
// 文章页路由 path 为 /post/:page(\\d*),故可以通过 route.params.page 来获取文章编号
page.value = Number(params.page) || 1;
}, {
immediate: true
});
return {
// 略...
}
},
computed: {
view() {
const page = this.page;
return getAsyncMdComponent(page, (info) => {
console.log(info.toc); // 可以用来做侧边栏 toc
// 略...
})
}
},
}
</script>
复制代码
另外补充下路由配置:
/** src/js/router.js **/
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '@pages/index/components/Home.vue'
import Post from '@pages/post/components/Post.vue'
const routes = [
{ path: '/', component: Home },
{
path: '/post/:page(\\d*)', // page 参数对应文章编号
component: Post,
},
]
// 略...
复制代码
3.2.2 代码块和数学表达式样式
二者分别借助了 Prism.js 和 Katex 的能力,有配套的样式文件来支持展示。
⑴ 当需要在 markdown 中书写代码块时,需要引入 src/scss/prism.scss
样式文件,才能实现代码高亮效果:
<style lang="sass">
@import '@src/scss/prism.scss'
</style>
复制代码
⑵ 当需要在 markdown 中书写数学表达式时,需要引入 src/scss/katex.scss
样式文件,才能展示正确的表达式效果:
<style lang="sass">
@import '@src/scss/katex.scss'
</style>
复制代码
3.3.3 分栏展示
以上图的分栏为例,它对应的 markdown 文件内容如下:
分栏的实现,除了使用 ^^^
语法,还利用了 .fr
和 .w-45
两个 class,它们样式为:
.fr {
float: right;
margin-left: 20px;
}
.w-40 {
width: 40%;
}
复制代码
即利用了 float
和 width
特性将包裹住的 DOM 设定为右侧浮动。
四. 自动化构建部署到你的 github page
从项目的 package.json
可以得知,执行 npm run build
指令会构建我们的项目,它最终会在项目根目录下生成一个 dist
文件夹:
dist
├─assets // 构建后的静态资源(脚本、图片、字体等)
└─index.html // 构建后的页面入口
复制代码
我们只需要将 dist
下的文件部署到 github page 上,就能拥有一个自定义的博客了。
但如同文章开头所说,我们希望利用 GitLab 的 CI/CD 能力,将“构建”和“部署”交给 pipeline 去做,从而解放双手。
4.1 创建 Github Page
在 Github 上创建一个空仓库,点击 uploading an existing file
,随便传一个 index.html
文件上去,此举仅仅是为了让其生成 main
分支:
接着点击 Settings
-Pages
,在右侧 Branch 模块选择 main
分支,再点 Save
按钮。
此时你的 Github Page 便设定完毕,可以通过 帐户名.github.io/仓库名
访问当前的仓库(的 index.html
文件)。
如果仓库名和你的帐户名同名,那么直接通过
帐户名.github.io
访问即可。 更多规则请查阅 Github Pages。
4.2 生成 github token
后续我们要让 GitLab 把项目构建文件部署到 github 仓库,是需要 github 的 token 做为接入许可凭证的,需要到 github.com/settings/to… 页面生成 token。
点击页面的 Generate new token
按钮,进入 token 配置界面后,在 Expiration 模块选择 No expiration
选项(确保 token 不过期),并在 Select scopes 模块选中 repo
项(确保 token 具备仓库操作权限):
然后点击页面底部生成 token 的按钮,并保存你的 token 内容(在下一小节要用到)。
4.3 配置 CI/CD
我们先把第二节获取到的项目代码提交到 GitLab 仓库,然后点击仓库左侧菜单的 CI/CD - Pipelines
,在进入的界面选择 Node.js 镜像的 Use Template
按钮:
GitLab 会生成一个 .gitlab-ci.yml
配置文件到你的项目中,将该配置内容修改为:
image: node:latest
deploy:
script:
- npm install
- npm run build
- git config --global user.name '你的名字'
- git config --global user.email '你的邮箱'
- git clone https://github.com/你的github账户/你新建的github仓库名.git
- rm -rf 你新建的github仓库名/index.html
- rm -rf 你新建的github仓库名/assets
- ls
- cp -R dist/assets 你新建的github仓库名
- cp -R dist/index.html 你新建的github仓库名
- cd 你新建的github仓库名
- git add .
- git commit -m "update"
- git push https://你的github账户:你的[email protected]/你的github账户/你新建的github仓库名.git HEAD:main
cache:
paths:
- node_modules/
复制代码
以我为例,我的 github 账户是 vajoy
,我新建的 github 仓库名为 blog
,则我的配置内容为:
保存配置后,如果流水线 Pipeline 运行成功了,你回到你的 Github 仓库,会发现构建后的脚本已经成功部署在了上面。
如果你的仓库名,和你的 Github 账号名是一致的,那么你现在直接访问你的 Github Page 会发现可以正常展示博客内容。后续你只需要在本地继续扩展你的博客样式和功能、书写 markdown 文章,然后提交 GitLab 即可。
但如果你的仓库名,和你的 Github 账号名是不一样的(例如我),此时你访问 Github Page 会发现页面会报错:
这是因为这种情况下的 Github Page,URL 会多了一个仓库名后缀,例如我的 Github Page 地址是 vajoy.github.io/blog ,页面会多了一个 /blog
后缀,导致 assets 资源文件请求的相对路径异常,自然也就 404 了。
解决方案很简单,修改项目下 package.json
中的 build
脚本,在尾部加上 --base=/仓库名/
参数:
这样构建后的静态资源引用地址会在前头补上仓库名的路径:
此时再提交到 GitLab 仓库,等 CI/CD 完毕后再访问你的 Github Page,一切都变正常了。
五. 来客访问统计
目前市面上存在一些免费的访客统计服务(例如 freevisitorcounters),可以在它们的页面生成接口脚本,再植入到自己的页面上。但常规会存在两个问题:
- 免费的访客统计,一般会通过脚本动态地在你的页面上插入一块广告(天上确实没有免费的馅饼);
- 在
dev
模式下,页面是会不断热加载的,如果开发场景也应用着访客统计,会产生很多污染数据。
这里我们可以利用样式来隐藏掉其动态插入的广告,并借用 vite-plugin-html
插件,仅在生产环境下往页面插入访客统计的接口:
/** vite.config.js **/
import { createHtmlPlugin } from 'vite-plugin-html'
// 访客统计模板
const visitorCountHtml = `<div style="position: absolute;overflow:hidden;height:0;width:0;">
{{这里填写访客统计站点提供的接口脚本}}
</div>`;
const plugins = [
mdPlugin.plugin({}),
splitVendorChunkPlugin(),
viteImagemin(),
];
export default defineConfig(({ mode }) => {
plugins.push(createHtmlPlugin({
minify: true,
inject: {
data: { // 开发模式下为空字符串,不应用访客统计接口
visitorCountersHtml: mode === 'development' ? '' : visitorCountHtml
}
},
}));
return {
plugins,
// 略...
}
})
复制代码
项目根目录下的 html
文件也要同步加上模板渲染的标志位:
<html lang="en">
<!--略-->
<body>
<div id="wrap"></div>
<%- visitorCountersHtml %>
</body>
</html>
复制代码
这样就薅了一个无广告又免费的访客统计了,后续直接去平台后台查阅统计结果即可。
小结
本文虽然提供了脚手架可以快速生成博客项目,但我想分享给大家的更多是思路,例如可以自己封装一个 vite 插件来支持更多的 markdown 能力、利用 GitLab 的 CI/CD 能力等。
如果本文对你有所帮助,帮忙点个赞吧。共勉~