网络日志

我想写一个 Vue3 组件库,我该怎么开始?

如何从零开始搭建一个 Vue3 组件库?

最近有一些小伙伴问雨声:想要自己做一个组件库,但不知道如何开始?看到别人那些组件库各种各样、五花八门的配置文件和文件结构,根本不知道该从何下手。

别慌,其实再复杂的结构与配置,也是从一开始非常简单的项目,在开发过程中根据需要,经历不断的迭代、重构与完善后才逐步形成的。

我们想要开始的时候,其实最重要的第一步就是行动起来,先开一个最简单的项目,写一个最简单的组件,完成一次最简单的打包。

开始☕!

创建项目

首先是包管理工具,推荐大家选用 pnpm,相较于其他包管理,它更快、更节约空间,还有一个很重要的优势就是能非常方便地创建和管理 monorepo,安装可自行查阅 官网

然后就是脚手架的选择,既然是 Vue3 组件库,那 Vite 必然是不二之选了。首先它几乎可以说是 Vue3 的官配,其次在做库项目方面,Vite 在打包时没 Webpack 这么麻烦,在开发时也比 Rollup 更容易搭建开发服务。

我们直接使用 Vite 官方提供的 vue-ts 模版来快速创建一个原始项目:

pnpm create vite demo-ui --template vue-ts

之后我们把 Git 初始化一下:

git init

接着把 package.jsonvue-tsc 的内容去掉,因为类型错误的检查我们可以直接借助编辑器的 Volar 插件实时进行。

package.json

{
  "name": "demo-ui",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.2.25"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^2.3.3",
    "typescript": "^4.5.4",
    "vite": "^2.9.9"
  }
}

最后安装一下依赖,项目创建就 OK 了。

pnpm i

第一个组件

有了项目之后,我们不管其他乱七八糟的,先来个最简单的组件。

我们先对目录结构做简单的改造,使其适应 Lib 型项目。

这是我们原始创建后的结构:

我们把 publicsrc/assetssrc/components/HelloWorld.vue 都删掉,添加 src/index.tssrc/components/button.vue

其中的 src/App.vuesrc/main.ts 留着用于开发组件的时候随时看效果。

接着我们快快地写一个按钮组件,不管其他的,页面上能看到就行。

src/components/button.vue

<template>
  <button class="d-button">
    <slot></slot>
  </button>
</template>

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

export default defineComponent({
  name: 'DButton'
})
</script>

src/App.vue

<template>
  <DButton>
    一个按钮
  </DButton>
</template>

<script setup lang="ts">
import DButton from './components/button.vue'
</script>

然后我们启动开发服务:

pnpm run dev

打开浏览器:

平平无奇,没有任何样式的按钮组件出来了。

不过通常的组件库应该是以 app.use(DemoUI) 的形式来安装的,我们再来改装一下,导出一个具有 install 方法的对象。

src/index.ts

import DButton from './components/button.vue'

import type { App } from 'vue'

const components = [
  DButton
]

export function install(app: App) {
  components.forEach(component => {
    app.component(component.name, component)
  })
}

export default {
  install
}

export {
  DButton
}

src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
import DemoUI from './index'

createApp(App).use(DemoUI).mount('#app')

src/App.vue

<template>
  <DButton>
    一个按钮
  </DButton>
</template>

<script setup lang="ts">
// import DButton from './components/button.vue'
</script>

打包组件

下一步我们就来完成我们的组件打包。

借助 vitebuild.lib 配置快速完成库打包,注意打包的时候要排除 Vue 本身。

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['cjs', 'es']
    },
    rollupOptions: {
      external: ['vue']
    }
  },
  plugins: [vue()]
})

然后我们执行打包命令:

pnpm run build

最后我们为 package.json 添加上 mainmodule 字段,那么一个可发布的组件库雏形就出来了(注意把 private 去掉或者改为 false)。

package.json

{
  "name": "demo-ui",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "main": "dist/demo-ui.cjs.js",
  "module": "dist/demo-ui.es.js",
  "dependencies": {
    "vue": "^3.2.25"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^2.3.3",
    "typescript": "^4.5.4",
    "vite": "^2.9.9"
  }
}

这个时候如果你执行 pnpm publish 那它就真的可以发布出去了(版本号要更新),不过它还有点单薄,还是先不要着急。

添加样式

经过上面的步骤,我们得到一个最简单的组件库,不过是光溜溜的,我们还需要为这个组件添加样式。

