使用 Vue cli 3.0 构建自定义组件库(第二弹)

本文旨在给大家提供一种构建一个完整 UI 库脚手架的思路:包括如何快速并优雅地构建UI库的主页、如何托管主页、如何编写脚本提升自己的开发效率、如何生成 CHANGELOG 等, 此文乃第二弹版本, 看过第一弹版本的小伙伴, 想必都知道第一弹版本的组件库文档 UI 是需要自己写的, 最近也是刚研究出来其实 vuepress 也可以作为组件库文档的 UI, 该文档 UI 绝对不输于第一弹版本的

前言

想看第一弹版本的小伙伴, 请戳我

第二弹前期具备知识点

vuepress
Vue CLI
Docker

前置工作

以下工作全部基于 Vue CLI 3.x,所以首先要保证机子上有 @vue/cli

vue create vtp-component # vtp-component 作为教学的库名

vue-router , dart-sass , babel , eslint 这些是该项目使用的依赖项,小主可以根据自己的需求进行相应的切换

start

开始造轮子了

工作目录

默认生成的 src 目录以及 public 目录在该项目下是没有作用的, 小主可以删掉了, 这么做的主要目的当然只是保留生成的 package.json 文件

调整项目目录如下, 其中 build 文件夹用来存放编译脚本代码, docs 文件夹则是 vuepress 的主体部分了, 其中 .vuepress 是用来存放 vuepress 相关的配置文件, 而组件文档则放置在 docs 的根目录, packages 文件夹是用来存放组件的核心代码, scripts 文件夹用来存放自定义生成组件和组件的说明文档等脚本

|-- build
|
|-- docs
    |-- .vuepress
        |-- config.js
        |-- enhanceApp.js
    |-- README.md
|
|-- packages 
|
|-- scripts

添加编译脚本

package.json

其中的组件 name 推荐和创建的项目名一致

{
  "scripts": {
    "lib": "vue-cli-service build --target lib --name vtp-component --dest lib packages/index.js"
  }
}

修改 main 主入口文件

{
  "main": "lib/vtp-component.common.js"
}

一个组件例子

vuepress 配置

这里提供一些简单的配置, 当然想要高级的配置甚至想要自定义主题皆可从官方文档中获取到帮助

config.js

const config = {
    dest: 'public',
    serviceWorker: true,
    themeConfig: {
        sidebar: [
            ['/CHANGELOG', '更新日志'],
            ['/', '指南'],
            {
                title: '组件',
                collapsable: false,
                children: [

                ]
            }
        ]
    },
    markdown: {
        lineNumbers: true
    },
    title: 'vtp-component',
    base: '/vtp-component'
}

module.exports = config

enhanceApp.js

import Vtp from '../../packages'

export default ({
    Vue, // the version of Vue being used in the VuePress app
    options, // the options for the root Vue instance
    router, // the router instance for the app
    siteData // site metadata
}) => {
    Vue.use(Vtp)
}

基础的 packages 配置

import './assets/scss/common.scss'

const version = require('../package.json').version
const components = [

]

const install = Vue => {
    if (install.installed) return
    components.map(component => Vue.component(component.name, component))
}

export {
    install,
    version

}

export default {
    install,
    version
}
@for $i from 1 through 30 {
  .fs#{$i} {
    font-size: #{$i}px !important;
  }

  .br#{$i} {
    border-radius: #{$i}px !important;
  }

  .l-h#{$i} {
    line-height: #{$i}px !important;
  }
}

@for $i from 1 through 40 {
  .margin#{$i} {
    margin:#{$i}px !important;
  }

  .padding#{$i} {
    padding:#{$i}px !important;
  }

  .m-l#{$i} {
    margin-left: #{$i}px !important;
  }

  .m-r#{$i} {
    margin-right: #{$i}px !important;
  }

  .m-lr#{$i} {
    margin-left: #{$i}px !important;
    margin-right: #{$i}px !important;
  }

  .m-t#{$i} {
    margin-top: #{$i}px !important;
  }

  .m-b#{$i} {
    margin-bottom: #{$i}px !important;
  }

  .m-tb#{$i} {
    margin-top: #{$i}px !important;
    margin-bottom: #{$i}px !important;
  }

  .p-l#{$i} {
    padding-left: #{$i}px !important;
  }

  .p-r#{$i} {
    padding-right: #{$i}px !important;
  }

  .p-lf#{$i} {
    padding-left: #{$i}px !important;
    padding-right: #{$i}px !important;
  }

  .p-t#{$i} {
    padding-top: #{$i}px !important;
  }

  .p-b#{$i} {
    padding-bottom: #{$i}px !important;
  }

  .p-tb#{$i} {
    padding-top: #{$i}px !important;
    padding-bottom: #{$i}px !important;
  }
}

