前言

在我的开源项目中有一个组件是用来发送消息和展示消息的,这个组件的逻辑很复杂也是我整个项目的灵魂所在,单文件代码有1100多行。我每次用webstorm编辑这个文件时,电脑cpu温度都会飙升并伴随着卡顿。

就在前几天我终于忍不住了,意识到了Vue2的optionsAPI的缺陷,决定用Vue3的CompositionAPI来解决这个问题,本文就跟大家分享下我在优化过程中踩到的坑以及我所采用的解决方案,欢迎各位感兴趣的开发者阅读本文。

问题分析

我们先来看看组件的整体代码结构,如下图所示:

image-20210114095802363

  • template部分占用267行
  • script部分占用889行
  • style部分为外部引用占用1行

罪魁祸首就是script部分,本文要优化的就是这一部分的代码,我们再来细看下script中的代码结构:

  • props部分占用6行
  • data部分占用52行
  • created部分占用8行
  • mounted部分占用98行
  • methods部分占用672行
  • emits部分占用6行
  • computed部分占用8行
  • watch部分占用26行

现在罪魁祸首是methods部分,那么我们只需要把methods部分的代码拆分出去,单文件代码量就大大减少了。

优化方案

经过上述分析后,我们已经知道了问题所在,接下来就跟大家分享下我一开始想到的方案以及最终所采用的方案。

直接拆分成文件

一开始我觉得既然methods方法占用的行数太多,那么我在src下创建一个methods文件夹,把每个组件中的methods的方法按照组件名进行划分,创建对应的文件夹,在对应的组件文件夹内部,将methods中的方法拆分成独立的ts文件,最后创建index.ts文件,将其进行统一导出,在组件中使用时按需导入index.ts中暴露出来的模块,如下图所示:

image-20210114103824562

  • 创建methods文件夹
  • 把每个组件中的methods的方法按照组件名进行划分,创建对应的文件夹,即:message-display
  • 将methods中的方法拆分成独立的ts文件,即:message-display文件夹下的ts文件
  • 创建index.ts文件,即:methods下的index.ts文件

index.ts代码

如下所示,我们将拆分的模块方法进行导入,然后统一export出去

import compressPic from "@/methods/message-display/CompressPic"; import pasteHandle from "@/methods/message-display/PasteHandle"; export { compressPic, pasteHandle };

在组件中使用

最后,我们在组件中按需导入即可,如下所示:

import { compressPic, pasteHandle } from "@/methods/index"; export default defineComponent({ mounted() { compressPic(); pasteHandle(); } })

运行结果

当我自信满满的开始跑项目时,发现浏览器的控制台报错了,提示我this未定义,突然间我意识到将代码拆分成文件后,this是指向那个文件的,并没有指向当前组件实例,当然可以将this作为参数传进去,但我觉得这样并不妥,用到一个方法就传一个this进去,会产生很多冗余代码,因此这个方案被我pass了。

使用mixins

前一个方案因为this的问题以失败告终,在Vue2.x的时候官方提供了mixins来解决this问题,我们使用mixin来定义我们的函数,最后使用mixins进行混入,这样就可以在任意地方使用了。

由于mixins是全局混入的,一旦有重名的mixin原来的就会被覆盖,所以这个方案也不合适,pass。

image-20210114111746208

使用CompositionAPI

上述两个方案都不合适,那 么CompositionAPI就刚好弥补上述方案的短处,成功的实现了我们想要实现的需求。

我们先来看看什么是CompositionAPI,正如文档所述,我们可以将原先optionsAPI中定义的函数以及这个函数需要用到的data变量,全部归类到一起,放到setup函数里,功能开发完成后,将组件需要的函数和data在setup进行return。

setup函数在创建组件之前执行,因此它是没有this的,这个函数可以接收2个参数: props和context,他们的类型定义如下:

interface Data { [key: string]: unknown } interface SetupContext { attrs: Data slots: Slots emit: (event: string, ...args: unknown[]) => void } function setup(props: Data, context: SetupContext): Data

