从业务角度看如何设计Dialog、Modal组件

2,305 阅读6分钟

唠叨一个工作中让人不太爽的场景,现在可以的UI组件库一大堆,但在实际业务开发中他们提供的Dialog组件(有的库叫做Model,文中统一称作Dialog)却没有一个用的顺手。怎么回事呢?

我们先看一个业务场景设计。

一,业务场景

现在有一个弹框注册功能:

  • 1,注册信息表单
  • 2,可以控制关闭
  • 3,可以控制显示
class Register {
    isShow = false
    show() {
        this.isShow = true
        this.render()
    }
    close() {
        this.isShow = false
        this.render()
    }
    render() {
        if (!this.isShow) return null
        console.log('display register form')
    }
}

过来一段时间产品来了一个需求,要做一个弹框登录功能:

  • 1,登录信息表单
  • 2,可以控制关闭
  • 3,可以控制显示

现在怎么办呢,接到需求撸起袖子就干的做法:
Register类copy一下造一个Login类,然后修改render()的逻辑这个需求很快就搞定了。不管后面要增加多少个类似功能都是这样复制粘贴就行了。

从上面的需求实现可以发现两个问题:

  • 1,在页面A中注册功能是dialog交互形式,假设现在有个页面B注册功能是直接作为页面元素一直存在的。问题:dialog的交互逻辑和注册业务逻辑耦合
  • 2,当要增加一个相同交互的登录功能时,选择直接copy注册功能的代码进行修改。问题:代码复用问题

解决问题

1,逻辑复用

// Dialog 基础类提取共用逻辑
class Dialog {
    isShow = false
    show() {
        this.isShow = true
        this.render()
    }
    close() {
        this.isShow = false
        this.render()
    }
    render() {
        throw new Error('请实现render')
    }
}

// 注册功能;只需实现注册表单信息渲染
class Register extends Dialog {
    render() {
        if (!this.isShow) return null
        console.log('display register form')
    }
}
// 登录功能:只需实现登录表单信息渲染
class Login extends Dialog {
    render() {
        if (!this.isShow) return null
        console.log('display login form')    
    }
}

2,解耦

// 通用注册
class Register {
    render() {
        console.log('display register form')
    }
}
// 通用登录
class Login {
    render() {
        console.log('display login form')
    }
}

// dialog交互逻辑
class Dialog{
    isShow = false
    form = null
    show() {
        this.isShow = true
        this.render()
    }
    close() {
        this.isShow = false
        this.render()
    }
    render() {
        throw new Error('请实现render')
    }
}
// 弹窗注册实现
class RegisterDialog extends Dialog {
    constructor() {
        this.handle = new Register()
    }
    render() {
        if (!this.isShow) return null
        this.handle.render()
    }
}
// 弹框登录实现
class LoginDialog extends Dialog {
    constructor() {
        this.handle = new Login()
    }
    render() {
        if (!this.isShow) return null
        this.handle.render()
    }
}

在进行面向对象开发时,我经常会听到少用继承,为什么呢?扩展性太差(现在是通过isShow来控制显示,如果后期要增加一个disabled的条件则每个子类都要做修改)。在上面的解耦过程中虽然实现了dialog与通用注册功能的解耦,但RegisterDialog类又与Register强关联,在实现login的dialog逻辑时又重新创建了一个LoginDialog,这样有导致了复用的问题。哪怎么办呢?

控制反转

// 通用注册
class Register {
    render() {
        console.log('display register form')
    }
}
// 通用登录
class Login {
    render() {
        console.log('display login form')
    }
}
// 通用dialog
class Dialog {
    constructor(handle) {
        this.isShow = false
        this.handle = handle
    }
    show() {
        this.isShow = true
        this.render()
    }
    close() {
        this.isShow = false
        this.render()
    }
    render() {
        if (!this.isShow) return null
        this.handle && this.handle.render()
    }
}

// 注册dialog
const registerDialog = new Dialog(new Register())
registerDialog.show()
// 注册页面
const register= new Register()
register.render()

// 登录dialgo
const loginDialog = new Dialog(new Login())
loginDialog.show()
// 登录页面
const login = new Login()
login.render()

通过上面的场景分析可以总结出Dialog的设计思路,更重要的是明确了Dialog的职责。接下来进入本文正题,为什么会BB现有的Dialog组件设计。

二,拥有什么

下面两张截图来自两个比较知名的vue组件库的官方文档demo

截图1

截图2

使用方式总结如下:

<dialog>
    <child />
</dialog>

看起来好像和我们前面的设计思路完全吻合

