使用vuepress2重构博客

2023-08-08

参考文章 https://juejin.cn/post/7136825713411227679open in new window

几个关键变化

  • 样式表的变化

  • 布局文件的变化

    • 默认布局路径不再是.vuepress/theme/layouts/Layout.vuemarkdown文件frontmatter中指明的layout布局文件必须创建
    • 所有布局文件需要在.vuepress/client.ts注册
    • 404.vue不再是自定义主题默认404时返回的布局文件
  • 配置文件的变化

    • .vuepress/enhanceApp.js重命名为 .vuepress/client.{js,ts} ,且不再支持common.js
  • 语法变化

    • 从vue2到vue3,使用<script setup>语法糖,从定义变量到声明函数的写法都出现的了变化

404布局

由于vuepress v2的404默认布局为NotFound(同vue插件的layouts可查看,两个默认布局为LayoutNotFound)。

编写404的布局文件,在.vuepress/client.ts注册为NotFound即可生效

import { defineClientConfig } from '@vuepress/client'

import NotFound from './theme/layouts/404.vue'


// 原enhanceApp.js。布局文件(md等文件中需要引用的)需要都在此注册,子组件可在父组件中引用,无需在此注册
export default defineClientConfig({
  layouts: {
    NotFound
  },
})

主题配置变量存储的变化

  • v1

    • 在配置文件里配置一些变量(如github链接等信息)

      // vuepress v1
      // .vuepress/config.js
      module.exports = {
          themeConfig: {
              footer: {
                copyright: [
                  {
                    text: 'Released under the MIT License.',
                  },
                  {
                    text: 'Copyright © 2023-present xxx',
                  },
                ],
              },
              connection_link: {
                  github: 'github.com/xxx',
                  gitee: 'gitee.com/xxx',
                  mail: 'xxx@xx.com'
              },
          },
      }
      
    • vue组件中通过 $themeConfig 直接访问 themeConfig,从而进行渲染。

      <template>
          <footer class="page_footer">
              <div class="footer_middle card_border">
                	<!-- 通过$themeConfig读取配置文件中存储的变量 -->
                  <span v-for="item in $themeConfig.footer.copyright" :key="item.text">{{ item.text }} <br></span>
              </div>
          </footer>
      </template>
      
      <style scoped lang="stylus">
          @import '../styles/footer'
      </style>
      
      
  • 在v2中

    • 根据官方文档open in new window$themeConfig 已经从用户配置和站点数据中移除。现在需要使用 @vuepress/plugin-theme-dataopen in new window 插件进行配置

      // vuepress v2
      // .vuepress/config.ts
      import { themeDataPlugin } from '@vuepress/plugin-theme-data'
      
      
      export default {
          plugins: [
            themeDataPlugin({
              themeData: {
                  footer: {
                      copyright: [
                      {
                          text: 'Released under the MIT License.',
                      },
                      {
                          text: 'Copyright © 2023-present Leopold',
                      },
                      ],
                  },
                  connection_link: {
                      github: 'github.com/SayNop',
                      gitee: 'gitee.com/WhenTimeGoesBy',
                      mail: 'fur999immer@gmail.com'
                  },
              }
          }),
        ],
      }
      
    • 在vue组件中通过插件配合computed进行读取

      <template>
          <footer class="page_footer">
              <div class="footer_middle card_border">
                  <span v-for="item in footerData.copyright" :key="item.text">{{ item.text }} <br></span>
              </div>
          </footer>
      </template>
      
      <style scoped lang="stylus">
          // scoped: 该组件才能使用的样式
          @import '../styles/footer'
      </style>
      
      <script lang="ts">
      import { useThemeData } from '@vuepress/plugin-theme-data/client'
      import type { ThemeData } from '@vuepress/plugin-theme-data/client'
      
      
      export default({
          computed: {
            	// 通过computed返回配置文件中配置的变量
              footerData() {
                  const themeData = useThemeData<ThemeData>()
                  return themeData.value.footer
              }
          }
      })
      </script>
      
      

框架的搭建

环境的安装

# 初始化
yarn init
# 安装vuepress2
yarn add vuepress@next

Vue2迁移Vue3

