Vue3丨TS丨7 個思路封裝一個靈活的 Modal 對話框

先講段話

一個靈活的,足夠抽象的組件可以使我們提高編碼效率,規范代碼,統一 UI 風格...,在 Vue3 中,我們以常見的 Modal 對話框組件為例,探討幾個思路點與實現。

思考丨7個思路

  1. ? 一個對話框需要的基本要素「標題,內容,確定/取消按鈕」。內容需要靈活,所以可以是字符串,或一段 html 代碼(也就是 slot )。
  2. ? 對話框需要“跳出”,避免來自父組件的“束縛”,用 Vue3 Teleport 內置組件包裹。
  3. ? 調用對話框需要在每個父組件都進行引入 import Modal from '@/Modal',比較繁瑣。考慮還可以采用 API 的形式,如在 Vue2 中:this.$modal.show({ /* 選項 */ })
  4. ? API 的形式調用,內容可以是字符串,靈活的 h 函數,或jsx語法進行渲染。
  5. ? 可全局配置對話框的樣式,或者行為...,局部配置可進行覆蓋。
  6. ? 國際化,可靈活與 vue-i18n 糅合,即:如果沒有引入vue-i18n默認顯示中文版,反之,則會用 vue-i18nt 方法來切換語言。
  7. ? 與 ts 結合,使基于 API 的形式調用更友好。

思路有了就讓我們來做行動的巨人~

實踐

Modal 組件相關的目錄結構

├── plugins
│   └── modal
│       ├── Content.tsx // 維護 Modal 的內容,用于 h 函數和 jsx 語法
│       ├── Modal.vue // 基礎組件
│       ├── config.ts // 全局默認配置
│       ├── index.ts // 入口
│       ├── locale // 國際化相關
│       │   ├── index.ts
│       │   └── lang
│       │       ├── en-US.ts
│       │       ├── zh-CN.ts
│       │       └── zh-TW.ts
│       └── modal.type.ts // ts類型聲明相關

說明:因為 Modal 會被 app.use(Modal) 調用作為一個插件,所以我們把它放在 plugins 目錄下。

Modal.vue 的基礎封裝(只展示template)

<template>
  <Teleport to="body"
            :disabled="!isTeleport">>
    <div v-if="modelValue"
         class="modal">

      <div class="mask"
           :style="style"
           @click="handleCancel"></div>

      <div class="modal__main">
        <div class="modal__title">
          <span>{{title||'系統提示'}}</span>
          <span v-if="close"
                title="關閉"
                class="close"
                @click="handleCancel">?</span>
        </div>

        <div class="modal__content">
          <Content v-if="typeof content==='function'"
                   :render="content" />
          <slot v-else>
            {{content}}
          </slot>
        </div>

        <div class="modal__btns">
          <button :disabled="loading"
                  @click="handleConfirm">
            <span class="loading"
                  v-if="loading"> ? </span>確定
          </button>
          <button @click="handleCancel">取消</button>
        </div>

      </div>
    </div>
  </Teleport>
</template>

說明:從 template 我們可以看到,Modal 的 dom 結構,有遮罩層、標題、內容、和底部按鈕幾部分。這幾塊我們都可以定義并接收對應 prop 進行不同的樣式或行為配置。

現在讓我們關注于 content(內容)這塊:

<div class="modal__content">
  <Content v-if="typeof content==='function'"
            :render="content" />
  <slot v-else>
    {{content}}
  </slot>
</div>

<Content /> 是一個函數式組件:

// Content.tsx
import { h } from 'vue';
const Content = (props: { render: (h: any) => void }) => props.render(h);
Content.props = ['render'];
export default Content;

場景1:基于 API 形式的調用,當 content 是一個方法,就調用 Content 組件,如:

  • 使用 h 函數:
$modal.show({
  title: '演示 h 函數',
  content(h) {
    return h(
      'div',
      {
        style: 'color:red;',
        onClick: ($event: Event) => console.log('clicked', $event.target)
      },
      'hello world ~'
    );
  }
});
  • 使用便捷的 jsx 語法
$modal.show({
  title: '演示 jsx 語法',
  content() {
    return (
      <div
        onClick={($event: Event) => console.log('clicked', $event.target)}
      >
        hello world ~
      </div>
    );
  }
});

場景2:傳統的調用組件方式,當 content 不是一個方法(在 v-else 分支),如:

  • default slot
<Modal v-model="show"
          title="演示 slot">
  <div>hello world~</div>
</Modal>
  • 直接傳遞 content 屬性
<Modal v-model="show"
          title="演示 content"
          content="hello world~" />

如上,一個 Modal 的內容就可以支持我們用 4 種方式 來寫。

API 化

在 Vue2 中我們要 API 化一個組件用Vue.extend的方式,來獲取一個組件的實例,然后動態 append 到 body,如:

import Modal from './Modal.vue';
const ComponentClass = Vue.extend(Modal);
const instance = new ComponentClass({ el: document.createElement("div") });
document.body.appendChild(instance.$el);

在 Vue3 移除了 Vue.extend 方法,但我們可以這樣做