这里我们选用 sass 作为 css 的预处理语言,当然你也可以选择你喜欢的方案,不过雨声认为 sasscss 的过渡足够平滑,而且功能也足够强大,所以比较喜欢用。

pnpm i -D sass

然后我们在 src 下创建一个 style 目录,用来专门存放样式文件,并添加 index.scssbutton.scss

接着我们给 button.scss 写上一些按钮的样式,并在 index.scss 中导出。

src/style/button.scss

.d-button {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  padding: 0 14px;
  color: #fff;
  background-color: #339af0;
  border: 1px solid #339af0;
  border-radius: 4px;
  outline: 0;
}

src/style/index.scss

@use './button.scss';

随后我们在 src/index.ts 中将样式也引入进来。

src/index.ts

import './style/index.scss'

import DButton from './components/button.vue'

// 省略下面

这时我们就得到一个具有自定义样式的按钮组件了:

再执行 pnpm run build 就可以看到打包后的文件有了 style.css 的样式文件。

类型声明

作为一个 Vue3 + TypeScript 的项目,自然不能少了自动生成类型声明文件。

这里我们借助 vite-plugin-dts 插件来实现打包时自动生成类型声明文件。

pnpm i -D vite-plugin-dts

然后在 Vite 配置文件中添加插件:

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['cjs', 'es']
    },
    rollupOptions: {
      external: ['vue']
    }
  },
  plugins: [
    vue(),
    dts({ insertTypesEntry: true, copyDtsFiles: false })
  ]
})

我们再一次执行 pnpm run build 进行打包,可以看到打包后的文件也包含类型声明文件了:

最后在 package.json 中添加 types 字段:

package.json

{
  // ...
  "main": "dist/demo-ui.cjs.js",
  "module": "dist/demo-ui.es.js",
  "types": "dist/index.d.ts",
  // ...
}

Lint 工具

就和常规项目一样,组件库的项目也需要像是 ESLint 和 Stylelint 这样的来规范统一代码风格的工具的。

在项目的搭建上,雨声觉得到了这一步才是最有趣的,就好像拼乐高一样,有了主体之后,就可以开始往上拼上各种各样的模块,然后看着项目变得丰富起来,非常有意思。

我们先来安装 Eslint 全家桶,包含 Vue 和 TypeScript 相关的:

pnpm i -D eslint eslint-plugin-import eslint-plugin-n eslint-plugin-node eslint-plugin-promise
pnpm i -D eslint-plugin-vue @vue/eslint-config-typescript vue-eslint-parser

这里我们基础 ESLint 配置采用 eslint-config-standard 作为基础,你可以选择你喜欢的:

pnpm i -D eslint-config-standard

接着在根目录创建 .eslintrc.js.eslintignore 两个文件,配置大部分来自继承,在这基础上将一小部分不太适合组件库情况的做一些调整。

.eslintrc.js

module.exports = {
  root: true,
  env: {
    node: true
  },
  parser: 'vue-eslint-parser',
  extends: [
    'plugin:vue/vue3-essential',
    'plugin:vue/vue3-strongly-recommended',
    'plugin:vue/vue3-recommended',
    'standard',
    '@vue/typescript/recommended'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    'no-console':
      process.env.NODE_ENV === 'production'
        ? [
            'error',
            {
              allow: ['warn', 'error']
            }
          ]
        : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    'space-before-function-paren': [
      'error',
      {
        anonymous: 'always',
        named: 'never',
        asyncArrow: 'always'
      }
    ],
    'vue/match-component-file-name': 'off',
    'vue/html-self-closing': 'off'
  }
}

.eslintignore

dist/
node_modules/

.*rc.js
*.config.js
*.css
*.pcss
*.scss
*.json

同时记得在编辑器上安装 ESLint 相关的插件,然后回过头看看先前的代码有没有报错,有的话就调整一下。

下一个是 Stylelint 全家桶~

当然了,如果你喜欢 Free Style 的话,完全可以不装 Stylelint 系列。

pnpm i -D postcss postcss-html postcss-preset-env
pnpm i -D stylelint stylelint-config-html stylelint-config-recess-order stylelint-config-recommended-vue
pnpm i -D stylelint-config-standard stylelint-config-standard-scss stylelint-order

在根目录创建 .stylelintrc.js.stylelintignore 两个文件,Stylelint 为了适应 sass 的书写习惯,这里做了比较多的定制化,有兴趣深入了解的可以自行拓展阅读。

.stylelintrc.js

