如何搭建一个自己的组件库

927 阅读5分钟

主要内容

  • pnpm使用
  • ci搭建 eslint + prettier + husky + commitlint + stylelint
  • 单元测试 vitest
  • 搭建文档 vitepress
  • 文档部署 GitHub
  • 项目打包 tsup
  • 测试路径搭建 vite+antd-design-pro
  • npm发布 changesets
  • npm自动部署 GitHub workflow

pnpm使用

  • pnpm安装:
// Windows
iwr https://get.pnpm.io/install.ps1 -useb | iex
// mac
brew install pnpm
// 通用
npm install -g pnpm
  • 初始化
pnpm init
npx tsc --init
  • pnpm-workspace.yaml:
// pnpm包路径
packages:
  - packages/*
  - playground
  - docs
// 安装公共依赖
pnpm i typescript -w 
// 安装开发依赖
pnpm i typescript -Dw 
// 安装指定依赖
pnpm add <package_name> --filter <package_selector>
// 运行单个包的scripts脚本
pnpm dev --filter <package_selector>
// 各个 packages/* 模块包间的相互依赖
pnpm install <package_selector1> -r --filter <package_selector2>
// 但是安装后的包会带上具体版本 所以需要我们手动更改"package_selector1": "workspace:^1.0.0"
"package_selector1": "workspace:*"

ci搭建

  • 配置 eslint

    • 安装

    •   pnpm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -Dw
      
    • 新建 .eslintrc(eslint配置) 和 .eslintignore(eslint检查忽略文件) eslint官网

      • 需要自定义插件可以使用yo eslint:plugin开发,命名需要以eslint-plugin开头
      • eslint和prettier配置可能出现冲突,一般会使用eslint-plugin-prettier解决冲突
      •   pnpm i eslint-plugin-prettier -Dw
        
    •   // eslint配置 可以根据自己需要添加规则 配置eslint插件时需要注意插件支持的node版本号
        {
          "root": true,
          "env": {
            "browser": true,
            "es2021": true,
            "es6": true,
            "node": true
          },
          "extends": ["prettier"],
          "parser": "@typescript-eslint/parser",
          "parserOptions": {
            "sourceType": "module",
            "ecmaVersion": 12,
            "ecmaFeatures": {
              "jsx": true,
              "tsx": true
            }
          },
          "plugins": ["@typescript-eslint"],
          "ignorePatterns": ["*.md", "!.vitepress/**"],
          "rules": {
            "no-console": "warn",
            "no-debugger": "warn"
          }
        }
      
    • package.jsonscript 添加脚本

    •   "lint:js": "eslint . --ext .js --ext .ts --ext .jsx --ext .tsx --no-fix --cache",
        "lint:js:fix": "eslint . --ext .js --ext .ts --ext .jsx --ext .tsx --fix",
      
  • 配置 prettier:

    • 安装
    •   pnpm i prettier eslint-config-prettier eslint-plugin-prettier -Dw
      
    • 新建 .prettierrc(非必须,可不填)
    •   {
          "bracketSpacing": true,
          "jsxBracketSameLine": true,
          "jsxSingleQuote": false,
          "printWidth": 140,
          "semi": false,
          "useTabs": false,
          "singleQuote": true,
          "tabWidth": 2,
          "endOfLine": "auto",
          "trailingComma": "none"
        }
      
    • package.jsonscript 添加脚本
    •   "lint:format": "prettier -c ./ --cache .",
        "lint:format:fix": "prettier -w ./",
      
  • 配置 stylelint

    • 安装
    •   pnpm i stylelint stylelint-config-recess-order stylelint-config-standard stylelint-scss -Dw
      
    • 新建 .stylelintrc.json
    •   {
          "extends": ["stylelint-config-standard", "stylelint-config-recess-order"],
          "plugins": ["stylelint-scss"],
          "rules": {
            "selector-pseudo-class-no-unknown": null,
            "at-rule-no-unknown": null
          }
        }
      
    • package.jsonscript 添加脚本
    •   "lint:style": "stylelint **/*.{css,less,scss} --cache",
        "lint:style:fix": "stylelint  **/*.{css,less,scss} --fix",
      
  • 配置 type-check:

    • 安装
    •   pnpm i type-check -Dw
      
  • 配置 husky:

    • 安装

    •   pnpm i husky lint-staged -Dw
      
    • package.jsonscript 添加脚本 提交时可以自动修复代码

    •   "script":{
          "prepare": "husky",
        },
         "lint-staged": {
            "*.{vue,js,ts,jsx,tsx,json}": [
              "eslint --fix",
              "prettier --write",
              "bash -c 'npm run type-check'"
            ],
            "*.{scss,css,less}": [
               "stylelint --fix"
            ]
          },
      
    • 初始化 husky(按顺序运行以下命令)

    •   # 按顺序运行以下命令
        npx husky install
        npx husky init
      
    • 编辑pre-commit文件

      ```js
      npx --no-install lint-staged
      ```
      
  • 配置 commitlint:

    • 安装
    •   pnpm i @commitlint/config-conventional @commitlint/cli  -Dw
      
    • 创建 commitlint.config.ts
    •   module.exports = {
          extends: ['@commitlint/config-conventional'],
        };
      
    • 新增.husky/commit-msg文件,并且输入
    •   npx --no -- commitlint --edit "$1"
      

