Vue3: 前端小白教你怎么命令式地写数据驱动的弹窗

2,845 阅读8分钟

数据驱动 or 命令式 ?

什么是数据驱动/命令式的弹窗?

如果使用 Antdv 等框架,一般会提供以下的 api:

Modal.info({
    title: 'Title',
    content: 'content',
    onOk: () => console.log('ok'),
});

这种在代码运行过程中动态创建一个弹窗,并在代码中间销毁的,可以称为命令式弹窗。

而使用Vue弹窗组件,并使用 modalVisible = true modalVisible = false 控制弹窗的,可以称为数据驱动的弹窗。

<template>
    <div>
        <a-button @click="visible = true">Open Modal</a-button>
        <a-modal v-model:visible="visible" title="Basic Modal" @ok="visible = false">
            <p>Some contents...</p>
        </a-modal>
    </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';

const visible = ref<boolean>(false);
</script>

相信大家两种代码都见过或用过,对于倾向于使用哪一种也有自己的理解。可能大家都有体会到,数据驱动的方式在复杂流程中会出现很恶心的代码逻辑。

而实际上,早在 Vue2 时期就有过讨论此问题,知名偶像尤大亲自宣布:

数据驱动 好! 命令式 不好!

为啥不能命令式

因为将弹窗脱离Vue组件树流动态插入这个行为,跟Vue格格不入:

尤大:从模板的角度来看:在父模板里直接写入 <modal> 标签,那么这个 modal 渲染的位置是清晰明确的,你看一眼父模板就知道,哦,这里可能会有个 modal,也就是说,你的模板描述了最终可能渲染出来的 DOM 结构。但命令式思维下异步添加的 modal,你看模板的时候是根本看不见的,你的模板和最终的 DOM 结构没有可靠的映射关系,因为你完全可能随手把 modal 插到任何地方。你觉得这两者哪个更容易维护?

我列一下有可能的缺点:

  • 对于弹窗的深度自定义,只能手写 h 函数解决
  • 即使手写 h 函数,与SFC编译相关的功能可能会不好打配合(如css)
  • 脱离Vue树,provide inject 等依赖组件树功能的代码会失效

那对于复杂场景呢?数据驱动怎么解决?

尤大的回答并没有给使用怎么去处理复杂场景的问题。下方评论直接出现了 “你不懂vue就不要乱说” 这样的批判(玩笑)。

不过评论也说的很有道理:

小鹿仓美羽 我认为这种写法恐怕没有上面命令式的更清晰。
为什么?如果是非模态对话框我也同意这样,但是模态对话框的语义就是用户必须完成这个对话框,然后接下来的逻辑才能处理。
模态对话框本身就是用来要求用户必须做某件事情,否则程序的逻辑没法继续下去,这个语义本身是命令式的,使用状态驱动的写法反而把连贯的交互逻辑打散了,并不清晰。

然而不用命令式弹窗就不能处理复杂场景了吗?

构建一个复杂一点的场景

现在先整一个复杂场景的案例。

image.png

用户点击按钮上传文件,上传文件前我们需要对选中的文件进行一些检查(比如,文件名称、数据大小、文件实际数据的某种特征提取等)

我们假设这个检查的时间会比较久,同时我们希望检查过程中需要阻塞用户的其他操作,所以我们选择弹出一个进度弹窗(ProcessingModal)去展示进度和阻止用户操作。分析完成后:

  • 如果检查发现问题,那么希望弹窗告诉用户哪个文件出现了什么问题。用户可以点击重试;
  • 如果没有发现问题,那么我望弹窗提醒用户成功并显示此次处理了多少个文件

OK,我们可以开始写代码了,我们首先用数据驱动的方式声明一些变量:

const process_modal_visible = ref(false)
const fail_modal_visible = ref(false)
const success_modal_visible = ref(false)

const files = ref<File[]>()

const success_msg = ref('')
const fail_reasons = ref<FailReason[]>()
  1. 三个弹窗的显示控制
  2. 待处理的文件列表
  3. 成功信息
  4. 失败原因

然后写业务逻辑:

// template
<button @click="startImport">选择文件上传</button>

<Modal :visible="process_modal_visible">
  <div> 进度 {{ processing }} % </div>
  <button @click="startProcess">
    开始
  </button>
</Modal>

<Modal :visible="fail_modal_visible">
  <div> 失败 {{ fail_reasons }} </div>
  <button @click="retry">
    重试
  </button>
</Modal>

<Modal :visible="success_modal_visible">
  <div> 成功 {{ success_msg }} /div>
  <button @click="success_modal_visible = false">
    确认
  </button>
</Modal>

// script

// 1. 点击上传按钮。
const startImport = async () => {
  const _files = await doImport()
  if (_files) {
    files.value = _files
    process_modal_visible.value = true
  }
}

// 2. 开始分析文件。为了增加复杂度,这里需要在 进度弹窗 点击“开始”才会分析
const startProcess = async () => {
  // 等待处理完成
  const [success, fail] = await doSomeWork(files.value, (process) => { /* 进度通知 */ })

  if (success) {
    success_msg.value = `成功完成 ${files.value} 个文件上传`
    success_modal_visible.value = true
  }
  else {
    fail_reasons.value = fail.reasons
    fail_modal_visible.value = true
  }
}

// 3. 失败后假设需要一个重试按钮
const retry = () => { /***/ }

上面的代码将无关紧要的部分都简化了。可以看到,如果按简单的方式将3个弹窗堆到一起,代码阅读是很不通畅的:

  1. startImport 开始
  2. 阅读代码,发现 startImport 最后打开了 process 弹窗 process_modal_visible.value = true
  3. 翻代码,发现弹窗点击确认触发 startProcess
  4. 翻代码,阅读 startProcess
  5. 发现打开了成功/失败弹窗,并且对两个值赋值去传递信息
  6. 翻代码,发现失败之后还有一个点击确认 retry
  7. 翻代码,找到 retry 并阅读