我的组件需要拿到父组件传过来的props中的值,需要通过emit来向父组件传递数据,props和context这两个参数正好解决了我这个问题。

setup又是个函数,也就意味着我们可以将所有的函数拆分成独立的ts文件,然后在组件中导入,在setup中将其return给组件即可,这样就很完美的实现了一开始我们一开始所说的的拆分。

实现思路

接下来的内容会涉及到响应性API,如果对响应式API不了解的开发者请先移步官方文档。

我们分析出方案后,接下来我们就来看看具体的实现路:

  • 在组件的导出对象中添加setup属性,传入props和context

  • 在src下创建module文件夹,将拆分出来的功能代码按组件进行划分

  • 将每一个组件中的函数进一步按功能进行细分,此处我分了四个文件夹出来

    • common-methods 公共方法,存放不需要依赖组件实例的方法
    • components-methods 组件方法,存放当前组件模版需要使用的方法
    • main-entrance 主入口,存放setup中使用的函数
    • split-method 拆分出来的方法,存放需要依赖组件实例的方法,setup中函数拆分出来的文件也放在此处
  • 在主入口文件夹中创建InitData.ts文件,该文件用于保存、共享当前组件需要用到的响应式data变量

  • 所有函数拆分完成后,我们在组件中将其导入,在setup中进行return即可

实现过程

接下来我们将上述思路进行实现。

添加setup选项

我们在vue组件的导出部分,在其对象内部添加setup选项,如下所示:

<template>
  <!---其他内容省略-->