.tc {
  text-align: center !important;
}

.tl {
  text-align: left !important;
}

.tr {
  text-align: right !important;
}

.fl {
  float: left !important;
}

.fr {
  float: riaght !important;
}

.cl-both {
  clear: both !important;
}

.fw-b {
  font-weight: bold !important;
}

.absolute {
  position: absolute !important;
}

.relative {
  position: relative !important;
}

.fixed {
  position: fixed !important;
}

主入口 README

# 指南

## 介绍

::: tip
开箱即用的 Vue 组件库
:::

## Install

\`\`\` bash
npm install vtp
# or
yarn add vtp
\`\`\`

## Used

### 一键全局引入

\`\`\` javascript
import Vue from 'vue'
import Vtp from 'vtp'

Vue.use(Vtp)
\`\`\`

### 按需引入

\`\`\` javascript
import Vue from 'vue'
import {
    VtpButton
} from 'vtp'

Vue.use(VtpButton)
\`\`\`

## 组件库贡献指南

创建组件和组件文档生成脚本

scripts 中创建以下几个文件,其中 create-comp.js 是用来生成自定义组件目录和自定义组件说明文档脚本, delete-comp.js 是用来删除无用的组件目录和自定义组件说明文档脚本, template.js 是生成代码的模板文件

|-- create-comp.js
|
|-- delete-comp.js
|
|-- template.js

相关的代码如下,小主可以根据自己的需求进行相应的简单修改,下面的代码参考来源 vue-cli3 项目优化之通过 node 自动生成组件模板 generate View、Component, 当然也是第一弹中的代码改进

create-comp.js

// 创建自定义组件脚本

const chalk = require('chalk')
const path = require('path')
const fs = require('fs-extra')
const uppercamelize = require('uppercamelcase')
const resolve = (...file) => path.resolve(__dirname, ...file)
const log = message => console.log(chalk.green( `${message}` ))
const successLog = message => console.log(chalk.blue( `${message}` ))
const errorLog = error => console.log(chalk.red( `${error}` ))
const {
    vueTemplate,
    entryTemplate,
    scssTemplate,
    mdDocs
} = require('./template')

const generateFile = (path, data) => {
    if (fs.existsSync(path)) {
        errorLog( `${path}文件已存在` )
        return
    }
    return new Promise((resolve, reject) => {
        fs.writeFile(path, data, 'utf8', err => {
            if (err) {
                errorLog(err.message)
                reject(err)
            } else {
                resolve(true)
            }
        })
    })
}

// 这里生成自定义组件
log('请输入要生成的组件名称, 形如 demo 或者 demo-test')
let componentName = ''
process.stdin.on('data', async chunk => {
    const inputName = String(chunk).trim().toString()
    const upperInputname = uppercamelize(inputName)
    const componentDirectory = resolve('../packages', upperInputname)
    const componentVueName = resolve(componentDirectory, `${upperInputname}.vue` )
    const scssName = resolve(componentDirectory, `${upperInputname}.scss` )
    const entryComponentName = resolve(componentDirectory, 'index.js')

    const hasComponentDirectory = fs.existsSync(componentDirectory)
    if (upperInputname) {
        // 这里生成组件
        if (hasComponentDirectory) {
            errorLog( `${upperInputname}组件目录已存在,请重新输入` )
            return
        } else {
            log( `生成 component 目录 ${componentDirectory}` )
            await dotExistDirectoryCreate(componentDirectory)
        }
        try {
            if (upperInputname.includes('/')) {
                const inputArr = upperInputname.split('/')
                componentName = inputArr[inputArr.length - 1]
            } else {
                componentName = upperInputname
            }
            log( `生成 vue 文件 ${componentVueName}` )
            await generateFile(componentVueName, vueTemplate(inputName))
            log( `生成 scss 文件 ${componentVueName}` )
            await generateFile(scssName, scssTemplate(inputName))
            log( `生成 entry 文件 ${entryComponentName}` )
            await generateFile(entryComponentName, entryTemplate(componentName))
            successLog('生成 component 成功')
        } catch (e) {
            errorLog(e.message)
        }
    } else {
        errorLog( `请重新输入组件名称:` )
        return
    }

    // 这里生成自定义组件说明文档
    const docsDirectory = resolve('../docs')
    const docsMdName = resolve(docsDirectory, `${upperInputname}.md` )

    try {
        log( `生成 component 文档 ${docsMdName}` )
        await generateFile(docsMdName, mdDocs( `${inputName}` ))
        successLog('生成 component 文档成功')
    } catch (e) {
        errorLog(e.message)
    }

    process.stdin.emit('end')
})

process.stdin.on('end', () => {
    log('exit')
    process.exit()
})

function dotExistDirectoryCreate(directory) {
    return new Promise((resolve) => {
        mkdirs(directory, function() {
            resolve(true)
        })
    })
}

// 递归创建目录
function mkdirs(directory, callback) {
    var exists = fs.existsSync(directory)
    if (exists) {
        callback()
    } else {
        mkdirs(path.dirname(directory), function() {
            fs.mkdirSync(directory)
            callback()
        })
    }
}

delete-comp.js

// 删除自定义组件脚本

const chalk = require('chalk')
const path = require('path')
const fs = require('fs-extra')
const uppercamelize = require('uppercamelcase')
const resolve = (...file) => path.resolve(__dirname, ...file)
const log = message => console.log(chalk.green( `${message}` ))
const successLog = message => console.log(chalk.blue( `${message}` ))
const errorLog = error => console.log(chalk.red( `${error}` ))

log('请输入要删除的组件名称, 形如 demo 或者 demo-test')
process.stdin.on('data', async chunk => {
    let inputName = String(chunk).trim().toString()
    inputName = uppercamelize(inputName)
    const componentDirectory = resolve('../packages', inputName)

    const hasComponentDirectory = fs.existsSync(componentDirectory)

    const docsDirectory = resolve('../docs')
    const docsMdName = resolve(docsDirectory, `${inputName}.md` )
    if (inputName) {
        if (hasComponentDirectory) {
            log( `删除 component 目录 ${componentDirectory}` )
            await removePromise(componentDirectory)
            successLog( `已删除 ${inputName} 组件目录` )

            log( `删除 component 文档 ${docsMdName}` )
            fs.unlink(docsMdName)
            successLog( `已删除 ${inputName} 组件说明文档` )
        } else {
            errorLog( `${inputName}组件目录不存在` )
            return
        }
    } else {
        errorLog( `请重新输入组件名称:` )
        return
    }

    process.stdin.emit('end')
})

process.stdin.on('end', () => {
    log('exit')
    process.exit()
})

function removePromise(dir) {
    return new Promise(function(resolve, reject) {
        // 先读文件夹
        fs.stat(dir, function(_err, stat) {
            if (stat.isDirectory()) {
                fs.readdir(dir, function(_err, files) {
                    files = files.map(file => path.join(dir, file)) // a/b  a/m
                    files = files.map(file => removePromise(file)) // 这时候变成了promise
                    Promise.all(files).then(function() {
                        fs.rmdir(dir, resolve)
                    })
                })
            } else {
                fs.unlink(dir, resolve)
            }
        })
    })
}

template.js

module.exports = {
    vueTemplate: compoenntName => {
        return `<template>
  <div class="vtp-${compoenntName}">
    ${compoenntName}
  </div>
