JS 中实例化 Vue 组件
近期在写封装组件的时候,写了 dialog,搜了一下很多开源组件库的很多这类组件都支持直接使用 JS 调用组件,稍微研究了下这些组件的源码,记录一下相关的方法;
本文涉及 Vue2.x 和 3.0 的两种方式,由于 3.0 去掉了很多 api,在写的时候也遇到了一些问题,同步记录一下;
JS 调用 Vue 组件的方式
在 JS 文件中,直接引入xx.vue
文件,得到的是一个组件对象,等会在示例代码里面会输出来看看;接下来要借助Vue
提供的一些工具函数,来将一个组件对象,改造成VNode
对象;最后再将VNode
对象渲染成真实的DOM
对象,插入到 DOM 树中;
光看着上面这段描述,可能还是一头雾水,其实这个过程和Vue
项目里的main.js
做的事是一样的,我们来看普通项目中main.js
做了什么(项目用 Vue2.x 的 template 项目做演示):
1
2
3
4
5
6
7
8
9
10
import Vue from 'vue';
import App from './App.vue';
// 引入Vue,引入App组件,这里我们把App打印出来看看;
console.warn(App); // object...
Vue.config.productionTip = false;
new Vue({
render: h => h(App)
}).$mount('#app');
引入 App 组件后,new Vue
会为我们实例化一个Vue
的实例,然后链式调用了Vue
实例上的$mount()
方法;而实例化的参数是一个对象,对象内包含一个render
属性,是一个函数,这个函数又接收了h
函数,h
函数的参数是组件对象;
new Vue
的参数
大概梳理完了之后,我们来细说每个部分;首先是new Vue
:
- 实例化一个 Vue 对象,接受一个对象作为参数,而对象内部所包含的属性在文档里的Vue 实例所有的选项;
- 所有的选项除了选项/DOM不常用以外,其他都是写 Vue 组件经常用到的;着重说说这几个:
el
:指定组件挂载的 DOM 元素,只在new Vue
的时候生效,也就是说上面的main.js
也可以改写成new Vue({el: document.querySelector('#app')})
,只是此时<div id="app"></div>
会被覆盖;render
:render 函数如果经常用js
写组件的话应该不陌生,该函数的参数是一个h
函数(函数内部接受一个 Vue 组件对象,并返回由该组件对象生成的 VNode 对象),render 函数返回的也是对象生成的 VNode 对象;template
:一个模板字符串,和在Vue
文件里写的template
是一样的,只是变成模板字符串的形式,这个属性会被render
函数覆盖;
写一些测试代码来看看上面的描述是否正确;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Vue from 'vue';
import App from './App.vue';
Vue.config.productionTip = false;
// 这段是抄element-ui里的一个工具函数,用于判断是不是VNode对象
function isVNode(node) {
return node !== null && typeof node === 'object' && Object.prototype.hasOwnProperty.call(node, 'componentOptions');
}
// 一个对象,false
console.warn(App, isVNode(App));
const app = new Vue({
render: h => {
const vnode = h(app);
// 一个VNode对象,true
console.warn(vnode, isVNode(vnode));
return h(App);
}
});
console.warn(app);
app.$mount('#app');
Vue 实例
new Vue
的返回值很明显是一个组件实例了,而在main.js
里创建的就是根实例;
根实例上包含所有的Vue 实例上的属性及方法;所以可以调用$mount
方法,挂载在一个已存在的 DOM 元素上;
综上所述,有了一个大致的思路,和main.js
差不多的步骤:
- 写好一个组件,获取组件对象;
- 实例化组件对象;
- 将组件实例挂载到真实 DOM 上;
示例
针对上述步骤,进行代码实现:
- 每次需要调用时,手动引入下面的文件,调用
dialogModal.show()
方法; - 调用
show
方法后返回的是一个promise
对象,很多情况下在关闭了对话框或者点了确定的时候需要触发回调函数,所以回调函数写在了then | catch
方法中; - 组件内部可以
emit
一些事件,然后在current
组件实例对象上调用$on
方法,添加事件的响应函数,在这些响应函数里面,就可以调用res | rej
方法了。
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
40
41
42
43
44
45
import Vue from 'vue';
import dialog from './dialog.vue';
// 定义需要导出的对象
const dialogModal = {};
// 由组件对象生成组件的构造函数
const dialogModalInstance = Vue.extend(dialog);
// 保存当前组件实例
let current;
// 实例化组件实例
const initInstance = () => {
current = new dialogModalInstance();
};
// 定义show方法,可以渲染dialog
dialogModal.show = () => {
// 大部分开源项目的dialog都是返回一个promise,这样便于捕获关闭或者其他的事件
return new Promise((res, rej) => {
if (!current) {
initInstance();
}
// let modal = current.$mount().$el;
let modal = document.createElement('div');
modal.classList.add('test');
current.$mount(modal);
current.modal = modal;
document.body.appendChild(modal);
// 随便添加点监听器方法,根据组件内部的方法来
current.$on('success', () => {
console.warn('test');
dialogModal.close();
res();
});
});
};
// 定义close方法,关闭dialog,卸载组件,移除DOM
dialogModal.close = () => {
if (!current) {
return;
}
document.body.removeChild(current.modal);
current.$destroy();
current = null;
};
export default dialogModal;
整体流程都差不多,具体差异都在组件实现上了;在 element-ui 的开源项目中,允许多个 dialog 弹出,实现上也复杂了很多,并且关闭方法有一些差异;也许是考虑到可能会频繁触发打开和关闭,所以 element-ui 实现关闭方法没有直接去操作 DOM 进行删减,而是通过 CSS 定位去隐藏了对话框。
Vue3.0 的实现
上述的代码实例是基于 Vue2.x 实现的,3.0 的 API 差异挺多的,这里也讲一讲;
- 取消了
Vue.extend
这个全局方法; - 取消了实例上的
$mount
方法; - 取消了实例上的
$on
方法; - 没有了组件的构造函数了,也不能对组件使用
new
关键字;
首先我们的整体思路还是没有变的,针对以上几个已经改掉的 API,Vue3.0 也给我们提供了其他的 API 来替代;
h
函数,文档介绍createVNode 函数;render
函数,麻了,这个函数在文档里没介绍,但在源码里是有export
的,源码路径如下:node_modules\@vue\runtime-dom\dist\runtime-dom.esm-browser.js
,找到如下函数,一步步往下找调用函数即可;- 没有
$on
方法了,可以在调用h
方法时,在第二个参数中添加组件emit
的事件响应函数,比如组件内部emit('success', args)
,此时就可以h(component, onSuccess:()=>{})
,而关闭窗口的回调res | rej
则手动挂载在组件实例上,方便调用。
来看一下源码中render
函数的调用入口:
1
2
3
const render = (...args) => {
ensureRenderer().render(...args);
};
会发现最后调用的是下面这个函数,传入两个参数,如果VNode
是null
则会调用unmount
方法卸载组件实例,否则调用patch
把VNode
渲染到实际 DOM 中去;
1
2
3
4
5
6
7
8
9
10
11
const render = (vnode, container) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true);
}
} else {
patch(container._vnode || null, vnode, container);
}
flushPostFlushCbs();
container._vnode = vnode;
};
后面源码中有一些方法我都看不懂了,就不往下看了,反正render
函数接收两个参数,第一个参数是VNode
,第二个参数是实际的一个 DOM 元素,调用后会把VNode
渲染到实际 DOM 上。
整体示例代码就是这样了,思路也是一样的,只是一些 API 改了;另外我在 Vue3.0 的版本里去掉了show | close
方法,而是直接调用LoginDialog()
方法,即可不用手动调用咯,当然想手动调用也可以加上去的;
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
40
41
42
43
44
45
46
47
48
49
50
import login from './login-dialog-test.vue';
import { render, h, defineComponent } from 'vue';
const COMPONENT_CONTAINER_SYMBOL = Symbol('componentContainer');
let instance = null;
let current = {};
const onSuccess = async args => {
console.warn('onSuccess');
await current.res();
removeInstance();
};
const initInstance = (options = {}) => {
// const loginConstructor = defineComponent(login);
const vnode = h(login, {
onSuccess
});
const container = document.createElement('div');
container.classList.add('test');
vnode[COMPONENT_CONTAINER_SYMBOL] = container;
render(vnode, container);
instance = vnode.component;
document.body.appendChild(container);
};
const removeInstance = () => {
if (!instance) {
return;
}
document.body.removeChild(instance.vnode[COMPONENT_CONTAINER_SYMBOL]);
render(undefined, instance.vnode[COMPONENT_CONTAINER_SYMBOL]);
instance = null;
current = {};
};
const LoginDialog = callback => {
return new Promise((res, rej) => {
if (instance) {
rej(new Error('同时只能有一个弹窗登录'));
return;
}
console.warn('LoginDialog', res);
current.res = res;
current.rej = rej;
initInstance();
});
};
export { LoginDialog };
在 JS 文件中调用
其实前面也讲了,在需要调用的 JS 文件中,引入即可,使用:
1
2
3
4
5
6
7
8
9
// Vue3.0
LoginDialog().then(() => {
// console.warn('test');
});
// Vue2.x
dialogModal.show().then(() => {
// console.warn('test');
});