使用 Pnpm + Vite + Typescript 搭建组件库
初衷
最近在看 element-plus、vant 这类适配 vue3 的组件库的时候,发现这些第三方的组件库都在用 pnpm 来管理整个项目,而恰巧 pnpm 是我之前未接触过的,于是就有了想法为何不像 element-plus 一样用 pnpm 搭建一套属于自己的组件库呢?
提示
项目结构部分代码参考 element-plus
参考 element-plus 项目结构
element-plus
├─ docs # 文档
├─ packages # 子包
├─ internal # 构建
└─ types # 类型
└─ package.json
在研究 element 项目代码的时候发现,element 是将多个项目放在同一个模块下开发的,而如果看过 vant、vue3 等项目会发现都是用的类似的结构,经过一番查找发现这种项目结构叫做 Monorepo,那么什么是 Monorepo 呢?
Monorepo
Monorepo 是一种将多个项目存放在同一个代码库中的开发策略
element、vant、vue3 等我们熟悉的项目都是基于 Monorepo 打造的,目前流行的 pnpm 的 workspace 可以支持 Monorepo。
项目结构
pnpm-ui
├─ docs # 文档
├─ packages # 子包
├─ internal # 构建
└─ package.json
Pnpm
安装 pnpm
npm install pnpm -g
创建项目目录
mkdir pnpm-ui
进入项目
cd pnpm-ui
初始化项目
该步骤会在 pnpm-ui 根目录下生成 package.json
pnpm init
创建 pnpm-workspace.yaml
pnpm-workspace.yaml
是定义了工作空间的根目录,可以通过通配符来包含和排除不需要的目录
packages:
- packages/*
- docs
- play
- internal/*
创建 .npmrc
pnpm 可以从 .npmrc
获取配置
shamefully-hoist=true
strict-peer-dependencies=false
shell-emulator=true
fetch-retries=5
fetch-retry-mintimeout=200000
fetch-retry-maxtimeout=1200000
fetch-timeout=1800000
registry=https://registry.npm.taobao.org
很多时候网络原因包下载不下来,这里的 fetch-retries
、fetch-retry-maxtimeout
、fetch-timeout
、registry
可以根据自己的网络情况决定是否配置。
安装 vue
pnpm install vue -D -w
配置 typescript
安装
pnpm install typescript -D -w
配置
在阅读 element-plus 源码的时候,发现 element 将 tsconfig 根据项目分成了几个部分:
tsconfig.json
tsconfig.base.json
tsconfig.web.json
tsconfig.play.json
tsconfig.node.json
tsconfig.vite-config.json
tsconfig.vitest.json
占时用不到 tsconfig.vitest.json
,所以删除 tsconfig.vitest.json
并且保留其他配置。
于是下面是详细的代码配置(几个文件都在根目录下):
tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.web.json" },
{ "path": "./tsconfig.play.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.vite-config.json" }
]
}
tsconfig.base.json
{
"compilerOptions": {
"outDir": "dist",
"target": "es2018",
"module": "esnext",
"baseUrl": ".",
"sourceMap": false,
"moduleResolution": "node",
"allowJs": false,
"strict": true,
"noUnusedLocals": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"removeComments": false,
"rootDir": ".",
"types": [],
"paths": {
"@pnpm-ui/*": ["packages/*"]
}
}
}
tsconfig.web.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"jsx": "preserve",
"lib": ["ES2018", "DOM", "DOM.Iterable"],
"types": ["unplugin-vue-macros/macros-global"],
"skipLibCheck": true
},
"include": ["packages", "typings/components.d.ts", "typings/env.d.ts"],
"exclude": [
"node_modules",
"**/dist"
]
}
tsconfig.play.json
{
"extends": "./tsconfig.web.json",
"compilerOptions": {
"allowJs": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"]
},
"include": [
"packages",
"typings/components.d.ts",
"typings/env.d.ts",
// playground
"play/main.ts",
"play/env.d.ts",
"play/src/**/*"
]
}
tsconfig.node.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ESNext"],
"types": ["node"],
"skipLibCheck": true
},
"include": [
"internal/**/*",
"internal/**/*.json",
],
"exclude": []
}
tsconfig.vite-config.json
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"composite": true,
"types": ["node"]
},
"include": ["**/vite.config.*", "**/vite.init.*"],
"exclude": []
}
错误处理
找不到“node”的类型定义文件
配置完上述配置后会发现 tsconfig.node.json
报了以下错误:
找不到“node”的类型定义文件。
程序包含该文件是因为:
在 compilerOptions 中指定的类型库 "node" 的入口点
这时候需要全局安装 @types/node
pnpm install @types/node -D -w
这时候就会发现上面的问题解决了~
include、exclude 报错
这是因为读取不到相关的文件,把报错的地方注释掉就好了~
配置 eslint + prettier
配置 eslint 和 prettier 是为了统一代码的质量风格,在阅读 element-plus 项目的时候,我们发现 element 把 eslint 主要规则放在根目录的 internal 文件下,这种做法让整个 eslint 配置更直观且易于维护,因此我们就直接参照 element 就好了。
项目安装 eslint + prettier
pnpm install eslint prettier -D -w
配置 eslint-config 包
创建 eslint-config
mkdir internal/eslint-config
init
然后再定位到 internal/eslint-config
文件下执行 pnpm init
生成 package.json
:
pnpm init
把修改下 internal/eslint-config
文件下 package.json
的包名改成 @pnpm-ui/eslint-config
:
package.json
{
"name": "@pnpm-ui/eslint-config",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
安装包
在 internal/eslint-config
执行以下命令:
pnpm install eslint prettier typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-define-config eslint-plugin-eslint-comments eslint-plugin-import eslint-plugin-jsonc eslint-plugin-markdown eslint-plugin-prettier eslint-plugin-unicorn eslint-plugin-vue jsonc-eslint-parser yaml-eslint-parser -D -F @pnpm-ui/eslint-config
pnpm install -F packagename
的作用是在 packagename 下安装包,所有上述命令的作用就是在 internal/eslint-config
目录下安装上面的这些包。
简单描述下一些包的作用:
- eslint-define-config:eslint 语法提示
- prettier、eslint-config-prettier、eslint-plugin-prettier: 兼容 eslint 配置,防止规则冲突报错
- eslint-plugin-eslint-comments:用来检查eslint 的特殊注解
- eslint-plugin-import:让 import 导入自动排序,让代码更简洁,更加清晰
- eslint-plugin-jsonc:为 JSON, JSONC and JSON5 提供验证规则
- eslint-plugin-markdown:为 markdown 提供规则
- eslint-plugin-unicorn:内置很多有用的 eslint 规则
创建 eslint-config 规则
internal/eslint-config/index.js
const { defineConfig } = require('eslint-define-config')
module.exports = defineConfig({
env: {
es6: true,
browser: true,
node: true,
},
plugins: ['@typescript-eslint', 'prettier', 'unicorn'],
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:eslint-comments/recommended',
'plugin:jsonc/recommended-with-jsonc',
'plugin:markdown/recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
settings: {
'import/resolver': {
node: { extensions: ['.js', '.mjs', '.ts', '.d.ts', '.tsx'] },
},
},
overrides: [
{
files: ['*.json', '*.json5', '*.jsonc'],
parser: 'jsonc-eslint-parser',
},
{
files: ['*.ts', '*.vue'],
rules: {
'no-undef': 'off',
},
},
{
files: ['package.json'],
parser: 'jsonc-eslint-parser',
rules: {
'jsonc/sort-keys': [
'error',
{
pathPattern: '^$',
order: [
'name',
'version',
'private',
'packageManager',
'description',
'type',
'keywords',
'homepage',
'bugs',
'license',
'author',
'contributors',
'funding',
'files',
'main',
'module',
'exports',
'unpkg',
'jsdelivr',
'browser',
'bin',
'man',
'directories',
'repository',
'publishConfig',
'scripts',
'peerDependencies',
'peerDependenciesMeta',
'optionalDependencies',
'dependencies',
'devDependencies',
'engines',
'config',
'overrides',
'pnpm',
'husky',
'lint-staged',
'eslintConfig',
],
},
{
pathPattern: '^(?:dev|peer|optional|bundled)?[Dd]ependencies$',
order: { type: 'asc' },
},
],
},
},
{
files: ['*.d.ts'],
rules: {
'import/no-duplicates': 'off',
},
},
{
files: ['*.js'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
{
files: ['*.vue'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
extraFileExtensions: ['.vue'],
ecmaVersion: 'latest',
ecmaFeatures: {
jsx: true,
},
},
rules: {
'no-undef': 'off',
},
},
{
files: ['**/*.md/*.js', '**/*.md/*.ts'],
rules: {
'no-console': 'off',
'import/no-unresolved': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
],
rules: {
// js/ts
camelcase: ['error', { properties: 'never' }],
'no-console': ['warn', { allow: ['error'] }],
'no-debugger': 'warn',
'no-constant-condition': ['error', { checkLoops: false }],
'no-restricted-syntax': ['error', 'LabeledStatement', 'WithStatement'],
'no-return-await': 'error',
'no-var': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'prefer-const': [
'warn',
{ destructuring: 'all', ignoreReadBeforeAssign: true },
],
'prefer-arrow-callback': [
'error',
{ allowNamedFunctions: false, allowUnboundThis: true },
],
'object-shorthand': [
'error',
'always',
{ ignoreConstructors: false, avoidQuotes: true },
],
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'error',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
// best-practice
'array-callback-return': 'error',
'block-scoped-var': 'error',
'no-alert': 'warn',
'no-case-declarations': 'error',
'no-multi-str': 'error',
'no-with': 'error',
'no-void': 'error',
'sort-imports': [
'warn',
{
ignoreCase: false,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
allowSeparatedGroups: false,
},
],
// stylistic-issues
'prefer-exponentiation-operator': 'error',
// ts
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false },
],
'@typescript-eslint/ban-ts-comment': ['off', { 'ts-ignore': false }],
// vue
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'vue/require-explicit-emits': 'off',
'vue/multi-word-component-names': 'off',
'vue/prefer-import-from-vue': 'off',
'vue/no-v-text-v-html-on-component': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
// prettier
'prettier/prettier': 'error',
// import
'import/first': 'error',
'import/no-duplicates': 'error',
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
'type',
],
pathGroups: [
{
pattern: 'vue',
group: 'external',
position: 'before',
},
{
pattern: '@vue/**',
group: 'external',
position: 'before',
},
{
pattern: '@w-ui/**',
group: 'internal',
},
],
pathGroupsExcludedImportTypes: ['type'],
},
],
'import/no-unresolved': 'off',
'import/namespace': 'off',
'import/default': 'off',
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/named': 'off',
// eslint-plugin-eslint-comments
'eslint-comments/disable-enable-pair': ['error', { allowWholeFile: true }],
},
})
引入 eslint-config
项目根目录安装 @pnpm-ui/eslint-config
项目根目录执行命令
pnpm install @pnpm-ui/eslint-config -D -w
创建 eslintrc
在项目根目录下创建 .eslintrc.json
touch .eslintrc.json
.eslintrc.json
内容为:
{
"root": true,
"extends": ["@pnpm-ui/eslint-config"]
}
配置 prettier
创建 .prettierrc
{
"semi": false,
"singleQuote": true,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}
创建 .prettierignore
dist
node_modules
pnpm-lock.yaml
配置 settings
到这里你会发现我们已经配置了 eslint 和 prettier 相关的内容,但是修改文件还是不会生效!
这是因为我们没有配置相关的规则让我们在保存的时候生效
在根目录创建 .vscode
文件夹
mkdir .vscode
在 .vscode
下创建 settings.json
{
"cSpell.words": ["Pnpm Ui", "pnpm-ui"],
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"npm.packageManager": "pnpm",
"eslint.probe": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"html",
"vue",
"markdown",
"json",
"jsonc"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"html",
"vue",
"markdown",
"json",
"jsonc"
],
"vite.devCommand": "pnpm run dev -- --",
}
注意
注意这里配置后需要重启下编辑器!
这个时候发现当修改项目下的文件后保存会自动格式化啦~
提醒
以上就是 pnpm + monorepo 基本的项目配置了,下面我们开始创建 play 测试项目吧!
play
安装相关配置
首先要在项目根目录创建 play 文件夹
mkdir play
然后进入 play
cd play
执行 pnpm init
创建 package.json
pnpm init
修改 paly 下 package.json
名称为:@pnpm-ui/play
然后安装相关包:
pnpm install vite @vitejs/plugin-vue unplugin-vue-components unplugin-vue-components unplugin-vue-macros -D -F @pnpm-ui/play
在 play 下的 package.json
添加相关运行的 scripts:
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
}
创建 index.html
/paly/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>W UI App</title>
<!-- element css cdn -->
</head>
<body>
<div id="play"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
创建入口 vue 文件
首先在 /play 文件夹下创建 src 文件夹,并在 src 文件夹下创建 App.vue
文件和 .gitignore
文件
mkdir /play/src
cd /play/src
touch App.vue
touch .gitignore
.gitignore 内容:
*
!.gitignore
表示忽略 src 文件夹下面的所有文件,不做提交,这个文件夹下的文件都是本地测试用的
App.vue 内容:
<template>App</template>
<script lang="ts" setup></script>
接下来在 /play 创建 app.example.vue
文件,内容为:
<template>
<div>app.example</div>
</template>
<script lang="ts" setup></script>
创建 main.ts
import { createApp } from 'vue'
;(async () => {
const apps = import.meta.glob('./src/*.vue')
const name = location.pathname.replace(/^\//, '') || 'App'
const file = apps[`./src/${name}.vue`]
if (!file) {
location.pathname = 'App'
return
}
const App = ((await file()) as unknown as any).default
const app = createApp(App)
app.mount('#play')
})()
这时候你会发现 import.meta.glob
语法爆红了,不过没关系,我们只需要在 /play 文件夹下创建 env.d.ts
文件,并将下面内容放进去就好了:
/// <reference types="vite/client" />
或者在在 tsconfig 中增加:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
创建 vite.init.ts
import { existsSync, readFileSync, writeFileSync } from 'fs'
const app = 'src/App.vue'
const example = 'app.example.vue'
if (!existsSync(app)) {
writeFileSync(app, readFileSync(example))
}
创建 vite.config.ts
/play/vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import VueMacros from 'unplugin-vue-macros/vite'
import './vite.init'
export default defineConfig(async ({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
server: {
host: true,
https: !!env.HTTPS,
},
plugins: [
VueMacros({
setupComponent: false,
setupSFC: false,
plugins: {
vue: vue(),
},
}),
Components({
dirs: `${__dirname}/../packages/components/**`,
extensions: ['vue'],
dts: false,
resolvers: [
(componentName) => {
if (componentName.startsWith('P'))
return { name: componentName, from: '@pnpm-ui/components' }
},
],
}),
],
optimizeDeps: {
include: ['vue', '@vue/shared'],
},
esbuild: {
target: 'chrome64',
},
}
})
在根目录下 package.json
添加 scripts
"scripts": {
"dev": "pnpm -C play dev",
}
测试运行
在根目录下运行:
pnpm dev
如果没什么问题的话,项目就正常跑起来啦!
提示
通过上面的步骤,我们已经把测试组件的项目搭建完成,接下来就要开始开发我们的组件了!
组件搭建
我们首先要在 /packages 目录下创建 components 文件夹,后续的 ui 组件都会放在 components 文件夹下。
mkdir packages/components
components 内容如下:
/packages/components
├─ button
├─src
├─button.vue
├─index.ts
├─ index.ts
└─ package.json
init
在 packages/components 执行 pnpm init 生成 package.json
pnpm init
修改 package.json
的 name:
{
"name": "@pnpm-ui/components",
"version": "0.0.1",
"description": "all components are settled here",
"main": "index.ts",
"module": "index.ts",
"unpkg": "index.js",
"jsdelivr": "index.js",
"types": "index.d.ts",
"sideEffects": false
}
创建 button 组件
这里只做简单的演示
在 packages/components/ 下创建 button 文件夹
mkdir packages/components/button
在 packages/components/button 创建 src 文件夹用于存放 相关组件
mkdir packages/components/button/src
在 packages/components/button/src 创建 button.vue 文件
touch packages/components/button/src/button.vue
button.vue
中内容
<template>
<div>this is button component</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'PButton',
})
</script>
导出 button
在 packages/components/button 下创建 index.ts
touch packages/components/button/index.ts
packages/components/button/index.ts 中内容如下:
import _button from './src/button.vue'
export const PButton = _button
export default PButton
在 packages/components 下创建 index.ts
引入 button
touch packages/components/index.ts
packages/components/index.ts 内容如下
export * from './button'
安装 @pnpm-ui/components
根目录下安装 @pnpm-ui/components
pnpm install @pnpm-ui/components -D -w
到这里基本上一个简单的 ui 组件就已经搭建完成了,下面测试下组件。
测试 PButton
现在我们稍微修改下 play/src/App.vue
的文件内容,将 PButton 引入,代码如下
<template>
App
<p-button />
</template>
<script lang="ts" setup></script>
然后在根目录运行:
pnpm dev
不出意外的话,我们的按钮就展示到页面上了!
那么接下来开始做规范化提交相关的配置啦!
规范化 commit message
初始化 git
根目录运行
git init
安装相关包
项目根目录安装:
pnpm install @commitlint/cli @commitlint/config-conventional cz-git czg husky lint-staged @esbuild-kit/cjs-loader fast-glob -D -w
在项目根目录创建 commitlint.config.js
和 commitlint.config.ts
commitlint.config.js
内容如下:
require('@esbuild-kit/cjs-loader')
module.exports = require('./commitlint.config.ts').default
commitlint.config.ts
内容如下:
commitlint.config.ts
import { execSync } from 'child_process'
import fg from 'fast-glob'
const getPackages = (packagePath: string) =>
fg.sync('*', { cwd: packagePath, onlyDirectories: true })
const scopes = [
...getPackages('packages'),
...getPackages('internal'),
'docs',
'play',
'project',
'core',
'style',
'ci',
'dev',
'deploy',
'other',
'typography',
'color',
'border',
'var',
'ssr',
]
const gitStatus = execSync('git status --porcelain || true')
.toString()
.trim()
.split('\n')
const scopeComplete = gitStatus
.find((r) => ~r.indexOf('M packages'))
?.replace(/\//g, '%%')
?.match(/packages%%((\w|-)*)/)?.[1]
const subjectComplete = gitStatus
.find((r) => ~r.indexOf('M packages/components'))
?.replace(/\//g, '%%')
?.match(/packages%%components%%((\w|-)*)/)?.[1]
export default {
rules: {
/**
* type[scope]: [function] description
* ^^^^^
*/
'scope-enum': [2, 'always', scopes],
/**
* type[scope]: [function] description
*
* ^^^^^^^^^^^^^^ empty line.
* - Something here
*/
'body-leading-blank': [1, 'always'],
/**
* type[scope]: [function] description
*
* - something here
*
* ^^^^^^^^^^^^^^
*/
'footer-leading-blank': [1, 'always'],
/**
* type[scope]: [function] description [No more than 72 characters]
* ^^^^^
*/
'header-max-length': [2, 'always', 72],
'scope-case': [2, 'always', 'lower-case'],
'subject-case': [
1,
'never',
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
/**
* type[scope]: [function] description
* ^^^^
*/
'type-enum': [
2,
'always',
[
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'release',
'style',
'test',
'improvement',
],
],
},
prompt: {
defaultScope: scopeComplete,
customScopesAlign: !scopeComplete ? 'top' : 'bottom',
defaultSubject: subjectComplete && `[${subjectComplete}] `,
allowCustomIssuePrefixs: false,
allowEmptyIssuePrefixs: false,
},
}
修改 package.json
"scripts": {
"cz": "czg",
},
"config": {
"commitizen": {
"path": "./node_modules/cz-git"
}
},
"lint-staged": {
"*.{vue,js,ts,jsx,tsx,md,json}": "eslint --fix"
}
配置 husky
执行 init
npx husky-init
添加 hooks
npx husky add .husky/commit-msg 'pnpm exec commitlint --config commitlint.config.js --edit "${1}"'
这时候会发现项目根目录下创建了 .husky 文件夹,.husky 结构如下:
📦.husky
┣ 📂_
┃ ┣ 📜.gitignore
┃ ┗ 📜husky.sh
┣ 📜commit-msg
┗ 📜pre-commit
commit-msg 内容修改如下:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm exec commitlint --config commitlint.config.js --edit "${1}"
pre-commit 内容修改如下:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm exec lint-staged
.gitignore
# Editor directories and files
.idea
# Package Manager
node_modules
.pnpm-debug.log*
# System
.DS_Store
# Bundle
dist
# local env files
*.local
.eslintcache
到此项目的规范化 commit 就完成啦!
总结
除了组件库的项目文档外,基本上到这里就完成了 pnpm + monorepo 的 ui 组件库的项目搭建了!
后面有时间把文档搭建也加上~