module.exports = {
  defaultSeverity: 'error',
  extends: [
    'stylelint-config-standard',
    'stylelint-config-standard-scss',
    'stylelint-config-html',
    'stylelint-config-recommended-vue',
    'stylelint-config-recess-order'
  ],
  plugins: ['stylelint-order'],
  rules: {
    'no-empty-source': process.env.NODE_ENV === 'production' ? true : null,
    'block-no-empty': process.env.NODE_ENV === 'production' ? true : null,
    'string-quotes': 'single',
    'at-rule-no-unknown': null,
    'at-rule-no-vendor-prefix': true,
    'declaration-property-value-disallowed-list': {
      '/^transition/': ['/all/'],
      '/^background/': ['http:', 'https:'],
      '/^border/': ['none'],
      '/.+/': ['initial']
    },
    'media-feature-name-no-vendor-prefix': true,
    'property-no-vendor-prefix': true,
    'selector-no-vendor-prefix': true,
    'value-no-vendor-prefix': true,
    'at-rule-empty-line-before': [
      'always',
      {
        except: ['first-nested'],
        ignore: [
          'after-comment',
          'blockless-after-same-name-blockless',
          'blockless-after-blockless'
        ],
        ignoreAtRules: ['else']
      }
    ],
    'no-descending-specificity': null,
    'custom-property-empty-line-before': null,
    'selector-class-pattern': [
      '^([#a-z][$#{}a-z0-9]*)((-{1,2}|_{2})[$#{}a-z0-9]+)*$',
      {
        message: 'Expected class selector to be kebab-case'
      }
    ],
    'keyframes-name-pattern': [
      '^([#a-z][$#{}a-z0-9]*)((-{1,2}|_{2})[$#{}a-z0-9]+)*$',
      {
        message: 'Expected keyframe name to be kebab-case'
      }
    ],
    'color-function-notation': null,
    'scss/at-import-partial-extension': 'always',
    'function-no-unknown': null,
    'alpha-value-notation': 'percentage',
    'scss/dollar-variable-empty-line-before': null,
    'scss/operator-no-newline-after': null
  },
  ignoreFiles: [
    /* see .stylelintignore */
  ]
}

.stylelintignore

dist/
node_modules/

*.js
*.ts
*.tsx
*.svg
*.gif
*.md

一样别忘了在编辑器上安装 Stylelint 相关的插件,以获得编辑器的错误提示,之前的样式如果有报错那也跟着调整一下。

至此,我们就把最关键的两个 Lint 工具初始化了。

提交

提交 Message 的规范化和提交时的自动格式化代码,我们采用 Commitlint、lint-staged 和 husky 配合完成。

虽然 Commitlint 也属于 Lint 工具,不过因为涉及到 Git 提交,就专门放到这个章节一起讲。

老样子,我们先来安装相关的依赖:

pnpm i -D @commitlint/cli @commitlint/config-conventional
pnpm i -D husky is-ci lint-staged && pnpm dlx husky install

# 在 windows 系统下只需要把 && 改为 ; 即可拼接多条命令
pnpm i -D husky is-ci lint-staged; pnpm dlx husky install

其中 pnpm dlx husky install 会帮助我们自动完成 husky 的初始化。

之后我们在 package.jsonscripts 下找到 prepare 这个命令,并做一些小调整:

package.json

{
  "scripts": {
    "prepare": "is-ci || husky install"
  }
}

安装完成后,我们开始配置 Commitlint,在根目录创建 .commitlintrc.js,看到我们上面安装 Commitlint 的时候还多安装了一个配置,我们就继承这个配置再做一些拓展:

.commitlintrc.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'body-leading-blank': [2, 'always'],
    'footer-leading-blank': [1, 'always'],
    'header-max-length': [2, 'always', 108],
    'subject-empty': [2, 'never'],
    'type-empty': [2, 'never'],
    'type-enum': [
      2,
      'always',
      [
        'feat',
        'fix',
        'perf',
        'style',
        'docs',
        'test',
        'refactor',
        'build',
        'ci',
        'chore',
        'revert',
        'wip',
        'workflow',
        'types',
        'release'
      ]
    ]
  }
}

这里可以稍微锁一下 type-enum 里面的内容,可以看到有很多的提交类型,这些类型来自于 Augular 提交规范,并在其基础上做了一些拓展,是目前开源社区里比较流行的方案,感兴趣的小伙伴可以自行拓展阅读。

接着我们开始配置 husky,它可以让我们方便地创建一些 Git 钩子脚本,使的我们可以在提交过程的各个阶段执行一些命令,其中就包括调用各种 Lint 工具来检查代码和提交 Message。

