Vue3 组件化高级开发技巧

5,857 阅读9分钟

组件化概述

Vue 组件化是指将应用划分为独立的、可复用的模块,每个模块都拥有自己的模板、样式和逻辑,从而构建出灵活、易于维护和扩展的应用

在 Vue3 提供了一种新的代码组织和复用方式,引入了 Composition API 和 script setup 语法糖,可以将相关的逻辑代码拆分为独立的模块,在 setup 函数中引入并使用这些模块,组件可以抽象出这些复用的模块,方便扩展和维护。

Composition API 实践

自定义 Hook — 优化逻辑组织和复用

在 Vue2 中复用代码,一般是抽象到 Mixin 中,不过 Mixin 自身存在缺陷,如一个组件引入多个Mixin 会导致代码执行变得复杂和难以理解,命名容易重复,数据来源不清晰,调试和维护困难

为了解决这些问题,Vue3 引入了 Composition API ,将响应式、computed、watch、生命周期等封装成一个个独立的 Hook 函数,利于代码组织和复用

以拖拽功能为例,将拖拽相关的逻辑封装在一个独立的组合函数中,从业务中分离,内聚方法,在不同的模块复用拖拽的功能

import { reactive, toRefs } from 'vue';

export function useDraggable() {
  // 拖拽数据
  const state = reactive({
    isDragging: false,
    x: 0,
    y: 0
  });

  // 拖拽开始,如记录当前位置,注册拖拽移动和拖拽结束事件
  function startDrag(event) {
    state.isDragging = true;
    state.x = event.clientX;
    state.y = event.clientY;
    // 注册事件
    document.addEventListener('mousemove', handleDrag);
    document.addEventListener('mouseup', endDrag)
  }
  
  // 拖拽中,计算移动距离
  function handleDrag(event) {
    if (state.isDragging) {
      const dx = event.clientX - state.x;
      const dy = event.clientY - state.y;
      // 更新拖拽元素的位置
      // ...
    }
  }

  // 拖拽结束,释放资源,还原状态
  function endDrag() {
    state.isDragging = false;
    // 销毁事件,释放资源
    document.removeEventListener('mousemove', handleDrag);
    document.removeEventListener('mouseup', endDrag);
  }
  
  onMounted(() => {
      // 其他初始化逻辑
    });

  onBeforeUnmount(() => {
    // 清理工作
  });

  // 暴露属性和方法
  return {
    ...toRefs(state),
    startDrag,
    endDrag,
    handleDrag
  };
}

useDraggable 将开始拖拽、拖拽中、拖拽结束的逻辑定义在一个函数中,在需要拖拽的元素上使用指令或绑定事件监听器

<template>
  <div v-draggable="dragHandlers"></div>
</template>

<script setup>
import { useDraggable } from './useDraggable';

const dragHandlers = useDraggable();
</script>

生命周期钩子函数

Vue 3 的生命周期钩子函数在组件的不同阶段执行特定的操作,以便控制和管理组件的状态和数据。

1、数据获取和初始化:

setup 函数初始化或组件挂载后,获取数据并进行初始化操作

import { onMounted, reactive } from 'vue';

const data = reactive({
  items: []
});

// 初始化渲染,执行数据获取逻辑
fetchItems().then(response => {
  data.items = response.data;
});

onMounted(() => {
  // 在DOM挂载后,执行数据获取逻辑
  fetchItems2().then(response => {
    data.items = response.data;
  });
});

2、第三方库初始化

在组件挂载后,使用 onMounted 钩子函数来初始化第三方库或执行需要访问 DOM 的操作。

import { onMounted } from 'vue';
import { initChart } from 'chart-library';

onMounted(() => {
  // 初始化图表库
  initChart();
  // 执行其他 DOM 操作
});

3、资源清理和取消异步任务

在组件卸载前,使用 onBeforeUnmount 钩子函数来清理资源或取消未完成的异步任务,及时释放资源,防止内存泄漏

