🚀为 nuxt 项目写一个 面包屑cli 工具,自动生成页面与面包屑配置

2,407 阅读5分钟

前言

公司项目的面包屑导航是使用 element 的面包屑组件,配合一份 json 配置文件来实现的,每次写新页面都需要去写 json 配置,非常麻烦,所以写一个面包屑cli,自动生成页面、自动配置面包屑数据,提高效率🚀

明确目标

  1. 提供 init 命令,在一个新项目中能够通过初始化生成面包屑相关文件
  2. 能够通过命令生成页面,并且自动配置面包屑 json 数据
  3. 按照项目原有需求,能够配置面包屑是否可点击跳转
  4. 按照项目原有需求,能够配置某路径下是否展示面包屑
  5. 支持仅配置而不生成文件,能够为已存在的页面生成配置
  6. 能够动态配置当前面包屑导航的数据
  7. …… (后续在使用中发现问题并优化)

实现分成两部分

  1. 面包屑实现
  2. cli 命令实现

面包屑实现

  1. 在路由前置守卫 beforEach 中根据当前路径在配置文件中匹配到相应的数据
  2. 把这些配置存到 vuex
  3. 在面包屑组件中根据 vuex 中的数据 v-for 循环渲染出面包屑

JSON 配置文件

json 配置文件是通过命令生成的,一个配置对象包含 name path clickable isShow 属性
[
  {
    "name": "应用", // 面包屑名称(在命令交互中输入)
    "path": "/app", // 面包屑对应路径(根据文件自动生成)
    "clickable": true, // 是否可点击跳转
    "isShow": true // 是否显示
  },
  {
    "name": "应用详情",
    "path": "/app/detail",
    "clickable": true, // 是否可点击跳转
    "isShow": true // 是否显示
  }
]

匹配配置文件中的数据

比如按照上面的配置文件,进入 /app/detail 时,将会匹配到如下数据

[
    {
        "name": "应用",
        "path": "/app",
        "clickable": true,
        "isShow": true
    },
    {
        "name": "应用",
        "path": "/app/detail",
        "clickable": true,
        "isShow": true
    }
]

动态面包屑实现

有时候需要动态修改面包屑数据(比如动态路由),由于数据是存在 vuex 中的,所以修改起来非常方便,只需在 vuex 相关文件中提供 mutation 即可,这些 mutation 在数据中寻找相应的项,并改掉

export const state = () => ({
    breadcrumbData: []
})

export const mutations = {
  setBreadcrumb (state, breadcrumbData) {
    state.breadcrumbData = breadcrumbData
  },

  setBreadcrumbByName (state, {oldName, newName}) {
    let curBreadcrumb = state.breadcrumbData.find(breadcrumb => breadcrumb.name === oldName)

    curBreadcrumb && (curBreadcrumb.name = newName)
  },
  
  setBreadcrumbByPath (state, {path, name}) {
    let curBreadcrumb = state.breadcrumbData.find(
      breadcrumb => breadcrumb.path === path
    )
    curBreadcrumb && (curBreadcrumb.name = name)
  }
}

根据路径匹配相应配置数据具体代码

import breadcrumbs from '@/components/breadcrumb/breadcrumb.config.json'

function path2Arr(path) {
  return path.split('/').filter(p => p)
}

function matchBreadcrumbData (matchPath) {
  return path2Arr(matchPath)
    .map(path => {
      path = path.replace(/^:([^:?]+)(\?)?$/, (match, $1) => {
        return `_${$1}`
      })
      return '/' + path
    })
    .map((path, index, paths) => {

      // 第 0 个不需拼接
      if (index) {
        let result = ''
        for (let i = 0; i <= index; i++) {
          result += paths[i]
        }
        return result
      }
      return path
    })
    .map(path => {
      const item = breadcrumbs.find(bread => bread.path === path)
      if (item) {
        return item
      }
      return {
        name: path.split('/').pop(),
        path,
        clickable: false,
        isShow: true
      }
    })
}