import Modal from './Modal.vue';
const container = document.createElement('div');
const vnode = createVNode(Modal);
render(vnode, container);
const instance = vnode.component;
document.body.appendChild(container);

把 Modal 組件轉換為虛擬 dom,通過渲染函數,渲染到 div(當組件被控制為顯示時 )。再動態 append 到 body。

來看具體代碼(省略掉部分,詳細請看注釋):

// index.ts
import { App, createVNode, render } from 'vue';
import Modal from './Modal.vue';
import config from './config';

// 新增 Modal 的 install 方法,為了可以被 `app.use(Modal)`(Vue使用插件的的規則)
Modal.install = (app: App, options) => {
  // 可覆蓋默認的全局配置
  Object.assign(config.props, options.props || {});

  // 注冊全局組件 Modal
  app.component(Modal.name, Modal);
  
  // 注冊全局 API
  app.config.globalProperties.$modal = {
    show({
      title = '',
      content = '',
      close = config.props!.close
    }) {
      const container = document.createElement('div');
      const vnode = createVNode(Modal);
      render(vnode, container);
      const instance = vnode.component;
      document.body.appendChild(container);
        
      // 獲取實例的 props ,進行傳遞 props
      const { props } = instance;

      Object.assign(props, {
        isTeleport: false,
        // 在父組件上我們用 v-model 來控制顯示,語法糖對應的 prop 為 modelValue
        modelValue: true,
        title,
        content,
        close
      });
    }
  };
};
export default Modal;

細心的小伙伴就會問,那 API 調用 Modal 該如何去處理點擊事件呢?讓我們帶著疑問往下看。

基于 API 事件的處理

我們在封裝 Modal.vue 時,已經寫好了對應的「確定」「取消」事件:

// Modal.vue
setup(props, ctx) {
  let instance = getCurrentInstance();
  onBeforeMount(() => {
    instance._hub = {
      'on-cancel': () => {},
      'on-confirm': () => {}
    };
  });

  const handleConfirm = () => {
    ctx.emit('on-confirm');
    instance._hub['on-confirm']();
  };
  const handleCancel = () => {
    ctx.emit('on-cancel');
    ctx.emit('update:modelValue', false);
    instance._hub['on-cancel']();
  };

  return {
    handleConfirm,
    handleCancel
  };
}

這里的 ctx.emit 只是讓我們在父組件中調用組件時使用@on-confirm的形式來監聽。那我們怎么樣才能在 API 里監聽呢?換句話來講,我們怎么樣才能在 $modal.show 方法里“監聽”。

// index.ts
app.config.globalProperties.$modal = {
   show({}) {
     /* 監聽 確定、取消 事件 */
   }
}

我們可以看到在 上面的 setup 方法內部,獲取了當前組件的實例,在組件掛載前,我們擅自添加了一個屬性 _hub(且叫它事件處理中心吧~),并且添加了兩個空語句方法 on-cancelon-confirm,且在點擊事件里都有被對應的調用到了。

這里我們給自己加了一些 “難度”,我們要實現點擊確定,如果確定事件是一個異步操作,那我們需要在確定按鈕上顯示 loading 圖標,且禁用按鈕,來等待異步完成。

直接看代碼:

// index.ts
app.config.globalProperties.$modal = {
  show({
    /* 其他選項 */
    onConfirm,
    onCancel
  }) {
    /* ... */

    const { props, _hub } = instance;
    
    const _closeModal = () => {
      props.modelValue = false;
      container.parentNode!.removeChild(container);
    };
    // 往 _hub 新增事件的具體實現
    Object.assign(_hub, {
      async 'on-confirm'() {
        if (onConfirm) {
          const fn = onConfirm();
          // 當方法返回為 Promise
          if (fn && fn.then) {
            try {
              props.loading = true;
              await fn;
              props.loading = false;
              _closeModal();
            } catch (err) {
              // 發生錯誤時,不關閉彈框
              console.error(err);
              props.loading = false;
            }
          } else {
            _closeModal();
          }
        } else {
          _closeModal();
        }
      },
      'on-cancel'() {
        onCancel && onCancel();
        _closeModal();
      }
    });

    /* ... */

  }
};

i18n

組件自帶

考慮到我們的組件也可能做 i18n ,于是我們這里留了一手。默認為中文的 i18n 配置,翻到上面 Modal.vue 的基礎封裝 可以看到,有 4 個常量是我們需要進行配置的,如:

<span>{{title||'系統提示'}}</span>
title="關閉"
<button @click="handleConfirm">確定</button>
<button @click="handleCancel">取消</button>

需替換成

<span>{{title||t('r.title')}}</span>
:title="t('r.close')"
<button @click="handleConfirm">{{t('r.confirm')}}</button>
<button @click="handleCancel">{{t('r.cancel')}}</button>

我們還需要封裝一個方法 t

// locale/index.ts
import { getCurrentInstance } from 'vue';
import defaultLang from './lang/zh-CN';

