封装vue3组件最佳实践

2,854 阅读9分钟

Vue 的组件可以按两种不同的风格书写:选项式 API 和组合式 API

  • 选项式 API (Options API)

使用选项式 API,我们可以用包含多个选项的对象来描述组件的逻辑,例如 datamethods 和 mounted。选项所定义的属性都会暴露在函数内部的 this 上,它会指向当前的组件实例。这是vue2时我们常用的风格。

<script> 
export default { 
// data() 返回的属性将会成为响应式的状态 
// 并且暴露在 `this` 上 
data() { return { count: 0 } }, 
// methods 是一些用来更改状态与触发更新的函数 
// 它们可以在模板中作为事件监听器绑定 
methods: { increment() { this.count++ } }, 
// 生命周期钩子会在组件生命周期的各个不同阶段被调用 
// 例如这个函数就会在组件挂载完成后被调用 
mounted() { 
    console.log(`The initial count is ${this.count}.`) 
  } 
} </script> 
<template> 
    <button @click="increment">Count is: {{ count }}</button> 
</template>
  • 组合式 API (Composition API)

通过组合式 API,我们可以使用导入的 API 函数来描述组件逻辑。在单文件组件中,组合式 API 通常会与 <script setup> 搭配使用。这个 setup attribute 是一个标识,告诉 Vue 需要在编译时进行一些处理,让我们可以更简洁地使用组合式 API。比如,<script setup> 中的导入和顶层变量/函数都能够在模板中直接使用。

<script setup> 
import { ref, onMounted } from 'vue' 
// 响应式状态 const count = ref(0) 
// 用来修改状态、触发更新的函数 
function increment() { count.value++ } 
// 生命周期钩子 
onMounted(() => { 
    console.log(`The initial count is ${count.value}.`) 
}) 
</script> 
<template> 
    <button @click="increment">Count is: {{ count }}</button> 
</template>
  • 该选哪一个?

两种 API 风格都能够覆盖大部分的应用场景。它们只是同一个底层系统所提供的两套不同的接口。实际上,选项式 API 是在组合式 API 的基础上实现的!关于 Vue 的基础概念和知识在它们之间都是通用的。

选项式 API 以“组件实例”的概念为中心 (即上述例子中的 this),对于有面向对象语言背景的用户来说,这通常与基于类的心智模型更为一致。同时,它将响应性相关的细节抽象出来,并强制按照选项来组织代码,从而对初学者而言更为友好。

组合式 API 的核心思想是直接在函数作用域内定义响应式状态变量,并将从多个函数中得到的状态组合起来处理复杂问题。这种形式更加自由,也需要你对 Vue 的响应式系统有更深的理解才能高效使用。相应的,它的灵活性也使得组织和重用逻辑的模式变得更加强大。

如果你是使用 Vue 的新手,这里是我们的大致建议:

  • 在学习的过程中,推荐采用更易于自己理解的风格。再强调一下,大部分的核心概念在这两种风格之间都是通用的。熟悉了一种风格以后,你也能够很快地理解另一种风格。

  • 在生产项目中:

    • 当你不需要使用构建工具,或者打算主要在低复杂度的场景中使用 Vue,例如渐进增强的应用场景,推荐采用选项式 API。
    • 当你打算用 Vue 构建完整的单页应用,推荐采用组合式 API + 单文件组件。

区分好这两个概念后,以下组件封装讲解都是基于组合式API

需要注意:

组合式 API 是否覆盖了所有场景?

与普通的 <script> 一起使用

组件示例

1.简单组件 vant-button

属性:

  • type支持 defaultprimarysuccesswarningdanger 五种类型,也就是五种主题配色
  • plain 属性设置为朴素按钮,朴素按钮的文字为按钮颜色,背景为白色。
  • hairline 属性可以展示 0.5px 的细边框。
  • disabled 属性来禁用按钮,禁用状态下按钮不可点击。

... 等

事件:

  • click
  • touchstart

