vue组件库

167 阅读4分钟

包含基础组件、动态渲染组件(dialog、message)、布局组件、受控表单组件、动态表单组件五类

1. 定制化主题

(1)主题的方案设计需要以下两种规范:

  • 颜色的设计规范
  • CSS的开发规范

(2)开发一个多状态的按钮组件

这里维度分成按钮类型、按钮种类和按钮禁用状三种维度状态。按钮类型有Default、Primary、Success、Warn和Danger这五类;按钮种类有Contented和Outlined两类;按钮禁用状态有Enabled和Diabled两类。 三种维度的叠加管理分解成三个步骤:

  • 第一步,基础按钮组件样式的开发
  • 第二步,实现按钮不同维度组合的样式
  • 第三步,组件的使用状态叠加

第一步:

.@{prefix-name}-button {
  position: relative;
  display: inline-block;
  font-weight: 400;
  white-space: nowrap;
  text-align: center;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  height: 32px;
  padding: 4px 15px;
  font-size: 14px;
  border-radius: 2px;
  box-sizing: border-box;
  border-width: 1px;
}

第二步:

具体 CSS Variable 语义化,如下面所示:

:root {

  // 按钮 default-contained: 默认状态
  --@{prefix-name}-btn-default-contained-color: @gray-1;
  --@{prefix-name}-btn-default-contained-border-color: @gray-6;
  --@{prefix-name}-btn-default-contained-bg-color: @gray-6;

  // 按钮 primary-contained: 默认状态
  --@{prefix-name}-btn-primary-contained-color: @blue-1;
  --@{prefix-name}-btn-primary-contained-border-color: @blue-6;
  --@{prefix-name}-btn-primary-contained-bg-color: @blue-6;

  // 按钮 success-contained: 默认状态
  --@{prefix-name}-btn-success-contained-color: @green-1;
  --@{prefix-name}-btn-success-contained-border-color: @green-6;
  --@{prefix-name}-btn-success-contained-bg-color: @green-6;

  // 按钮 warning-contained: 默认状态
  --@{prefix-name}-btn-warning-contained-color: @gold-1;
  --@{prefix-name}-btn-warning-contained-border-color: @gold-6;
  --@{prefix-name}-btn-warning-contained-bg-color: @gold-6;

  // 按钮 danger-contained: 默认状态
  --@{prefix-name}-btn-danger-contained-color: @red-1;
  --@{prefix-name}-btn-danger-contained-border-color: @red-6;
  --@{prefix-name}-btn-danger-contained-bg-color: @red-6;

  // 按钮 default-outlined: 默认状态
  --@{prefix-name}-btn-default-outlined-color: @gray-6;
  --@{prefix-name}-btn-default-outlined-border-color: @gray-6;
  --@{prefix-name}-btn-default-outlined-bg-color: @gray-1;

  // 按钮 primary-outlined: 默认状态
  --@{prefix-name}-btn-primary-outlined-color: @blue-6;
  --@{prefix-name}-btn-primary-outlined-border-color: @blue-6;
  --@{prefix-name}-btn-primary-outlined-bg-color: @blue-1;

  // 按钮 success-outlined: 默认状态
  --@{prefix-name}-btn-success-outlined-color: @green-6;
  --@{prefix-name}-btn-success-outlined-border-color: @green-6;
  --@{prefix-name}-btn-success-outlined-bg-color: @green-1;

  // 按钮 warning-outlined: 默认状态
  --@{prefix-name}-btn-warning-outlined-color: @gold-6;
  --@{prefix-name}-btn-warning-outlined-border-color: @gold-6;
  --@{prefix-name}-btn-warning-outlined-bg-color: @gold-1;

  // 按钮 danger-outlined: 默认状态
  --@{prefix-name}-btn-danger-outlined-color: @red-6;
  --@{prefix-name}-btn-danger-outlined-border-color: @red-6;
  --@{prefix-name}-btn-danger-outlined-bg-color: @red-1;
}

具体 className 组合实现如下述所示:

@import '../theme/variable.less';

