Vue3+markdown+CI/CD,撸一个免费的 github 个人博客

一. 需求和方案

前段时间想给自己搭建一个技术博客,并希望它满足如下几点:

  1. 高定制性,样式全部自己写;
  2. 支持 markdown 格式来书写文章;
  3. 利用宽屏空间,文章支持分栏展示;
  4. 支持 CI/CD,提交了直接构建和部署;
  5. 免费。

基于第 1 点需求,就绕过了已有的 Hexo 等现成工具。我自己算半个设计师,希望可以打造一个具备个人风格的站点,所以页面结构和样式都计划自己手撸,也算是属于自己的小任性吧。

技术选型这块毫不犹豫地选用 Vue3 + Vite,因为使用 Vite 走 dev 构建实在爽快,加上博客面向的受众也是前端开发,一般都使用的新版主流浏览器,不需要操心兼容性的问题(当然,对不达标的浏览器用户依旧要有引导界面)。

对 Vue3 感兴趣的读者可以查阅我的《Vue3源码解析》掘金专栏

第 4~5 的需求点很好解决 —— 通过 GitLab 的 CI/CD 能力,对提交到 GitLab 仓库的项目进行自动构建,接着自动部署到免费的 Github Page 上。
这么做可以顺便薅到一个好处 —— 单个免费的 Github 仓库是有空间限制的,而我们会把构建前的资源留在 GitLab 仓库,只部署构建后的资源到 Github 仓库,从而减少空间焦虑。

比较头疼的是第 2~3 的需求点,其中第 3 点的「利用宽屏空间,文章支持分栏展示」是我个人倾向 —— 现代大部分显示器都是宽屏,如果页面左右留白过多,技术文章阅读起来是很低效的。我希望可以实现如下的排版:

QQ截图20220809043228.png

这样可以将较长的代码块一分为二,减少需要滚动页面查阅代码的问题。

在目前市面上支持 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 中打开:

image.png

这样我们便能直接在 IDE 上开发和提交改动了。

请确保项目保持 Private 私有,后续建立 CI/CD 配置文件时会填上 Github 的认证 token,要避免泄漏。

2.2 创建 Vue3 + Vite 项目内容

在项目根目录执行 npm init vue3-blog,会通过我的脚手架下载博客的基础框架内容。

下载完毕后,执行 npm install 安装依赖,再执行 npm run dev,便可以通过访问 http://localhost:3001/ 查看博客效果:

GIF3.gif

为了方便后续读者自行扩展,本项目的结构尽可能简洁,可以说只实现了基础功能和组件。
其结构大致如下:

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 分栏展示

image.png

以上图的分栏为例,它对应的 markdown 文件内容如下:

分栏的实现,除了使用 ^^^ 语法,还利用了 .fr.w-45 两个 class,它们样式为:

  .fr {
    float: right;
    margin-left: 20px;
  }
  
  .w-40 {
      width: 40%;
  }
复制代码

即利用了 floatwidth 特性将包裹住的 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 分支:

image.png

接着点击 Settings-Pages,在右侧 Branch 模块选择 main 分支,再点 Save 按钮。

image.png

此时你的 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 具备仓库操作权限):

image.png

然后点击页面底部生成 token 的按钮,并保存你的 token 内容(在下一小节要用到)。

4.3 配置 CI/CD

我们先把第二节获取到的项目代码提交到 GitLab 仓库,然后点击仓库左侧菜单的 CI/CD - Pipelines,在进入的界面选择 Node.js 镜像的 Use Template 按钮:

image.png

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,则我的配置内容为:

image.png

保存配置后,如果流水线 Pipeline 运行成功了,你回到你的 Github 仓库,会发现构建后的脚本已经成功部署在了上面。

如果你的仓库名,和你的 Github 账号名是一致的,那么你现在直接访问你的 Github Page 会发现可以正常展示博客内容。后续你只需要在本地继续扩展你的博客样式和功能、书写 markdown 文章,然后提交 GitLab 即可。

但如果你的仓库名,和你的 Github 账号名是不一样的(例如我),此时你访问 Github Page 会发现页面会报错:

image.png

这是因为这种情况下的 Github Page,URL 会多了一个仓库名后缀,例如我的 Github Page 地址是 vajoy.github.io/blog ,页面会多了一个 /blog 后缀,导致 assets 资源文件请求的相对路径异常,自然也就 404 了。

解决方案很简单,修改项目下 package.json 中的 build 脚本,在尾部加上 --base=/仓库名/ 参数:

image.png

这样构建后的静态资源引用地址会在前头补上仓库名的路径:

image.png

此时再提交到 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 能力等。

如果本文对你有所帮助,帮忙点个赞吧。共勉~

猜你喜欢

转载自juejin.im/post/7130132628694368264