import { onBeforeUnmount } from 'vue';
import { cleanupResources, cancelAsyncTask } from 'utils';

onBeforeUnmount(() => {
  // 清理资源
  cleanupResources();
  // 取消异步任务
  cancelAsyncTask();
});

watch和computed数据监测和计算

1、使用 watch 监听响应式数据变化并动态更新 classstyle 属性:

<template>
  <div :class="classNames" :style="dynamicStyle">
    <!-- 内容 -->
  </div>
</template>

<script setup>
import { watch, ref } from 'vue';

const isActive = ref(false);
const color = ref('red');
const classNames = ref('');
const dynamicStyle = ref({});

watch([isActive, color], ([newIsActive, newColor], [oldIsActive, oldColor]) => {
  // 执行逻辑,更新 class 和 style 属性
  updateClassNames();
  updateDynamicStyle();
});

  // 监听逻辑
function updateClassNames() {
  // 根据 isActive 值更新 class
  if (isActive.value) {
    classNames.value = 'active';
  } else {
    classNames.value = '';
  }
}

function updateDynamicStyle() {
  // 根据 color 值更新动态的 style
  dynamicStyle.value = {
    backgroundColor: color.value
  };
}

updateClassNames();
updateDynamicStyle();
</script>

2、使用 computed 计算派生的 classstyle 属性:

<template>
  <div :class="classNames" :style="dynamicStyle">
    <!-- 内容 -->
  </div>
</template>

<script setup>
import { computed, reactive } from 'vue';

const data = reactive({
  isActive: false,
  color: 'red'
});

const classNames = computed(() => {
  // 根据 isActive 值计算 class
  return data.isActive ? 'active' : '';
});

const dynamicStyle = computed(() => {
  // 根据 color 值计算动态的 style
  return {
    backgroundColor: data.color
  };
});
</script>

根据具体的业务需求和场景,灵活运用 watchcomputed,

watch 的使用场景:

  • 监听数据的变化并执行副作用操作,如发送网络请求、更新 DOM、触发动画效果等。
  • 监听数据的变化并触发其他逻辑,如根据数据变化更新状态、触发路由导航等。
  • 监听多个数据的变化并进行联动操作,如两个日期选择器之间的日期范围联动。

computed 的使用场景:

  • 根据多个数据的组合计算动态样式,如根据用户喜好的主题和字体大小计算动态的样式对象。
  • 根据数据的变化动态生成 HTML 或其他模板内容,如根据用户的输入生成动态的表格或列表。

使用注意事项:

  • 避免过度使用 watchcomputed

过度使用它们可能导致代码复杂性和维护性的降低。在使用时要权衡利弊,避免不必要的计算和监听。

  • 注意 computed 的计算开销:

computed 是惰性求值的,只有在其依赖的响应式数据变化时才会重新计算。然而,如果 computed 的计算逻辑复杂或依赖的数据较多,可能会引起性能问题。确保计算逻辑的复杂度和依赖关系的合理性。

组件通信

在 Vue3 组件通信,常用方案有

  • 父子通信:props 和 Events
  • 夸组件通信:provide 和 inject、透传属性($attrs)
  • 应用通信:pinia 或 vuex
  • 兄弟组件通信,可以使用 pinia 或 vuex,但过度使用 pinia 或 vuex 会造成业务逻辑变得复杂,数据管理混乱,

除了以上通信方式,在业务上使用 Vue3 api 和 模式封装一些通信方式,如事件总线、Hooks 状态函数,管理数据更方便,

事件总线通信实践

1、mitt 第三方库封装事件总线

总线文件

// EventBus.js
import mitt from 'mitt';
export const eventBus = mitt();

组件监听,接收信息

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { eventBus } from './EventBus';

const receivedMessage = ref('');
const handleMessage = (message) => {
  receivedMessage.value = message;
};
  
// 监听接收信息
  eventBus.on('messageSent', handleMessage);

onBeforeUnmount(() => {
  // 组件销毁注销
  eventBus.off('messageSent', handleMessage);
});
</script>