.@{prefix-name}-button {
  // ....

  // contented
  &.@{prefix-name}-button-default-contained {
    background: ~'var(--@{prefix-name}-btn-default-contained-bg-color)';
    color: ~'var(--@{prefix-name}-btn-default-contained-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-default-contained-border-color)';
  }
  &.@{prefix-name}-button-primary-contained {
    background: ~'var(--@{prefix-name}-btn-primary-contained-bg-color)';
    color: ~'var(--@{prefix-name}-btn-primary-contained-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-primary-contained-border-color)';
  }
  &.@{prefix-name}-button-success-contained {
    background: ~'var(--@{prefix-name}-btn-success-contained-bg-color)';
    color: ~'var(--@{prefix-name}-btn-success-contained-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-success-contained-border-color)';
  }
  &.@{prefix-name}-button-warning-contained {
    background: ~'var(--@{prefix-name}-btn-warning-contained-bg-color)';
    color: ~'var(--@{prefix-name}-btn-warning-contained-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-warning-contained-border-color)';
  }
  &.@{prefix-name}-button-danger-contained {
    background: ~'var(--@{prefix-name}-btn-danger-contained-bg-color)';
    color: ~'var(--@{prefix-name}-btn-danger-contained-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-danger-contained-border-color)';
  }
  // outlined
  &.@{prefix-name}-button-default-outlined {
    background: ~'var(--@{prefix-name}-btn-default-outlined-bg-color)';
    color: ~'var(--@{prefix-name}-btn-default-outlined-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-default-outlined-border-color)';
  }
  &.@{prefix-name}-button-primary-outlined {
    background: ~'var(--@{prefix-name}-btn-primary-outlined-bg-color)';
    color: ~'var(--@{prefix-name}-btn-primary-outlined-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-primary-outlined-border-color)';
  }
  &.@{prefix-name}-button-success-outlined {
    background: ~'var(--@{prefix-name}-btn-success-outlined-bg-color)';
    color: ~'var(--@{prefix-name}-btn-success-outlined-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-success-outlined-border-color)';
  }
  &.@{prefix-name}-button-warning-outlined {
    background: ~'var(--@{prefix-name}-btn-warning-outlined-bg-color)';
    color: ~'var(--@{prefix-name}-btn-warning-outlined-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-warning-outlined-border-color)';
  }
  &.@{prefix-name}-button-danger-outlined {
    background: ~'var(--@{prefix-name}-btn-danger-outlined-bg-color)';
    color: ~'var(--@{prefix-name}-btn-danger-outlined-color)';
    border: 1px solid ~'var(--@{prefix-name}-btn-danger-outlined-border-color)';
  }
}

Vue 代码实现如下:

<template>
  <button
    :class="{
      [baseClassName]: true,
      [btnClassName]: true
    }"
  >
    <slot v-if="$slots.default"></slot>
  </button>
</template>

<script setup lang="ts">
import { prefixName } from '../theme/index';
import type { ButtonType, ButtonVariant } from './types';
const props = withDefaults(
  defineProps<{
    type?: ButtonType;
    variant?: ButtonVariant;
  }>(),
  {
    type: 'default',
    variant: 'contained',
    disabled: false
  }
);

const baseClassName = `${prefixName}-button`;
const btnClassName = `${baseClassName}-${props.type}-${props.variant}`;
</script>

第三步:

实现按钮其他状态叠加,例如鼠标悬浮时候(Hover)等状态,也是添加对应的 CSS Variable,然后在 Less 里使用对应变量。

对多状态的按钮组件进行主题控制:

主题核心就是颜色梯度的控制,我们在处理按钮组件的不同颜色的时候,只是选择某个颜色的某个梯度号,这里具体拆解成两步:

  • 对按钮不同状态维度组合选择对应色板的颜色梯度;
  • 将选好的颜色用新 className 来覆盖原来的 CSS Variable。

2. 动态渲染组件

(1)动态渲染组件的两个技术特点就是:

  • 以直接函数式地使用来执行渲染,使用者不需要写代码来挂载组件;
  • 组件内部实现了动态挂载和卸载节点的操作。

(2)动态渲染组件一般可以实现组件库里的哪些功能组件?

  • 消息提醒组件(Message)
  • 对话框组件 (Dialog)

(3)实现动态渲染组件需要做什么技术准备?

在设计动态渲染组件的 API 时,使用步骤尽量少,使用代码尽量精简。而动态渲染组件整个生命周期,最核心的就是“动态挂载”和“动态卸载”两个步骤:

import { Module } from 'xxxx'

// 创建动态组件 mod1
const mod1 = Module.create({  /* 组件参数 */ });
// 挂载渲染 mod1
mod1.open();
// 卸载动态组件 mod1
mod1.close();


// 创建动态组件 mod2
const mod2 = Module.create({  /* 组件参数 */ });
// 挂载渲染 mod2
mod2.open();
// 卸载动态组件 mod2
mod2.close();

最简单的 Vue.js 3.x 代码实现:

import { defineComponent, createApp, h } from 'vue';

// 用 JSX 语法实现一个Vue.js 3.x的组件
const ModuleComponent = defineComponent({
  setup(props, context) {
    return () => {
      return (
        <div>这是一个动态渲染的组件</div>
      );
    };
  }
});

// 实现动态渲染组件的过程

