优雅的命令式弹窗

背景

"IOC"在前端开发中的应用一文中,我们介绍了DIP(依赖倒置原则)等设计模式,其中优雅的hook弹窗 一节中,我们简单介绍了当前弹窗类组件的几种设计思路,及其优劣。并简单介绍了Cube-UIcreateAPI,现在我们来深入研究一下其中的奥秘。

介绍

调用方式

我们说到弹窗类组件一般有两种设计:

  • template组件式调用
  • 命令式调用

组件式调用

其中组件式调用一般为如下形式:

js 复制代码
<template>
  <button type="text" @click="visible = true">点击打开外层 Dialog</button>
  <dialog :visible.sync="visible"></dialog>
</template>

<script>
  export default {
    data() {
      return {
        visible: false
      };
    }
  }
</script>

命令式调用一般为如下形式:

ts 复制代码
import Dialog from 'xxx'

Dialog.show(options)
Dialog.hide()

命令式调用

这块儿要实现上述Dialog,可以通过Vue.extend工厂函数,根据dialog.vue制作一个弹窗组件构建函数,如:

ts 复制代码
<template>
  <div class="dialog" v-if="visible">
    <div class="mask" />
    <div class="content">
      <h3>标题</h3>
      <div>内容</div>
      <footer>
        <button @click="hide">关闭</button>
      </footer>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
  name: 'my-dialog',
  setup() {
    const visible = ref(false)
    const show = () => {
      visible.value = true
    }
    const hide = () => {
      visible.value = false
    }

    return {
      visible,
      show,
      hide
    }
  }
})
</script>

<style scoped>
.dialog {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  .mask {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background: #ccc;
    opacity: 0.8;
    z-index: 1;
  }
  .content {
    position: relative;
    z-index: 2;
    background: #fff;
    border-radius: 20px;
    padding: 20px;
    min-width: 300px;
    text-align: center;
  }
}
</style>

然后我们写一个ts文件,把我们写好的弹窗组件引入,并通过Vue.extend进行挂载:

ts 复制代码
import Vue, { ComponentInstance } from 'vue'
import DialogVue from './dialog.vue'

const Dialog = Vue.extend(DialogVue)
const el = document.createElement('div')
el.className = 'custom-dialog'
document.body.appendChild(el)
let instance = null as unknown as ComponentInstance & { show(): void; hide(): void }

export default {
  show() {
    if (!instance) {
      instance = new Dialog()
      instance.$mount(el)
    }
    instance.show()
  },
  hide() {
    instance.hide()
  }
}

封装完成后我们就可以进行简单的调用了(目前这个demo为单例)

ts 复制代码
<template>
  <div class="hello" style="margin-top: 60px">
    <button @click="show">show</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import Dialog from './dialog'

export default defineComponent({
  setup() {
    return {
      show: Dialog.show
    }
  }
})
</script>

项目目录结构如下:

vue版本:2.7.14,效果如下:

createAPI

上述方式可以在需要使用弹窗的组件中很方便的进行调用,而我们在dialog/index.ts中对dialog.vue进行挂载的过程比较繁琐,如果我们有很多个不同类型的弹窗组件,如:popupalertmessage等,那么这些模版代码是少不了的,这个时候我们就需要一种方法(协议)去淡化组件与挂载的过程,这个统一封装的API就是createAPI干的事。

我们可以将上述代码用createAPI改写一下:

ts 复制代码
import Vue, { ComponentInstance } from 'vue'
import DialogVue from './dialog.vue'
import { createAPI } from 'cube-ui'

let instance = null as unknown as ComponentInstance & { show(): void; hide(): void }
const Dialog = DialogVue as any
createAPI(Vue, Dialog, [], true)

export default {
  show() {
    if (!instance) {
      instance = Dialog.$create()
    }
    instance.show()
  },
  hide() {
    instance.hide()
  }
}

以上就是通过createAPI 的使用方法,更多复杂的实例可以去官网查看。

虽然以上代码看上去与我们的封装少不了多少代码,但实际上还有很多工作没做,比如:单例控制props数据events绑定处理、卸载后的清理工作

原理

这么好用的API,只会用还不够,咱们还得去把他的底裤给扒下来,看看究竟做了什么事儿?原理是什么?

首先,此工具是开源的,可以在Github上浏览其源码。目录结构如下:

打开src目录,从index.js入手,咱们去看看主要流程是什么。

index.js