插槽slots,自定义渲染内容:

  • default
  • icon
  • loading

以下是vant中Button组件的源码分析:

import {
  defineComponent,
  type PropType,
  type CSSProperties,
  type ExtractPropTypes,
} from 'vue';

// Utils
import {
  extend,
  numericProp,
  preventDefault,
  makeStringProp,
  createNamespace,
  BORDER_SURROUND,
} from '../utils';
import { useRoute, routeProps } from '../composables/use-route';

// Components
import { Icon } from '../icon';
import { Loading, LoadingType } from '../loading';

// Types
import {
  ButtonSize,
  ButtonType,
  ButtonNativeType,
  ButtonIconPosition,
} from './types';

// 命名公共方法,添加vant前缀等 bem css命名规范
const [name, bem] = createNamespace('button');

// 所有的button属性,并拓展了route属性,支持按钮跳转
export const buttonProps = extend({}, routeProps, {
  tag: makeStringProp<keyof HTMLElementTagNameMap>('button'),
  text: String,
  icon: String,
  type: makeStringProp<ButtonType>('default'),
  size: makeStringProp<ButtonSize>('normal'),
  color: String,
  block: Boolean,
  plain: Boolean,
  round: Boolean,
  square: Boolean,
  loading: Boolean,
  hairline: Boolean,
  disabled: Boolean,
  iconPrefix: String,
  nativeType: makeStringProp<ButtonNativeType>('button'),
  loadingSize: numericProp,
  loadingText: String,
  loadingType: String as PropType<LoadingType>,
  iconPosition: makeStringProp<ButtonIconPosition>('left'),
});

export type ButtonProps = ExtractPropTypes<typeof buttonProps>;

export default defineComponent({
  name,
// 属性
  props: buttonProps,
// 事件
  emits: ['click'],
// composition API
// props是响应式的
// context{emit, slots,...}等是非响应式的
  setup(props, { emit, slots }) {
    // 引入路由模块
    const route = useRoute();
    // 渲染loading slot
    const renderLoadingIcon = () => {
      if (slots.loading) {
        return slots.loading();
      }

      return (
        <Loading
          size={props.loadingSize}
          type={props.loadingType}
          class={bem('loading')}
        />
      );
    };
    // 渲染icon slot
    // 如果loading中展示loading内容
    // 否则展示icon内容
    const renderIcon = () => {
      if (props.loading) {
        return renderLoadingIcon();
      }

      if (slots.icon) {
        return <div class={bem('icon')}>{slots.icon()}</div>;
      }

      if (props.icon) {
        return (
          <Icon
            name={props.icon}
            class={bem('icon')}
            classPrefix={props.iconPrefix}
          />
        );
      }
    };
    // 渲染default slot
    const renderText = () => {
      let text;
      if (props.loading) {
        text = props.loadingText;
      } else {
        text = slots.default ? slots.default() : props.text;
      }

      if (text) {
        return <span class={bem('text')}>{text}</span>;
      }
    };

    // 设置文字颜色与背景色
    const getStyle = () => {
      const { color, plain } = props;
      if (color) {
        const style: CSSProperties = {
          color: plain ? color : 'white',
        };

        if (!plain) {
          // Use background instead of backgroundColor to make linear-gradient work
          style.background = color;
        }

        // hide border when color is linear-gradient
        if (color.includes('gradient')) {
          style.border = 0;
        } else {
          style.borderColor = color;
        }

        return style;
      }
    };
    // 监听click事件
    const onClick = (event: MouseEvent) => {
      if (props.loading) {
        preventDefault(event);
      } else if (!props.disabled) {
        emit('click', event);
        route();
      }
    };
    // 返回函数,代表作为渲染函数
    // 返回对象,暴露数据状态
    return () => {
      const {
        tag,
        type,
        size,
        block,
        round,
        plain,
        square,
        loading,
        disabled,
        hairline,
        nativeType,
        iconPosition,
      } = props;

      const classes = [
        // BEM的命名规矩很容易记:block-name__element-name--modifier-name,也就是模块名+ 元素名+ 修饰器名。
        bem([
          type,
          size,
          {
            plain,
            block,
            round,
            square,
            loading,
            disabled,
            hairline,
          },
        ]),
        { [BORDER_SURROUND]: hairline },
      ];
      /**
 * tag 是button element
 * nativeType button的类型
 * **/
      return (
        <tag
          type={nativeType}
          class={classes}
          style={getStyle()}
          disabled={disabled}
          onClick={onClick}
        >
          <div class={bem('content')}>
            {iconPosition === 'left' && renderIcon()}
            {renderText()}
            {iconPosition === 'right' && renderIcon()}
          </div>
        </tag>
      );
    };
  },
});