单元测试

  • 安装
pnpm i vitest -Dw
  • 新建 vitest.config.ts
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true
  },
  resolve: {
    alias: {
      '@yu-kit/utils': resolve(__dirname, 'packages/utils/index.ts'),
      '@yu-kit/kit': resolve(__dirname, 'packages/kit/index.ts'),
      '@yu-kit/components': resolve(__dirname, 'packages/components/index.ts'),
      '@yu-kit/hooks': resolve(__dirname, 'packages/hooks/index.ts')
    }
  }
})
  • 配置运行脚本:package.json
"script": {
    ...
    "test": "vitest test", // 执行测试
    "coverage": "vitest run --coverage" // 执行测试覆盖率,需要安装 @vitest/coverage-c8
    ...
}

搭建文档

这里选择的是vitepress,后续会采用dumi

  • 安装
pnpm i vitepress -Dw
  • 配置运行脚本: package.json
"script": {
    ...
    "docs:dev": "vitepress dev packages",
    "docs:build": "vitepress build packages",
    ...
}
  • 基本配置:新建 packages/index.md 文件 【主页配置】
---
layout: home
sidebar: false

title: yu-kit
titleTemplate: 前端工具库

hero:
  name: yu-kit
  text: 前端工具库
  tagline: 🎉 前端工具库
  actions:
    - theme: brand
      text: 快速开始
      link: /guide/
    - theme: brand
      text: 组件文档
      link: /components/Button/
    - theme: brand
      text: hooks
      link: /hooks/useCallback/
    - theme: brand
      text: 工具类
      link: /kits/ElementHandler/
    - theme: brand
      text: 实用函数
      link: /utils/interval/
---
  • 基本配置:新建 packages/.vitepress/config.ts 文件:【文件配置】
const Guide = [{ text: '快速开始', link: '/guide/' }]

const components = [
  { text: '按钮组件', link: '/components/Button/' },
  { text: '轻提示组件', link: '/components/Toast/' }
]

const hooks = [
  { text: 'useCallback', link: '/hooks/useCallback/' },
  { text: 'useStates', link: '/hooks/useStates/' },
  { text: 'useLoading', link: '/hooks/useLoading/' },
  { text: 'usePrev', link: '/hooks/usePrev/' },
  { text: 'useMemo', link: '/hooks/useMemo/' }
]

const kits = [{ text: '组件处理器', link: '/kits/ElementHandler/' }]