vue3常使用<script setup>语法糖进行编写

  • 以header组件为例说明常用写法的不同

    • vue2
    export default {
            // 声明当前组件名
        name: 'header_wrapper',
        
            // 组件变量
            data() {
            return {
                is_dark: false,
                show_slide: false,
            }
        }, 
    
        // 声明组件方法
        methods: {
            handleDark(){
                this.is_dark = !this.is_dark
                if(this.is_dark){
                    document.documentElement.className = 'dark'
                    localStorage.setItem('theme', 'dark')
    
                } else {
                    document.documentElement.className = ''
                    localStorage.setItem('theme', 'light')
                }
            },
            show_Slide(){
                // 触发事件
                this.$emit('slide_switch')
            }
        },
        
        // 初始化执行函数
        mounted() {
            if( localStorage.getItem('theme') ) {
                if( localStorage.getItem('theme') == 'dark' ) {
                    this.is_dark=true
                    document.documentElement.className = 'dark'
                } else {
                    this.is_dark=false
                    document.documentElement.className = ''
                }
            } else {
                localStorage.setItem('theme', 'light')
                this.is_dark = false
            }
            // window.addEventListener('scroll', this.handleScroll, true)
            // if(window.screen.availWidth > 767) document.body.addEventListener('touchstart',function(){})
            if(document.body.clientWidth > 767) document.body.addEventListener('touchstart',function(){})
        }
    }
    
    • vue3
    import {getCurrentInstance, ref, onMounted} from 'vue'
    
    // 组件导入后无需声明
    import icon_sun from './icons/sun.vue'
    import icon_moon from './icons/moon.vue'
    
    // 定义简单类型变量
    const is_dark = ref(false)
    const show_slide = ref(false)
    
    // 定义方法
    const handleDark = () => {
        is_dark.value = !is_dark.value
        if( is_dark.value ){
            document.documentElement.className = 'dark'
            localStorage.setItem('theme', 'dark')
    
        } else {
            document.documentElement.className = ''
            localStorage.setItem('theme', 'light')
        }
    }
    
    const instance = getCurrentInstance();
    const emit = instance.emit;
    
    const show_Slide = () => {
        // 触发事件
        emit('slide_switch')
    }
    
    // 初始化
    onMounted(() => {
        if( localStorage.getItem('theme') ) {
            if( localStorage.getItem('theme') == 'dark' ) {
                is_dark.value = true
                setTimeout(() => {
                    document.documentElement.className = 'dark'
                }, 50);
                // 加载顺序与vue2不同,通过延时使其正常赋值
                // document.documentElement.className = 'dark'
            } else {
                is_dark.value = false
                document.documentElement.className = ''
            }
        } else {
            localStorage.setItem('theme', 'light')
            is_dark = false
        }
        // window.addEventListener('scroll', this.handleScroll, true)
        // if(window.screen.availWidth > 767) document.body.addEventListener('touchstart',function(){})
        if(document.body.clientWidth > 767) document.body.addEventListener('touchstart',function(){})
    })
    
  • 利用props父组件向子组件传值

    • vue2
    export default {
        props: [
            'datas'  // 组件中使用datas直接获取到传入值
        ],
    }
    
    • vue3
    const props = defineProps({
        datas: String  // 声明props变量类型
    })
    

计算当前所处章节

通过获取当前视窗内的标题与标题列表比较,将标题一致的设置为active状态

其中,通过遍历所有标题并使用dom.getBoundingClientRect()来判断标题是否在视窗内

const titles = document.getElementsByClassName('header-anchor')

const links = document.getElementsByClassName('sidebar-link')

const scroll_acitve = () => {
    let viewPortHeight = window.innerHeight || documentElement.clientHeight
    for (let i = 0; i < titles.length; i++) {
        let { 
            top,
            left, 
            bottom, 
            right
        } = titles[i].getBoundingClientRect()
        if (top >=100 && bottom <= viewPortHeight) {
            target.value = titles[i].href
            break
        }
    }

    if (target.value) {
        for (var link of links) {
            if (link.href == target.value)
                link.classList.add('active')
            else
                link.classList.remove('active')
        }
    }
}

插槽的使用