export default ({ app, store }) => {
  app.router.beforeEach((to, from, next) => {
    const toPathArr = path2Arr(to.path)
    const toPathArrLength = toPathArr.length
    let matchPath = ''

    // 从 matched 中找出当前路径的路由配置
    for (let match of to.matched) {
      const matchPathArr = path2Arr(match.path)
      if (matchPathArr.length === toPathArrLength) {
        matchPath = match.path
        break
      }
    }

    const breadcrumbData = matchBreadcrumbData(matchPath)

    store.commit('breadcrumb/setBreadcrumb', breadcrumbData)
    next()
  })
}

面包屑组件

面包屑组件中渲染匹配到的数据

<template>
  <div class="bcg-breadcrumb" v-if="isBreadcrumbShow">
    <el-breadcrumb separator="/">
      <el-breadcrumb-item
        v-for="(item, index) in breadcrumbData"
        :to="item.clickable ? replacePath(item.path) : ''"
        :key="index">
        {{ item.name }}
      </el-breadcrumb-item>
    </el-breadcrumb>
  </div>
</template>

<script>
import breadcrumbs from "./breadcrumb.config"
export default {
  name: 'Breadcrumb',
  computed: {
    isBreadcrumbShow () {
      return this.curBreadcrumb && this.curBreadcrumb.isShow
    },
    breadcrumbData () {
      return this.$store.state.breadcrumb.breadcrumbData
    },
    curBreadcrumb () {
      return this.breadcrumbData[this.breadcrumbData.length - 1]
    }
  },
  methods: {
    replacePath (path) {
      return path
        .split('/')
        .map(item =>
          item.startsWith('_')
           ? this.$route.params[item.substring(1)]
              : item)
        .join('/')
    }
  }
}
</script>

cli命令实现

cli命令开发用到的相关库如下:这些就不细说了,基本上看下 README 就知道怎么用了

  1. commander :命令行工具
  2. boxen:在终端画一个框
  3. inquirer:命令行交互工具
  4. handlebar:模版引擎

目录结构

lib // 存命令行文件
   |-- bcg.js
template // 存模版
   |-- breadcrumb // 面包屑配置文件与组件,将生成在项目 @/components 中
       |-- breadcrumb.config.json
       |-- index.vue
   |-- braadcrumb.js // vuex 相关文件,将生成在项目 @/store 中
   |-- new-page.vue // 新文件模版,将生成在命令行输入的新路径中
   |-- route.js // 路由前置守卫配置文件,将生成在 @/plugins 中
test // 单元测试相关文件

node 支持命令行,只需在 package.json 的 bin 字段中关联命令行执行文件

// 执行 bcg 命令时,就会执行 lib/bcg.js 的代码
{
  "bin": {
    "bcg": "lib/bcg.js"
  }
}

实现命令

实现一个 init 命令,生成相关面包屑文件(面包屑组件、 json配置文件、 前置守卫plugin、 面包屑store)

bcg init

实现一个 new 命令生成文件,默认基础路径是 src/pages,带一个 -b 选项,可用来修改基础路径

bcg new <file-path> -b <base-path>

具体代码如下

#!/usr/bin/env node
const path = require('path')
const fs = require('fs-extra')

const boxen = require('boxen')
const inquirer = require('inquirer')
const commander = require('commander')
const Handlebars = require('handlebars')

const {
  createPathArr,
  log,
  errorLog,
  successLog,
  infoLog,
  copyFile
} = require('./utils')

const VUE_SUFFIX = '.vue'

const source = {
  VUE_PAGE_PATH: path.resolve(__dirname, '../template/new-page.vue'),
  BREADCRUMB_COMPONENT_PATH: path.resolve(__dirname, '../template/breadcrumb'),
  PLUGIN_PATH: path.resolve(__dirname, '../template/route.js'),
  STORE_PATH: path.resolve(__dirname, '../template/breadcrumb.js')
}

const target = {
  BREADCRUMB_COMPONENT_PATH: 'src/components/breadcrumb',
  BREADCRUMB_JSON_PATH: 'src/components/breadcrumb/breadcrumb.config.json',
  PLUGIN_PATH: 'src/plugins/route.js',
  STORE_PATH: 'src/store/breadcrumb.js'
}