const functions = [
  { text: 'interval', link: '/utils/interval/' },
  { text: 'copyToClipboard', link: '/utils/copyToClipboard/' },
  { text: 'cleanFileUrl', link: '/utils/cleanFileUrl/' },
  { text: 'downloadFile', link: '/utils/downloadFile/' },
  { text: 'delNulOp', link: '/utils/delNulOp/' }
]

const DefaultSideBar = [
  { text: '指南', items: Guide },
  { text: '组件文档', items: components },
  { text: 'hooks', items: hooks },
  { text: '工具类', items: kits },
  { text: '实用函数', items: functions }
]

export default {
  base: '/yu-kit/',
  title: 'yu-kit',
  lang: 'zh-CN',
  themeConfig: {
    logo: '/logo.png',
    lastUpdated: true,
    lastUpdatedText: '最后修改时间',
    socialLinks: [{ icon: 'github', link: 'https://github.com/encodedecod/yu-kit/' }],
    nav: Guide,
    // 侧边栏
    sidebar: DefaultSideBar
  }
}
  • 基本配置:新建 packages/.vitepress/theme/index.ts 文件:【主题】
import DefaultTheme from 'vitepress/theme'

export default {
  ...DefaultTheme
}
  • vitepress的一些基础使用 文档参考 vitepress
// 表格
| 名称      |      类型       |                 描述 | 默认值 | 是否必填 |
| --------- | :-------------: | -------------------: | -----: | -------: |
| title     | React.ReactNode |           轻提示内容 |        |        |
| visible   |     boolean     |             是否显示 |  false |          |
| mask      |     boolean     |         是否有遮罩层 |  false |          |
| duration  |     number      | 轻提示持续显示的时间 |   1500 |          |
| className |     string      |       className 扩展 |        |          |

// 主题
---title: Blogging Like a Hacker
lang: en-US
---
// js代码
```js
console.log('Hello, VuePress!')
```
  • 运行 pnpm docs:dev

文档部署

  • 生成 GitHub 的 Secerts

  • 创建 .github/workflows/docs-deploy.yml 文件 下面的GITHUB_TOKEN: ${{ secrets.H_DEVKIT }}的H_DEVKIT为设置的

name: docs-deploy

on: # 触发条件
  # 每当 push 到 main 分支时触发部署
  push:
    branches: [main]

jobs:
  docs:
    runs-on: ubuntu-latest # 指定运行所需要的虚拟机环境(必填)

    steps:
      - uses: actions/checkout@v3
        with:
          # “最近更新时间” 等 git 日志相关信息,需要拉取全部提交记录
          fetch-depth: 0

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 7

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          # 选择要使用的 node 版本
          node-version: '16'
          cache: 'pnpm'

      # 如果缓存没有命中,安装依赖
      - name: Install dependencies
        run: pnpm install --no-frozen-lockfile --ignore-scripts

      # 运行构建脚本
      - name: Build vitepress site
        run: pnpm docs:build
        env:
          DOC_ENV: preview
          NODE_OPTIONS: --max-old-space-size=4096

      # 查看 workflow 的文档来获取更多信息
      # @see https://github.com/crazy-max/ghaction-github-pages
      - name: Deploy to GitHub Pages
        uses: crazy-max/ghaction-github-pages@v3
        # 环境变量
        env:
          GITHUB_TOKEN: ${{ secrets.H_DEVKIT }}
        with:
          # 部署到 gh-pages 分支
          target_branch: gh-pages
          # 部署目录为 vitepress 的默认输出目录
          build_dir: docs/.vitepress/dist

项目打包

什么是 esm、cjs、iife 格式

  • esm 格式:ECMAScript Module,现在使用的模块方案,使用 import export 来管理依赖;

  • cjs 格式:CommonJS,只能在 NodeJS 上运行,使用 require("module") 读取并加载模块;

  • iife 格式:通过 <script> 标签引入的自执行函数;

  • 安装

pnpm add tsup -Dw
  • 在根目录下配置文件: tsup.config.ts ****tsup配置文档

    • 实现了packpage下的文件夹多包自动打包
    • 针对esbuild不引入css模块做了引入处理