这个组件实现了以下功能:

  • 支持多种类型、大小、颜色等属性配置,可以满足不同的需求。
  • 支持loading状态和禁用状态,可以提升用户体验。
  • 支持自定义icon和文本,可以满足不同的场景需求。
  • 支持touch事件和click事件的处理,可以提高组件的交互性。
  • 支持自定义tag和attrs,可以满足不同的开发需求。

组件的实现采用了Vue.js的函数渲染,通过组合式api实现了组件的属性和方法。通过使用bem命名规范和createNamespace工具函数,实现了组件的样式管理和命名空间的管理。

总之,Button组件是vant中非常重要的一个组件,它的实现体现了组件化开发和Vue.js框架的特点。

2.复杂组件 form表单

源码演示

我们需要实现以下三类组件

  • form 提供表单的容器组件,负责全局的输入对象 model 和校验规则 rules 的配置,并且在用户点击提交的时候,可以执行全部输入项的校验规则;
  • 其次是 input 类组件,我们日常输入内容的输入框、下拉框、滑块等都属于这一类组件,这类组件主要负责显示对应的交互组件,并且监听所有的输入项,用户在交互的同时通知执行校验;
  • 然后就是介于 form 和 input 中间的 filed 组件,这个组件负责每一个具体输入的管理,从 form 组件中获取校验规则,从 input 中获取用户输入的内容,通过 async-validator 校验输入是否合法后显示对应的输入状态,并且还能把校验方法提供给 form 组件,form 可以很方便地管理所有 form-item。

3.进阶组件 DDL(领域模型)

DDL使用协议来描述,不管是再复杂的布局,还是很复杂的联动,都能做到可配置。具体形式为JSON SchemaJSON Schema独立存在,给 UI 层消费,保证了协议驱动在不同 UI 框架下的绝对一致性,与技术框架解耦。以下是form表单用JSON Schema描述的实践。

// index.ts 导出
import BasicForm from './src/BasicForm.vue';
export { useForm } from './src/hooks/useForm';

// data.ts
export const queryFormSchemas: FormSchema[] = [
  {
    field: 'applyCode',
    component: 'Input',
    label: '投保单号',
    colProps: {
      offset: 1,
    },
    componentProps: ({ formModel }) => {
      return {
        onblur: (e) => {
          const value = e.target.value;
          if (value) {
            formModel.applyCode = formatPolicy(value);
          }
        },
      };
    },
    rules: [
      {
        validator(_, value) {
          return checkApplyCodeValid(value);
        },
      },
    ],
  },
  {
    field: 'taskStatus',
    component: 'Select',
    label: '任务状态',
    required: true,
    componentProps: {
      // options: taskStatusOptions,
      getPopupContainer: () => document.body,
    },
    defaultValue: 1,
  },
];
// demo.vue使用
<template>
    <BasicForm @register="queryformRegister" />
</template>
<script lang="ts" setup>
    import { BasicForm, useForm } from '/@/components/Form';
    import queryFormSchemas from './data';

    const [queryformRegister, { getFieldsValue, validate }] = useForm({
        baseColProps: {
          span: 5,
        },
        schemas: queryFormSchemas,
        showActionButtonGroup: true,
        submitFunc: initQuery,
      });
