Skip to content

为什么我更推荐使用命令式组件

最近半年,我陆陆续续在生产环境尝试使用了一些命令式组件,也算是颇有心得,决定借由本文聊一下我认为的命令式组件相关的最佳实践。

什么是命令式组件

正式开始之前,先解决这个问题——到底什么是命令式组件?
其实哪怕你没正式封装过,那也肯定用过类似的东西:

html
<template>
  <el-button @click="open">点击打开确认消息弹窗</el-button>
</template>

<script lang="ts" setup>
import { ElMessage, ElMessageBox } from "element-plus";

const open = async () => {
  try {
    await ElMessageBox.confirm("请确认删除", "警告");  // 注意,这里是直接调用了组件的方法
    // 点击确认后的逻辑
  } catch (e) {
    // 点击取消后的逻辑
  }
};
</script>

以上这段代码是ElementPlus的消息弹框组件的一种用法,虽然这里并未显式地引入和放置弹框组件到该组件中(而是隐式地挂载到body上),但是其通过调用方法来控制组件的行为,就是命令式组件的重要特征。

同时,我专门制作了一些小示例,你可以访问https://vue-examples.rjiazhen.top/imperative-component 来使用和查看对应源码。

说回到命令式组件,与之相对的就是声明式组件。顾名思义,就是使用时并不需要显式地执行各种方法,而是通过参数将组件运行过程中可能要用到的各种变量(包括回调函数)传入即可。这也是目前主流框架所使用的主要组件形式。

以下是使用两种不同形式对弹窗组件的封装:

声明式:

html
<!-- NormalSimpleDialog.vue -->
<template>
  <el-dialog v-model="model">
    <span>这是一个声明式弹窗</span>
  </el-dialog>
</template>

<script setup lang="ts">
const model = defineModel();
</script>

<!-- App.vue -->
<template>
  <div>
    <el-button @click="model = true">打开声明式弹窗</el-button>
    <NormalSimpleDialog v-model="dialogVisible" />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import NormalSimpleDialog from "./NormalSimpleDialog.vue";

const model = ref(false);
</script>

命令式:

html
<!-- ImperativeSimpleDialog.vue -->
<template>
  <el-dialog v-model="isOpen">
    <span>这是一个命令式弹窗</span>
  </el-dialog>
</template>

<script setup lang="ts">
const isOpen = ref(false);

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

defineExpose({
  open,
});
</script>

<!-- App.vue -->
<template>
  <div>
    <el-button @click="openDialog">打开命令式弹窗</el-button>
    <!-- 注意,这里使用组件时没有声明其属性 -->
    <ImperativeSimpleDialog ref="dialog" />
  </div>
</template>


<script setup lang="ts">
const dialog = ref<InstanceType<typeof ImperativeSimpleDialog>>();

const openDialog = () => {
  dialog.value?.open();
};

可以看到,声明式的弹窗组件的打开行为是通过v-model这一属性进行控制的,这也是目前各种组件库和日常封装组件的常见方式。
而命令式的弹窗组件则是显式地调用组件实例的方法来控制的。
需要特别说明的是,声明式和命令式不是完全对立的,甚至可以说声明式是现在前端开发中绝对无法离开的一种设计模式,而命令式组件更应该看作是在声明式组件的基础上,部分使用命令式设计思想而派生的一种特殊的声明式组件

为什么要使用命令式组件

实现代码的高内聚

相信各位看完上面的例子,多少会觉得使用命令式组件就是脱裤子放屁,封装之后不仅代码量增加,还不易于理解。
确实,声明式组件其实就足以胜任90%的情况,但面对与组件存在复杂交互的情况,声明式组件就不是那么合适了。
比如现在在上面例子的基础上,再加上“确认”和取消“按钮,并且根据点击不同按钮执行不同逻辑。以下是分别用声明式和命令式实现的例子(对应示例网址中的两个确认对话框样例)。 声明式:

vue
<!-- NormalConfirmDialog.vue -->
<template>
  <el-dialog v-model="model">
    <span>这是一个声明式确认对话框</span>
    <template #footer>
      <el-button @click="onCancel">取消</el-button>
      <el-button
        type="primary"
        @click="onConfirm"
      >
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
const model = defineModel();

const emit = defineEmits(["confirm", "cancel"]);

const onCancel = () => {
  emit("cancel");
  model.value = false;
};
const onConfirm = () => {
  emit("confirm");
  model.value = false;
};
</script>

<!-- App.vue -->
<template>
  <div>
    <el-button @click="model = true">打开声明式确认对话框</el-button>
    <NormalConfirmDialog
      v-model="dialogVisible"
      @confirm="onConfirm"
      @cancel="onCancel"
    />
  </div>
</template>

<script setup lang="ts">
const model = ref(false);

const onCancel = () => {
  ElMessage.error("对话框点击取消");
};

const onConfirm = () => {
  ElMessage.success("对话框点击确认");
};
</script>

命令式:

vue
<!-- ImperativeConfirmDialog.vue -->
<template>
  <el-dialog v-model="isOpen">
    <span>这是一个命令式确认对话框</span>
    <template #footer>
      <el-button @click="onCancel">取消</el-button>
      <el-button
        type="primary"
        @click="onConfirm"
      >
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
const isOpen = ref(false);

let dialogResolve: (value?: any) => void;
const open = () => {
  isOpen.value = true;

  // 返回一个 Promise 对象,并且将resolve函数赋值给一个外部变量
  return new Promise((resolve) => {
    dialogResolve = resolve;
  });
};

const onConfirm = () => {
  isOpen.value = false;
  dialogResolve?.(true);
};

const onCancel = () => {
  isOpen.value = false;
  dialogResolve?.(false);
};