import { defineConfig, Format, Options } from 'tsup'
import fg from 'fast-glob'
import { sassPlugin } from 'esbuild-sass-plugin'
import fs from 'fs'
import postcss from 'postcss'
import autoprefixer from 'autoprefixer'

const baseConfigs = [
  {
    dts: true, // 添加 .d.ts 文件
    metafile: false, // 添加 meta 文件
    minify: true, // 压缩
    splitting: false,
    sourcemap: false, // 添加 sourcemap 文件
    clean: true, // 是否先清除打包的目录,例如 dist
    format: ['cjs'] as Format[]
  },
  {
    dts: true, // 添加 .d.ts 文件
    metafile: false, // 添加 meta 文件
    minify: true, // 压缩
    splitting: false,
    sourcemap: false, // 添加 sourcemap 文件
    clean: true, // 是否先清除打包的目录,例如 dist
    format: ['esm'] as Format[]
  }
]
const filePaths: { text: string; path: string }[] = []
const hasHandlePath: string[] = []

const myReadfile = () => {
  const entries = fg.sync([`./packages/**/index.ts`, `./packages/**/index.tsx`], {
    onlyFiles: false,
    deep: Infinity,
    ignore: [`**/cli/**`, `**/node_modules/**`, `**/*.test.ts`]
  })
  const configs: Options[] = []
  baseConfigs.forEach((baseConfig) =>
    entries.forEach((file) => {
      const outDir = file.replace(/(packages/)(.*?)//, `./packages/$2/cli/${baseConfig.format[0]}/`).replace(//index.(ts|tsx)$/, '')
      configs.push({
        target: ['esnext'],
        entry: [file],
        outDir: outDir,
        loader: {
          '.js': 'jsx',
          '.jsx': 'jsx',
          '.scss': 'css',
          '.sass': 'css',
          '.less': 'css',
          '.css': 'css',
          '.tsx': 'tsx'
        },
        ...baseConfig,
        esbuildPlugins: [
          sassPlugin({
            async transform(source) {
              const { css } = await postcss([autoprefixer]).process(source)
              return css
            }
          }),
          {
            name: 'scss-plugin',
            setup: (build) => {
              build.onEnd((result) => {
                result.outputFiles?.forEach((item) => {
                  if (
                    /index.(mjs|js)$/.test(item.path) &&
                    result.outputFiles?.find((outputItem) => outputItem.path === item.path.replace(/(.js|.mjs)$/, '.css'))
                  ) {
                    filePaths.push({ text: item.text, path: item.path })
                  }
                })
              })
            }
          }
        ],
        onSuccess: async () => {
          filePaths.forEach((item) => {
            if (!hasHandlePath.find((val) => val === item.path)) {
              fs.access(item.path, (err) => {
                if (!err) {
                  let data = item.text
                  data = `import "./index.css"; ${data}`
                  fs.writeFile(
                    item.path,
                    `import "./index.css"; ${item.text}`,
                    {
                      encoding: 'utf-8'
                    },
                    (fileError) => {
                      if (!fileError) {
                        hasHandlePath.push(item.path)
                      }
                    }
                  )
                }
              })
            }
          })
        }
      })
    })
  )
  return defineConfig(configs)
}

export default myReadfile()
  • package.json 下添加脚本
"scripts": {
  "dev": "tsup --watch",
  "build": "tsup"
},
  • 运行效果

测试路径搭建

  • 根目录新建test文件夹, pnpm-workspace.yaml新增test
packages:
  - packages/*
  - playground
  - docs
  - test
  • 在test执行
pnpm init
npx tsc --init
  • 根路径执行下面命令
pnpm add antd react-dom react-router-dom @ant-design/icons @ant-design/pro-components -D --filter test
pnpm add @types/react @types/react-dom vite @vitejs/plugin-react -Dw --filter test
  • 在新建test/index.html文件
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>yu-kit-test</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
    <script>
      window.global = window
    </script>
  </body>
</html>
  • test/src/index.tsx
import { createRoot } from 'react-dom/client'
import App from './App'
import React from 'react'
import { BrowserRouter } from 'react-router-dom'

const root = document.getElementById('root')
if (root) {
  createRoot(root).render(
    <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </React.StrictMode>
  )
}
  • test/src/App.tsx
import { Route, Routes } from 'react-router-dom'
import Home from '@/pages/home'

import './App.scss'

const App = () => {
  return (
    <div className="App">
      <Routes>
        <Route path="/*" element={<Home />} />
      </Routes>
    </div>
  )
}

export default App
  • test/src/index.tsx(主文件目录)
import { createRoot } from 'react-dom/client'
import App from './App'
import React from 'react'
import { BrowserRouter } from 'react-router-dom'

const root = document.getElementById('root')
if (root) {
  createRoot(root).render(
    <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </React.StrictMode>
  )
}
  • test/src/App.tsx(路由总目录)
import { Route, Routes } from 'react-router-dom'
import Home from '@/pages/home'

import './App.scss'

const App = () => {
  return (
    <div className="App">
      <Routes>
        <Route path="/*" element={<Home />} />
      </Routes>
    </div>
  )
}

export default App
  • test/vite.config.ts
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  base: '/',
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})
  • 在 test/package.jsonscript 添加脚本
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build"
  },
  • package.jsonscript 添加脚本
"test:dev": "pnpm --filter @yu-kit/test dev",
"test:build": "pnpm --filter @yu-kit/test build"
  • 运行pnpm test:dev效果

npm发布

  • 登录 npm(按照提示输入用户名密码 邮箱 即可,未注册需在 www.npmjs.com/ 注册)
npm login
  • package.json配置

    • 如果发布的 npm 包名为:@xxx/yyy 格式,需要先在 npm 注册名为:xxx 的 organization,否则会出现提交不成功;如@yu-kit/components组件需要注册名为yu-kit的organization
    • 发布到 npm group 时默认为 private,所以我们需要手动在每个 packages 子包中的 package.json 中添加如下配置 (为private时不支持发布,对于不需要打包的包文件可以改为private
    •   "publishConfig": {
             "access": "public"
         },
      
    • 添加packages子包导出路径
    •     "main": "./cli/cjs/index.js",
          "module": "./cli/esm/index.mjs",
          "types": "./cli/cjs/index.d.ts",
          "exports": {
            ".": {
              "require": "./cli/cjs/index.js",
              "import": "./cli/esm/index.mjs",
              "types": "./cli/cjs/index.d.ts"
            },
            "./*": "./*"
          },
      
    • 设置npm打包导出的具体包
    •     "files": [
            "cli"
          ],
      
  • 由于项目存在多个包文件,需安装changesets

pnpm i @changesets/cli -Dw
  • 初始化 changesets
pnpm changeset init
  • 配置 package.json 的发布脚本
{
    "script": {
        "release": "changeset publish",
    }
}
  • 发布效果

npm自动部署

  • 配置NPM Token

  • 设置当前仓库的 Secerts

  • 配置好后再gitflow下加 (NPM_PUBLISH为配置的秘钥名)
      - name: NPM BUILD KIT # 打包工具的cli包
        run: pnpm build
        env:
          DOC_ENV: preview
      - name: Publish to NPM # 推送到 NPM 上
        run: |
          pnpm config set //registry.npmjs.org/:_authToken=$NPM_PUBLISH
          pnpm release
        env:
          NPM_PUBLISH: ${{ secrets.NPM_PUBLISH }}
  • 打包效果

转存失败,建议直接上传图片文件

后续:对于公司级项目,需要自己搭建npm私有域 一般借助verdaccio 需要对应的服务器资源

参考文档

vscode eslint prettier stylelint typescript 配置

利用 GitHub Actions 自动更新Docs文档和发布NPM包

手摸手教你搭建npm私有库