从Dialog原理谈到Vue渲染原理

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

大量的弹窗有什么问题

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

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

把每个弹窗抽成模块

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

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

使用动态Component

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

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

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

全局Dialog

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

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

使用函数创建组件

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

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

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

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

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

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

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

Let’s go.

使用$mount

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 这个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函数的结果,也就是说

1
() => <Test >

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

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

为什么要注入事件

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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显示一个弹窗

1
2
3
4
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_component() {
// 这一步很重要,让component收集到了这个计算属性的依赖,否则当component变化时不会重新渲染组件
const fn = this.component
let vNode

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

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

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

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

等于

1
2
3
4
5
6
7
8
9
10
// this.text的值为text
this.$dialog({
title: '臣妾做不到啊~',
component: createElement(
Text,
props: {
text: 'text',
}
)
})

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