手把手创建Vue3组件库
动机
当市面上主流的组件库不能满足我们业务需求的时候,那么我们就有必要开发一套属于自己团队的组件库。
环境
开发环境:
- vue 3.0
- vue/cli 4.5.13
- nodeJs 12.16.3
- npm 6.14.4
custom-npm-ui
$ vue create custom-npm-ui
手动选择设置。
规划目录
├─ build// 打包脚本,用于存放打包配置文件
│├─ rollup.config.js
├─ examples// 原 src 目录,改成 examples 用于示例展示
│├─ App.vue
│├─ main.ts
├─ packages// 新增 packages 目录,用于编写存放组件,如button
│├─ SubmitForm
││├─ src/
││├─ index.ts
│├─ index.ts
├─ typings// 新增 typings 目录, 用于存放 .d.ts 文件,把 shims-vue.d.ts 移动到这里
│├─ shims-vue.d.ts
├─ .npmignore// 新增 .npmignore 配置文件
├─ vue.config.js // 新增 vue.config.js 配置文件
将
src
目录改为 examples
,并将里面的 assets
和 components
目录删除,移除 App.vue
里的组件引用。项目配置 vue.config.js
新增
vue.config.js
配置文件,适配重新规划后的项目目录:// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path')module.exports = {
// 修改 pages 入口
pages: {
index: {
entry: "examples/main.ts", //入口
template: "public/index.html", //模板
filename: "index.html" //输出文件
}
},
// 扩展 webpack 配置
chainWebpack: (config) => {
// 新增一个 ~ 指向 packages 目录, 方便示例代码中使用
config.resolve.alias
.set('~', path.resolve('packages'))
}
}
.npmignore
新增
.npmignore
配置文件,组件发布到 npm
中,只有编译后的发布目录(例如lib
)、package.json
、README.md
才是需要被发布的,所以我们需要设置忽略目录和文件# 忽略目录
.idea
.vscode
build/
docs/
examples/
packages/
public/
node_modules/
typings/# 忽略指定文件
babel.config.js
tsconfig.json
tslint.json
vue.config.js
.gitignore
.browserslistrc
*.map
或者配置
pkg#files
:"files": [
"lib/",
"package.json",
"README.md"
],
安装依赖后的目录结构:
└─custom-npm-ui
│package.json
│README.md
└─lib
index.css
index.esm.js
index.min.js
tsconfig.json
修改 tsconfig.json 中 paths 的路径
"paths": {
"@/*": [
"src/*"
]
}
改为
"paths": {
"~/*": [
"packages/*"
]
}
Notes:
typescript
支持的别名。修改 include 的路径
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
]
改为
"include": [
"examples/**/*.ts",
"examples/**/*.tsx",
"examples/**/*.vue",
"packages/**/*.ts",
"packages/**/*.tsx",
"packages/**/*.vue",
"typings/**/*.ts",
"typings/shims-vue.d.ts",
"tests/**/*.ts",
"tests/**/*.tsx"
]
package.json
修改 package.json 中发布到 npm 的字段
name
:包名,该名字是唯一的。可在npm远程源搜索名字,如果存在则需换个名字。version
:版本号,每次发布至 npm 需要修改版本号,不能和历史版本号相同。description
:描述。main
:入口文件,该字段需指向我们最终编译后的包文件。typings
:types文件,TS组件需要。keyword
:关键字,以空格分离希望用户最终搜索的词。author
:作者信息private
:是否私有,需要修改为 false 才能发布到 npmlicense
: 开源协议
{
"name": "custom-npm-ui",
"version": "0.1.0",
"private": false,
"description": "基于ElementPlus二次开发的前端组件库",
"main": "lib/index.min.js",
"module": "lib/index.esm.js",
"typings": "lib/index.d.ts",
"keyword": "vue3 element-plus",
"license": "MIT",
"author": {
"name": "yourname",
"email": "youremail@163.com"
}
}
在 package.json 的 scripts 新增编译和发布的命令
"scripts": {
"build": "yarn build:clean && yarn build:lib && yarn build:esm-bundle && rimraf lib/demo.html",
"build:clean": "rimraf lib",
"build:lib": "vue-cli-service build --target lib --name index --dest lib packages/index.ts",
"build:esm-bundle": "rollup --config ./build/rollup.config.js"
}
其中
build:lib
是利用 vue-cli
进行 umd
方式打包,build:esm-bundle
是利用 rollup
进行 es
方式打包。build:lib
具体参数解析如下:--target
: 构建目标,默认为应用模式。改为lib
启用库模式。--name
: 输出文件名--dest
: 输出目录,默认dist
。改成lib
[entry]
: 入口文件路径,默认为src/App.vue
。这里我们指定编译packages/
组件库目录。
build:esm-bundle
打包后的资源在webpack2+
、rollup
环境中可通过pkg#module
配置载入使用。rollup.config.js
新增
build/rollup.config.js
,rollup
打包脚本:import cjs from "@rollup/plugin-commonjs";
// commonjs转es module —— rollup只支持es module
import resolve from "@rollup/plugin-node-resolve";
// 搭配@rollup/plugin-commonjs使用
// import ts from '@rollup/plugin-typescript' // 【报错】使用ts报错
import typescript from "rollup-plugin-typescript2";
// 解析TS语法
import vue from "rollup-plugin-vue";
// 解析vue
import babel from "@rollup/plugin-babel";
import scss from "rollup-plugin-scss";
// 解析scss
// import requireContext from "rollup-plugin-require-context";
// 【不可用】支持webpack的require.context API —— 需要安装npm install --save-dev generate-source-map@0.0.5
import { writeFileSync, existsSync, mkdirSync } from "fs";
const extensions = [".js", ".ts", ".vue"];
export default {
input: "packages/index.ts",
output: [
{
file: "lib/index.esm.js", // 多文件输出的话,需要使用dir替代file
format: "es",
globals: {
vue: "Vue", // 告诉rollup全局变量Vue即是vue
},
},
],
extensions,
plugins: [ // 顺序很重要
scss({
output: function (styles, styleNodes) {
if (!existsSync("lib/")) {
mkdirSync("lib/");
}
writeFileSync("lib/index.css", styles);
},
}),
vue({
compileTemplate: true,
}),
// requireContext(),
resolve({
jsnext: true,
main: true,
browser: true,
extensions,
}),
cjs(),
typescript(),
babel({}),
],
external: ["vue", "element-plus"],
};
开发组件 注意事项
- 组件内不能使用懒加载
- 组件不能使用
require.context()
统一管理 - 不支持
JSX
语法编写模版 —— 更好的选择React
环境依赖
$ npm i -D rimraf rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-typescript2 rollup-plugin-vue @rollup/plugin-babel rollup-plugin-scss
开发依赖
$ npm i -D element-plus@1.0.2-beta.69 babel-plugin-import
【手把手创建Vue3组件库】配置
.babel.config.js
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
plugins: [
[
"import",
{
libraryName: "element-plus",
},
],
],
};
更新
examples/main.ts
:import { createApp } from "vue";
import App from "./App.vue";
import "element-plus/lib/theme-chalk/index.css";
createApp(App).mount("#app");
编写组件
在 packages 目录下新建
index.ts
文件和 SubmitForm/
文件夹,在 SubmitForm
下新建 index.ts
和 src/index.vue
,结构如下:.
├── SubmitForm
│├── SubmitForm.stories.ts
│├── index.ts
│└── src
│├── FormRender.vue
│├── fileds
││├── Color.vue
││├── Datetime.vue
││├── Radio.vue
││├── Select.vue
││├── Switch.vue
││├── Text.vue
││├── Upload.vue
││├── hooks
│││└── useValueHandleHook.ts
││└── index.ts
│├── index.vue
│├── schemas
││├── baseSchema.ts
││└── schemasProp.ts
│└── store
│└── index.ts
├── common
│└── getType.ts
└── index.ts
packages/SubmitForm/src/index.vue
packages/SubmitForm/index.ts
,单独组件的入口文件,在其他项目可以使用 import { SubmitForm
} from 'custom-npm-ui' 方式进行单个组件引用import type { App } from "vue";
import SubmitForm from "./src/index.vue";
// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,那么所有的组件都会被注册
SubmitForm.install = function (Vue: App) {
// 遍历注册全局组件
Vue.component(SubmitForm.name, SubmitForm);
};
export default SubmitForm;
packages/index.ts
作为组件库的入口文件,可以在其他项目的 main.ts
引入整个组件库,内容如下import type { App } from "vue";
import SubmitForm from "./SubmitForm";
const components = [SubmitForm];
// 定义 install 方法,接收 Vue 作为参数。如果使用 use 注册插件,那么所有的组件都会被注册
const install = function (Vue: App): void {
// 遍历注册全局组件
components.map((component) => Vue.component(component.name, component));
};
export {
// 以下是具体的组件列表
SubmitForm,
};
export default {
// 导出的对象必须具有 install,才能被 Vue.use() 方法安装
install,
};
这样,我们就完成一个简单的
SubmitForm
组件,后续需要扩展其他组件,按照 SubmitForm
的结构进行开发,并且在 index.ts
文件中 components
组件列表添加即可。编写示例调试
examples/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import CustomeUI from "~/index";
import "element-plus/lib/theme-chalk/index.css";
createApp(App).use(CustomeUI).mount("#app");
examples/App.vue
删除项目初始化的 HelloWorld
组件
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
启动项目,测试一下
$ npm run serve
组件开发完成后,执行编译库命令:
$ npm run build
引入打包后的文件回归测试一下,没有问题再发布到 npm 仓库。
在示例入口
main.ts
引用我们的组件库:import { createApp } from "vue";
import App from "./App.vue";
import CustomeUI from "../lib/index.esm.js";
import "element-plus/lib/theme-chalk/index.css";
createApp(App).use(CustomeUI).mount("#app");
编写声明文件
创建目录结构
.
├── typings
│├── index.d.ts
│├── component.d.ts
│└── packages
│└── submit-form.vue.d.ts
├── common
│└── getType.ts
└── index.ts
更新
package.json
配置
// package.json
{
...
"typings": "./typings/index.d.ts",
"files": [
"lib/",
"package.json",
"typings/"
],
"publishConfig": {
"registry": "https://abc.com/"
}
}
核心文件
// typings/index.d.ts
import type { App } from "vue";
export * from "./component.d";
export declare const install: (app: App, opt: any) => void;
declare const _default: {
install: (app: App, opt: any) => void;
};
export default _default;
// typings/component.d.ts
export { default as SubmitForm } from "./packages/submit-form.d";
// typings/packages/submit-form.d.ts
import {
DefineComponent,
ComponentOptionsMixin,
VNodeProps,
AllowedComponentProps,
ComponentCustomProps,
EmitsOptions,
ComputedGetter,
WritableComputedOptions,
} from "vue";
import { FormRowType } from "../schemas/schemasProp";
declare const _default: DefineComponent<
Record,
{
refName: string;
schema: FormRowType[];
defaultValue: Record;
},
Record,
Record, // computed
Record void>, // methods
ComponentOptionsMixin,
ComponentOptionsMixin,
EmitsOptions,
string,
VNodeProps & AllowedComponentProps & ComponentCustomProps,
Readonly,
Record
>;
export default _default;
发布组件
配置NPM仓库地址 组件开发并测试通过后,就可以发布到 npm 仓库提供给其他项目使用了,首先编写
.npmrc
文件配置要上传的源地址:registry=https://abc.com
更推荐更新
pkg#publishConfig
指定仓库地址:"publishConfig": {
"registry": "https://abc.com"
},
Notes:使用
.npmrc
配置NPM仓库地址,nrm
无法切换源。获取NPM账号、密码 在npm官网注册即可
登录npm账号 在项目中 terminal 命令窗口登录 npm 账号
$ npm login
Username:
Password:
Email:(this IS public)
输入在 npm的账号、密码、邮箱
发布
$ npm publish
组件文档 创建
Storybook
友好型环境在项目中 terminal 命令窗口执行命令:
$ npx -p @storybook/cli sb init
storybook
是一个可以辅助UI开发的工具,是一个UI组件的开发环境。在
sb init
初始化过程中,storybook
会检查现有项目的dependencies
,然后依据项目的现有框架,提供最佳的组装方式。Storybook
初始化做了以下步骤:- 安装
Storybook
需要的依赖 - 更新
pkg#run-script
- 增加预设的配置文件
.storybook/main.js
.storybook/preview.js
- 增加示例模版
stories/
package.json
...
"scripts": {
"storybook": "start-storybook -p 6006 -h 0.0.0.0", // 启动本地服务以预览
"build-storybook": "build-storybook"// 构建
},
...
$ npm run storybook # 启动本地服务访问storybook项目
更新目录结构
├─ .storybook// 预设的配置文件
│├─ main.js// 入口文件
│├─ preview.js // 控制Stories的呈现、全局资源的加载
├─ stories// 示例模版
main.js
module.exports = {
"stories": [// Storybook会抓取、载入配置路径下的指定文件渲染展示
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [// Storybook所用插件 —— Storybook功能增强
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"framework": "@storybook/vue3" // Storybook所用框架 —— Vue环境支持
}
编写示例
入口配置 更新
.storybook/main.js
module.exports = {
"stories": [// Storybook会抓取、载入配置路径下的指定文件渲染展示
"../packages/**/*.stories.@(js|jsx|ts|tsx)",
"../stories/**/*.stories.mdx",
"../stories/**/*.stories.@(js|jsx|ts|tsx)"
],
...
}
组件Story编写
import SubmitForm from "./index";
// 引入组件
import { SchemaType, RuleTrigger } from "./src/schemas/baseSchema";
const caseSchema = [ // 示例数据
{
key: "moduleName",
name: "title",
type: SchemaType.Text,
label: "栏目名称",
placeholder: "请输入栏目名称",
attrs: {
//
},
rules: [
{
required: true,
message: "栏目名称必填~",
trigger: RuleTrigger.Blur,
},
],
},
...
];
export default {
title: "ui组件/SubmitForm", // 展示标题:使用路径定义命名空间 —— 分组、分类
component: SubmitForm,
};
const Template = (args: any) => ({ // 渲染组件
components: { SubmitForm },
setup() {
return {
...args,
};
},
template: '',
});
export const 基本应用 = Template.bind({});
// 组件应用示例(基本应用 as any).args = {
schema: caseSchema,
ref: "submitFormRef",
};
可以使用
props
&computed
去承接args
这样更符合Vue3
的书写格式:// 后续的补充内容,和此处上下文无关。
const Template = (args: any) => ({
props: Object.keys(args),
components: { SubmitForm, ElButton },
setup(props) {
const refName = computed(() => props.refName)
const submitFormRef = ref();
function submit() {
console.log(submitFormRef.value.values);
}
function onRuntimeChange(name: string, value: any) {
console.log(name, " = ", value);
}
return {
submit,
onRuntimeChange,
[refName.value]: submitFormRef,
...props,
};
},
template: `提交
`,
});
全局依赖配置 因为示例代码中依赖
element-plus
,通过上述展现的页面没有样式,所以,StoryBook
渲染需要额外引入element-plus
主题:// preview.js
import "element-plus/lib/theme-chalk/index.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
}
}
启动本地服务 更新命令脚本
// package.json
"scripts": {
"storybook": "start-storybook -p 6006 -h 0.0.0.0",
"build-storybook": "build-storybook"
},
-h 0.0.0.0
以支持局域网访问。执行命令
$ npm run storybook
效果展示
文章图片
在
Stories
中使用第三方UI库
以ElementPlus
为例:全局配置 如果
babel.config
没有配置按需加载,可直接编辑.storybook/preview.js
:// .storybook/preview.js
import elementPlus from 'element-plus';
import { app } from '@storybook/vue3'app.use(elementPlus);
export const decorators = [
(story) => ({
components: { story, elementPlus },
template: ' '
})
];
import "element-plus/lib/theme-chalk/index.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
}
}
Notes:配置按需加载后,
import elementPlus from 'element-plus';
导入elementPlus
报错:elementPlus is not defined
—— 全局加载、按需加载不能在同一项目中使用。按需加载 在需要使用
ElementPlus
的Stories
中直接引入即可:// packages/SubmitForm/SubmitForm.stories.ts
import { ElButton } from 'element-plus';
import SubmitForm from "./index";
import { SchemaType, RuleTrigger } from "./src/schemas/baseSchema";
const caseSchema = [
{
key: "moduleName",
name: "title",
type: SchemaType.Text,
label: "栏目名称",
placeholder: "请输入栏目名称",
attrs: {
//
},
rules: [
{
required: true,
message: "栏目名称必填~",
trigger: RuleTrigger.Blur,
},
],
},
...
];
export default {
title: "ui组件/SubmitForm",
component: SubmitForm,
};
const Template = (args: any) => ({
components: { SubmitForm, ElButton },
setup() {
return {
...args,
};
},
template: '提交 ',
});
export const 基本应用 = Template.bind({});
(基本应用 as any).args = {
schema: caseSchema,
};
示例代码添加交互
// packages/SubmitForm/SubmitForm.stories.ts
import { ElButton } from "element-plus";
import { ref } from "vue";
import SubmitForm from "./index";
import { SchemaType, RuleTrigger } from "./src/schemas/baseSchema";
const caseSchema = [
{
key: "moduleName",
name: "title",
type: SchemaType.Text,
label: "栏目名称",
placeholder: "请输入栏目名称",
attrs: {
//
},
rules: [
{
required: true,
message: "栏目名称必填~",
trigger: RuleTrigger.Blur,
},
],
},
...
];
export default {
title: "ui组件/SubmitForm",
component: SubmitForm,
};
const Template = (args: any) => ({
components: { SubmitForm, ElButton },
setup() {
const { refName } = args;
const submitFormRef = ref();
function submit() {
console.log(submitFormRef.value.values);
}
function onRuntimeChange(name: string, value: any) {
console.log(name, " = ", value);
}
return {
submit,
onRuntimeChange,
[refName]: submitFormRef,
...args,
};
},
template: `提交
`,
});
export const 基本应用 = Template.bind({});
(基本应用 as any).args = {
refName: "submitFormRef",
schema: caseSchema,
};
这里做了两件事:
- 增加提交按钮
- 增加数据提交交互
文章图片
参数配置 通过配置
argTypes
可以补充参数信息:// packages/SubmitForm/SubmitForm.stories.ts
...
export default {
title: "ui组件/SubmitForm",
component: SubmitForm,
argTypes: {
refName: {
description: '表单组件引用',
type: {
required: true,
},
table: {
defaultValue: {
summary: 'defaultNameRef',
}
},
control: {
type: 'text'
}
},
schema: {
type: {
required: true,
},
table: {
type: {
summary: '渲染表单所需JSON结构',
detail: 'JSON结构包含表单渲染、交互所需要的必要字段,也包含表单的校验规则',
},
defaultValue: {
summary: '[]',
detail: `[
{
key: "moduleName",
name: "title",
type: SchemaType.Text,
label: "栏目名称",
placeholder: "请输入栏目名称",
attrs: {
//
},
rules: [
{
required: true,
message: "栏目名称必填~",
trigger: RuleTrigger.Blur,
},
],
}
]
`
}
}
},
runtimeChange: {
description: '实时监听表单的更新',
table: {
category: 'Events',
},
}
}
};
...
详细配置见链接。
理想效果
文章图片
文档部署
执行命令:
$ npm run build-storybook
生成静态页面,直接部署静态页面即可。
目录结构:
│0.0a0da810.iframe.bundle.js
│0.0a0da810.iframe.bundle.js.LICENSE.txt
│0.0a0da810.iframe.bundle.js.map
│0.799c368cbe88266827ba.manager.bundle.js
│1.9ebd2fb519f6726108de.manager.bundle.js
│1.9face5ef.iframe.bundle.js
│1.9face5ef.iframe.bundle.js.LICENSE.txt
│1.9face5ef.iframe.bundle.js.map
│10.07ff4e93.iframe.bundle.js
│10.a85ea1a67689be8e19ff.manager.bundle.js
│11.f4e922583ae35da460f3.manager.bundle.js
│11.f4e922583ae35da460f3.manager.bundle.js.LICENSE.txt
│12.1415460941f0bdcb8fa8.manager.bundle.js
│2.8a28fd4e.iframe.bundle.js
│2.8a28fd4e.iframe.bundle.js.LICENSE.txt
│2.8a28fd4e.iframe.bundle.js.map
│3.50826d47.iframe.bundle.js
│4.779a6efa.iframe.bundle.js
│5.f459d151315e6780c20f.manager.bundle.js
│5.f459d151315e6780c20f.manager.bundle.js.LICENSE.txt
│6.3bd64d820f3745f262ff.manager.bundle.js
│7.3d04765dbf3f1dcd706c.manager.bundle.js
│8.b541eadfcb9164835dfc.manager.bundle.js
│8.c6cb825f.iframe.bundle.js
│9.411ac8e451bbb10926c7.manager.bundle.js
│9.51f84f13.iframe.bundle.js
│9.51f84f13.iframe.bundle.js.LICENSE.txt
│9.51f84f13.iframe.bundle.js.map
│favicon.ico
│iframe.html
│index.html // 入口页面
│main.4c3140a78c06c6b39fba.manager.bundle.js
│main.e86e1837.iframe.bundle.js
│runtime~main.1e621db5.iframe.bundle.js
│runtime~main.91a0c7330ab317d35c4a.manager.bundle.js
│vendors~main.0d1916dd840230bedd21.manager.bundle.js
│vendors~main.0d1916dd840230bedd21.manager.bundle.js.LICENSE.txt
│vendors~main.8b18b60f.iframe.bundle.js
│vendors~main.8b18b60f.iframe.bundle.js.LICENSE.txt
│vendors~main.8b18b60f.iframe.bundle.js.map
│
└─static
└─media
element-icons.5bba4d97.ttf
element-icons.dcdb1ef8.woff
参考文档
- Storybook官网
推荐阅读
- 张高兴的|张高兴的 Entity Framework Core 即学即用((一)创建第一个 EF Core 应用)
- 通过vue3学习react17(二)|通过vue3学习react17(二) - 父组件调用子组件方法Ref(ts)
- CCF-CSP|【手把手刷CCF】201809-2-买菜100分(含详细注释)
- bat根据当前日期创建文件夹的方法
- 程序员|阿里P7大牛手把手教你!mysql数据库应用案例教程
- harmony|手把手教你移植openharmony3.0到stm32(liteos_m)
- 可移植的python环境
- vue3的ref|vue3的ref,computed,reactive和toRefs你都了解吗
- SSM集成Thymeleaf
- Jeecgboot-Vue3|Jeecgboot-Vue3 v1.0.0 版本正式发布,基于代码生成器的企业级低代码平台