8

作为一个中后台表单&表格工程师,经常需要在一个页面中处理多个弹窗。我自己的项目中,一个复杂的审核页面中的弹窗数量超过了30个,如何管理大量的弹窗就成为了一个需要考虑的问题。

大量的弹窗有什么问题

假设你有一个弹窗组件,类似于element-ui的Dialog,如果简单粗暴的每一个弹窗都写一个dialog,那么会有以下问题:

  • 模板过长,且大量冗余
  • 命名困难,每一个弹窗需要一个变量去控制显示,通常每一个弹窗里面也是一个表单,又需要一个变量保存表单数据,每个弹窗也有自己的逻辑(method),都要写在这个页面,要绞尽脑汁去取名
  • 非常的不优雅,简直就是Repeat yourself反模式的示范。。。

把每个弹窗抽成模块

一个很容易想到的优化方法就是把一个弹窗作为一个组件抽离出去,每个弹窗的逻辑单独写在组件中。

这样通过组件拆分做很好的解决了模板过长的问题,也基本解决了命名困难的问题,不过还是需要很多的变量去控制每个组件的显示。

使用动态Component

第一个办法本质上并没有减少重复的代码和逻辑(弹窗显示/关闭),只是把代码放在了不同的文件当中。

显然,我并不需要写那么多的Dialog,Dialog本身并没有变,作为一个「包裹」组件,变的只是内容。

所以,只需要写一个dialog,配合Vue的动态组件Component,切换不同的组件就行了。

全局Dialog

使用Component,我们做到了一个页面只需要一个Dialog,但其实整个网页,也只需要一个全局的Dialog。

我们在根组件下挂一个Dialog组件,组件内容依然使用动态component,组件的数据流转,component传递等使用Vuex进行。

使用函数创建组件

作为单个项目的解决方案,全局Dialog加动态Component其实已经足够好了,使用一个函数调用就可以显示弹窗。

this.$dialog({
  title: '我是弹窗',
  component: Test,
  props: { props }, // Test的props通过这样传递
})

但是想要作为通用解决方案,还不够:

  • 引入不方便,需要手动在跟组件下引入并写上封装好的弹窗组件
  • 必须使用Vuex进行数据流转,而并不是每个Vue项目都使用Vuex的
  • 没法监听事件,只能传入回调
  • props的传递方式不够优雅,不够声明式

在我心中,一个理想的弹窗组件,需要是这样的:

  • 引入方便,Vue.use(Dialog)就行了
  • 使用简洁

      this.$dialog({
        title: '哎呀不错哦',
        component: () => <Test onDone={ this.fetchData } name={ this.name }/>
      })

Let's go.

使用$mount

Vue作为一个视图层的框架,核心其实就是渲染函数,所以一定有一个办法,可以把一个Vue组件渲染成一个DOM,这个方法就是$mount。

// 这个Dialog组件就是写好的弹窗组件
import Dialog from './Dialog'

// dialog是一个单例,不需要重复创建
let dialog
export default function createDialog(Vue, { store = {}, router = {} }, options) {
  if (dialog) {
    dialog.options = {
      ...options,
    }

    dialog.$children[0].visible = true
  } else {
    dialog = new Vue({
      name: 'Root-Dialog',
      router,
      store,
      data() {
        return {
          options: { ...options },
        }
      },
      render(h) {
        return h(Dialog, {
          props: this.options,
        })
      },
    })

    // 渲染出DOM并手动插入到body
    dialog.$mount()
    document.body.appendChild(dialog.$el)
  }

  // 暴露close方法
  return {
    close: () => dialog.$children[0].close(),
  }
}

Dialog组件

基于element-ui的Dialog组件二次封装,在原有的props之外,添加一个component,使用动态Component渲染上去就行了。
思路很简单,但是有几个问题需要考虑。

生命周期问题

如果不做任何处理,当弹窗消失的时候component并不会销毁;当再次显示弹窗时,会传入一个新的组件,这个时候,上一个组件才销毁,这非常不合理。所以我们需要在弹窗消失的时候手动销毁传入的component。

注入事件

Vue的动态Component组件的is属性接受的值有3种类型:

  • string,在当前组件内注册过的组件的名称
  • ComponentDefinition,就是一个组件的选项对象,new Vue时传的那个对象
  • ComponentConstructor,返回一个ComponentDefinition的函数,比如动态import函数

而我们希望的调用形式里,component是一个返回jsx的函数,而它会被babel插件babel-plugin-transform-vue-jsx转换为调用createElement函数的结果,也就是说

() => <Test >