由于多个页面的布局类似,只有内容部分不一致。可将共用部分写为插槽文件,其余布局只需编写内容部分,共用部分通过插槽文件进行填充。

<!-- Base.vue -->
<template>
    <div>
        <header_wrapper :style="{opacity: header_opacity}" @slide_switch="showSlide" />
        <home_bg />
        <div class="main">
            <div style="display: flex;">
                <div class="sider_keeper" :class="is_mobile ? (show_sidebar ? 'show_info' : 'hidden_info') : ''">
                    <sidebar />
                </div>
                <div class="content_container">
                    <slot></slot>
                    <footer_wrapper />
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import header_wrapper from '../components/header.vue'
import home_bg from '../components/home_bg.vue'
import sidebar from '../components/sidebar.vue'
import footer_wrapper from '../components/footer.vue'

import { onMounted, ref } from 'vue'

const header_opacity = ref(0)
const is_mobile = ref(false)
const show_sidebar = ref(false)


const handleScroll = () => {
    const scrollTop = window.pageYOffset
        || document.documentElement.scrollTop
        || document.body.scrollTop
    header_opacity.value = scrollTop / 100
}

const showSlide = () => {
    show_sidebar.value = !show_sidebar.value
}

onMounted(() => {
    if(document.body.clientWidth > 767) {
            // 滚动触发头部与文章页导航
        window.addEventListener('scroll', handleScroll)
        document.body.addEventListener('touchstart',function(){})
        is_mobile.value = false
    } else {
        is_mobile.value = true
    }
})
</script>

其余布局引用插槽文件对共用部分进行填充。即引用插槽组件时,该组件内的内容会填充到引用处,而引用处包裹的内容会用来填充<slot>

如下为分类详情页,返回指定分类下的全部笔记

<template>
    <Base>
        <div class="card_border category_tag_key">
            <span class="icon category_tag_icon"><category_icon /></span>
            <span class="title_font">{{ $frontmatter.current }}</span>
        </div>
        <articles :articles="categoryMap.currentItems" />
    </Base>
</template>

<script setup>
import Base from './Base.vue'  // 注意 插槽组件需要大写 
import category_icon from '../components/icons/category.vue'
import articles from '../components/articles.vue'

import { useBlogCategory } from "vuepress-plugin-blog2/client"

const categoryMap = useBlogCategory("category")
</script>

多组件访问的公共变量进行处理