js 复制代码
function install(Vue, options = {}) {
  const {componentPrefix = '', apiPrefix = '$create-'} = options

  Vue.createAPI = function (Component, events, single) {
    if (isBoolean(events)) {
      single = events
      events = []
    }
    const api = apiCreator.call(this, Component, events, single)
    const createName = processComponentName(Component, {
      componentPrefix,
      apiPrefix,
    })
    Vue.prototype[createName] = Component.$create = api.create
    return api
  }
}

// ...

export default {
  install
}

这个文件主要基于Vue.use协议,暴露install方法,将api挂载到Vue原型链上。其中,apiCreator是一个返回包含create方法的对象的函数。create方法可以将咱们的函数挂载到document.body上。

creator.js

这个函数内容比较多,详细代码在这里,大致分为以下模块儿:

render与createElement函数

在解析代码之前,我们需要了解Vue的render函数以及createElement语法。 基于options api,我们写的每个组件都是一个对象配置,他包含propsmethodsdata等,当然也包含render函数

使用render函数,我们可以通过js去渲染template,而无需使用sfc的形式,而template模版语法最终也会通过vue-loaderloader转换成render函数。Vue本质上就是一长串js代码,和我们通过js调用dom相关api生成dom实例并插入文档中的思路是一致的。

ts 复制代码
Vue.component('my-dialog', {
    props: {},
    render: (createElement) => {
        return createElement('div', {})
    }
})

createElement函数执行后会生成VNode,然后通过VNode构建虚拟dom树。我们此文的重点在于理解createElement所包含的协议内容,这对我们理解createAPI的核心实现思想至关重要。

js 复制代码
{
  // 与 `v-bind:class` 的 API 相同,
  // 接受一个字符串、对象或字符串和对象组成的数组
  'class': {
    foo: true,
    bar: false
  },
  // 与 `v-bind:style` 的 API 相同,
  // 接受一个字符串、对象,或对象组成的数组
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 普通的 HTML attribute
  attrs: {
    id: 'foo'
  },
  // 组件 prop
  props: {
    myProp: 'bar'
  },
  // DOM property
  domProps: {
    innerHTML: 'baz'
  },
  // 事件监听器在 `on` 内,
  // 但不再支持如 `v-on:keyup.enter` 这样的修饰器。
  // 需要在处理函数中手动检查 keyCode。
  on: {
    click: this.clickHandler
  },
  // 仅用于组件,用于监听原生事件,而不是组件内部使用
  // `vm.$emit` 触发的事件。
  nativeOn: {
    click: this.nativeClickHandler
  },
  // 自定义指令。注意,你无法对 `binding` 中的 `oldValue`
  // 赋值,因为 Vue 已经自动为你进行了同步。
  directives: [
    {
      name: 'my-custom-directive',
      value: '2',
      expression: '1 + 1',
      arg: 'foo',
      modifiers: {
        bar: true
      }
    }
  ],
  // 作用域插槽的格式为
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  // 如果组件是其它组件的子组件,需为插槽指定名称
  slot: 'name-of-slot',
  // 其它特殊顶层 property
  key: 'myKey',
  ref: 'myRef',
  // 如果你在渲染函数中给多个元素都应用了相同的 ref 名,
  // 那么 `$refs.myRef` 会变成一个数组。
  refInFor: true
}

更多内容见Vue官方文档

createComponent

这个方法主要调用instantiateComponent函数对我们书写的Vue组件进行包装,通过createElement函数,对Vue组件进行渲染。

js 复制代码
export default function instantiateComponent(Vue, Component, data, renderFn, options) {
    const instance = new Vue({
        ...
        render(createElement) {
          ...
          return createElement(Component, {...renderData}, children || [])
        },
        methods: {
          init() {
            document.body.appendChild(this.$el)
          },
          destroy() {
            this.$destroy()
            if (this.$el && this.$el.parentNode === document.body) {
              document.body.removeChild(this.$el)
            }
          }
        }
        ...
    })
    instance.updateRenderData = function (data, render) {
        renderData = data
        childrenRenderFn = render
      }
    instance.updateRenderData(data, renderFn)
    instance.$mount()
    instance.init()
    const component = instance.$children[0]
    component.$updateProps = function (props) {
        Object.assign(renderData.props, props)
        instance.$forceUpdate()
    }
    return component
}

instantiateComponent函数做的事就是对组件进行示例,并进行挂载。和我们在上文中的思路是一致的。

组件创建好后还会给组件实例添加showhide方法,这就是createAPI所提供的DIP 协议,我们写的弹窗组件只要包含以上方法,就可以进行打开和关闭,从而解耦createAPI与组件之间的联系,当然我们也可以调用自定义的方法。