defineExpose({
  open,
});
</script>

<!-- App.vue -->
<template>
  <div>
    <el-button @click="openDialog">打开命令式确认对话框</el-button>
    <ImperativeConfirmDialog ref="dialog" />
  </div>
</template>

<script setup lang="ts">
const dialog = ref<InstanceType<typeof ImperativeConfirmDialog>>();

// 打开对话框和对话框确认后的逻辑可以放在同一个方法中
const openDialog = async() => {
  try {
    const result = await dialog.value?.open();
    if (!result) throw new Error("取消");
    ElMessage.success("对话框点击确认");
  } catch (e) {
    ElMessage.error("对话框点击取消");
  }
}

在这个例子中,声明式组件依然保持着封装简单方便的优点,但是在使用时却需要在父组件中定义三个不同的变量(控制对话框显隐的model、点击确认的回调函数、点击取消的回调函数)。
这时候就体现出了命令式组件的优点——在使用时不需要声明这么多变量、组件的相关逻辑也可以很方便地聚合在一起。
对于常见的对话框组件,其通常的交互流程就基本只有一条直线——打开对话框→和对话框交互→关闭对话框→根据关闭时的操作执行对应的逻辑。那么,将这个交互流程相关的代码组织在一起,即不影响正常的交互,同时代码内部也实现了高内聚

复杂功能的封装

这一点和“实现代码的高内聚”其实有相通之处,但会更倾向于实际的业务逻辑。
假设现在需要封装个二维码生成组件,有一个输入框供用户输入内容,还有一张动态生成的二维码图片,而使用该组件的父组件需要在点击按钮后下载该图片。
按照声明式组件的思想,那么就会考虑使用onUpdate属性,让父组件挂载一个回调函数(onImgUpdate)实时地将图片的数据同步到一个变量(img)中。
而如果是命令式组件,则是让该组件暴露一个getImg的方法来获取图片数据,然后在需要的时候通过实例的引用(childComponentRef)调用该方法即可。
先不说频繁地调用onUpdate挂载的回调函数会不会有性能问题,单纯从代码的层面来说,为了实现这一功能,声明式组件就需要父组件创建一个回调函数(onImgUpdate)和一个变量(img)了,而命令式组件则只需要父组件创建一个变量(childComponentRef)。

在这种业务场景中,其实父组件是不关心每时每刻子组件内部中的值是怎样的,它只希望在需要时拿到那个值即可。那么这时专门创建一个接收该值变量和对应的更新函数就会显得多余,凭空增加了代码的复杂度。
虽然只是一个变量和一个函数,看起来并不多,但是当父组件中逻辑复杂起来时,这样一个独立存在的变量就会让人考虑该值是否在其他地方发生了变更,从而影响理解代码的效率。 而更常见的情况是,这个生成二维码的组件后续需要进行功能的扩充,例如生成并且支持下载的二维码种类变多了。
那么这时候,声明式组件不仅需要子组件内部新增新的逻辑,父组件更是要么将img的数据类型修改为对象,要么新增类似的字符串类型数据来接收新的二维码图片数据,同时onUpdateImg()函数也需要增加对应的赋值逻辑。
而命令式组件则只需要在子组件内部新增对应的获取方法,或者给原有的getImg()方法添加传入的参数用来区分获取不同种类的二维码。 所以,当一个组件的逻辑愈发复杂时,如果按照命令式组件的形式,将其各种功能只通过方法进行暴露,那么在父组件使用时,就只需要调用对应的方法,而不需要编写对应的复杂逻辑。

增加代码易读性

从阅读的角度来说,声明式组件的核心在于使用属性来描述组件应该是怎么样的,例如上面的对话框组件示例中,就是使用v-modelonConfirmonCancel分别描述了组件的显隐状态、点击“确认”后应该做什么、点击“取消”后应该做什么。而不关心各属性之间的关联。
而命令式组件则是不预先描述组件,而是在需要的时候再“命令”组件去执行特定的任务
例如v-model这一属性,虽然是描述组件的显隐状态的,但是在语言描述中,我们更倾向于表述为“打开或关闭弹窗“,而不是“修改弹窗的显示状态”。
当然,“增加代码易读性”并不是绝对的。由于在Vue和React中,ref属性不属于基础内容,以及使用命令式组件并非主流,那么在实际开发时,也更容易出现需要额外学习才能理解代码含义的情况。如果考虑到这一点,也还是请使用声明式组件。

一些开发框架/库对命令式组件的态度

以下是一些开发框架或者UI库对命令式组件的使用情况,你也可以以此为参考,考虑是否大规模使用这种开发范式:

  • Vue2/3:无相关说明,但是在TS中支持使用InstanceType这个工具类型来获取组件实例的类型;
  • React:在useImperativeHandle一章中提到了命令式组件,但是着重在末尾强调应尽量使用prop实现(即声明式),而不要使用ref。同时在TS中,对于函数式组件没法直接获取组件实例类型,需要手动定义类型;
  • Element Plus:大量组件有使用命令式思想暴露方法(参见各组件的Exposes说明);
  • Ant Design:部分组件有使用命令式思想暴露方法(参见各组件的XXX ref和instance相关的说明);

总结

命令式组件适合对一些组件内复杂逻辑进行封装,以保证父组件在使用时只需要调用一个方法即可,而无需声明过多的变量。 如果在实际开发中无法确定是否需要封装成命令式组件,则可以思考当前要实现的组件功能如果用语言来表达是否为命令语句,如果是则可以考虑封装成命令式组件。 同时需要明白的一点,命令式组件不是银弹,更多的时候依然是纯声明式组件更合适,切不可为了使用命令式而去使用。