</script>

// useForm.ts 实现
export function useForm(props?: Props): UseFormReturnType {
  const formRef = ref<Nullable<FormActionType>>(null);
  const loadedRef = ref<Nullable<boolean>>(false);

  async function getForm() {
    const form = unref(formRef);
    if (!form) {
      error(
        'The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!',
      );
    }
    await nextTick();
    return form as FormActionType;
  }

  function register(instance: FormActionType) {
    isProdMode() &&
      onUnmounted(() => {
      // 组件销毁前释放资源
        formRef.value = null;
        loadedRef.value = null;
      });
    if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return;
    // 获取组件实例
    formRef.value = instance;
    loadedRef.value = true;
    // 监听属性改变,更新实例
    watch(
      () => props,
      () => {
        props && instance.setProps(getDynamicProps({ size: DEFAULT_SIZE, ...props }));
      },
      {
        immediate: true,
        deep: true,
      },
    );
  }

  const methods: FormActionType = {
    // TODO promisify
    getFieldsValue: <T>() => {
      return unref(formRef)?.getFieldsValue() as T;
    },

    setFieldsValue: async <T>(values: T) => {
      const form = await getForm();
      form.setFieldsValue<T>(values);
    },
    submit: async (): Promise<any> => {
      const form = await getForm();
      return form.submit();
    },

    validate: async (nameList?: NamePath[]): Promise<Recordable> => {
      const form = await getForm();
      return form.validate(nameList);
    },
    ...
  };

  return [register, methods];
}

组件功能复用

vue2中的mixin

有以下缺点:

  1. 不清晰的数据来源:当使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使追溯实现和理解组件行为变得困难。这也是我们推荐在组合式函数中使用 ref + 解构模式的理由:让属性的来源在消费组件时一目了然。
  2. 命名空间冲突:多个来自不同作者的 mixin 可能会注册相同的属性名,造成命名冲突。若使用组合式函数,你可以通过在解构变量时对变量进行重命名来避免相同的键名。
  3. 隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得它们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。

vue3中的组合式函数

我们也可称为hooks,是在组合式API的基础上对组件进行更进一步的封装,它是带状态的函数,可以把渲染逻辑,属性,方法以一个函数的形式提取到外部文件中,以进行复用。按照惯例,组合式函数名以“use”开头。

常见的钩子包括:

  • created: 这个钩子在第一次创建组件时被调用
  • mounted: 当组件被挂载到 DOM 时调用这个钩子
  • updated: 当组件的状态改变时调用这个钩子
  • beforeUnmount: 这个钩子在组件从 DOM 卸载之前被调用

钩子可用于执行各种任务,例如:

  • 更新组件的状态
  • 渲染组件
  • 观察数据变化
  • 调用副作用

组件渲染方式

image.png Vue 模板会被预编译成虚拟 DOM 渲染函数。Vue 也提供了 API 使我们可以不使用模板编译,直接手写渲染函数。在处理高度动态的逻辑时,渲染函数相比于模板更加灵活,因为你可以完全地使用 JavaScript 来构造你想要的 vnode。

那么为什么 Vue 默认推荐使用模板呢?有以下几点原因:

  1. 模板更贴近实际的 HTML。这使得我们能够更方便地重用一些已有的 HTML 代码片段,能够带来更好的可访问性体验、能更方便地使用 CSS 应用样式,并且更容易使设计师理解和修改。
  2. 由于其确定的语法,更容易对模板做静态分析。这使得 Vue 的模板编译器能够应用许多编译时优化来提升虚拟 DOM 的性能表现 (下面我们将展开讨论)。

在实践中,模板对大多数的应用场景都是够用且高效的。渲染函数一般只会在需要处理高度动态渲染逻辑的可重用组件中使用。想了解渲染函数的更多使用细节可以去到渲染函数 & JSX 章节继续阅读。

v-if举例,以下三种写法等价:

1.模版渲染

