使用Vue3的CompositionAPI來優化代碼量

前言

在個人開源項目中有一個組件是用來發送消息和展現消息的,這個組件的邏輯很複雜也是我整個項目的靈魂所在,單文件代碼有1100多行。我每次用webstorm編輯這個文件時,電腦cpu溫度都會飆升並伴隨着卡頓。html

就在前幾天我終於忍不住了,意識到了Vue2的optionsAPI的缺陷,決定用Vue3的CompositionAPI來解決這個問題,本文就跟你們分享下我在優化過程當中踩到的坑以及我所採用的解決方案,歡迎各位感興趣的開發者閱讀本文。前端

問題分析

咱們先來看看組件的總體代碼結構,以下圖所示:vue

image-20210114095802363

  • template部分佔用267行
  • script部分佔用889行
  • style部分爲外部引用佔用1行

罪魁禍首就是script部分,本文要優化的就是這一部分的代碼,咱們再來細看下script中的代碼結構:react

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

如今罪魁禍首是methods部分,那麼咱們只須要把methods部分的代碼拆分出去,單文件代碼量就大大減小了。git

優化方案

通過上述分析後,咱們已經知道了問題所在,接下來就跟你們分享下我一開始想到的方案以及最終所採用的方案。github

直接拆分紅文件

一開始我以爲既然methods方法佔用的行數太多,那麼我在src下建立一個methods文件夾,把每一個組件中的methods的方法按照組件名進行劃分,建立對應的文件夾,在對應的組件文件夾內部,將methods中的方法拆分紅獨立的ts文件,最後建立index.ts文件,將其進行統一導出,在組件中使用時按需導入index.ts中暴露出來的模塊,以下圖所示:web

image-20210114103824562

  • 建立methods文件夾
  • 把每一個組件中的methods的方法按照組件名進行劃分,建立對應的文件夾,即:message-display
  • 將methods中的方法拆分紅獨立的ts文件,即:message-display文件夾下的ts文件
  • 建立index.ts文件,即:methods下的index.ts文件

index.ts代碼

以下所示,咱們將拆分的模塊方法進行導入,而後統一export出去vuex

import compressPic from "@/methods/message-display/CompressPic";
import pasteHandle from "@/methods/message-display/PasteHandle";

export { compressPic, pasteHandle };
複製代碼

在組件中使用

最後,咱們在組件中按需導入便可,以下所示:typescript

import { compressPic, pasteHandle } from "@/methods/index";

export default defineComponent({
    mounted() {
      compressPic();
      pasteHandle();
    }
})
複製代碼

運行結果

當我自信滿滿的開始跑項目時,發現瀏覽器的控制檯報錯了,提示我this未定義,忽然間我意識到將代碼拆分紅文件後,this是指向那個文件的,並無指向當前組件實例,固然能夠將this做爲參數傳進去,但我以爲這樣並不妥,用到一個方法就傳一個this進去,會產生不少冗餘代碼,所以這個方案被我pass了。api

使用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

寫在最後

  • 文中若有錯誤,歡迎在評論區指正,若是這篇文章幫到了你,歡迎點贊和關注😊
  • 本文首發於掘金,未經許可禁止轉載💌
相關文章
相關標籤/搜索