背景
在"IOC"在前端开发中的应用一文中,我们介绍了DIP(依赖倒置原则)等设计模式,其中优雅的hook弹窗 一节中,我们简单介绍了当前弹窗类组件的几种设计思路,及其优劣。并简单介绍了Cube-UI的createAPI,现在我们来深入研究一下其中的奥秘。
介绍
调用方式
我们说到弹窗类组件一般有两种设计:
- 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
进行挂载的过程比较繁琐,如果我们有很多个不同类型的弹窗组件,如:popup
、alert
、message
等,那么这些模版代码是少不了的,这个时候我们就需要一种方法(协议)去淡化组件与挂载的过程,这个统一封装的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
,我们写的每个组件都是一个对象配置,他包含props
、methods
、data
等,当然也包含render函数
。
使用render函数
,我们可以通过js去渲染template
,而无需使用sfc
的形式,而template
模版语法最终也会通过vue-loader
等loader
转换成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
函数做的事就是对组件进行示例,并进行挂载。和我们在上文中的思路是一致的。
组件创建好后还会给组件实例添加show
、hide
方法,这就是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
主要暴露的方法,主要流程为:
- 格式化需要传递给组件参数
- 基于处理后的参数执行processProps
- 基于处理后的参数执行processEvents
- 调用createComponent生成组件实例并挂载
- 组件销毁时执行清理操作
以下代码均有删减,以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
从使用到源码解析的完整过程,代码中无论是parseRenderData
、processProps
还是processEvents
,都是将用户输入参数转换成createElement
所需格式,并利用new Vue
实例化组件。如果有响应式数据的话,那么还会监听响应式数据的变化,并重新渲染组件内容。
createAPI
基于面向接口编程的思想,为用户组件提供show
、hide
方法,提供before
方法注册前置hook,用户无需关注内部实现即可基于createAPI
方便、快捷的生成组件实例并挂载到body上。
createAPI
也有一些缺陷:
- 源码中无一行注释,有些地方不是那么一目了然,还是要结合更多的上下文进行理解。
- 这块儿默认的是弹窗类型的组件,可能出于对层叠上下文的考虑,所有组件一视同仁的挂载到了
document.body
上,那么对于非弹窗类型的组件,是否可以提供可以挂载到任意地方的api?