export const createModule = () => {
  // 创建动态节点DOM
  const dom = document.createElement('div');
  // 把 DOM 追加到页面 body标签里
  const body = document.querySelector('body') as HTMLBodyElement;
  body.appendChild(dom)
  const app = createApp({
    render() {
      return h(DialogComponent, {});
    }
  });
 

  // 返回当前组件的操作实例
  // 其中封装了挂载和卸载组件的方法
  return {
    open(): () => {
      // 把组件 ModuleComponent 作为一个独立应用挂载在 DOM 节点上
      app.mount(dom);
    },
    close: () => {
      // 卸载组件
      app.unmount();
      // 销毁动态节点
      dom.remove();
    }
  }
}

(4)如何实现一个动态 Dialog 组件?

实现过程,具体步骤如下:

  • 第一步,实现 Dialog 的实体组件,用 JSX 语法或模板语法都可以。这里,Dialog 组件要用 Emit 方式注册好回调事件;
  • 第二步,封装 createDialog 函数来创建一个 Dialog 的实例。这个过程要注意配置好 Dialog 的回调函数等操作;
  • 第三步,封装 close 函数来控制卸载关闭这个组件。

这个是 JSX 实现的 Dialog 实体组件:

// ./dialog.tsx
import { defineComponent } from 'vue';
import { prefixName } from '../theme/index';

export const DialogComponent = defineComponent({
  props: {
    text: String
  },
  emits: ['onOk'],
  setup(props, context) {
    const { emit } = context;
    const onOk = () => {
      emit('onOk');
    };
    return () => {
      return (
        <div class={`${prefixName}-dialog-mask`}>
          <div class={`${prefixName}-dialog`}>
            <div class={`${prefixName}-dialog-text`}>{props.text}</div>
            <div class={`${prefixName}-dialog-footer`}>
              <button class={`${prefixName}-dialog-btn`} onClick={onOk}>
                确定
              </button>
            </div>
          </div>
        </div>
      );
    };
  }
});

以下是封装了函数方法调用的动态渲染组件的方式:

import { createApp, h } from 'vue';
import { DialogComponent } from './dialog';

function createDialog(params: { text: string; onOk: () => void }) {
  const dom = document.createElement('div');
  const body = document.querySelector('body') as HTMLBodyElement;
  body.appendChild(dom);
  const app = createApp({
    render() {
      return h(DialogComponent, {
        text: params.text,
        onOnOk: params.onOk
      });
    }
  });
  app.mount(dom);

  return {
    close: () => {
      app.unmount();
      dom.remove();
    }
  };
}

const Dialog: { createDialog: typeof createDialog } = {
  createDialog
};

export default Dialog;

3. 动态Form组件

(1) 代表表单结构的Schema
const formSchema = [
    {
        field: '用户名',
        type: 'input',
        prop: 'username',
        value: '',
        attr: {
            placeholder: '请输入用户名'
        }
    },
    {
        field: '密码',
        type: 'input',
        prop: 'password',
        value: '',
        attr: {
            placeholder: '请输入密码'
        }
    },
    {
        field: '角色',
        type: 'checkbox',
        prop: 'roles',
        value: [],
        children: [
            {
                value: 1,
                field: '管理员'
            },
            {
                value: 2,
                field: '普通用户'
            },
            {
                value: 3,
                field: '测试用户'
            }
        ]
    },
    {
        field: '性别',
        type: 'radio',
        prop: 'gender',
        value: 0,
        children: [
            {
                value: 1,
                field: '男'
            },
            {
                value: 2,
                field: '女'
            }
        ]
    },
    {
        field: '头像',
        type: 'input',
        prop: 'photo',
        value: '',
        attr: {
            placeholder: '请输入头像链接'
        }
    },
]
(2) 表单组件
import { cloneDeep } from 'lodash-es'

export default {
    props: {
        schema: {
            type: Array as PropType<FormItem[]>,
            default: () => []
        }
    },
    emits: ['change'],
    setup(props, { emit }) {
        const rand = () > {
            return Math.random().toString(36).subString(2)
        }
        
        const model = ref<any>({
            ...cloneDeep(
                props.schema.reduce((prev, cur) => {
                    prev[cur.prop] = cur.value
                    if(['radio', 'checkbox'].includes(cur.type)) {
                        cur.children?.forEach(o => {
                            // id也可数据库取值
                            o.id = rand()
                        })
                    }
                    return prev
                }, {} as Record<string, any>)
            )
        })
        
        watch(() => model.value, () => {
            emit('change', model.value)
        }, {
            deep: true
        })
        
        return {
            model
        }
    }
}
(3) 表单组件使用
<base-form :schema="formSchema"></base-form>