手把手创建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": "[email protected]"
  }
 }

在 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 [email protected]
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 [email protected] babel-plugin-import

配置.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 组件





启动项目,测试一下

$ 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 | WritableComputedOptions>, // computed
  Record void>, // methods
  ComponentOptionsMixin,
  ComponentOptionsMixin,
  EmitsOptions,
  string,
  VNodeProps & AllowedComponentProps & ComponentCustomProps,
  Readonly & Record>,
  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组件库_第1张图片

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组件库_第2张图片

参数配置

通过配置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组件库_第3张图片

文档部署

执行命令:

$ 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

参考文档

参考文章很多,如怀疑内容参考,请联系,会考虑增加到参考文档中

你可能感兴趣的:(手把手创建Vue3组件库)