function initBreadCrumbs() {
  try {
    copyFile(source.BREADCRUMB_COMPONENT_PATH, target.BREADCRUMB_COMPONENT_PATH)
    copyFile(source.PLUGIN_PATH, target.PLUGIN_PATH)
    copyFile(source.STORE_PATH, target.STORE_PATH)
  } catch (err) {
    throw err
  }
}

function generateVueFile(newPagePath) {
  try {
    if (fs.existsSync(newPagePath)) {
      log(errorLog(`${newPagePath} 已存在`))
      return
    }

    const fileName = path.basename(newPagePath).replace(VUE_SUFFIX, '')
    const vuePage = fs.readFileSync(source.VUE_PAGE_PATH, 'utf8')
    const template = Handlebars.compile(vuePage)
    const result = template({ filename: fileName })

    fs.outputFileSync(newPagePath, result)

    log(successLog('\nvue页面生成成功咯\n'))
  } catch (err) {
    throw err
  }
}

function updateConfiguration(filePath, {
  clickable,
  isShow
} = {}) {
  try {
    if (!fs.existsSync(target.BREADCRUMB_JSON_PATH)) {
      log(errorLog('面包屑配置文件不存在, 配置失败咯, 可通过 bcg init 生成相关文件'))
      return
    }

    let pathArr = createPathArr(filePath)
    const configurationArr = fs.readJsonSync(target.BREADCRUMB_JSON_PATH)

    // 如果已经有配置就过滤掉
    pathArr = pathArr.filter(pathItem => !configurationArr.some(configurationItem => configurationItem.path === pathItem))

    const questions = pathArr.map(pathItem => {
      return {
        type: 'input',
        name: pathItem,
        message: `请输入 ${pathItem} 的面包屑显示名称`,
        default: pathItem
      }
    })

    inquirer.prompt(questions).then(answers => {
      const pathArrLastIdx = pathArr.length - 1

      pathArr.forEach((pathItem, index) => {
        configurationArr.push({
          clickable: index === pathArrLastIdx ? clickable : false,
          isShow: index === pathArrLastIdx ? isShow : true,
          name: answers[pathItem],
          path: pathItem
        })
      })

      fs.writeJsonSync(target.BREADCRUMB_JSON_PATH, configurationArr, {
        spaces: 2
      })

      log(successLog('\n生成面包屑配置成功咯'))
    })
  } catch (err) {
    log(errorLog('生成面包屑配置失败咯'))
    throw err
  }
}

function generating(newPagePath, filePath) {
  inquirer.prompt([
    {
      type: 'confirm',
      name: 'clickable',
      message: '是否可点击跳转? (默认 yes)',
      default: true
    },
    {
      type: 'confirm',
      name: 'isShow',
      message: '是否展示面包屑? (默认 yes)',
      default: true
    },
    {
      type: 'confirm',
      name: 'onlyConfig',
      message: '是否仅生成配置而不生成文件? (默认 no)',
      default: false
    }
  ]).then(({ clickable, isShow, onlyConfig }) => {
    if (onlyConfig) {
      updateConfiguration(filePath, { clickable, isShow })
      return
    }

    generateVueFile(newPagePath)
    updateConfiguration(filePath, { clickable, isShow })
  })
}

const program = new commander.Command()

program
  .command('init')
  .description('初始化面包屑')
  .action(initBreadCrumbs)

program
  .version('0.1.0')
  .command('new <file-path>')
  .description('生成页面并配置面包屑,默认基础路径为 src/pages,可通过 -b 修改')
  .option('-b, --basePath <base-path>', '修改基础路径 (不要以 / 开头)')
  .action((filePath, opts) => {
    filePath = filePath.endsWith(VUE_SUFFIX) ? filePath : `${filePath}${VUE_SUFFIX}`
    const basePath = opts.basePath || 'src/pages'
    const newPagePath = path.join(basePath, filePath)

    log(
      infoLog(
        boxen(`即将配置 ${newPagePath}`, {
          padding: 1,
          margin: 1,
          borderStyle: 'round'
        })
      )
    )

    generating(newPagePath, filePath)
  })

program.parse(process.argv)

if (!process.argv.slice(2)[0]) {
  program.help()
}

发布 npm

开发完成后,发布到 npm,具体方法就不细说了,发布后全局安装就能愉快的使用咯!😂