组件派发,传递信息

<template>
  <div>
    <p>{{ receivedMessage }}</p>
  </div>
</template>

<script setup>
import { useEventBus } from './EventBus';

const eventBus = useEventBus();

function sendMessage() {
  // 派发事件
  eventBus.emit('messageSent', 'Hello from Component A!');
}
</script>

2、provide 和 inject 实现事件总线

  1. 根据发布订阅模式,封装事件总线,创建一个新的 eventBus.js 文件
// eventBus.js
import { provide, inject } from 'vue';

const EventBusSymbol = Symbol();

export function createEventBus() {
  const listeners = {};

  // 监听事件
  function on(event, callback) {
    if (!listeners[event]) {
      listeners[event] = [];
    }
    listeners[event].push(callback);
  }

  // 注销事件
  function off(event, callback) {
    if (listeners[event]) {
      const index = listeners[event].indexOf(callback);
      if (index !== -1) {
        listeners[event].splice(index, 1);
      }
    }
  }

  // 派发事件
  function emit(event, ...args) {
    if (listeners[event]) {
      listeners[event].forEach(callback => {
        callback(...args);
      });
    }
  }

  return {
    on,
    off,
    emit
  };
}

export function provideEventBus() {
  const eventBus = createEventBus();
  provide(EventBusSymbol, eventBus);
}

export function useEventBus() {
  const eventBus = inject(EventBusSymbol);
  if (!eventBus) {
    throw new Error('EventBus is not provided.');
  }
  return eventBus;
}

2、在父组件中使用 provideEventBus 提供事件总线

// ParentComponent.vue
<template>
  <div>
    <ChildComponentA />
    <ChildComponentB />
  </div>
</template>

<script setup>
import { provideEventBus } from './eventBus';
import ChildComponentA from './ChildComponentA.vue';
import ChildComponentB from './ChildComponentB.vue';

provideEventBus();
</script>

3、在兄弟组件中使用 useEventBus 来获取事件总线并进行通信:

ChildComponentA.vue

// ChildComponentA.vue
<template>
  <button @click="sendMessage">Send Message to Component B</button>
</template>

<script setup>
import { useEventBus } from './eventBus';

const eventBus = useEventBus();

function sendMessage() {
  eventBus.emit('messageSent', 'Hello from Component A!');
}
</script>

ChildComponentB.vue

// ChildComponentB.vue
<template>
  <div>
    <p>{{ receivedMessage }}</p>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useEventBus } from './eventBus';

const eventBus = useEventBus();
const receivedMessage = ref('');

function handleMessage(message) {
  receivedMessage.value = message;
}

onMounted(() => {
  eventBus.on('messageSent', handleMessage);
});

onBeforeUnmount(() => {
  eventBus.off('messageSent', handleMessage);
});
</script>

封装 Hooks 实现通信

封装 hook 实现应用通信,定义 createHooks 函数,创建一个 state 数据,本质上利用了 闭包 原理,维护一个私有变量,use 用于注册添加一个函数,返回一个函数用于取消注册,exec 派发执行函数

// 创建hook
function createHooks (isReactive) {
    const state = isReactive ? reactive({
        innerHandlers: []
    }) : ({
        innerHandlers: []
    })
    // 注册事件
    const use = (handler) => {
        state.innerHandlers = [...state.innerHandlers, handler]
        return () => eject(handler)
    }
    // 注销事件
    const eject = (handler) => {
        state.innerHandlers = state.innerHandlers.filter(i => i !== handler)
    }
    // 派发执行事件
    const exec = (arg, refresh) => {
        if (state.innerHandlers.length === 0) {
            return arg
        }
        let index = 0
        const innerHandlers = [...state.innerHandlers]
        let innerHandler = innerHandlers[index]
        while (innerHandler) {
            innerHandler(arg, refresh)
            index++
            innerHandler = innerHandlers[index]
        }
        return arg
    }
    return { use, eject, exec, state, getListeners: () => [...state.innerHandlers] }
}