</template>
<script lang="ts">
export default defineComponent({
  name: "message-display",
  props: {
    listId: String, // 消息id
    messageStatus: Number, // 消息类型
    buddyId: String, // 好友id
    buddyName: String, // 好友昵称
    serverTime: String // 服务器时间
  },
  setup(props, context) {
    // 在此处即可写响应性API提供的方法,注意⚠️此处不能用this
  }
}
</script>

创建module模块

我们在src下创建module文件夹,用于存放我们拆分出来的功能代码文件。

如下所示,为我创建好的目录,我的划分依据是将相同类别的文件放到一起,每个文件夹的所代表的含义已在实现思路进行说明,此处不作过多解释。

创建InitData.ts文件

我们将组件中用到的响应式数据,统一在这里进行定义,然后在setup中进行return,该文件的部分代码定义如下,完整代码请移步:InitData.ts

import { reactive, Ref, ref, getCurrentInstance, ComponentInternalInstance } from "vue"; import { emojiObj, messageDisplayDataType, msgListType, toolbarObj } from "@/type/ComponentDataType"; import { Store, useStore } from "vuex"; // DOM操作,必须return否则不会生效 const messagesContainer = ref<HTMLDivElement | null>(null); const msgInputContainer = ref<HTMLDivElement | null>(null); const selectImg = ref<HTMLImageElement | null>(null); // 响应式Data变量 const messageContent = ref<string>(""); const emoticonShowStatus = ref<string>("none"); const senderMessageList = reactive([]); const isBottomOut = ref<boolean>(true); let listId = ref<string>(""); let messageStatus = ref<number>(0); let buddyId = ref<string>(""); let buddyName = ref<string>(""); let serverTime = ref<string>(""); let emit: (event: string, ...args: any[]) => void = () => { return 0; }; // store与当前实例 let $store = useStore(); let currentInstance = getCurrentInstance(); export default function initData(): messageDisplayDataType { // 定义set方法,将props中的数据写入当前实例 const setData = ( listIdParam: Ref<string>, messageStatusParam: Ref<number>, buddyIdParam: Ref<string>, buddyNameParam: Ref<string>, serverTimeParam: Ref<string>, emitParam: (event: string, ...args: any[]) => void ) => { listId = listIdParam; messageStatus = messageStatusParam; buddyId = buddyIdParam; buddyName = buddyNameParam; serverTime = serverTimeParam; emit = emitParam; }; const setProperty = ( storeParam: Store<any>, instanceParam: ComponentInternalInstance | null ) => { $store = storeParam; currentInstance = instanceParam; }; // 返回组件需要的Data return { messagesContainer, msgInputContainer, selectImg, $store, emoticonShowStatus, currentInstance, // .... 其他部分省略.... emit } }

⚠️细心的开发者可能已经发现,我把响应式变量定义在导出的函数外面了,之所以这么做是因为setup的一些特殊原因,在下面的踩坑章节我将会详解我为什么要这样做。

在组件中使用

定义完相应死变量后,我们就可以在组件中导入使用了,部分代码如下所示,完整代码请移步:message-display.vue

import initData from "@/module/message-display/main-entrance/InitData"; export default defineComponent({ setup(props, context) { // 初始化组件需要的data数据 const { createDisSrc, resourceObj, messageContent, emoticonShowStatus, emojiList, toolbarList, senderMessageList, isBottomOut, audioCtx, arrFrequency, pageStart, pageEnd, pageNo, pageSize, sessionMessageData, msgListPanelHeight, isLoading, isLastPage, msgTotals, isFirstLoading, messagesContainer, msgInputContainer, selectImg } = initData(); // 返回组件需要用到的方法 return { createDisSrc, resourceObj, messageContent, emoticonShowStatus, emojiList, toolbarList, senderMessageList, isBottomOut, audioCtx, arrFrequency, pageStart, pageEnd, pageNo, pageSize, sessionMessageData, msgListPanelHeight, isLoading, isLastPage, msgTotals, isFirstLoading, messagesContainer, msgInputContainer, selectImg }; } })

我们定义后响应式变量后,就可以在拆分出来的文件中导入initData函数,访问里面存储的变量了。

在文件中访问initData

我将页面内所有的事件监听也拆分成了文件,放在了EventMonitoring.ts中,在事件监听的处理函数是需要访问initData里存储的变量的,接下来我们就来看下如何访问,部分代码如下所示,完整代码请移步EventMonitoring.ts)

import { computed, Ref, ComputedRef, watch, getCurrentInstance, toRefs } from "vue"; import { useStore } from "vuex"; import initData from "@/module/message-display/main-entrance/InitData"; import { SetupContext } from "@vue/runtime-core"; import _ from "lodash"; export default function eventMonitoring( props: messageDisplayPropsType, context: SetupContext<any> ): { userID: ComputedRef<string>; onlineUsers: ComputedRef<number>; } | void { const $store = useStore(); const currentInstance = getCurrentInstance(); // 获取传递的参数 const data = initData(); // 将props改为响应式 const prop = toRefs(props); // 获取data中的数据 const senderMessageList = data.senderMessageList; const sessionMessageData = data.sessionMessageData; const pageStart = data.pageStart; const pageEnd = data.pageEnd; const pageNo = data.pageNo; const isLastPage = data.isLastPage; const msgTotals = data.msgTotals; const msgListPanelHeight = data.msgListPanelHeight; const isLoading = data.isLoading; const isFirstLoading = data.isFirstLoading; const listId = data.listId; const messageStatus = data.messageStatus; const buddyId = data.buddyId; const buddyName = data.buddyName; const serverTime = data.serverTime; const messagesContainer = data.messagesContainer as Ref<HTMLDivElement>; // 监听listID改变 watch(prop.listId, (newMsgId: string) => { listId.value = newMsgId; messageStatus.value = prop.messageStatus.value; buddyId.value = prop.buddyId.value; buddyName.value = prop.buddyName.value; serverTime.value = prop.serverTime.value; // 消息id发生改变,清空消息列表数据 senderMessageList.length = 0; // 初始化分页数据 sessionMessageData.length = 0; pageStart.value = 0; pageEnd.value = 0; pageNo.value = 1; isLastPage.value = false; msgTotals.value = 0; msgListPanelHeight.value = 0; isLoading.value = false; isFirstLoading.value = true; }); }

正如代码中那样,在文件中使用时,拿出initData中对应的变量,需要修改其值时,只需要修改他的value即可。

至此,有关compositionAPI的基本使用就跟大家讲解完了,下面将跟大家分享下我在实现过程中所踩的坑,以及我的解决方案。

踩坑分享

今天是周四,我周一开始决定使用CompositionAPI来重构我这个组件的,一直搞到昨天晚上才重构完成,前前后后踩了很多坑,正所谓踩坑越多你越强,这句话还是很有道理的?。

接下来就跟大家分享下我踩到的一些坑以及我的解决方案。

dom操作

我的组件需要对dom进行操作,在optionsAPI中可以使用this.$refs.xxx来访问组件dom,在setup中是没有this的,翻了下官方文档后,发现需要通过ref来定义,如下所示:

<template>
<div ref="msgInputContainer"></div>
<ul v-for="(item, i) in list" :ref="el => { ulContainer[i] = el }"></ul>
</template>

<script lang="ts">
  import { ref, reactive, onBeforeUpdate } from "vue";
  setup(){
    export default defineComponent({
    // DOM操作,必须return否则不会生效
    // 获取单一dom
    const messagesContainer = ref<HTMLDivElement | null>(null);
    // 获取列表dom
    const ulContainer = ref<HTMLUListElement>([]);
    const list = reactive([1, 2, 3]);
    // 列表dom在组件更新前必须初始化
    onBeforeUpdate(() => {
       ulContainer.value = [];
    });
    return {
      messagesContainer,
      list,
      ulContainer
    }
  })
  }
</script>


访问vuex

在setup中访问vuex需要通过useStore()来访问,代码如下所示:

import { useStore } from "vuex"; const $store = useStore(); console.log($store.state.token);

访问当前实例

在组件中需要访问挂载在globalProperties上的东西,在setup中就需要通过getCurrentInstance()来访问了,代码如下所示:

import { getCurrentInstance } from "vue"; const currentInstance = getCurrentInstance(); currentInstance?.appContext.config.globalProperties.$socket.sendObj({ code: 200, token: $store.state.token, userID: $store.state.userID, msg: $store.state.userID + "上线" });

无法访问$options

我重构的websocket插件是将监听消息接收方法放在options上的,需要通过this.$options.xxx来访问,文档翻了一圈没找到有关在setup中使用的内容,那看来是不能访问了,那么我只能选择妥协,把插件挂载在options上的方法放到globalProperties上,这样问题就解决了。

拆分文件的class写法

上面介绍的拆分出来的文件,采用的是export function的写法,既然项目用上了ts,那么拆分出来的文件也完全可以采用export class的写法,使用class写法的代码看起来会更整洁,可读性也会提升很多。

接下来,我就以项目中的截图组件为列,跟大家演示下class写法,部分代码如下所示,完整代码请移步:screen-short/main-entrance/InitData.ts

import { ComponentInternalInstance, ref } from "vue"; import { Store } from "vuex"; const screenshortLeftPosition = ref<number>(10); // 截图框选区域距离屏幕左侧的位置 const screenshortTopPosition = ref<number>(20); // 截图框选区域距离屏幕左侧的位置 const mouseDownStatus = ref<boolean>(false); // 鼠标是否按下 const mouseX = ref<number>(0); // 鼠标的X轴位置 const mouseY = ref<number>(0); // 鼠标的Y轴位置 const mouseL = ref<number>(0); // 鼠标距离左边的偏移量 const mouseT = ref<number>(0); // 鼠标距离顶部的偏移量 // 获取截图选择框dom const frameSelectionController = ref<HTMLDivElement | null>(null); let emit: ((event: string, ...args: any[]) => void) | undefined; // 事件处理 // store与当前实例 let $store: Store<any> | undefined; let currentInstance: ComponentInternalInstance | null | undefined; // 数据是否存在 let hasData: boolean | undefined; export default class InitData { constructor() { // 数据为空时则初始化数据 if (!hasData) { // 初始化完成设置其值为true hasData = true; screenshortLeftPosition.value = 0; screenshortTopPosition.value = 0; mouseDownStatus.value = false; mouseX.value = 0; mouseY.value = 0; mouseL.value = 0; mouseT.value = 0; } } /** * 设置hasData属性 * @param ststus */ public setHasData(ststus: boolean) { hasData = ststus; } // 获取截图框选区域距离屏幕左侧的位置 public getScreenshortLeftPosition() { return screenshortLeftPosition; } // 获取截图框选区域距离屏幕顶部的位置 public getScreenshortTopPosition() { return screenshortTopPosition; } /** * 设置父组件传递的数据 * @param emitParam */ public setPropsData(emitParam: (event: string, ...args: any[]) => void) { emit = emitParam; } /** * 设置实例属性 * @param storeParam * @param instanceParam */ public setProperty( storeParam: Store<any>, instanceParam: ComponentInternalInstance | null ) { $store = storeParam; currentInstance = instanceParam; } }

随后,在setup中使用new关键词实例化后即可调用class中的public方法,代码如下所示:

<template>
  <teleport to="body">
    <div id="screenshortContainer">
      <div
        class="frame-selection-panel"
        ref="frameSelectionController"
        :style="{
          top: topPosition + 'px',
          left: leftPosition + 'px'
        }"
      ></div>
    </div>
  </teleport>
</template>

<script lang="ts">
import initData from "@/module/screen-short/main-entrance/InitData";
import eventMonitoring from "@/module/screen-short/main-entrance/EventMonitoring";
import { SetupContext } from "@vue/runtime-core";

export default {
  name: "screen-short",
  props: {},
  setup(props: Record<string, any>, context: SetupContext<any>) {
    const data = new initData();
    const leftPosition = data.getScreenshortLeftPosition();
    const topPosition = data.getScreenshortTopPosition();
    const frameSelectionController = data.getFrameSelectionController();
    new eventMonitoring(props, context as SetupContext<any>);
    return {
      leftPosition,
      topPosition,
      frameSelectionController
    };
  }
};
</script>

内置方法只有在setup中调用时才能访问

如上所述,我们使用到了getCurrentInstanceuseStore,这两个内置方法还有initData中定义的那些响应式数据,只有当拆分出来的文件在setup中使用且在同步代码中才能拿到数据,否则就是null,一开始我这里说的不严谨,我在debug问题时,发现了拆分出来的文件必须在setup里调用才能拿到这些内置方法所返回的数据,写文章时就写了内置方法方法只有在setup中使用时才能拿到数据可能我表达能力有问题,被评论区的掘友误解了?,感谢评论区掘友@4Ark指出我这里的问题所在,他从源码的角度分析了为什么会出现这个问题,从他的分析文章中我还知道了其在异步方法中调用时也拿不到数据,对此问题感兴趣的开发者可以移步至他的分析文章:从 Composition API 源码分析 getCurrentInstance() 为何返回 null

我的文件是拆分出去的,有些函数是运行在某个拆分出来的文件中的,不可能都在setup中执行一遍的,响应式变量也不可能全当作参数进行传递的,为了解决这个问题,我有试过使用provide注入然后通过inject访问,结果运行后发现不好使,控制台报黄色警告说provideinject只能运行在setup中,我直接裂开,当时发了一条沸点求助了下,到了晚上也没得到解决方案?。

经过一番求助后,我的好友@前端印象给我提供了一个思路,成功的解决了这个问题,也就是我上面initData的做法,将响应式变量定义在导出函数的外面,这样我们在拆分出来的文件中导入initData方法时,里面的变量都是指向同一个地址,可以直接访问存储在里面的变量且不会将其进行初始化。

至于getCurrentInstanceuseStore访问出现null的情景,还有props、emit的使用问题,我们可以在initData的导出函数内部定义set方法,在setup里执行的方法中获取到实例后,通过set方法将其设置进我们定义的变量中。

至此,问题就完美解决了,最后跟大家看下优化后的组件代码,393行?

image-20210114201837539

项目地址

项目地址:chat-system-github

在线体验地址:chat-system

写在最后

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注?
  • 本文首发于掘金,未经许可禁止转载?