processProps

这个方法主要是将组件所需props,进行拷贝,并调用组件实例的$watch方法对这些响应式数据进行监听。从而做到响应式数据变化时,组件能正确的更新内容:

js 复制代码
...
const unwatchFn = ownerInstance.$watch(function () {
  const props = {}
  watchKeys.forEach((key, i) => {
    props[key] = ownerInstance[watchPropKeys[i]]
  })
  return props
}, onChange)
ownerInstance.__unwatchFns__.push(unwatchFn)
...

并将$watch函数的返回函数收集起来,以便对数据进行接触监听。

processEvents

这个方法主要是将组件需要的监听的事件,与组件本身的方法进行绑定。

默认所有的值都会当做 props,但是要排除 createAPI 传入的 events 中的事件(默认会做转换,例如:events 的值为 ['click'],那么 config 中的 onClick 就是作为 click 事件的回调函数,而不是作为 props 传递给组件)。

cancelWatchProps

如上文所说,在组件销毁的时候将组件收集的所有$watch返回函数,执行一遍,从而将这些watcher从对应的响应式数据的deps中进行移除。

js 复制代码
function cancelWatchProps(ownerInstance) {
  if (ownerInstance.__unwatchFns__) {
    ownerInstance.__unwatchFns__.forEach((unwatchFn) => {
      unwatchFn()
    })
    ownerInstance.__unwatchFns__ = null
  }
}

返回值:api

before

这里提供了一个钩子,可以在执行createComponent之前将这些hooks先执行一遍,做一些前置操作。这里也是面向对象的编程思想。

create

这个函数就是createAPI主要暴露的方法,主要流程为:

  1. 格式化需要传递给组件参数
  2. 基于处理后的参数执行processProps
  3. 基于处理后的参数执行processEvents
  4. 调用createComponent生成组件实例并挂载
  5. 组件销毁时执行清理操作

以下代码均有删减,以github代码为准。

js 复制代码
create(config, renderFn, _single) {
  ...
  const renderData = parseRenderData(config, events)
  let component = null
  processProps(ownerInstance, renderData, isInVueInstance, (newProps) => {
    component && component.$updateProps(newProps)
  })
  processEvents(renderData, ownerInstance)
  component = createComponent(renderData, renderFn, options, _single)

  function beforeDestroy() {
    cancelWatchProps(ownerInstance)
    component.remove()
    component = null
  }

  if (isInVueInstance) {
    ownerInstance.$on(eventBeforeDestroy, beforeDestroy)
  }

  return component
}

总结

到此为止,我们就完成了对createAPI从使用到源码解析的完整过程,代码中无论是parseRenderDataprocessProps还是processEvents,都是将用户输入参数转换成createElement所需格式,并利用new Vue实例化组件。如果有响应式数据的话,那么还会监听响应式数据的变化,并重新渲染组件内容。

createAPI基于面向接口编程的思想,为用户组件提供showhide方法,提供before方法注册前置hook,用户无需关注内部实现即可基于createAPI方便、快捷的生成组件实例并挂载到body上。

createAPI也有一些缺陷:

  • 源码中无一行注释,有些地方不是那么一目了然,还是要结合更多的上下文进行理解。
  • 这块儿默认的是弹窗类型的组件,可能出于对层叠上下文的考虑,所有组件一视同仁的挂载到了document.body上,那么对于非弹窗类型的组件,是否可以提供可以挂载到任意地方的api?
相关推荐
刘志辉20 分钟前
vue传参方法
android·vue.js·flutter
dream_ready43 分钟前
linux安装nginx+前端部署vue项目(实际测试react项目也可以)
前端·javascript·vue.js·nginx·react·html5
编写美好前程44 分钟前
ruoyi-vue若依前端是如何防止接口重复请求
前端·javascript·vue.js
flytam1 小时前
ES5 在 Web 上的现状
前端·javascript
喵喵酱仔__1 小时前
阻止冒泡事件
前端·javascript·vue.js
GISer_Jing1 小时前
前端面试CSS常见题目
前端·css·面试
八了个戒1 小时前
【TypeScript入坑】什么是TypeScript?
开发语言·前端·javascript·面试·typescript
不悔哥1 小时前
vue 案例使用
前端·javascript·vue.js
工业互联网专业1 小时前
毕业设计选题:基于ssm+vue+uniapp的捷邻小程序
vue.js·小程序·uni-app·毕业设计·ssm·源码·课程设计
陈无左耳、2 小时前
Vue.js 与后端配合:打造强大的现代 Web 应用
vue.js