<div> 
    <div v-if="ok">yes</div> 
    <span v-else>no</span> 
</div>

2.函数渲染

// h渲染函数
h('div', [ok.value ? h('div', 'yes') : h('span', 'no')])
// jsx
<div>{ok.value ? <div>yes</div> : <span>no</span>}</div>

组件封装技巧(总结)

组件设计我们需要考虑的就是内部交互的逻辑,对子组件提供什么数据,对父组件提供什么方法,需不需要通过 provide 或者 inject 来进行跨组件通信等等。

  1. 属性(props)的传递与变化
// 定义props属性
const props = defineProps({
    visible: { type: Boolean },
  });
  
 // 监听props变化
 watch(
    () => props.visible,
    (newValue) => {
      console.log(newValue)
    },
  );
  
 const isActive = computed(() => props.visible)
  1. 通信:父与子(emit),祖与孙(provide/inject)

    • 模版引用:获取子元素,调用子元素方法
    <script setup> 
        import { ref, onMounted } from 'vue' 
        const list = ref([ /* ... */ ]) 
        const itemRefs = ref([]) 
        onMounted(() => console.log(itemRefs.value)) 
    </script> 
    <template> 
        <ul> 
            <li v-for="item in list" ref="itemRefs"> {{ item }} </li> 
        </ul> 
    </template>
    
    • provide/inject祖孙通信
    //在子组件中调用
    export function useParent<T>(key: InjectionKey<ParentProvide<T>>) {
      // 获取父元素提供的属性和方法,默认为null
      const parent = inject(key, null);
    
      if (parent) {
        const instance = getCurrentInstance()!;
        const { link, unlink, internalChildren } = parent;
        // 将实例绑定到children
        link(instance);
        // 实例销毁时从children删除
        onUnmounted(() => unlink(instance));
    
        const index = computed(() => internalChildren.indexOf(instance));
    
        return {
          parent,
          index,
        };
      }
    
      return {
        parent: null,
        index: ref(-1),
      };
    }
    
    // 在父组件中调用
    export function useChildren<
      // eslint-disable-next-line
      Child extends ComponentPublicInstance = ComponentPublicInstance<{}, any>,
      ProvideValue = never
    >(key: InjectionKey<ProvideValue>) {
      const publicChildren: Child[] = reactive([]);
      const internalChildren: ComponentInternalInstance[] = reactive([]);
      // getCurrentInstance 允许访问内部组件实例,仅针对高级用例公开,通常在库中。强烈建议不要在应用程序代码中使用,仅在setup或生命周期挂钩期间有效
      // https://v3.ru.vuejs.org/api/composition-api.html
      const parent = getCurrentInstance()!; 
      const linkChildren = (value?: ProvideValue) => {
        const link = (child: ComponentInternalInstance) => {
          if (child.proxy) {
            internalChildren.push(child);
            publicChildren.push(child.proxy as Child);
            sortChildren(parent, publicChildren, internalChildren);
          }
        };
    
        const unlink = (child: ComponentInternalInstance) => {
          const index = internalChildren.indexOf(child);
          publicChildren.splice(index, 1);
          internalChildren.splice(index, 1);
        };
    
        provide(
          key,
          Object.assign(
            {
              link,
              unlink,
              children: publicChildren,
              internalChildren,
            },
            value
          )
        );
      };
    
      return {
        children: publicChildren,
        linkChildren,
      };
    }
    
  2. 使用attrs传递隐式属性:包含了父作用域中不作为组件 props自定义事件的 attribute 绑定和事件(包括 class 和 style自定义事件),同时可以通过 v-bind="$attrs" 传入内部组件。在 <script setup>中辅助函数useAttrs可以获取到$attrs。光这样写,我们绑定的属性(包括 class 和 style)同时会在根元素(上面的例子是class="my-input"的Dom节点)上起作用。要阻止这个默认行为,我们需要设置inheritAttrsfalse

参考:透传属性

// MyInput.vue 
<template>
    <div class="my-input">
    <Input v-bind="attrs" />
    </div> 
