通过Vue3中的CompositionAPI使我的代码更加清爽
在我的开源项目中有一个组件是用来发送消息和展示消息的,这个组件的逻辑很复杂也是我整个项目的灵魂所在,单文件代码有 1100 多行。我每次用 webstorm 编辑这个文件时,电脑 cpu 温度都会飙升并伴随着卡顿。
就在前几天我终于忍不住了,意识到了 Vue2 的 optionsAPI 的缺陷,决定用 Vue3 的 CompositionAPI 来解决这个问题,本文就跟大家分享下我在优化过程中踩到的坑以及我所采用的解决方案,欢迎各位感兴趣的开发者阅读本文。
问题分析
我们先来看看组件的整体代码结构,如下图所示:
- 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 中暴露出来的模块,如下图所示:
- 创建 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。
使用 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(null); const msgInputContainer = ref(null); const selectImg = ref(null); // 响应式 Data 变量 const messageContent = ref(""); const emoticonShowStatus = ref("none"); const senderMessageList = reactive([]); const isBottomOut = ref(true); let listId = ref(""); let messageStatus = ref(0); let buddyId = ref(""); let buddyName = ref(""); let serverTime = ref(""); 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, messageStatusParam: Ref, buddyIdParam: Ref, buddyNameParam: Ref, serverTimeParam: Ref, emitParam: (event: string, ...args: any[]) => void ) => { listId = listIdParam; messageStatus = messageStatusParam; buddyId = buddyIdParam; buddyName = buddyNameParam; serverTime = serverTimeParam; emit = emitParam; }; const setProperty = ( storeParam: Store, 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 ): { userID: ComputedRef; onlineUsers: ComputedRef; } | 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; // 监听 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 来重构我这个组件的,一直搞到昨天晚上才重构完成,前前后后踩了很多坑,正所谓踩坑越多你越强,这句话还是很有道理的😎。
接下来就跟大家分享下我踩到的一些坑以及我的解决方案。
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 中调用时才能访问
如上所述,我们使用到了getCurrentInstance
和useStore
,这两个内置方法还有 initData 中定义的那些响应式数据,只有当拆分出来的文件在 setup 中使用且在同步代码中才能拿到数据,否则就是 null,一开始我这里说的不严谨,我在 debug 问题时,发现了拆分出来的文件必须在 setup 里调用才能拿到这些内置方法所返回的数据,
我的文件是拆分出去的,有些函数是运行在某个拆分出来的文件中的,不可能都在 setup 中执行一遍的,响应式变量也不可能全当作参数进行传递的,为了解决这个问题,我有试过使用provide
注入然后通过inject
访问,结果运行后发现不好使,控制台报黄色警告说provide
和inject
只能运行在setup
中,我直接裂开,当时发了一条沸点求助了下,到了晚上也没得到解决方案😪。
经过一番求助后,我的好友@前端印象给我提供了一个思路,成功的解决了这个问题,也就是我上面initData
的做法,将响应式变量定义在导出函数的外面,这样我们在拆分出来的文件中导入initData
方法时,里面的变量都是指向同一个地址,可以直接访问存储在里面的变量且不会将其进行初始化。
至于getCurrentInstance
和useStore
访问出现 null 的情景,还有 props、emit 的使用问题,我们可以在initData
的导出函数内部定义 set 方法,在 setup 里执行的方法中获取到实例后,通过 set 方法将其设置进我们定义的变量中。
至此,问题就完美解决了,最后跟大家看下优化后的组件代码,393 行。
原文件链接:传送门
码云笔记 » 通过Vue3中的CompositionAPI使我的代码更加清爽