Vue 組件:給 Bootstrap Modal 增長縮放功能

需求

Bootstrap 應該仍是目前最流行的前端基礎框架之一。由於架構方面的優點,它的侵入性很低,能夠以各類方式集成到其它項目當中。在我廠的各類產品裏,都有它的用武之地。css

前兩天,老闆抱怨,說 Modal(彈窗)在他的屏幕上過小,浪費他的 5K 顯示器。html

我看了一下,按照 Bootstrap 的設計,超過 1200px 就算是 XL,此時.modal-lg 的寬度固定在 1140px。其實 Bootstrap 這麼設計也有它的道理,由於人眼聚焦後寬度有限,若是彈窗太寬的話,內容一眼看不全,也很差。不過在我廠的產品裏,彈窗要呈現火焰圖,因此寬一些也有好處。前端

技術方案

那麼,綜合來看,最合適的作法,就給 Modal 添加一個拖拽的功能:用戶以爲夠大了,就這麼着;用戶想看大一點,就本身拉大一些,而後我記錄用戶的選擇,以便複用。vue

看過我《用 `resize` 和 MutationObserver 實現縮放 DOM 並記錄尺寸》的同窗,應該知道resize這個 CSS 屬性,使用它能夠很方便的給元素添加縮放功能。參考caniuse上面的普及度,大部分新版本的瀏覽器都已經支持,能夠放心使用。bootstrap

使用它的時候要注意兩點:瀏覽器

首先,咱們在縮放元素的同時,也會對它的子元素、父元素同時形成影響。由於在靜態文檔流當中,塊級元素的寬度默認是父元素 content-box 的 100%,而高度由子元素決定。因此,對一個塊級元素的縮放,不可能寬過它的父元素(若是限制了寬度的話),也不可能矮於它的子元素。架構

其次,拖拽手柄的顯示優先級很低,會被子元素蓋住,哪怕子元素沒有填充任何內容。換言之,必定要有 padding 的元素才適合添加 resize 縮放。框架

實施方案

總而言之,把這個屬性加在哪一個元素上面,頗有講究。具體到本次需求,Bootstrap Modal,最合適添加 resize 屬性的的是 modal-content,由於它有 1rem 的內邊距。異步

可是限制寬度的是父元素,也就是 modal-dialog,它是響應式的,會根據顯示器的寬度設置一個最大寬度。若是不修改它的 max-widthmodal-content 的最大寬度就沒法超過它,達不到預期效果。可是也不能改爲 width,這樣的話,彈窗會失去彈性,分辨率低的時候表現很差。async

因此仍是要在 max-width 上作文章。若是直接去掉它,modal-dialog 的寬度就會是 100%,失去彈窗效果,因此也不能這樣作。最終,個人方案是:

  1. 窗口徹底展開後,獲取寬高,寫入 modal-contentstyle
  2. 而後去掉 modal-dialogmax-width,此時,由於子元素 modal-content 已經定寬,因此仍然是窗口樣式
  3. 用 MutationObserver 監測 modal-content 的寬高,保存到 localStorage,以便在全局使用

完整代碼展現

我廠的產品基於 Vue 開發,因此邏輯用 Vue 組件實現。

效果演示

爲方便在 Codepen 裏呈現,有部分修改。

https://codepen.io/meathill/p...

代碼及解釋

<template lang="pug">
.modal.simple-modal(
  :style="{display: visibility ? 'block' : 'none'}",
  @click="doCloseFromBackdrop",
)
  .modal-dialog.modal-dialog-scrollable(
    ref="dialog",
    :class="dialogClass",
  )
    .modal-content(ref="content", :)
      .modal-header.p-2
        slot(name="header")
          h4 {{title}}
        span.close(v-if="canClose", @click="doClose") ×
      .modal-body
        slot(name="body")
</template>

<script>
import debounce from 'lodash/debounce';

const RESIZED_SIZE = 'resized_width_key';
let sharedSize = null;

