在JS中实例化Vue的组件

Posted by Youzi on April 2, 2021

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);
};

会发现最后调用的是下面这个函数,传入两个参数,如果VNodenull则会调用unmount方法卸载组件实例,否则调用patchVNode渲染到实际 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');
});