const register = new Register()
const registerDialog = new Dialog(register)

但是。。。

在业务代码中的实际使用情况

具体业务场景

// contenxt.vue
<tempalte>
    <div>
        <div>hello world!</div>
        <Dialog>
            <!-- 注册表单信息 -->
            <Form>
                ...
                <Input />
                ...
            </Form>
        </Dialog>
    </div>
</template>

因为注册的逻辑是独立,而且上面的写法让context.vue逻辑比较重,相信大多数同学实际的使用方式如下(将registerDialog抽离为一个单独的组件)

// registerDialog.vue
<template>
    <Dialog>
        <Form>
            ....
            <Input />
            ...
        </Form>
    </Dialgo>
</template>

// context.vue
<template>
    <div>
        <div>hello world</div>
        <RegisterDialog />
    </div>
</template>

那么问题就来了,registerDialog让dialog和注册的逻辑变成了强耦合,当我们要实现login的逻辑

// loginDialog.vue
<template>
    <Dialog>
        <Form>
            ....
            <Input />
            ...
        </Form>
    </Dialgo>
</template>

// context.vue
<template>
    <div>
        <div>hello world</div>
        <RegisterDialog />
        <LoginDialog />
    </div>
</template>

后面每次接到类似的需求都得重复registerDialogloginDialog的逻辑,同时要处理vue组件属性和事件的传递。这种大家普遍使用的registerDialog方式是设计上的倒退,为什么会出现这种情况呢?

三,理想实现

<template>
    <div>
        <div>hello world</div>
        <!-- 注册 -->
        <Dialog>
            <Register />
        </Dialog>
        <!-- 登录 -->
        <Dialgo>
            <Login />
        </Dialog>
    </div>
</template>

个人看来这是一种最理想的实现方案,为什么这种方式并没有出现在我们的代码中呢?
原因:目前所有的Dialog组件不支持这种方式

register有提交表单和重置表单的操作,dialog组件有确定和取消两个按钮,在实际业务场景中dialog的确定操作应该和表单的提交、dialog的取消操作应该和表单的重置进行关联。可是现有的dialog组件并有实现这种关联关系

// 现有的方案
class Register {
    submit() {
        console.log('提交表单')
    }
    reset() {
        console.log('重置表单')
    }
    render() {
        console.log('渲染表单')
    }
}
class Dialog {
    constructor(handle) {
        this.isShow = false
        this.handle = handle
    }
    show() {
        this.isShow = true
        this.render()
    }
    cancle() {
        this.isShow = false
        this.render()
    }
    ok() {
        this.isShow = false
        this.render()
    }
    render() {
        if (!this.isShow) return null
        this.handle && this.handle.render()
    }
}

const registerDialog = new Dialog(new Register())
// dialog 只控制了register的显示和隐藏,无法感知register的submit、reset
register.ok() // 我想提交form然后关闭dialog,但是做不到

这是现阶段我在使用dialog组件时一个比较苦恼的问题,那么这个问题是不是没法解决呢?

改造Dialog

class Dialog {
    constructor(handle) {
        this.isShow = false
        this.handle = handle
    }
    show() {
        this.isShow = true
        this.render()
    }
    cancle() {
        this.isShow = false
        // 取消按钮执行form的reset
        this.handle && this.handle.reset()
        this.render()
    }
    ok() {
        this.isShow = false
        // 确定按钮执行form的submit
        this.handle && this.handle.submit()
        this.render()
    }
    render() {
        if (!this.isShow) return null
        this.handle && this.handle.render()
    }
}

const registerDialog = new Dialog(new Register()
registerDialog.show()
registerDialog.ok() // 会执行register的submit方法

vue实现

伪代码,实际设计应该考虑适应多种场景

// Dialog.vue
<template>
    <div>
        ...
        <div class="body">
            <slot />
        </div>
        <div class="footer">
            <Button @click="handleCancle">取消</Button>
            <Button @click="handleOk">确定</Button>
        </div>
    </div>
</template>
<script>
    export default {
        ...
        mounted() {
            this.handle = this.$children.length > 0 ? this.$children[0] : null
        },
        methods: {
            handleCancle() {
                this.visible = false
                this.handle && this.handle.$emit('onReset')
            },
            handleOk() {
                this.visible = false
                this.handle && this.handle.$emit('onSubmit')
            }
        }
    }
</script>

四,最后

目前常见的UI框架,vue系的element-ui、iview,react系的ant-design关注点都只在dialog的交互上,从业务角度出发个人认为他们在设计上是有缺陷的。当然在自己的项目中可以选择高阶组件的方式改造现有Dialog组件。