</template>

<script>
export default {
  name: 'vtp-${compoenntName}',

  data () {
    return {}
  },

  props: {},

  methods: {}
}
</script>

<style lang="scss">
  @import './${compoenntName}';
</style>
`
    },
    entryTemplate: compoenntName => {
        return `import ${compoenntName} from './${compoenntName}'

${compoenntName}.install = function (Vue) {
  Vue.component(${compoenntName}.name, ${compoenntName})
}

export default ${compoenntName}

if (typeof window !== 'undefined' && window.Vue) {
  window.Vue.component(${compoenntName}.name, ${compoenntName})
}
`
    },
    scssTemplate: compoenntName => {
        return `.vtp-${compoenntName} {}` 
    },
    mdDocs: compoenntName => {
        return `# ${compoenntName}

::: tip 组件作用说明
${compoenntName}
::: 

## Code

<vtp-${compoenntName}></vtp-${compoenntName}>

\`\`\` vue
<vtp-${compoenntName}></vtp-${compoenntName}>
\`\`\` 

## Used

### 按需引入

\`\`\` javascript
import Vue from 'vue'
import {
  Vtp${compoenntName}
} from 'vtp'

Vue.use(Vtp${compoenntName})
\`\`\` 

### 局部引入

\`\`\`  javascript
import {
  Vtp${compoenntName}
} from 'vtp'

export default {
  components: {
    Vtp${compoenntName}
  }
}
\`\`\` 

## Attributes

| 参数  | 说明  | 类型  | 可选值 | 默认值 |
|-----|-----|-----|-----|-----|
| -   | -   | -   | -   | -   |

  `
    }
}