这个函数最终返回的是一个Virtual Node。
而Vue的选项里面,render最终返回的也是一个VNode。
也就是说,() => <Test >这个函数可以作为一个Vue组件的render选项,所以,我们需要构造一个完整的Vue选项对象,然后将这个对象作为动态component的is属性,这样就可以渲染出这个Test组件了。

在这个过程中,我们可以在这个Vnode里面做一些有趣的事情,比如注入事件。

为什么要注入事件

首先,这里有一个刚需:弹窗内的组件需要可以关闭弹窗,也就是它的父组件。
通常有两个办法可以做到:

  • 通过props接收一个函数,调用它可以关闭弹窗
  • 主动抛出一个事件,dialog组件监听这个事件,然后把自己关了

略微比较一下就可以发现,抛出事件的方法优于回调函数的办法(通常来说,「事件」都优于「回调」):

  • 代码少, $emit('complete')就行了,使用回调需要添加一个props,调用的时候还需要判断它是否存在
  • 通用性更好,这个组件可能不仅仅只在弹窗内调用,它可以在其它任何地方被调用,使用事件只需要简单的抛出一个事件,表示我完成了,调用它的组件根据自身的逻辑来进行接下来的工作,这样组件本身做到了低耦合。

但是,抛出事件的实现却要比传入回调难很多,需要对VNode比较熟悉。

在Dialog组件内,我们触及不到组件的模板,所以简单的在动态component模板上添加 @done 并不能完成事件监听。因为事件监听其实是在render的过程中进行的,而我们的render是通过jsx的方式在调用$dialog函数时传入的,所以只能手动在生成的VNode上添加事件监听:

在 vNode.componentOptions.listeners中,添加我们需要监听的事件和事件处理函数:

let listeners = vNode.componentOptions.listeners

if (!listeners) {
  listeners = {}
  vNode.componentOptions.listeners = listeners
}

// 添加done
const orginDoneHandler = listeners.done
listeners.done = function () {
  if (orginDoneHandler) orginDoneHandler()
  doneHandler()
}

// 添加cancel
const orginCancelHandler = listeners.cancel
listeners.cancel = function () {
  if (orginCancelHandler) orginCancelHandler()
  cancelHandler()
}

在Dialog中,监听了动态component的donecancel事件,在任一事件触发后都会关闭Dialog,组件$emit('done')表示完成了自己的业务,$emit('cancel)表示取消了自己的业务

主动收集依赖

到这里,还有一个问题没有解决:这个组件还不是响应式的,比如说,你在一个index组件中通过$dialog显示一个弹窗

this.$dialog({
  title: '响应式',
  component: () => <Test text={ this.text }/>
})

当text更新时,弹窗中的内容并没有更新,也就说,组件没有重新渲染。

Vue的渲染流程与依赖收集

这里就要涉及到一些Vue的原理了,比如说渲染流程,依赖收集,一两句话也讲不清楚,我试着大概的说一下:

首先,页面上显示的数据变了,一定是触发了重新渲染,this.text = '新的text' 之所以会更新页面,可以理解为一个渲染函数在this.text的setter中执行了。

那么,this.text的getter怎么样才能知道要执行哪些函数,就是通过所谓的依赖收集。简单来说,依赖收集是在渲染函数(渲染Vnode的函数)中进行的,在createElement中一旦通过this.text使用了这个变量,通过这个变量的getter就收集到了正在执行的渲染函数这一个依赖。

所以,粗暴的讲,需要把this.text的访问放在一个render函数(Vue选项对象的render)中进行。平常用的模板其实也是这样,因为它最终都被Vue-loader编译成了render。

_component() {
  // 这一步很重要,让component收集到了这个计算属性的依赖,否则当component变化时不会重新渲染组件
  const fn = this.component
  let vNode

  // 返回vue选项对象
  const that = this
  return {
    name: 'dynamic-wrapper',

    render() {
      // fn的运行一定要在render函数中,也是为了挂载依赖
      vNode = fn()
      ...
    }
}

所以,这就是为什么一定要使用一个返回jsx的函数作为,而不是直接美滋滋的使用jsx。因为,臣妾实在是做不到响应式呀~

this.$dialog({
  title: '臣妾做不到啊~',
  component: <Text text={ this.text }/>,
})

等于

// this.text的值为text
this.$dialog({
  title: '臣妾做不到啊~',
  component: createElement(
    Text,
    props: {
      text: 'text',
    }
  )
})

完整代码,拍着胸脯保证可用,已经在生产环境大量使用超过3个月的时间了。


呱呱呱
646 声望3 粉丝

hello world