// 创建钩子方法,统一管理
export function useVisualEditorHooks ({ state }) {
  const hooks = {
      // 拖拽开始动作
      onDragstart: createHooks(),
      // 拖拽结束动作
      onDragend: createHooks(),
      ...
  }
  return hooks
}

createHooks 可以根据业务场景创建多个对象,然后利用该对象实现业务之间的通信,以编辑器组件开始拖拽为例

dragstart function({ component, event }) => {
    ...
    // 派发事件
    const exitStart = hooks.onDragstart.exec(component, event)
    
    // 在需要地方执行注销释放资源
    exitStart()
},

dragstart 开始拖拽,将组件信息传入 exec,在需要监听拖拽事件,进行接收数据,处理业务逻辑

hooks.onDragstart.use((data) => {
    state.isDragging = true
    ...
})

动态组件高级用法

为了减少使用 if-else 使用动态组件 <component> , 通过 :is 动态渲染组件

1、使用key属性:

Vue 遵循就地复用策略,在某些情况下会复用,切换就出现异常,可以在动态组件切换时,为组件添加唯一的 key 属性,确保组件之间的状态得到正确地保留和更新,而不会出现冲突:

<template>
  <div>
    <component :is="currentComponent" :key="currentComponent"></component>
    <button @click="toggleComponent">Toggle Component</button>
  </div>
</template>

2、动态组件的异步加载

动态组件异步加载,可以拆离单独文件,提高应用程序的性能。使用 import 函数可以实现异步加载组件:

<template>
  <div>
    <component :is="currentComponent" :key="currentComponent" :message="message"></component>
    <button @click="toggleComponent">Toggle Component</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const currentComponent = ref(null);

const toggleComponent = async () => {
  const component = currentComponent.value === 'ComponentA' ? 'ComponentB' : 'ComponentA';
  currentComponent.value = await import(`./components/${component}.vue`);
};
</script>

3、动态组件的缓存策略

动态组件在切换后会被销毁和重新创建,但在某些情况下,希望保留组件的状态和避免重新渲染,可以设置 keep-alive 属性来缓存动态组件

<keep-alive>
  <component :is="currentComponent" :key="currentComponent" :message="message"></component>
</keep-alive>

封装一个 Vue 插件

一些公共的组件库、业务包,抽离一个独立的 npm 包,方便做维护和扩展,进行版本管理,需要利用 vue 提供的插件接口 use 进行安装到项目中

以封装一个编辑器插件为例

  1. 创建一个 EditorPlugin.js 文件,用于封装编辑器插件
// EditorPlugin.js
import { createApp, ref, defineComponent, onMounted, onBeforeUnmount } from 'vue';

export const EditorPlugin = {
  // 在执行 app.use 会执行 install 方法,传入 app 应用实例
  install(app) {
    const editorContainer = ref(null);
    let editorInstance = null;

    // 创建编辑器组件
    const editorComponent = defineComponent({
      setup() {
        onMounted(() => {
          // 编辑器实例
          editorInstance = createEditor(editorContainer.value);
        });

        onBeforeUnmount(() => {
          // 组件销毁,释放编辑器资源
          destroyEditor(editorInstance);
        });
      },
      template: `
        <div ref="editorContainer"></div>
      `
    });
    // 全局注册组件
    app.component('Editor', editorComponent);
  }
};

// 编辑器实例函数
function createEditor(container) {
  const editor = {
    container,
    // Editor initialization logic goes here
    // ...
  };
  return editor;
}

// 编辑器销毁函数
function destroyEditor(editor) {
  // Editor cleanup logic goes here
  // ...
}
  1. main.js 中使用插件:
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import { EditorPlugin } from './EditorPlugin';

const app = createApp(App);
app.use(EditorPlugin);
app.mount('#app');
  1. 在需要使用编辑器的组件中使用 <Editor> 组件:
// SomeComponent.vue
<template>
  <div>
    <Editor />
  </div>