build 中创建以下几个文件,其中 build-entry.js 脚本是用来生成自定义组件导出 packages/index.jsget-components.js 脚本是用来获取 packages 目录下的所有组件

|-- build-entry.js
|
|-- get-components.js

相关的代码如下,小主可以根据自己的需求进行相应的简单修改,下面的代码参考来源 vue-cards

build-entry.js

const fs = require('fs-extra')
const path = require('path')
const chalk = require('chalk')
const uppercamelize = require('uppercamelcase')
const Components = require('./get-components')()
const log = message => console.log(chalk.green( `${message}` ))

function buildPackagesEntry() {
    const uninstallComponents = []

    const importList = Components.map(
        name => `import ${uppercamelize(name)} from './${name}'` 
    )
    const exportList = Components.map(name => `${uppercamelize(name)}` )
    const intallList = exportList.filter(
        name => !~uninstallComponents.indexOf(uppercamelize(name))
    )
    const content = `import './assets/scss/common.scss'

${importList.join('\n')}

const version = require('../package.json').version
const components = [
  ${intallList.join(',\n\t')}
]

const install = Vue => {
  if (install.installed) return
  components.map(component => Vue.component(component.name, component))
}

export {
  install,
  version,
  ${exportList.join(',\n\t')}
}

export default {
  install,
  version
}
`

    fs.writeFileSync(path.join(__dirname, '../packages/index.js'), content)
    log('packages/index.js 文件已更新依赖')
    log('exit')
}

function setDocsConfig() {
    const docsURL = []
    Components.forEach(item => {
        docsURL.push( `'/${item}'` )
    })
    const content = `const config = {
  dest: 'public',
  serviceWorker: true,
  themeConfig: {
    sidebar: [
      ['/CHANGELOG', '更新日志'],
      ['/', '指南'], 
      {
        title: '组件',
        collapsable: false,
        children: [
          ${docsURL.join(',\n\t')}
        ]
      }
    ]
  },
  markdown: {
    lineNumbers: true
  },
  title: 'vtp-component',
  base: '/'
}

module.exports = config
`

    fs.writeFileSync(path.join(__dirname, '../docs/.vuepress/config.js'), content)
    log('packages/index.js 文件已更新依赖')
    log('exit')
}

buildPackagesEntry()
setDocsConfig()

get-components.js

const fs = require('fs')
const path = require('path')

const excludes = [
    'index.js',
    'theme-chalk',
    'mixins',
    'utils',
    '.DS_Store',
    'assets'
]

module.exports = function() {
    const dirs = fs.readdirSync(path.resolve(__dirname, '../packages'))
    return dirs.filter(dirName => excludes.indexOf(dirName) === -1)
}

生成命令

package.json 中添加以下内容,使用命令 yarn new:comp 创建组件目录及其文档或者使用命令 yarn del:comp 即可删除组件目录及其文档

{
  "scripts": {
    "new:comp": "node scripts/create-comp.js && node build/build-entry.js",
    "del:comp": "node scripts/delete-comp.js && node build/build-entry.js"
  }
}

changelog

package.json 中修改 script 字段,接下来你懂的,另一篇博客有介绍哦,小主可以执行搜索

{
  "scripts": {
    "init": "npm install commitizen -g && commitizen init cz-conventional-changelog --save-dev --save-exact && npm run bootstrap",
    "bootstrap": "npm install",
    "changelog": "conventional-changelog -p angular -i docs/CHANGELOG.md -s -r 0"
  }
}

关于 pages 服务和发布至 npm

pages 服务

当然组件库以及文档完成之后, 都希望能够有个地址托管我们的组件文档, 这里比较推荐的是使用 Gitee 中的项目pages 服务

发布至 npm

分情况而言

一是该组件库是公开的, 希望该组件库有较多的社区小伙伴能够一起维护的, 这里就比较推荐使用 GitHub, 发布命令为 npm publish , 相关的 pages 服务可以参考本博客的托管机制(具体的还需要小主自行研究)

二是该组件库是是有的, 希望该组件库只是作为多个项目之间的共用 UI 框架, 这里比较推荐的是使用 Gitee, 发布命令为 npm publish , 当然只是这样是不够的, 比较推荐的是使用 npm 私有库来托管我们的组件库(npm 私有库搭建请参考 docker 使用指南-私有 npm 代理注册表, 具体的还需要小主自行研究)

亲!!! 听说给作者打赏一杯咖啡钱,会给自己带来好运哦!