如何创建 Vue 组件库并将其部署到 NPM

介绍

当跨多个共享同一个设计系统的 Vue 项目工作时,拥有一个可以为不同项目中的所有组件引用的组件库会更加高效和快捷。在本文中,我们将完成创建 Vue 组件库并将其部署到 npm 所需的步骤,以便我们可以在各种项目中重用它们。

  • 创建一个vue组件库
  • 注册库组件
  • 设置构建过程
  • 在本地测试然后将我们的库发布到 npm

创建一个Vue组件库

设置我们的项目

让我们从创建我们的 Vue 项目开始。我们将使用纱线进行包管理。

首先,让我们运行

npm init

对于本教程,我们将在我们的组件库中只创建一个组件。大规模构建组件库时要考虑的一件事是只允许导入单个组件以启用树抖动。

我们将为我们的组件库使用这个文件夹结构:

- src /
  - components /
    - button /
      - button.vue
      - index.ts

    - index.ts

  - styles /
    - components /
        - _button.scss

    - index.scss

- Package.json
- rollup.config.js

创建按钮组件

让我们创建一个简单的按钮组件。我们定义了我们的组件可以接受的基本 props,并根据这些 props 计算类。

将此添加到 button.vue 文件中。

<!-- src/components/button/button.vue -->
<template>
    <button
        v-bind="$attrs"
        :class="rootClasses"
        :type="type"
        :disabled="computedDisabled"
    >
      <slot></slot>
    </button>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
    name: 'DSButton',
    inheritAttrs: false,
    props: {
        /**
         * disabled status
         * @values true, false
         */
        disabled: {
            type: Boolean,
        },
        /**
        * Color of button
        * @values primary, secondary
        */
        variant: {
            type: String,
            validator: (value: string) => {
                return [
                    'primary',
                    'secondary'
                ].indexOf(value) >= 0
            }
        },
        /**
         * type of button
         * @values button, submit
         */
        type: {
            type: String,
            default: 'button',
            validator: (value: string) => {
                return [
                    'button',
                    'submit',
                    'reset'
                ].indexOf(value) >= 0
            }
        },
        /**
         * Size of button
         * @values sm, md, lg
         */
        size: {
            type: String,
            validator: (value: string) => {
                return [
                    'sm',
                    'md',
                    'lg'
                ].indexOf(value) >= 0
            }
        }
    },
    computed: {
        rootClasses() {
            return [
                'ds-button',
                'ds-button--' + this.size,
                'ds-button--' + this.variant
            ]
        },
        computedDisabled() {
            if (this.disabled) return true
            return null
        }
    }
})
</script>

让我们为按钮组件设置样式,根据按钮组件文件中指定的内容添加我们的变体和大小类。

将此添加到 _button.scss 文件中。

// src/styles/components/_button.scss
$primary: '#0e34cd';
$secondary: '#b9b9b9';
$white: '#ffffff';
$black: '#000000';
$small: '.75rem';
$medium: '1.25rem';
$large: '1.5rem';

.ds-button {
    position: relative;
    display: inline-flex;
    cursor: pointer;
    text-align: center;
    white-space: nowrap;
    align-items: center;
    justify-content: center;
    vertical-align: top;
    text-decoration: none;
    outline: none;

    // variant
    &--primary {
        background-color: $primary;
        color: $white;
    }
    &--secondary {
        background-color: $secondary;
        color: $black;
    }

    // size
    &--sm {
        min-width: $small;
    }
    &--md {
        min-width: $medium;
    }
    &--lg {
        min-width: $large;
    }
}

注册库组件

接下来我们需要注册我们的组件。为此,我们将所有组件导入到一个文件中并创建我们的安装方法。

// src/components/button/index.ts 该文件默认导出安装方法。对于我们需要在其他项目中仅导入此按钮组件的情况,它还会导出我们稍后将使用的 Button。

// src/components/button/index.ts
import { App, Plugin } from 'vue'

import Button from './button.vue'

export default {
    install(Vue: App) {
        Vue.component(Button.name, Button)
    }
} as Plugin

export {
    Button as DSButton
}

// src/components/index.ts 让我们在这里导入组件文件夹中的所有组件。因为我们只有我们的按钮组件,所以我们将 imort 它。

// src/components/index.ts
import Button from './button'

export {
    Button
}

// src/styles/index.scss 让我们将所有组件样式导入到我们的样式文件夹中的 index.scss 中。这有助于我们为所有样式提供一个出口来源。

// src/styles/index.scss
@import "components/_button";

// src/index.ts 让我们将所有组件导入到我们的 src 文件夹中的 index.ts 中。在这里,我们为所有组件创建安装方法。我们默认导出 DSLibrary,并导出我们所有的组件。

// src/index.ts
import { App } from 'vue'

import * as components from './components'

const DSLibrary = {
    install(app: App) {
        // Auto import all components
        for (const componentKey in components) {
            app.use((components as any)[componentKey])
        }
    }
}

export default DSLibrary

// export all components as vue plugin
export * from './components'

让我们创建一个名为 shim-vue.d.ts 的文件来帮助我们将 Vue 文件导入到我们的 TypeScript 文件中,并删除由它引起的任何 linting 错误。

// src/shim-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue';
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

设置构建过程

在构建库时,我们需要创建一个捆绑并缩小的库,该库将共享到 npm,为此我们将使用 Rollup。Rollup 是一个用于 JavaScript 的模块打包器,它将小段代码编译成更大的代码。

  • 构建组件(vue代码)
  • 构建样式(scss 代码)

安装汇总,以及我们需要的所有汇总模块。

