手把手创建Vue3组件库

动机 当市面上主流的组件库不能满足我们业务需求的时候,那么我们就有必要开发一套属于自己团队的组件库。
环境 开发环境:

  • vue 3.0
  • vue/cli 4.5.13
  • nodeJs 12.16.3
  • npm 6.14.4
步骤 创建项目 使用 vue-cli 创建一个 vue3 项目,假设项目名为 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 ,并将里面的 assetscomponents 目录删除,移除 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.jsonREADME.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 才能发布到 npm
  • license: 开源协议
参考设置:
{ "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.jsrollup 打包脚本:
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.tssrc/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

效果展示 手把手创建Vue3组件库
文章图片

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 —— 全局加载、按需加载不能在同一项目中使用。
按需加载 在需要使用ElementPlusStories中直接引入即可:
// 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, };

这里做了两件事:
  • 增加提交按钮
  • 增加数据提交交互
配置参数详情 默认文档展示 默认查看到的文档参数是以下样子:
手把手创建Vue3组件库
文章图片

参数配置 通过配置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', }, } } }; ...

详细配置见链接。
理想效果 手把手创建Vue3组件库
文章图片

文档部署
执行命令:
$ 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官网
参考文章很多,如怀疑内容参考,请联系,会考虑增加到参考文档中

    推荐阅读