对于需要多组件(超过2个)访问或控制的变量,可通过vuexopen in new windowpiniaopen in new window等包进行数据仓库搭建,声明公共变量与变量修改函数。避免组件间反复传值增加复杂性。这里以pinia为例说明如下两种情况。

  • 例1 移动端菜单栏是否显示的标识位变量

    • 原先的控制方法:移动端的sidebar通过header中的按钮进行展开操作。需要在header中emit一个事件到布局文件中,布局文件中声明标识位变量,将标识位变量传入sidebar组件中。
      问题: 在原本已涉及三个组件的情况下,如果需要在点击sidebar中的内容后自动收起sidebar,还需要在点击后修改布局文件中的标识位变量。
    <!-- header组件中,触发事件告知布局组件将切换状态 -->
    <button class="switch mobile_list_btn" type="button" @click="$emit('slide_switch')" />
    
    
    <!-- 布局文件中 -->
    <template>
        <!-- 根据事件修改状态 -->
        <header_wrapper :style="{opacity: header_opacity}" @slide_switch="show_sidebar.value = !show_sidebar.value" />
        <!-- 传值给子组件 -->
        <sidebar :show_sidebar=show_sidebar />
    </template>
    
    <script setup>
    import { ref } from 'vue'
    // 声明标识位变量
    const show_sidebar = ref(false)
    </script>
    
    
    <!-- sidebar 目标组件 -->
    <template>
        <!-- 根据标识位变量的状态信息进行控制 -->
        <div class="sider_keeper" :class="is_mobile ? (show_sidebar ? 'show_info' : 'hidden_info') : ''">
        </div>
    </template>
    
    <script setup>
    // 接受父组件传来的状态信息
    const props = defineProps({
        show_sidebar: Boolean
    })
    </script>
    
    • 使用数据仓库
      • 在数据仓库中存储标识位变量与修改状态函数
      import { ref } from 'vue'
      import { defineStore } from 'pinia'
      
      
      export const useStatusStore = defineStore('status', () => {
          // 声明sidebar状态标识位
          const show_sidebar = ref(false)
      
          // 修改sidebar状态函数
          const change_sidebar = () => {
              show_sidebar.value = !show_sidebar.value
          }
          return { show_sidebar, change_sidebar }
      })
      
      • 在修改时header等组件直接调用修改函数,目标组件只需读取变量即可。无需通过布局组件作为中间人接收与下发当前sidebar状态
      <!-- header -->
      <template>
          <!-- 修改方法的调用 -->
          <button class="switch mobile_list_btn" type="button" @click="store.change_sidebar()"></button>
      </template>
      
      <script setup> 
      import { useStatusStore } from '../utils/store'
      // 引入数据仓库中的修改方法
      const store = useStatusStore()
      </script>
      
      <!-- sidebar 目标组件 -->
      <template>
          <!-- 根据标识位变量的状态信息进行控制 -->
          <div class="sider_keeper" :class="is_mobile ? (show_sidebar ? 'show_info' : 'hidden_info') : ''">
          </div>
      </template>
      
      <script setup>
      import { useStatusStore } from '../utils/store'
      import { storeToRefs } from 'pinia'
      // 读取数据仓库中的状态标识位
      const store = useStatusStore()
      const { show_sidebar } = storeToRefs(store)
      </script>
      
      <!-- navbar 原先功能上的额外扩展 -->
      <template>
          <section class="article_sidebar">
              <ul>
                  <li class="level2" v-for="item2 in pageData.headers" :key="item2.slug">
                      <!-- 完成导航后自动关闭sidebar -->
                      <a class="sidebar-link" :href="'#'+item2.slug" @click="show_sidebar=false">{{ item2.title }}</a>
                  </li>
              </ul>
          </section>
      </template>
      
      <script setup> 
      import { useStatusStore } from '../utils/store'
      import { storeToRefs } from 'pinia'
      // 获取标识位
      const store = useStatusStore()
      const { show_sidebar } = storeToRefs(store)
      </script>
      
  • 例2 评论组件的主题控制:评论组件是否启用dark mode通过向组件中传入darkmode值进行控制。

    • 原先的实现方式:由于控制按钮在header组件中,跟评论组件间隔较多。读取localStorage是最方便的途径。
      问题:在页面点击按钮进行darkmode控制时,评论组件会保持页面打开时的主题状态,无法跟随按钮切换,刷新页面后,darkmode切换才会生效。
    <template>
        <div>
            <CommentService :darkmode="is_dark" />
        </div>
    </template>
    
    <script setup>
    const is_dark = ref(false)
    onMounted(() => {
        document.documentElement.setAttribute('style', 'overflow-y: scroll;scroll-behavior: smooth;')
        // 评论组件主题
        is_dark.value = window.localStorage.getItem('theme') == 'dark' ? true : false
        
        // 以下两种监听localStorage中dark mode的变化不能生效
        window.addEventListener("storage", () => {
            is_dark.value = window.localStorage.getItem('theme') == 'dark' ? true : false
        })
        window.onstorage = () => {
            is_dark.value = window.localStorage.getItem('theme') == 'dark' ? true : false
        }
    })
    </script>
    
    • 使用数据仓库
      • 在数据仓库中存储标识位变量与修改状态函数
      import { ref } from 'vue'
      import { defineStore } from 'pinia'
      
      
      export const useStatusStore = defineStore('status', () => {
          const comment_dark = ref(false)
      
          const change_comment_theme = () => {
              comment_dark.value = !comment_dark.value
          }
      
          return { comment_dark, change_comment_theme }
      })
      
      
      • 在修改时header组件直接调用修改函数,正文处直接获取数据仓库中的状态值
      <!-- header -->
      <template>
          <!-- 修改方法的调用 -->
          <button class="switch mobile_list_btn" type="button" @click="store.change_comment_theme()"></button>
      </template>
      
      <script setup> 
      import { useStatusStore } from '../utils/store'
      // 引入数据仓库中的修改方法
      const store = useStatusStore()
      // 根据localStorage中的状态赋予评论状态的初始值
      onMounted(() => {
          if( localStorage.getItem('theme') ) {
              if( localStorage.getItem('theme') == 'dark' ) {
                  store.comment_dark = true
              } else {
                  is_dark.value = false
                  store.comment_dark = false
              }
          } else {
              localStorage.setItem('theme', 'light')
              store.comment_dark = false
          }
      })
      </script>
      
      
      <!-- 详情页 目标位置 -->
      <template>
          <!-- 根据评论状态对组件进行控制 -->
          <CommentService :darkmode="comment_dark" />
      </template>
      
      <script setup>
      import { useStatusStore } from '../utils/store'
      import { storeToRefs } from 'pinia'
      const store = useStatusStore()
      // 获取评论状态
      const { comment_dark } = storeToRefs(store)
      </script>
      