$ npm i -D rollup rollup-plugin-vue rollup-plugin-terser rollup-plugin-typescript2 @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve

构建组件(vue代码)

我们的组件库需要两种主要的构建类型来支持各种项目。

  • ES 模块 – 它没有要求或导出
  • CommonJS 模块

让我们在根文件夹中创建一个 rollup.config.js 文件并将其粘贴到那里。

import { text } from './build/banner.json'
import packageInfo from './package.json'

import vue from 'rollup-plugin-vue'
import node from '@rollup/plugin-node-resolve'
import cjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2';

import fs from 'fs'
import path from 'path'

const baseFolderPath = './src/components/'
const banner = text.replace('${version}', packageInfo.version)

const components = fs
    .readdirSync(baseFolderPath)
    .filter((f) =>
        fs.statSync(path.join(baseFolderPath, f)).isDirectory()
    )

const entries = {
    'index': './src/index.ts',
    ...components.reduce((obj, name) => {
        obj[name] = (baseFolderPath + name)
        return obj
    }, {})
}

const babelOptions = {
    babelHelpers: 'bundled'
}

const vuePluginConfig = {
    template: {
        isProduction: true,
        compilerOptions: {
            whitespace: 'condense'
        }
    }
}

const capitalize = (s) => {
    if (typeof s !== 'string') return ''
    return s.charAt(0).toUpperCase() + s.slice(1)
}

export default () => {
    let config = []

    if (process.env.MINIFY === 'true') {
        config = config.filter((c) => !!c.output.file)
        config.forEach((c) => {
            c.output.file = c.output.file.replace(/.m?js/g, r => `.min${r}`)
            c.plugins.push(terser({
                output: {
                    comments: '/^!/'
                }
            }))
        })
    }
    return config
}

接下来,我们在项目的根目录中创建一个 build 文件夹,并向其中添加一个名为 banner.json 的文件。我们希望我们的构建在每次构建时都包含当前的应用程序版本。该文件已经导入到 rollup.config.js 文件中,我们使用 package.json 中的包版本来更新版本。

{
    "text": "/*! DS Library v${version} */\n"
}

目前,我们的配置是一个空数组。接下来,我们将添加我们想要的不同构建。

条目:我们要汇总到捆绑的文件的路径 external:需要的外部包 output.format:捆绑的文件格式 output.dir:捆绑的文件目录 output.banner:添加到文件开头的文本 plugins:指定用于自定义汇总行为

  • 首先,我们为库中的每个组件创建一个 esm 构建:
        config = [{         input: entries,         external: ['vue'],
             output: {
                 format: 'esm',
                 dir: `dist/esm`,
                 entryFileNames: '[name].mjs',
                 chunkFileNames: '[name]-[hash].mjs',
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }],
    
  • 接下来,我们为所有组件创建一个单一的 esm 构建:
        config = [      ...,     {         input: 'src/index.ts',         external: ['vue'],
             output: {
                 format: 'esm',
                 file: 'dist/ds-library.mjs',
                 banner: banner
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
       ],
    
  • 然后我们为库中的每个组件创建一个 cjs 构建:
        config = [      ...,      ...,     {         input: entries,         external: ['vue'],
             output: {
                 format: 'cjs',
                 dir: 'dist/cjs',
                 exports: 'named'
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
       ],
    
  • 之后,我们为库中的每个组件创建一个单独的 cjs 构建
        config = [      ...,      ...,      ...,     {         input: 'src/index.ts',         external: ['vue'],
             output: {
                 format: 'umd',
                 name: capitalize('ds-library'),
                 file: 'dist/ds-library.js',
                 exports: 'named',
                 banner: banner,
                 globals: {
                     vue: 'Vue'
                 }
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
    
       ],
    

最后,我们使用脚本命令更新 package.json。我们需要 rimraf(在创建新包之前删除旧的 dist 文件夹)和 clean-css(缩小捆绑的 css 文件),所以让我们安装:

$ npm i -D rimraf clean-css-cli

现在让我们更新我们的 package.json 脚本

build:vue = rollup 和 minify build:style = 将我们的样式从 scss 捆绑到 css,添加我们的横幅文本(版本号,就像我们上面所做的一样)并保存在 dist/ds-library.css 中,然后创建一个缩小版本。

对于我们的 css 文件中的横幅文本,我们需要在 build 文件夹中创建一个 print-banner.js 文件。它获取我们的横幅文本,并将其写入文件。


// build/print-banner.js
const packageInfo = require('../package.json')
const { text } = require('./banner.json')

process.stdout.write(text.replace('${version}', packageInfo.version))
process.stdin.pipe(process.stdout)

build:lib = 删除 dist 文件夹,构建 vue 和样式代码 publish:lib = 运行我们的 build lib 命令然后发布到 npm

    "scripts": {
        "build:vue": "rollup -c && rollup -c --environment MINIFY",
        "build:vue:watch": "rollup -c --watch",
        "build:style": "sass --no-charset ./src/styles/index.scss | node ./build/print-banner.js > dist/ds-library.css && cleancss -o dist/ds-library.min.css dist/ds-library.css",
        "build:lib": "rimraf dist && npm run build:vue && npm run build:style",
        "publish:lib": "npm run build:lib && npm publish"
    },
    "peerDependencies": {
        "vue": "^3.0.0"
    },

在本地测试然后将我们的库发布到 npm

现在我们已经完成了,我们可以通过在这个 repo 的根目录中运行 npm link 并在我们的测试项目的根目录中运行 npm link “package name” 来进行本地测试。在此之后,该包将可用于我们的测试包。

测试完成后,您可以运行 npm publish:lib 来构建和部署到 npm。