</template> 
// 1.第一种写法,与 script标签 混用,应用情景:
// -   声明无法在 `<script setup>` 中声明的选项,例如 `inheritAttrs` 或插件的自定义选项。
// -   声明模块的具名导出 (named exports)。
// -   运行只需要在模块作用域执行一次的副作用,或是创建单例对象。
<script> 
 import { Input } from 'ant-design-vue';
 export default { 
     name: 'MyInput', 
     inheritAttrs: false 
 } 
</script> 
<script setup> 
import { useAttrs } from 'vue' 
const attrs = useAttrs() 
</script>
// 2.第二种写法,选项式与组合式混合,setup(),应用场景
// -  需要在非单文件组件中使用组合式 API 时。
// -  需要在基于选项式 API 的组件中集成基于组合式 API 的代码时。
<script>
  export default {
    name: 'MyInput',
    inheritAttrs: false,
    setup({ attrs }) {
      // script setup logic
    },
  }
</script>
  1. 使用Teleport组件:Vue3中引入了Teleport组件,它可以将组件渲染到指定的DOM元素中,而不是直接渲染到自身的父元素中。这可以帮助开发者更好地管理组件的渲染位置和层级,一般用在通知组件中。
        return (
         <Teleport to={props.teleport}>
           {renderOverlay()}
           {renderTransition()}
         </Teleport>
       );
  1. 双向数据绑定

v-model 在原生元素上的用法:


<input v-model="searchText" />

在代码背后,模板编译器会对 v-model 进行更冗长的等价展开。因此上面的代码其实等价于下面这段

<input :value="searchText" @input="searchText = $event.target.value" />

而当使用在一个组件上时,v-model 会被展开为如下的形式:

<CustomInput :modelValue="searchText" @update:modelValue="newValue => searchText = newValue" />

要让这个例子实际工作起来,<CustomInput> 组件内部需要做两件事:

  1. 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  2. 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

这里是相应的代码:

<!-- CustomInput.vue --> 
<script setup> 
    defineProps(['modelValue']) 
    defineEmits(['update:modelValue']) 
</script> 
<template> 
    <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> 
</template>

现在 v-model 可以在这个组件上正常工作了:

<CustomInput v-model="searchText" />

组合式组件范本

  • 模版渲染
<template>
  <!-- 组件的模板代码 -->
</template>

<script>
  // 引入Vue.js和相关依赖
  import { defineComponent } from 'vue';

  export default defineComponent({
      // 选项式属性
      name,
      props: formProps,
      emits: [],
      // 需要在基于选项式 API 的组件中集成基于组合式 API 的代码时
      setup(props, { emit, slots }) {
           ...
      },
    })
</script>

<style scoped>
  /* 组件的样式定义 */
</style>
  • 渲染函数
export default defineComponent({
  // 选项式属性
  name,
  props: formProps,
  emits: [],
  // 需要在基于选项式 API 的组件中集成基于组合式 API 的代码时
  setup(props, { emit, slots }) {
    // 函数式组件
    return () => (
      <div>
          demo
      </div>
    );
  },
});

封装组件的基本原则:

  1. 单一职责原则:每个组件应该只关注单一的责任。这有助于提高组件的复用性和可维护性。
  2. 可组合性原则:组件应该是可组合的,以便开发者可以将它们组合在一起构建更复杂的应用。这可以通过使用插槽、props等技术实现。
  3. 可重用性原则:组件应该是可重用的,以便开发者可以在不同的应用中复用它们。这可以通过将组件与特定的应用逻辑解耦来实现。
  4. 可测试性原则:组件应该是可测试的,以便开发者可以轻松地编写和运行测试用例。这可以通过将组件的逻辑与模板解耦、使用单向数据流等技术实现。

总之,Vue 3中封装组件的基本原则是使组件具有单一职责、可组合、可重用和可测试的特性,以便开发者可以构建出易于维护和扩展的应用。