Vue3 + Esbuild + Typescript搭建组件库

1,216 阅读4分钟

前言

一个即将35岁退休的前端老白兔,玩点非工作内容。看前提醒:本文内容未经过生产测试,纯属学习娱乐 。废话不多说,直接开干

正文

1. 技术选型

技术描述
Vue3.0~
Esbuild构建工具
Typescript脚本
SassCss预处理
PostcssCss代码转换工具
Eslint语法规则和代码风格的检查工具

2. 目录结构

...
packages --------------- 组件库源码
 - assets -------------- scss、font文件
 - components ---------- 组件目录
  - Button ------------- 组件目录
   - index.ts ---------- 组件注册入口
   - src --------------- 组件源码
 - index.ts ----------- 组件库打包入口文件
examples -------------- 开发预览页面源码
 - main.ts ------------ 预览页入口文件
 - App.vue ------------ 预览页入口组件
 - index.html --------- 预览页htlm模板
types ----------------- ts声明文件目录
.editorconfig --------- 编辑器配置文件
.eslintignore --------- eslint检查忽略目录
.eslintrc ------------- eslint检查配置文件
esbuild.config.js ------ esbuild配置文件
tsconfig.json --------- ts编译配置文件
components.js --------- 单组件映射表
package.json ---------- npm项目配置文件
...

3. 环境搭建

  1. 创建文件夹并进行npm初始化 npm init
  2. 写入「package.json」文件
{
  "name": "fa-ui",
  "version": "1.0.0",
  "main": "lib/fa-ui.js",
  "module": "lib/fa-ui.js",
  "files": [
    "lib",
    "types",
    "packages"
  ],
  "types": "types/index.d.ts",
  "type": "module",
  "license": "MIT",
  "author": "fallen",
  "description": "description",
  "scripts": {
    "serve": "npm run clean && node esbuild.config.js serve",
    "build": "npm run clean && node esbuild.config.js",
    "clean": "rimraf ./lib ./examples/static",
    "lint": "eslint --ignore-path .eslintignore .",
    "lint:fix": "eslint --fix --ignore-path .eslintignore .",
    "dts": "vue-tsc --declaration --emitDeclarationOnly",
    "dts:check": "vue-tsc --noEmit"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^5.36.1",
    "@typescript-eslint/parser": "^5.36.1",
    "@vue/compiler-sfc": "^3.2.38",
    "autoprefixer": "^10.4.8",
    "browser-sync": "^2.27.10",
    "esbuild": "^0.15.6",
    "esbuild-plugin-filesize": "^0.3.0",
    "esbuild-plugin-progress": "^1.0.1",
    "esbuild-plugin-vue": "^0.2.4",
    "esbuild-sass-plugin": "^2.3.2",
    "eslint": "^8.23.0",
    "eslint-plugin-vue": "^9.4.0",
    "postcss": "^8.4.16",
    "postcss-import": "^15.0.0",
    "postcss-minify": "^1.1.0",
    "postcss-preset-env": "^7.8.0",
    "pre-commit": "^1.2.2",
    "rimraf": "^3.0.2",
    "sass": "^1.54.8",
    "typescript": "^4.8.2",
    "vue": "^3.2.38",
    "vue-tsc": "^0.40.6"
  },
  "pre-commit": [
    "lint"
  ]
}
  1. 写入后执行安装 npm install

4. eslint配置

.eslintrc 文件配置(配置根据自己需要进行调整)