整个过程非常难受,我甚至还没考虑怎么把3个弹窗抽离到三个组件,更没有考虑如何复用 startImport 这种将会到处触发函数。

实际上如果能使用命令式的思维去组织代码逻辑,没这么复杂,我甚至不需要看一次 template 就知道发生了什么:

const startImport = async () => {
  const _files = await doImport()
  if (!_files) return
  // 开始分析
  const result = await startProcess(_files) // 此时打开窗口,等待用户操作和结果
  if (result.success) {
      await showSuccessDialog(result.success_msg) // 打开成功弹窗
  } else {
      const action = await showErrorDialog(result.error_reasons) // 打开失败弹窗,等待用户操作
      if (action === 'retry') { // 如果用户点击了重试
          await retry()
      }
  }
}

命令式代码 !== 命令式弹窗

看到上面的例子,是不是想对尤大说一句“你不懂vue就不要乱说”(狗头)

其实我们犯了一个先入为主的刻板印象,命令式的弹窗出现的太多了,以至于忘记了命令式的代码不代表一定要写命令式的弹窗啊!

关键问题在于,我们如何在 showSuccessDialog 里面状态驱动弹窗。

包含用户操作的异步函数

showSuccessDialog 是一次包含用户操作的异步操作,这里需要借助到store:

const startProcess = async (files: File[]) => {
    const store = useStore() // 这里用类似 vuex 的 store 进行展示 
    const visible = store.process_modal_visible // 全局store中存窗口展示的状态
    visible.value = true // 展示弹窗
    // 创建一个 Promise 异步等待用户操作
    const action = await new Promise(resolve => {
        store.process_modal_ok_click = () => resolve('ok')
        store.process_modal_cancel_click = () => resolve('cancel')
    })
    if (action === 'ok') {
        return await doSomeWork(files)
    }
}

这里需要借助store(或者其他能达成类似效果的)将 visible 等参数提升到某个地方,从而可以在独立的 ProcessModal 组件使用 visible 等参数。

点击弹窗确定按钮时,执行 process_modal_ok_click 函数,使得 Promise fulfiil ,从而将用户的操作转化为一个Promise。

按照此种逻辑,我们可以顺畅的将每个弹窗单独封装成组件,将这些弹窗挂在App根节点下。同时 startProcess 也可以在任意地方复用(startProcess也可以直接定义在store中)。

比 store 更好的选择:VueUse createSharedComposable

store 有一个弊端,引入了 useStore 的代码基本上就无法在多个项目中通用。vue3 已经建立起了一套漂亮且独立的响应式系统,实际上我们并不一定需要依赖于store。

VueUse 的 createSharedComposable 就能很好的完成这个任务。来看看createSharedComposable的介绍:

Make a composable function usable with multiple Vue instances.

使得一个组合式函数(hook)可以在多个Vue实例中复用。

import { createSharedComposable } from '@vueuse/core'

const useSharedState = createSharedComposable(() => {
    const visible = ref(false)
    const process_modal_ok_click = ref()
    const process_modal_cancel_click = ref()

    const startProcess = async (files: File[]) => {
        visible.value = true // 展示弹窗
        // 创建一个 Promise 异步等待用户操作
        const action = await new Promise(resolve => {
            process_modal_ok_click.value = () => resolve('ok')
            process_modal_cancel_click.value = () => resolve('cancel')
        })
        if (action === 'ok') {
            return await doSomeWork(files)
        }
    }
    return {
        visible,
        process_modal_ok_click,
        process_modal_cancel_click,
        startProcess
    }
})

// Root.vue
const { visible, startProcess } = useSharedState()

startProcess(files) // 执行

<div>
    <ProcessModal />
</div>

// ProcessModal.vue - 会复用同一个返回值,即visible等值是同一份
const { visible, process_modal_ok_click, process_modal_cancel_click } = useSharedState()

<Modal :visible="visible">
  <button @click="process_modal_ok_click()">
    确认
  </button>
  <button @click="process_modal_cancel_click()">
    确认
  </button>
</Modal>

createSharedComposable 原理

import type { EffectScope } from 'vue-demi'
import { effectScope } from 'vue-demi'
import { tryOnScopeDispose } from '../tryOnScopeDispose'

/**
 * @see https://vueuse.org/createSharedComposable
 */
export function createSharedComposable<Fn extends((...args: any[]) => any)>(composable: Fn): Fn {
  let subscribers = 0
  let state: ReturnType<Fn> | undefined
  let scope: EffectScope | undefined

  const dispose = () => {
    subscribers -= 1
    if (scope && subscribers <= 0) {
      scope.stop()
      state = undefined
      scope = undefined
    }
  }

  return <Fn>((...args) => {
    subscribers += 1
    if (!state) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    tryOnScopeDispose(dispose)
    return state
  })
}

实际上代码很简单,闭包维持一个 subscribers 记录有多少组件正在依赖这个 composable function,state 缓存第一次执行该函数的返回,并在后续调用返回这个缓存 state。

使用 effectScope 创建一个新的 scope 并作为这个 composable function的上下文。

关于 effectScope 建议在 vue rfc 中查看其设计的缘由(是antfu大佬所参与贡献的)

总结

这些代码实际上源于经历真实项目中的代码。实际业务中更为复杂,导致bug频出,在重构后由于代码逻辑的清晰确确实实大大减少这了块出问题的可能性,不可能指望一段逻辑四处分散的代码在经历3、4个人维护之后还能清晰它到底在做什么。追求代码的优美、可读是确确实实会带来业务价值的。