先前我们执行了 pnpm dlx husky install 这个命令后,可以看到根目录下多了一个 .husky 的文件夹,这里面就是放置相关配置文件的地方。

我们使用 husky 提供的命令创建 commoncommit-msgpre-commit 三个脚本文件,或者直接在 .husky 目录下手动创建也可以:

pnpm dlx husky add .husky/common
pnpm dlx husky add .husky/commit-msg
pnpm dlx husky add .husky/pre-commit

接着分别调整里面的内容:

.husky/common

command_exists () {
  command -v "$1" >/dev/null 2>&1
}

# Workaround for Windows 10, Git Bash and Yarn
if command_exists winpty && test -t 1; then
  exec < /dev/tty
fi

.husky/commit-msg

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/common"

npx --no-install commitlint --edit "$1"

.husky/pre-commit

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/common"

[ -n "$CI" ] && exit 0

pnpm run precommit

husky 这样就算是配置好了,除去 common 是一个补丁脚本外,另外两个脚本会分别在我们进行提交 Message 处理时,以及即将开始提交时执行,我们也分别放入了调用 Commitlint 检查提交 Message 和一条 pnpm run precommit 的命令,不过这条命令在我们的项目里暂时还没有。

下一步,我们来配置 lint-staged 的同时把这条命令补全。

lint-staged 的配置文件为 .lintstagedrc,雨声习惯会把它一同放入 .husky 便于管理,你也可以放到自己喜欢的位置,比如根目录下。

.husky/.lintstagedrc

{
  "*.{js,jsx,ts,tsx}": [
    "eslint --fix"
  ],
  "*.{css,scss,html}": [
    "stylelint --fix"
  ],
  "*.vue": [
    "eslint --fix",
    "stylelint --fix"
  ]
}

然后我们在 package.json 中添加 precommit 命令:

{
  "scripts": {
    "precommit": "lint-staged -c ./.husky/.lintstagedrc -q"
  }
}

这样一来,在每次 pre-commit 脚本执行时,就会调用 lint-staged 对提交的文件执行 Lint 相关的命令,并且我们想要手动检查要提交的文件时也可以直接执行这条命令。

事不宜迟,我们马上来进行我们的第一次提交~

git add .

为了验证一下 Commitlint 是否能正常工作,我们先来一个不符合规范的提交 Message:

git commit -m "init"

可以看到控制台输出了一些错误信息,并且提交失败了:

确认了 Commitlint 可以正常工作后我们就来一次符合规范的提交:

git commit -m "chore: init"

提交成功,大功告成。

现在,我们的项目结构长这个样子:

告一段落

本篇介绍了从零开始搭建一个 Vue3 组件库的第一步,完成了一个最简单的组件库项目,并且能够打包。

同时还介绍了三个 Lint 工具的装配,以及如何实现提交规范化及自动格式化代码。

尽管我们没有真的发布到 npm 上,不过我们如果想试试的话,可以使用 link 协议在本地的其他项目上试一试。

同样用 pnpm create vite 创建一个项目,在 package.jsondependencies 上手动添加 link 协议的依赖:

{
  "dependencies": {
    "demo-ui": "link:到 demo-ui 的根目录的物理路径"
    // 如 "link:D:/ui-library/demo-ui"
  }
}

执行 pnpm i 后就可以像使用其他库一样,来使用本地的 demo-ui 了。

未完待续

🚧 组件库项目的搭建还远不止这些,随着深入我们还会遇到诸如:

  • 组件的开发工作流
  • 组件库的发布流程设计
  • 组件的单元测试
  • 组件的样式的管理
  • css 变量设计与主题
  • 实现直接的按需加载
  • 组件库文档的构建
  • 组件库 Playground 的实现
  • 使用 monorepo 管理组件库的多个模块
  • 等等...

👀 下一篇,我们继续围绕着从零开始搭建一个 Vue3 组件库会遇到的问题,继续讲讲如何完善组件库项目的配套设施。

如果有不同的见解或建议,欢迎在评论区理性讨论,或者如果上面列举的点中有哪些更想知道的也可以留言哦。

最后,推荐一下雨声的开源组件库 Vexip-UI - GitHub(小伙伴们赏一个🌟)

现正招募小伙伴来尝试使用或者维护与发展这个项目。

如果你对写组件感兴趣的话,非常欢迎来试一试,还有许多组件的功能等着你来开发与完善,你可以随时回复感兴趣的 issue 参与讨论或发起一个新的 issue。

与大家共勉,共同学习进步🎉~