export const t = (...args: any[]): string => {
  const instance = getCurrentInstance();
  // 當存在 vue-i18n 的 t 方法時,就直接使用它
  const _t = instance._hub.t;
  if (_t) return _t(...args);

  const [path] = args;
  const arr = path.split('.');
  let current: any = defaultLang,
    value: string = '',
    key: string;

  for (let i = 0, len = arr.length; i < len; i++) {
    key = arr[i];
    value = current[key];
    if (i === len - 1) return value;
    if (!value) return '';
    current = value;
  }
  return '';
};

使用這個 t 方法,我們只需在 Modal.vue 這樣做:

// Modal.vue
import { t } from './locale';
/* ... */
setup(props, ctx) {
  /* ... */
  return { t };
}

與 vue-i18n 糅合

我們可以看到上面有一行代碼 const _t = instance._hub.t; ,這個 .t 是這樣來的:

  • 在 Modal.vue 中,獲取掛載到全局的 vue-i18n$t 方法
setup(props, ctx) {
  let instance = getCurrentInstance();
  onBeforeMount(() => {
    instance._hub = {
      t: instance.appContext.config.globalProperties.$t,
      /* ... */
    };
  });
}
  • 在全局屬性注冊中,直接使用 app.use 回調方法的參數 app
Modal.install = (app: App, options) => {
  app.config.globalProperties.$modal = {
    show() {
      /* ... */
      const { props, _hub } = instance;
      Object.assign(_hub, {
        t: app.config.globalProperties.$t
      });
      /* ... */
    }
  };
};

切記,如果要與 vue-i18n 糅合,還需要有一個步驟,就是把 Modal 的語言包合并到項目工程的語言包。

const messages = {
  'zh-CN': { ...zhCN, ...modal_zhCN },
  'zh-TW': { ...zhTW, ...modal_zhTW },
  'en-US': { ...enUS, ...modal_enUS }
};

與ts

我們以「在 Vue3 要怎么樣用 API 的形式調用 Modal 組件」展開這個話題。
Vue3 的 setup 中已經沒有 this 概念了,需要這樣來調用一個掛載到全局的 API,如:

const {
  appContext: {
    config: { globalProperties }
  }
} = getCurrentInstance()!;

// 調用 $modal 
globalProperties.$modal.show({
  title: '基于 API 的調用',
  content: 'hello world~'
});

這樣的調用方式,個人認為有兩個缺點:

  1. 在每個頁面調用時都要深入獲取到 globalProperties
  2. ts 推導類型在 globalProperties 這個屬性就 “斷層” 了,也就是說我們需要自定義一個 interface 去擴展

我們在項目中新建一個文件夾 hooks

// hooks/useGlobal.ts
import { getCurrentInstance } from 'vue';
export default function useGlobal() {
  const {
    appContext: {
      config: { globalProperties }
    }
  } = (getCurrentInstance() as unknown) as ICurrentInstance;
  return globalProperties;
}

還需要新建全局的 ts 聲明文件 global.d.ts,然后這樣來寫 ICurrentInstance 接口:

// global.d.ts
import { ComponentInternalInstance } from 'vue';
import { IModal } from '@/plugins/modal/modal.type';

declare global {
  interface IGlobalAPI {
    $modal: IModal;
    // 一些其他
    $request: any;
    $xxx: any;
  }
  // 繼承 ComponentInternalInstance 接口
  interface ICurrentInstance extends ComponentInternalInstance {
    appContext: {
      config: { globalProperties: IGlobalAPI };
    };
  }
}
export {};

如上,我們繼承了原來的 ComponentInternalInstance 接口,就可以彌補這個 “斷層”。

所以在頁面級中使用 API 調用 Modal 組件的正確方式為:

// Home.vue
setup() {
  const { $modal } = useGlobal();
  const handleShowModal = () => {
    $modal.show({
      title: '演示',
      close: true,
      content: 'hello world~',
      onConfirm() {
        console.log('點擊確定');
      },
      onCancel() {
        console.log('點擊取消');
      }
    });
  };
 
  return {
    handleShowModal
  };
}

其實 useGlobal 方法是參考了 Vue3 的一個 useContext 方法:

// Vue3 源碼部分
export function useContext() {
    const i = getCurrentInstance();
    if ((process.env.NODE_ENV !== 'production') && !i) {
        warn(`useContext() called without active instance.`);
    }
    return i.setupContext || (i.setupContext = createSetupContext(i));
}

一些Demo

深入

喜歡封裝組件的小伙伴還可以去嘗試以下:

  • “確定”,“取消” 按鈕的文案實現可配置
  • 是否顯示 “取消” 按鈕 或 是否顯示底部所有按鈕
  • 內容超過一定的長度,顯示滾動條
  • 簡單的字符串內容的文本居中方式,left/center/right
  • 可被拖拽
  • ...

總結

API的調用形式可以較為固定,它的目的是簡單,頻繁的調用組件,如果有涉及到復雜場景的話就要用普通調用組件的方式。本文意在為如何封裝一個靈活的組件提供封裝思路。當我們的思路和實現有了,便可以舉一反十~

?? 2021年,「前端精」求關注

公眾號關注「前端精」,回復 1 即可獲取本文源碼相關~

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容