</template>

<script>
export default {
  components: {
    Editor: 'Editor'
  }
};
</script>

在上述示例中,通过 EditorPlugin 对编辑器插件进行封装。在 install 方法中,定义了一个 Editor 组件,该组件会在 onMounted 钩子中创建编辑器实例,并在 onBeforeUnmount 钩子中销毁编辑器。这样,在每个使用 <Editor> 组件的地方,都会自动创建和销毁编辑器实例,实现编辑器功能的复用。

扩展: Vue插件安装 use 函数在 vue 源码实现逻辑不多,挺清晰,以下是核心代码理解

// 简化版 use 函数
export function use(plugin: any, ...options: any[]): any {
  // 判断是否已经安装过该插件,避免重复安装
  if (plugin.__installed) {
    return;
  }

  // 插件可以是一个对象或一个函数
  if (isFunction(plugin)) {
    // 如果是函数,则执行该函数,并将 app 作为第一个参数传入
    plugin(app, ...options);
  } else if (isFunction(plugin.install)) {
    // 如果插件提供了 install 方法,则执行该方法,并将 app 作为第一个参数传入
    plugin.install(app, ...options);
  }

  // 标记插件已安装
  plugin.__installed = true;
}

纯 JS 编程组件

Vue template 模板编写带来了很大的便利,但也具有一定的局限性和灵活性,如果要编写复杂的交互的组件,模板上可能要加很多判断,扩展性和维护性不好,使用类似于 JSX 编程会有意想不到的效果

以创建一个任务列表组件为例:

const TaskList = {
  setup () {
    const tasks = ref([
      { id: 1, title: '完成Vue3学习', completed: false },
      { id: 2, title: '写一篇技术文章', completed: false },
      { id: 3, title: '学习新的前端框架', completed: false }
  ])

  return () => {
      return (
        <ul>
          { tasks.value.map(item =>
            <li>{ item.title }</li>
          )}
        </ul>
      )
    }
  }
}

// 初始化组件生成vdom
const vm = createVNode(TaskList)

// 创建容器
const container = document.createElement('div')

// render通过patch 变成dom
render(vm, container)

document.body.appendChild(container.firstElementChild)

1、在 setup 函数中编写页面,返回一个函数作为渲染内容,使用 jsx 代替 h 函数创建节点

2、createVNode 渲染函数,将模板生成虚拟 DOM 节点

3、render 调用 patch 把虚拟 DOM 转化为真实 DOM

4、最后拿到 DOM 添加到页面上

组件双向数据绑定 v-model

v-model 除了在表单输入框可以用外,其实也可以应用在组件上,可以实现父组件和子组件之间的双向绑定

  1. 在自定义组件上使用 v-model,需要在组件中声明 modelValue 属性,并在需要更新父组件数据时,使用 emit 方法触发 update:modelValue 事件
<!-- CustomInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

<script setup>
export default {
  props: {
    modelValue: String
  }
};
</script>

父组件引入使用,v-model 由于是双向绑定,父组件就不需要使用事件监听来更改数据了