export default {
  props: {
    canClose: {
      type: Boolean,
      default: true,
    },
    size: {
      type: String,
      default: null,
      validator: function(value) {
        return ['sm', 'lg', 'xl'].indexOf(value) !== -1;
      },
    },
    resizable: {
      type: Boolean,
      default: false,
    },
    backdrop: {
      type: Boolean,
      default: true,
    },
    title: {
      type: String,
      default: 'Modal title',
    },
  },

  computed: {
    dialogClass() {
      const classes = [];
      if (this.size) {
        classes.push(`modal-${this.size}`);
      }
      if (this.resizable) {
        classes.push('modal-dialog-resizable');
      }
      if (this.resizedSize) {
        classes.push('ready');
      }
      return classes.join(' ');
    },
    contentStyle() {
      if (!this.resizable || !this.resizedSize) {
        return null;
      }
      const {width, height} = this.resizedSize;
      return {
        width: `${width}px`,
        height: `${height}px`,
      };
    },
  },

  data() {
    return {
      visibility: false,
      resizedSize: null,
    };
  },

  methods: {
    async doOpen() {
      this.visibility = true;
      this.$emit('open');
      if (this.resizable) {
        // 經過 debounce 節流能夠下降函數運行次數
        const onResize = debounce(this.onEditorResize, 100);
        // 這裏用 MutationObserver 監測元素尺寸
        const observer = this.observer = new MutationObserver(onResize);
        observer.observe(this.$refs.content, {
          attributes: true,
        });
        
        if (sharedSize) {
          this.resizedSize = sharedSize;
        }
        // 第一次運行的時候,記錄 Modal 尺寸,避免太大
        if (!this.resizedSize) {
          await this.$nextTick();
          // 按照張鑫旭的說法,這裏用 `clientWidth` 有性能問題,不過暫時尚未更好的解決方案
          // https://weibo.com/1263362863/ImwIOmamC
          const width = this.$refs.dialog.clientWidth;
          this.resizedSize = {width};
          // 這裏產生紀錄以後,上面的 computed 屬性就會把 `max-width` 去掉了
        }
      }
    },
    doClose() {
      this.visibility = false;
      this.$emit('close');
    },
    doCloseFromBackdrop({target}) {
      if (!this.backdrop || target !== this.$el) {
        return;
      }
      this.doClose();
    },

    onEditorResize([{target}]) {
      const width = target.clientWidth;
      const height = target.clientHeight;
      if (width < 320 || height < 160) {
        return;
      }
      sharedSize = {width, height};
      localStorage.setItem(RESIZED_SIZE, JSON.stringify(sharedSize));
    },
  },

  beforeMount() {
    const size = localStorage.getItem(RESIZED_SIZE);
    if (size) {
      this.resizedSize = JSON.parse(size);
    }
  },

  beforeDestroy() {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
  },
};
</script>

<style lang="stylus">
.simple-modal
  background-color: rgba(0, 0, 0, 0.5)
  .modal-content
    padding 1em

    .close
      cursor pointer

.modal-dialog-resizable
  &.ready
    max-width unset !important

  .modal-content
    resize both
    margin 0 auto
</style>

注意

由於瀏覽器的異步加載機制,有可能在 modal 打開並完成佈局後,高度和寬度被內容撐開致使記錄不許,或者內容被異常遮蓋。請讀者本身想辦法處理,就當練習題吧。

總結

本次組件開發很是符合我理想的組件模式:

  1. 充分利用瀏覽器原生機制
  2. 配合儘可能少的 JS
  3. 須要什麼功能就加什麼功能,不須要大而全

在 MVVM 框架的配合下,這樣的方案很容易實現。另外一方面,每一個項目都有獨特的使用場景,經過長期在特定場景下工做,咱們能夠逐步整理出適用於這個場景的組件庫,不斷改進該項目的開發效率。我認爲這纔是組件化的正道。


同步發於個人博客:

相關文章
相關標籤/搜索