{
  "root": true,
  "env": {
    "node": true,
    "browser": true
  },
  "globals": {
    "window": true,
    "process": true
  },
  "parser": "vue-eslint-parser",
  "parserOptions": {
    "parser": "@typescript-eslint/parser",
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "extends": [
    "plugin:@typescript-eslint/recommended",
    "plugin:vue/vue3-recommended"
  ],
  "rules": {
    "no-tabs": "off",
    "indent": ["error", 2],
    "quotes": ["error", "single"],
    "comma-dangle": "error",
    "semi": ["error", "never"],
    "comma-spacing": "error",
    "key-spacing": "error",
    "keyword-spacing": "error",
    "arrow-spacing": "error",
    "block-spacing": ["error", "always"],
    "object-curly-spacing": ["error", "always"],
    "switch-colon-spacing": "error",
    "space-before-blocks": "error",
    "space-before-function-paren": "error",
    "spaced-comment": "error",
    "no-trailing-spaces": "error",
    "space-infix-ops": ["error", { "int32Hint": false }],
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/ban-types": "off",
    "vue/require-default-prop": "off",
    "vue/require-explicit-emits": "off"
  }
}

5. esbuild配置

编辑 esbuild.config.js 文件

import { build } from 'esbuild'
import vue from 'esbuild-plugin-vue'
import { sassPlugin } from 'esbuild-sass-plugin'
import progress from 'esbuild-plugin-progress'
import { esbuildPluginFileSize } from 'esbuild-plugin-filesize'
import postcss from 'postcss'
import autoprefixer from 'autoprefixer'
import postcssPresetEnv from 'postcss-preset-env'
import postcssImport from 'postcss-import'
import browserSync from 'browser-sync'
import components from './components.js'

// 判断当前环境
const isServe = process.argv.includes('serve')
// 包名
const libraryName = 'fa-ui.js'

// 封装esbuild本地服务插件
function servePlugin (serveOptions = {}) {
  // 创建服务实例
  const bs = browserSync.create('dev-server')

  return {
    name: 'devServer',
    setup (build) {
      build.onEnd(() => {
        // 避免重复启动服务
        if (!bs.active) {
          // 初始化服务
          bs.init({
            port: 3000,
            watch: true,
            open: true,
            ...serveOptions
          })
        }
      })
    }
  }
}

// 打包组件库
async function buildLibrary () {
  // 全量打包
  await build({
    entryPoints: ['packages/index.ts'],
    outfile: `lib/${libraryName}`,
    bundle: true,
    format: 'esm',
    tsconfig: 'tsconfig.json',
    treeShaking: true,
    minify: true,
    external: ['vue'],
    loader: {
      '.eot': 'file',
      '.svg': 'file',
      '.ttf': 'file',
      '.woff': 'file'
    },
    plugins: [
      sassPlugin({
        async transform (source) {
          const { css } = await postcss([
            autoprefixer,
            postcssPresetEnv(),
            postcssImport()
          ]).process(source, { from: undefined })
          return css
        }
      }),
      vue(),
      progress(),
      esbuildPluginFileSize()
    ]
  })
  // 打包单文件组件
  await build({
    entryPoints: Object.values(components),
    outdir: 'lib/components',
    bundle: true,
    format: 'esm',
    tsconfig: 'tsconfig.json',
    treeShaking: true,
    minify: true,
    external: ['vue'],
    loader: {
      '.eot': 'dataurl',
      '.svg': 'dataurl',
      '.ttf': 'dataurl',
      '.woff': 'dataurl'
    },
    plugins: [
      sassPlugin({
        async transform (source) {
          const { css } = await postcss([
            autoprefixer,
            postcssPresetEnv(),
            postcssImport()
          ]).process(source, { from: undefined })
          return css
        }
      }),
      vue(),
      progress()
    ]
  })
}

// 打包预览页面
function buildExamples () {
  build({
    entryPoints: ['examples/main.ts'],
    outdir: 'examples/static',
    bundle: true,
    tsconfig: 'tsconfig.json',
    format: 'iife',
    watch: true,
    sourcemap: true,
    loader: {
      '.eot': 'file',
      '.svg': 'file',
      '.ttf': 'file',
      '.woff': 'file'
    },
    plugins: [
      sassPlugin({
        async transform (source) {
          const { css } = await postcss([
            autoprefixer,
            postcssPresetEnv(),
            postcssImport()
          ]).process(source, { from: undefined })
          return css
        }
      }),
      vue(),
      progress(),
      servePlugin({
        server: 'examples'
      })
    ]
  })
}

// 启动函数
async function start () {
  if (isServe) {
    buildExamples()
  } else {
    buildLibrary()
  }
}

start()

6. 创建组件

  1. 创建/packages/components/Button文件夹
  2. Button文件夹下面创建index.ts「组件局部注册入口」src/index.vue 「组件源码」
  3. 创建/packages/index.ts 「全局注册入口文件」
  4. 创建/packages/assets/scss/variable.scss 「统一管理scss变量」
  5. 创建/packages/assets/scss/index.scss 「scss变量入口」
  6. 创建/packages/assets/scss/index.scss 「组件样式」,根据需要调整

文件目录大致如下:

WX20220906-232353@2x.png

7. 组件编写与注册

组件编写

编辑组件源码文件/packages/components/Button/src/index.vue

<template>
  <button
    class="fa-button"
    :class="classNames"
    @click="handleClick"
  >
    <span
      v-if="slots.icon"
      class="fa-button__icon"
    >
      <slot name="icon" />
    </span>
    <span
      v-if="slots.default"
      class="fa-button__inner"
    >
      <slot />
    </span>
  </button>
</template>
<script lang="ts" setup>
import { withDefaults, useSlots, reactive } from 'vue'

type ButtonTypes = 'default' | 'text'| 'primary' | 'success' | 'warning' | 'info' | 'danger' | undefined
type ButtonSize = 'medium' | 'small' | 'mini' | undefined

const props = withDefaults(defineProps<{
  type?: ButtonTypes
  disabled?: boolean
  round?: boolean
  plain?: boolean
  circle?: boolean
  size?: ButtonSize
}>(), {
  type: 'default',
  size: 'medium',
  disabled: false,
  round: false,
  plain: false,
  circle: false
})

const classNames = reactive({
  'fa-button--text': props.type === 'text',
  'fa-button--primary': props.type === 'primary',
  'fa-button--success': props.type === 'success',
  'fa-button--warning': props.type === 'warning',
  'fa-button--info': props.type === 'info',
  'fa-button--danger': props.type === 'danger',
  'is-disabled': props.disabled,
  'is-plain': props.plain,
  'is-circle': props.circle,
  'is-round': props.round,
  [`size--${props.size}`]: !!props.size
})

const emit = defineEmits(['click'])

const slots = useSlots()

const handleClick = (e: Event) => {
  if (props.disabled) {
    return false
  }
  emit('click', e)
}

</script>

样式/packages/scss/button.scss

@import './variable';
/*
variable.scss代码如下
$--color-primary: #409EFF !default;
$--color-success: #67C23A !default;
$--color-warning: #E6A23C !default;
$--color-danger: #F56C6C !default;
$--color-info: #909399 !default;
$--color-border--default: #DCDFE6 !default;
$--color-font--default: #303133 !default;
$--font-size--mini: 12px !default;
$--font-size--small: 13px !default;
$--font-size--normal: 14px !default;
*/
@mixin button-hover($--font-color, $--background-color, $--border-color) {
  &:hover {
    color: $--font-color;
    background-color: $--background-color;
    border-color: $--border-color;
  }
}
@mixin button-is-plain(
  $--font-color--default,
  $--background-color--default,
  $--border-color--default,
  $--font-color--hover,
  $--background-color--hover,
  $--border-color--hover
  ) {
  &.is-plain {
    color: $--font-color--default;
    background-color: $--background-color--default;
    border-color: $--border-color--default;
    @include button-hover($--font-color--hover, $--background-color--hover, $--border-color--hover);
  }
}
.fa-button {
  border: {
    width: 1px;
    style: solid;
    color: $--color-border--default;
  }
  line-height: 1;
  border-radius: 4px;
  color: $--color-font--default;
  display: inline-block;
  box-sizing: border-box;
  padding: 12px 20px;
  font-size: $--font-size--normal;
  transition: all ease-in .2s;
  background-color: #fff;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  white-space: nowrap;
  text-align: center;
  &.size--medium {
    padding: 10px 20px;
  }
  &.size--small {
    font-size: $--font-size--small;
    padding: 9px 15px;
    border-radius: 3px;
  }
  &.size-mini {
    font-size: $--font-size--mini;
    padding: 7px 15px;
    border-radius: 3px;
  }
  &.is-disabled {
    opacity: .5;
    cursor: not-allowed;
  }
  &.is-round {
    border-radius: 50px;
  }
  &.is-circle {
    border-radius: 50%;
    padding: 12px;
  }
  @include button-hover(
    $--color-primary,
    rgba($--color-primary, .2),
    $--color-primary
  );
  @include button-is-plain(
    $--color-font--default,
    #fff,
    $--color-border--default,
    $--color-primary,
    #fff,
    $--color-primary
  );
  &--text {
    border: 0;
    color: $--color-primary;
    @include button-hover(
      lighten($--color-primary, 10%),
      #fff,
      #fff
    );
  }
  &--primary {
    background-color: $--color-primary;
    color: #fff;
    border-color: $--color-primary;
    @include button-hover(#fff, lighten($--color-primary, 10%), $--color-primary);
    @include button-is-plain(
      $--color-primary,
      rgba($--color-primary, .2),
      $--color-primary,
      #fff,
      $--color-primary,
      $--color-primary
    );
  }
  &--success {
    background-color: $--color-success;
    color: #fff;
    border-color: $--color-success;
    @include button-hover(#fff, lighten($--color-success, 10%), $--color-success);
    @include button-is-plain(
      $--color-success,
      rgba($--color-success, .2),
      $--color-success,
      #fff,
      $--color-success,
      $--color-success
    );
  }
  &--warning {
    background-color: $--color-warning;
    color: #fff;
    border-color: $--color-warning;
    @include button-hover(#fff, lighten($--color-warning, 10%), $--color-warning);
    @include button-is-plain(
      $--color-warning,
      rgba($--color-warning, .2),
      $--color-warning,
      #fff,
      $--color-warning,
      $--color-warning
    );
  }
  &--danger {
    background-color: $--color-danger;
    color: #fff;
    border-color: $--color-danger;
    @include button-hover(#fff, lighten($--color-danger, 10%), $--color-danger);
    @include button-is-plain(
      $--color-danger,
      rgba($--color-danger, .2),
      $--color-danger,
      #fff,
      $--color-danger,
      $--color-danger
    );
  }
  &--info {
    background-color: $--color-info;
    color: #fff;
    border-color: $--color-info;
    @include button-hover(#fff, lighten($--color-info, 10%), $--color-info);
    @include button-is-plain(
      $--color-info,
      rgba($--color-info, .2),
      $--color-info,
      #fff,
      $--color-info,
      $--color-info
    );
  }
  &+.fa-button {
    margin-left: 10px;
  }
  .fa-button__icon {
    line-height: 1;
    &+.fa-button__inner {
      margin-left: 5px;
      vertical-align: baseline;
    }
  }
}

局部注册

/packages/components/Button/index.ts文件中编辑如下代码:

import { App } from 'vue'
import Button from './src/FaButton.vue'
import '../../assets/scss/button.scss'

Button.name = 'FaButton'
Button.install = (app: App) => {
  app.component(Button.name, Button)
}

export default Button

全局注册

/packages/index.ts文件中编辑如下代码:

import { App } from 'vue'
import Button from './components/Button'
import './assets/scss/index.scss'

const components = [
  Button
]
// 全局注册
const install = (app: App) => {
  components.forEach(component => {
    app.component(component.name, component)
  })
}

export {
  Button,
  install
}

export default {
  install
}

致此,我们就完成了组件库源码部分的搭建开发,接下来我们还要需要一个预览测试组件功能的页面

8. 组件预览及功能测试

  1. 创建/examples/App.vue
  2. 创建/examples/main.ts
  3. 创建/examples/index.html

预览页面编辑

main.ts

import { createApp } from 'vue'
import FaUI from '../packages'
import App from './App.vue'

const app = createApp(App)
app.use(FaUI)
app.mount('#app')

App.vue

<template>
  <div class="btns">
    <fa-button>
      default
    </fa-button>
    <fa-button type="text">
      text
    </fa-button>
    <fa-button type="success">
      success
    </fa-button>
    <fa-button type="danger">
      danger
    </fa-button>
    <fa-button type="info">
      info
    </fa-button>
    <fa-button type="primary">
      primary
    </fa-button>
  </div>

  <div class="btns">
    <fa-button plain>
      default
    </fa-button>
    <fa-button
      type="text"
      plain
    >
      text
    </fa-button>
    <fa-button
      type="success"
      plain
    >
      success
    </fa-button>
    <fa-button
      type="danger"
      plain
    >
      danger
    </fa-button>
    <fa-button
      type="info"
      plain
    >
      info
    </fa-button>
    <fa-button
      type="primary"
      plain
    >
      primary
    </fa-button>
  </div>

</template>
<script lang="ts" setup>

</script>
<style>
.btns {
   margin-bottom: 10px;
}
</style>

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>fa-ui</title>
  <link rel="stylesheet" href="./static/main.css">
</head>
<body>
  <div id="app"></div>
  <script src="./static/main.js"></script>
</body>
</html>

9. 本地开发和生产打包

本地开发

npm run serve
# or
yarn serve

生产打包

npm run build
# or
yarn build

结束语

至此,《Vue3 + Esbuild + Typescript搭建组件库》的全流程结束。再次强调,本文内容未经过生产测试,纯属学习娱乐,如有错误还请指正,大家一起学习进步,感谢大家阅读~~~