Skip to content
This repository has been archived by the owner on Sep 22, 2022. It is now read-only.

开发日志

Ben edited this page Jul 2, 2021 · 1 revision

以下部分是我基于 VuePress 的默认主题进行二次开发的踩坑笔记。

⚠️ 使用 VuePress 的版本是 v2.0.0-beta.20,该版本仍处于不稳定的 Beta 状态(后期可能会出现不兼容的版本更新),因此如果你需要将它应用到日常正式的开发中需要谨慎考量。

项目结构

project-structure

注意要点

  • 插件或者主题的入口文件会在 Node App 中被加载,因此它们需要使用 CommonJS 格式。

  • 客户端文件会在 Client App 中被加载,它们最好使用 ESM 格式。

  • VuePress 会在构建过程中生成一个 SSR 应用,用以对页面进行预渲染。

  • 如果一个组件在 setup() 中直接使用浏览器 / DOM API ,它会导致构建过程报错,因为这些 API 在 Node.js 的环境中是无法使用的。在这种情况下,你可以选择一种方式:

    • 将需要访问浏览器 / DOM API 的代码部分写在 onBeforeMount()onMounted() Hook 中。

    • 使用 <ClientOnly> 包裹这个组件。

    • 如果在组件中导入的模块会立即执行访问 DOM API 操作,使用 <ClientOnly> 包裹这个组件也可能会在构建过程报错,可以使用动态导入的方式引入模块。

      export default {
        setup(props) {
          onMounted(async () => {
            // dynamic import masonry
            const module = await import("masonry-layout");
            const Masonry = module.default;
            // do something
          })
        }
      }
  • 布局组件 Layout 应该包含 Content 组件来展示 Markdown 内容

tailwindcss 整合

项目的打包工具使用 Vite

  • 按照 Tailwind CSS 官方文档安装依赖并生成配置文件

    npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
    npx tailwindcss init -p

    💡 执行完以上命令后,会在项目的根文件夹下生成配置文件 tailwind.config.js ,可以配置其中的属性 purge,以在生产环境下优化 Tailwind CSS 文件大小。但是由于项目中存在通过拼接字符串构成的类名,如果执行优化会出现样式丢失的情况,因此没有配置 purge 属性。

  • 在文件 .vuepress/config.js 中为打包工具配置 postcss 参数

    // ...
    module.exports = {
      // ...
      bundlerConfig: {
        // vite 打包工具的选项
        viteOptions: {
          css: {
            postcss: {
                plugins: [
                  require('tailwindcss'),
                  require('autoprefixer')
                ]
            }
          },
        }
      },
      // ...
    }
  • 在文件 .vuepress/styles/index.scss 的开头中引入 Tailwind

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    ⚠️ 由于引入了 @tailwind base 可能会会造成默认主题样式的重置,可以手动添加相应的 CSS 样式修正。

markdown-it 插件

VuePress 使用 markdown-it 来解析 Markdown 内容,支持通过安装插件来实现语法扩展,还可以对插件进行参数配置。

安装插件的方法参考:

markdown-it-katex