Markdown代码块渲染方法的修改

在一些情况下,可能需要给md文档中代码块使用不同的样式。可通过增加代码块元素的类并编写对应的样式表来实现。

Vuepress1

在旧版本中,可直接在代码块语言类型处增加类名。

    ```js macos

md渲染器markdown-it会直接将代码块中的语言类型字符串拼接language-放入divclass中,得到下面的渲染结果

<div class="language-js macos">
    <pre class="language-js">
        <code>
            Code here...
        </code>
    </pre>
</div>

Vuepress2

新版本的markdown-it使用此方法增加代码块的class无法生效。
查询源码可知,解析器读取到的代码块会先将语言类型放入语言类型的hash表中进行查询,获取当前代码块的类型。即后面添加的自定义类会在代码块渲染器开始渲染前就过滤掉
因此需要在配置文件config.ts中,通过插件APIextendsMarkdownOptions,修改代码块渲染器的逻辑:

  • 在调用渲染器前,获取语言类型
  • 若语言类型包含不止一个,则包含自定义样式类
  • 调用默认渲染器得到渲染后的元素,在渲染后的元素中增加自定义样式类
extendsMarkdown: (md) => {
    // 获取代码块渲染器
    const origin_code_render = md.renderer.rules.fence
    // 自定义代码块渲染器 - 参数为固定写法
    const update_markdown_theme = (code_render) => (tokens, idx, options, env, self) => {
        const token = tokens[idx]
        // 读取代码块的语言类型
        const info = token.info ? md.utils.unescapeAll(token.info).trim() : ""
        // 查询语言类型中是否包含代码块的自定义样式类
        const theme = info && info.split(' ').length > 1 ? info.split(' ')[1] : null
        // 调用原始代码块渲染器
        const res = code_render(tokens, idx, options, env, self)
        if (theme) {
            // 若需要包含自定义样式类,则在返回元素的class中添加
            const new_res = '<div class="macos ' + res.slice(12,)
            return new_res
        }
        return res
    }
    md.renderer.rules.fence = update_markdown_theme(origin_code_render)
}

自定义简易分页器

定义一个只有上一页与下一页两个按钮的简易分页器。内容则通过对列表进行切片来实现。
其中分页器组件需要实时接收当前所在页码,因此传入的page参数需实时变化。其关键在于父组件如何将当前的页码实时传递给分页器组件。下面介绍两种实现方法

通过监听路由的变化判断

  • 分页器组件
    通过动态调整a标签内容来控制按钮,a标签为空的时候则无法点击与显示。
    若使用样式表的display: none控制,会因为展示元素个数的变动而影响按钮的布局
    使用&nbsp;使字体不贴边。若使用padding: 0 1rem,即使a标签内容为空翻页按钮仍可点击
<template>
    <div>
        <!-- 跳回第一页时不添加query -->
        <router-link :to="link + (page < 3 ? '' : `?page=${page-1}`)">
            {{ page > 1 ? '&nbsp;&nbsp;&nbsp;Previois' : "" }}
        </router-link>
        <router-link :to="link + `?page=${page+1}`">
            <!-- 下取整判断当前是否是最后一页 -->
            {{ page < Math.ceil(total/5) ? 'Next&nbsp;&nbsp;&nbsp;' : "" }}
        </router-link>
    </div>
</template>

<script setup>
    const props = defineProps({
        page: Number,
        total: Number,
        link: String
    })
</script>
  • 列表切片获取当前页面的内容
export const usePageContent = (list: any[], page: number) => {
    // 列表中的item为复合类型,使用any
    return list.slice((page-1)*5, page*5)
}
  • 列表页面调用分页器:监听路由变化
    由于页码与展示内容是实时变化的,需使用ref进行声明
<template>
    <Base>
        <articles :articles="article_list" />
        <pagination :page="page" :total="timelines.items.length" :link="route.path" />
    </Base>
</template>

<script setup>
import Base from './Base.vue'
import articles from '../components/articles.vue'
import pagination from '../components/pagination.vue'

import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBlogType } from "vuepress-plugin-blog2/client"

import { usePageContent } from '../utils/pagination'

// 获取全部文章
const timelines = useBlogType("timeline")

const route = useRoute()
// 获取当前页码
const page = ref(route.query.page ? Number(route.query.page) : 1)

// 切片获取当前需要展示内容
const article_list = ref(usePageContent(timelines.value.items, page.value))

// 监听是否由于分页器导致页码变化
watch(() => route.query, (current_query) => {
    // 加入路径校验,防止跳转到别的路径触发此逻辑
    if (/^\/$/.test(route.path)) {
        // 实时更新最新页码,分页器才能获取当前页码
        page.value = current_query.page ? Number(current_query.page) : 1
        // 根据页码实时需要展示内容
        article_list.value = usePageContent(timelines.value.items, page.value)
    }
})
</script>

通过分页器emit事件

当分页器的按钮被点击时触发事件,父组件接收到事件后改变当前页面的值即可实现分页器收到的页面值。不同的部分只有分页器与父组件

  • 分页器组件:只需给点击的元素额外增加emit事件,其余保持一致
<template>
    <div>
        <!-- 跳回第一页时不添加query -->
        <router-link :to="link + (page < 3 ? '' : `?page=${page-1}`)" @click="$emit('change_page')">
            {{ page > 1 ? '&nbsp;&nbsp;&nbsp;Previois' : "" }}
        </router-link>
        <router-link :to="link + `?page=${page+1}`" @click="$emit('change_page')">
            <!-- 下取整判断当前是否是最后一页 -->
            {{ page < Math.ceil(total/5) ? 'Next&nbsp;&nbsp;&nbsp;' : "" }}
        </router-link>
    </div>
</template>

<script setup>
    const props = defineProps({
        page: Number,
        total: Number,
        link: String
    })
</script>
  • 列表页面调用分页器:监听翻页事件
    其余部分相同,只需将监听路由变化的watch改为监听翻页事件
<template>
    <Base>
        <articles :articles="article_list" />
        <!-- 传入值时需要监听翻页事件 -->
        <pagination :page="page" :total="timelines.items.length" :link="route.path" @change_page="reload_page" />
    </Base>
</template>

<script setup>
import Base from './Base.vue'
import articles from '../components/articles.vue'
import pagination from '../components/pagination.vue'

import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useBlogType } from "vuepress-plugin-blog2/client"

import { usePageContent } from '../utils/pagination'

const timelines = useBlogType("timeline")

const route = useRoute()
const page = ref(route.query.page ? Number(route.query.page) : 1)

const article_list = ref(usePageContent(timelines.value.items, page.value))

// 其余部分一样,只需将修改页码的代码改为事件触发
const reload_page = () => {
    // emit比a标签修改query的触发要快,从路由中读取query需要增加延时
    setTimeout(() => {
        if (/^\/$/.test(route.path)) {
            page.value = route.query.page ? Number(route.query.page) : 1
            article_list.value = usePageContent(timelines.value.items, page.value)
        }
    }, 50)
}
</script>

注意: 由于分页器中a标签跳转引起路由变化的速度比点击后emit事件的速度要慢,在监听到分页器被点击事件后,需要增加延时,等待路由变化后再读取路由中包含的变动后的页码信息。不然将读取到变动前的页码信息,即翻页操作不生效。