给 Bootstrap Modal 增加缩放功能

需求

Bootstrap 应该还是目前最流行的前端基础框架之一。因为架构方面的优势,它的侵入性很低,可以以各种方式集成到其它项目当中。在我厂的各种产品里,都有它的用武之地。

前两天,老板抱怨,说 Modal(弹窗)在他的屏幕上太小,浪费他的 5K 显示器。

我看了一下,按照 Bootstrap 的设计,超过 1200px 就算是 XL,此时 .modal-lg 的宽度固定在 1140px。其实 Bootstrap 这么设计也有它的道理,因为人眼聚焦后宽度有限,如果弹窗太宽的话,内容一眼看不全,也不好。不过在我厂的产品里,弹窗要呈现火焰图,所以宽一些也有好处。

技术方案

那么,综合来看,最合适的做法,就给 Modal 添加一个拖拽的功能:用户觉得够大了,就这么着;用户想看大一点,就自己拉大一些,然后我记录用户的选择,以便复用。

看过我《用 `resize` 和 MutationObserver 实现缩放 DOM 并记录尺寸》的同学,应该知道 resize 这个 CSS 属性,使用它可以很方便的给元素添加缩放功能。参考 caniuse 上面的普及度,大部分新版本的浏览器都已经支持,可以放心使用。

使用它的时候要注意两点:

首先,我们在缩放元素的同时,也会对它的子元素、父元素同时造成影响。因为在静态文档流当中,块级元素的宽度默认是父元素 content-box 的 100%,而高度由子元素决定。所以,对一个块级元素的缩放,不可能宽过它的父元素(如果限制了宽度的话),也不可能矮于它的子元素。

其次,拖拽手柄的显示优先级很低,会被子元素盖住,哪怕子元素没有填充任何内容。换言之,一定要有 padding 的元素才适合添加 resize 缩放。

实施方案

总而言之,把这个属性加在哪个元素上面,很有讲究。具体到本次需求,Bootstrap Modal,最合适添加 resize 属性的的是 modal-content,因为它有 1rem 的内边距。

但是限制宽度的是父元素,也就是 modal-dialog,它是响应式的,会根据显示器的宽度设置一个最大宽度。如果不修改它的 max-widthmodal-content 的最大宽度就无法超过它,达不到预期效果。但是也不能改成 width,这样的话,弹窗会失去弹性,分辨率低的时候表现不好。

所以还是要在 max-width 上做文章。如果直接去掉它,modal-dialog 的宽度就会是 100%,失去弹窗效果,所以也不能这样做。最终,我的方案是:

  1. 窗口完全展开后,获取宽高,赋给 modal-content
  2. 去掉 modal-dialogmax-width
  3. 用 MutationObserver 监测 modal-content 的宽高,保存 localStorage,以便接下来使用

完整代码展示

我厂的产品基于 Vue 开发,所以部分逻辑用 Vue 组件实现。

效果演示

为方便在 Codepen 里呈现,有部分修改。

代码及解释

modal.vue
<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", :style="contentStyle") .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 框架的配合下,这样的方案很容易实现。另一方面,每个项目都有独特的使用场景,通过长期在特定场景下工作,我们可以逐步整理出适用于这个场景的组件库,不断改进该项目的开发效率。我认为这才是组件化的正道。

如果您觉得文章内容对您有用,不妨支持我创作更多有价值的分享:


已发布

分类

来自

评论

《 “给 Bootstrap Modal 增加缩放功能” 》 有 6 条评论

  1. yogwang 的头像

    我一直不喜欢用js,所以一直都是用的class,给modal预设了几种模式,小,中,大,超大,然后根据用户选择取改变class,但是看着代码量不大

    1. yogwang 的头像

      为啥我的表情不见了…..
      后边是看着代码量不大就马一下,以后就能用上了。

    2. meathill 的头像

      推荐你看下尤大最近的分享,“组件的现状”。作为前 Flash 开发者,我很赞同他的观点。所以,我并不赞同这种“用 class 的做法”。

      1. yogwang 的头像

        实习期的时候碰到过,后来就没有应用场景了,都是自己写的默认,或者适应内容大小,很少用js去控制,应为觉得相比css会多很多代码,就没有去写过

        1. yogwang 的头像

          当然和之前自己Js基础差也有关系,大部分都是用的jQ来做操,
          现在稍微好点了,但是也没有像CSS这样自信。

        2. meathill 的头像

          哦,我发现我理解错了你的意思……你的 class 是指 className,不是 JS 里的 类……

欢迎吐槽,共同进步

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理