添加 @neilsustc/markdown-it-katex 插件为 Markdown 添加数学公式的支持。

  • 安装依赖 npm i @neilsustc/markdown-it-katex

    💡 markdown-it-katex 这个包在 npm 里下载量最多,但是它依赖的 katex 版本很低,已经没有维护了,所以换成 @neilsustc/markdown-it-katex

  • 在文件 .vuepress/config.js 中配置 markdown-it 插件

    // ...
    module.exports = {
      // ...
      extendsMarkdown: (md) => {
        md.use(require('@neilsustc/markdown-it-katex'), {output: 'html'})
      },
      // ...
    }

    ⚠️ 根据 KaTex 官方文档可以通过属性 output 来设置数学公式的渲染模式,应该设置为 html,因为默认值 ouput: htmlAndMathml 会将一些 mathml 格式的非标准标签插入到 HTML,导致构建过程报错

  • 除了安装插件,还需要引入 katex 的样式表,在文件 .vuepress/config.js 中配置 head 参数

    // ...
    module.exports = {
      // ...
      head: [
        ['link', { rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.13.5/dist/katex.min.css' }],
      ],
      // ...
    }

插件开发

借助 VuePress 提供的插件 API 可以为网站新增页面(不依赖 Markdown 文件),还可以为指定的页面提供额外的数据。

💡 VuePress 插件是一个符合插件 API 的 JS 对象或返回值为 JS 对象的函数,具体参考官方文档开发插件一章。

VuePress 提供的插件 API 有多种 Hooks,它们的执行顺序和时间点都不同,可以参考官方文档的核心流程与 Hooks 一节,代码需要在合适 Hooks 下执行才不会报错。

vuepress-core-process

添加时间

参考官方插件 Git代码,通过插件 .vuepress/plugins/addTime.js 为 Markdown 文件添加时间修改的信息。

使用 extendsPageOptions Hook 将 Markdown 文件的创建时间 createdTime 和更新时间 updatedTime 作为 Frontmatter 字段添加到相应文件中。

/**
 * refer to @vuepress/plugin-git: https://www.npmjs.com/package/@vuepress/plugin-git
 */
 const execa = require('execa')

 /**
  * Check if the git repo is valid
  */
 const checkGitRepo = (cwd) => {
   try {
     execa.commandSync('git log', { cwd })
     return true
   } catch {
     return false
   }
 }
 
 const getUpdatedTime = async (filePath, cwd) => {
   const { stdout } = await execa(
     'git',
     ['--no-pager', 'log', '-1', '--format=%at', filePath],
     {
       cwd,
     }
   )
 
   return Number.parseInt(stdout, 10) * 1000
 }
 
 const getCreatedTime = async (filePath, cwd) => {
   const { stdout } = await execa(
     'git',
     ['--no-pager', 'log', '--diff-filter=A', '--format=%at', filePath],
     {
       cwd,
     }
   )
 
   return Number.parseInt(stdout, 10) * 1000
 }
 
 const addTime = {
   name: 'vuepress-plugin-addTime',
   async extendsPageOptions(options, app) {
     if (options.filePath) {
       filePath = options.filePath;
       const cwd = app.dir.source()
       const isGitRepoValid = checkGitRepo(cwd)
 
       let createdTime = null;
       let updatedTime = null;
 
       if (isGitRepoValid) {
         createdTime = await getCreatedTime(filePath, cwd)
         updatedTime = await getUpdatedTime(filePath, cwd)
       }
 
       return {
         frontmatter: {
           createdTime,
           updatedTime
         },
       }
     } else {
       return {}
     }
 
   }
 }
 
 module.exports = addTime

创建页面

参考官方文档添加额外页面一章,通过插件 .vuepress/plugins/createHomePage.js 为网站添加主页,通过插件 .vuepress/plugins/generateFolderPages.js.vuepress/plugins/generateListPages.js 创建一些导航页。

主要使用 createPage 方法异步创建页面,由于导航页需要基于所有 Markdown 文件的数据,代码所以需要在 onInitialized Hook 下执行,此时页面已经加载完毕。

// 创建首页
const { createPage } = require('@vuepress/core')

const createHomePage = (options, app) => {
  return {
    name: 'vuepress-plugin-createHomePage',
    async onInitialized(app) {
      // if homepage doesn't exist
      if (app.pages.every((page) => page.path !== '/')) {
        // async create a homepage 
        const homepage = await createPage(app, {
          path: '/',
          // set frontmatter
          frontmatter: {
            layout: 'HomeLayout',
            cards: options.cards || []
          },
        })

        // push the homepage to app.pages
        app.pages.push(homepage)
      }
    },
  }
}

module.exports = createHomePage

继承主题

参考官方文档继承一个主题一章。

由于我继承的主题并没有发布到 NPM 上而是作为本地主题,因此在文件 .vuepress/config.js 中配置 theme 参数时通过绝对路径来使用它

// ...
module.exports = {
  // ...
  theme: path.resolve(__dirname, './theme/index.js'),
  // ...
}

添加布局组件

有两种方式新增布局,然后就可以直接在 Markdown 文件的顶部 Frontmatter 字段 layout 中使用它们:

  • 方法一:如果布局组件放置在主题的 .vuepress/theme/layouts/ 目录下,需要在主题的入口文件 .vuepress/theme/index.js 中通过配置属性 layouts 来显式指定

    const { path } = require('@vuepress/utils')
    
    module.exports = {
      name: 'vuepress-theme-two-dish-cat-fish',
      extends: '@vuepress/theme-default',
      // registe 4 layouts
      layouts: {
        HomeLayout: path.resolve(__dirname, 'layouts/HomeLayout.vue'),
        ClassificationLayout: path.resolve(__dirname, 'layouts/ClassificationLayout.vue'),
        FolderLayout: path.resolve(__dirname, 'layouts/FolderLayout.vue'),
        Layout: path.resolve(__dirname, 'layouts/Layout.vue'),
      },
    }
  • 方法二:如果使用默认主题(不进行继承拓展),可以通过插件 API 的 clientAppEnhanceFiles Hook注册自定义的布局组件

    • 创建 .vuepress/clientAppEnhance.js 文件

    • 在文件中使用 clientAppEnhanceFile 方法注册组件

      import { defineClientAppEnhance } from '@vuepress/client'
      import CustomLayout from './CustomLayout.vue'
      
      export default defineClientAppEnhance(({ app }) => {
        app.component('CustomLayout', CustomLayout)
      })

💡 如果布局是基于默认主题的布局组件 Layout 进行二次开发,可以使用该组件提供的的插槽

  • navbar-before
  • navbar-after
  • sidebar-top
  • sidebar-bottom
  • page-top
  • page-bottom

树图导航

基于 Markdown 文件在存储系统的位置,使用 D3.js 构建树形图来可视化文件夹的嵌套层级关系。

  • 安装 D3.js 依赖 npm install d3@6.5.0

  • 在插件 .vuepress/plugins/generateFolderPages.js 中通过遍历所有 Markdown 文件(生成的页面),使用 extendsPageData Hook 为笔记导航页添加额外的数据。

    const { createPage } = require('@vuepress/core');
    
    const generateFolderPages = (options, app) => {
      let postFolders = {}
      options.postFolders.forEach(folder => {
        postFolders[folder] = {
          posts: [],
          tags: []
        }
      })
    
      return {
        name: 'vuepress-plugin-generateFolderPages',
        async onInitialized(app) {
          // rearrange posts to different folder
          app.pages.forEach((page) => {
            let folder = '';
            if (page.filePathRelative) {
              folder = page.filePathRelative.split("/")[0]
              if (!(folder in postFolders)) return
            } else {
              return
            }
    
            const post = {
              key: page.key,
              title: page.title,
              path: page.path,
              pathRelative: page.htmlFilePathRelative,
              filePathRelative: page.filePathRelative,
              tags: page.frontmatter.tags || [],
              createdTime: page.frontmatter.createdTime || null,
              updatedTime: page.frontmatter.updatedTime || null,
              date: page.frontmatter.date || null,
              collection: page.frontmatter.collection || '',
              collectionOrder: page.frontmatter.collectionOrder || 0,
            }
    
            postFolders[folder].posts.push(post);
            postFolders[folder].tags = [...new Set([...postFolders[folder].tags, ...post.tags])]
          })
    
          //...
        },
        extendsPageData: (page, app) => {
          // add data to each folder navigation pages
          if (page.frontmatter.folder) {
            return {
              postsData: postFolders[page.frontmatter.folder]
            }
          } else {
            return {}
          }
        },
      }
    }
    
    module.exports = generateFolderPages
  • 为笔记导航页添加的额外数据是一个数组,在布局组件 .vuepress/theme/layouts/FolderLayout.vue 中将这个扁平的数据结构转换为一个 JS 对象,使它符合 D3.js 用于计算层次布局的数据结构要求

    <script>
    //...
    function buildPostsTreeData(rootName, postsList) {
      let tree = {
        name: rootName,
        type: "root",
        parent: null,
        children: [],
      };
    
      const mdReg = /\.md$/;
    
      postsList.forEach((post) => {
        const paths = post.filePathRelative.split("/").slice(1);
        let folder = tree;
        let currentContent = tree.children;
    
        for (let index = 0; index < paths.length; index++) {
          const path = paths[index];
          // let existingPath = getLocation(currentLevel, "name", path);
          let existingPath = currentContent.find((item) => {
            return item.name === path;
          });
          if (existingPath) {
            folder = existingPath;
            currentContent = existingPath.children;
          } else if (mdReg.test(path)) {
            const newPath = {
              name: path,
              type: "post",
              parent: folder,
              data: post,
            };
            currentContent.push(newPath);
          } else {
            const newPath = {
              name: path,
              type: "folder",
              parent: folder,
              children: [],
            };
    
            currentContent.push(newPath);
            folder = newPath;
            currentContent = newPath.children;
          }
        }
      });
    
      return tree;
    }
        
    export default {
      setup(props) {
        // data
        const data = reactive({
          //...
          folder: "",
          posts: [],
          postsTreeData: null,
          //...
        });
        //...
        data.folder = page.value.frontmatter.folder;
        data.posts = page.value.postsData.posts;
        data.postsTreeData = buildPostsTreeData(data.folder, data.posts);
        //...
      }
    }
    </script>
  • 在组件 .vuepress/components/PostsTree.vue 中构建树形图,使用 D3.js 计算树图节点的定位等数据,再使用 Vue3 将数据绑定到 DOM 上控制 svg 的生成。