<!-- ParentComponent.vue -->
<template>
  <div>
    <h2>Parent Component</h2>
    <CustomInput v-model="message" />
    <p>Message from Child Component: {{ message }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';

const message = ref('');
</script>
  1. v-model 如果不希望使用 value 作为参数,并且需要绑定多个参数

在自定义组件上使用 v-model:propName 来指定不同的 prop 名称,并使用 @update:propName 来指定不同的事件名称

// 自定义组件
<!-- CustomInput.vue -->
<template>
  <div>
    <input :value="name" :disabled="isDisabled" @input="updateValue" />
  </div>
</template>

<script>
import { ref, defineProps, defineEmits } from 'vue';

// 使用 defineProps() 和 defineEmits() 定义 props 和 emit
const props = defineProps(['name', 'isDisabled']);
const emitUpdateValue = defineEmits(['update:name', 'update:isDisabled']);

// 创建一个响应式的 valueRef
const valueRef = ref(props.name);

// 在输入框值改变时触发自定义的 updateValue 事件
function updateValue(event) {
  valueRef.value = event.target.value;
  emitUpdateValue('update:name', valueRef.value);
}
</script>

在父组件引入使用

<template>
  <CustomInput v-model:name="message" v-model:disabled="isDisabled" />
</template>

内置高级组件开发

keep-alive 缓存组件

keep-alive 组件用于缓存其他组件,避免它们在切换时被销毁和重新创建。这样可以提高应用的性能,特别是对于包含复杂数据计算或渲染逻辑的组件

由于过度使用缓存组件,会占有大量内存,所以提供 includeexclude 属性,用于指定需要缓存或排除的组件

使用 keep-aliverouter-view 和动画效果相结合可以实现在切换路由时缓存组件,并为组件切换添加过渡效果

<router-view v-slot="{ Component }">
  <transition name="fade">
    <keep-alive :include="cachedComponents">
      <component :is="Component" :key="route.path" />
    </keep-alive>
  </transition>
</router-view>

Teleport 传送组件

经常有遇到一个弹窗的场景,Vue3 提供了非常好用实现弹窗的组件 Teleport ,它可以将组件的内容渲染到任意的 DOM 节点上,从而实现在组件外部插入弹窗内容的效果

下面实现一个简单的弹窗组件示例,使用 TeleportDialog 组件的内容渲染到 body 上,这样它可以在页面的任意位置显示。

<!-- Dialog.vue -->
<template>
  <teleport to="body">
    <div class="dialog-overlay" v-if="show">
      <div class="dialog-container">
        <div class="dialog-header">
          <h2>{{ title }}</h2>
          <button @click="close">Close</button>
        </div>
        <div class="dialog-content">
          <slot></slot>
        </div>
      </div>
    </div>
  </teleport>
</template>

<script>
import { ref, defineComponent } from 'vue';

export default defineComponent({
  props: {
    title: String,
  },
  setup(props) {
    const show = ref(false);

    const open = () => {
      show.value = true;
    };

    const close = () => {
      show.value = false;
    };

    return {
      show,
      open,
      close,
    };
  },
});
</script>

<style>
.dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.dialog-container {
  background-color: #fff;
  padding: 20px;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.dialog-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.dialog-content {
  margin-top: 10px;
}
</style>


异步组件 defineAsyncComponent 和 Suspense的应用

异步组件和 Suspense 可以用于实现代码分割和懒加载,从而提高应用的性能。异步组件允许将组件的加载推迟到需要的时候,而 Suspense 则用于处理异步组件加载过程中的加载状态。

  1. 使用 defineAsyncComponent 函数来定义一个异步组件。这样可以将组件的加载推迟到需要的时候,从而减少初始加载时的包体积
// 使用 defineAsyncComponent 定义异步组件
import { defineAsyncComponent } from 'vue';

const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'));

export default AsyncComponent;

  1. 在父组件中使用 <Suspense> 组件来处理异步组件的加载状态。在异步组件加载完成之前,可以在 <Suspense> 中添加加载中的提示或占位内容。
<!-- App.vue -->
<template>
  <div>
    <Suspense>
      <!-- 使用异步组件 -->
      <AsyncComponent />
      <!-- 在异步组件加载完成之前,可以在 Suspense 中添加加载中的提示 -->
      <template #fallback>
        <div>Loading...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { Suspense } from 'vue';
import AsyncComponent from './AsyncComponent.js';
</script>
  1. 预加载和错误组件

使用 defineAsyncComponent 函数的 loader 选项来预加载异步组件, loadingComponent 加载中的组件,errorComponent 处理异步组件加载的错误

const asyncComponent = defineAsyncComponent({
  loader: () => import('./components/AsyncComponent.vue'),
  loadingComponent: LoadingComponent, // 可选,加载过程中显示的组件
  errorComponent: ErrorComponent, // 可选,加载出错时显示的组件
});