前言

本文主要讲解vue2vue3如何创建js调用式组件。其中vue3包含了两种实现方案

vue2 创建 js 调用式组件

关键实现函数是Vue.extend,通过Vue.extend可以创建一个子类组件出来

我们以实现一个loading-bar组件为例,步骤如下:

  • 新建一个.vue文件,在里面编写loading-bar组件,实现你想要的的UI效果
<template>
  <div class="loading-bar">
    <div
      :style="{ transform: `translateX(-${100 - totalProgress}%)` }"
      class="loading-bar-progress"
      :class="{ 'is-error': isError }"
    >
      <div class="loading-bar-peg"></div>
    </div>
    <div class="loading-bar-spinner" v-if="showSpinner">
      <div
        :style="{ 'animation-timing-function': easing }"
        class="loading-bar-icon"
        :class="{ 'is-icon-error': isError }"
      ></div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 加载器(转圈圈的那个东西),运动形式
      easing: "linear",
      // 是否为错误类型
      isError: false,
      // 显示加载器
      showSpinner: true,
      // 加载的总进度
      totalProgress: 0,
      // 每次前进的百分比
      percentNum: 0,
      // 加载速度
      speed: 5,
    };
  },
};
</script>
  • 通过Vue.extend继承loading-bar组件
import LoadingBar from "./loading-bar.vue";
const LoadingBarConstructor = Vue.extend(LoadingBar);

LoadingBarConstructor是一个构造函数(类),我们可以添加一些额外的方法

// 设置全局配置信息
LoadingBarConstructor.prototype.config = function config(options) {
  //todo
};

// 初始化加载进度条
LoadingBarConstructor.prototype.init = function init() {
  //todo
};

// 显示加载进度条
LoadingBarConstructor.prototype.start = function start() {
  //todo
};

// 关闭加载进度条
LoadingBarConstructor.prototype.end = function end() {
  //todo
};

// 显示错误进度条
LoadingBarConstructor.prototype.error = function error() {
  //todo
};
  • 初始化 vue 实例
const instance = new LoadingBarConstructor();

instance.totalProgress = 10;

instance实例就是一个vue实例,你可以通过这个实例访问或者修改组件中的easingisErrortotalProgress等响应式数据

  • 生成并挂载 DOM
const vm = instance.$mount();
document.body.appendChild(vm.$el);

instance.$mount()是生成DOM的,可通过vm.$el访问DOM

最终还需要把生成出来的DOM挂载到页面上

  • 销毁
document.body.removeChild(vm.$el);
instance.$destroy();
instance = null;

销毁的时候需要先移除页面上的DOM元素,然后在进行实例的销毁

  • 最终效果

对于生成并挂载 DOM销毁这些流程,我们可以进行一些封装,对外屏蔽一些实现的细节,最终代码如下:

const LoadingBarConstructor = Vue.extend(LoadingBar);

LoadingBarConstructor.prototype.destroyTimer = function destroyTimer() {
  if (this.timer) {
    clearInterval(this.timer);
    this.timer = null;
  }
};

LoadingBarConstructor.prototype.destroyRemoveTimer =
  function destroyRemoveTimer() {
    if (this.removeTimer) {
      clearTimeout(this.removeTimer);
      this.removeTimer = null;
    }
  };

// 设置全局配置信息
LoadingBarConstructor.prototype.config = function config(options) {
  Object.keys(options).forEach((key) => {
    if (key === "isError" || key === "totalProgress") {
      return;
    }
    this[key] = options[key];
  });
};

// 初始化加载进度条
LoadingBarConstructor.prototype.init = function init() {
  this.destroyTimer();
  this.totalProgress = 0;
  this.isError = false;
  this.vm = this.$mount();
  document.body.appendChild(this.vm.$el);
  return this;
};

// 显示加载进度条
LoadingBarConstructor.prototype.start = function start() {
  this.init();
  this.timer = setInterval(() => {
    // 小于90的时候才进行前进
    if (this.totalProgress < 90) {
      this.totalProgress += (this.percentNum || Math.random()) * this.speed;
    }
  }, 100);
};

// 关闭加载进度条
LoadingBarConstructor.prototype.end = function end() {
  if (!timer) {
    this.init();
  }
  // 先把总进度设置为100,让他走完
  this.totalProgress = 100;
  this.destroyRemoveTimer();
  this.removeTimer = setTimeout(() => {
    this.destroyTimer();
    document.body.removeChild(this.vm.$el);
  }, 200);
};

// 显示错误进度条
LoadingBarConstructor.prototype.error = function error() {
  this.end();
  this.totalProgress = 100;
  this.isError = true;
};
  • 使用方式
const instance = new LoadingBarConstructor();

instance.start();

setTimeout(() => {
  instance.end();
}, 3000);

vue3 创建 js 调用式组件

vue3中,已经废弃了vue.extend全局 api。我们可以通过createApp或者createVNode+render的方式去实现

createApp

使用过 vue3 的同学应该知道,createApp是用来创建一个app实例的

我们以实现一个loading-bar组件为例,步骤如下:

  • 新建一个.vue文件,编写loading-bar组件,实现想要的UI效果
<template>
  <div class="loading-bar">
    <div
      :style="{ transform: `translateX(-${100 - totalProgress}%)` }"
      class="loading-bar-progress"
      :class="{ 'is-error': isError }"
    >
      <div class="loading-bar-peg"></div>
    </div>
    <div class="loading-bar-spinner" v-if="showSpinner">
      <div
        :style="{ 'animation-timing-function': easing }"
        class="loading-bar-icon"
        :class="{ 'is-icon-error': isError }"
      ></div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

export default defineComponent({
  setup() {
    // 加载器(转圈圈的那个东西),运动形式
    const easing = ref("linear");
    // 是否为错误类型
    const isError = ref(false);
    // 显示加载器
    const showSpinner = ref(true);
    // 加载的总进度
    const totalProgress = ref(0);
    // 每次前进的百分比
    const percentNum = ref(0);
    // 加载速度
    const speed = ref(5);
    return {
      easing,
      isError,
      showSpinner,
      totalProgress,
      percentNum,
      speed,
    };
  },
});
</script>
  • 创建app实例
import LoadingBar from "./index.vue";
const app = createApp(LoadingBar);
  • 创建vue实例
const rootContainer = document.createElement("div");
const vm = app.mount(rootContainer);

vm.isError = true;

vm实例就是vue实例,可以通过这个实例访问或者修改组件中的easingisErrortotalProgress等响应式数据

注意:app.mount 中的第一个参数要求必须传入一个根元素,但是这个根元素不能是 document.body

  • 挂载 DOM
document.body.appendChild(rootContainer);
// 或者
// document.body.appendChild(vm.$el);

在挂在 DOM 的时候,你可以选择把根元素rootContainer挂在上去,或者把组件的 DOM 元素挂在上去。区别只是在于rootContainer在组件的 DOM 元素外层多了一个div元素

  • 销毁
app.unmount();
rootContainer.remove();

销毁的时候,直接调用app实例上面的unmount函数进行销毁即可,同时需要把 DOM 进行移除

  • 最终效果

根据上面的步骤,我们进行一次封装,对外屏蔽一些实现的细节,最终代码如下:

import { isPlainObject } from "@packages/utils";
import { App, createApp } from "vue";
import LoadingBar from "./index.vue";

interface RootData {
  easing?: string;
  isError?: boolean;
  showSpinner?: boolean;
  totalProgress?: number;
  percentNum?: number;
  speed?: number;
}

class LoadingBarConstructor {
  private app: App | null = null;
  private vm: any | null = null;
  private timer: number | null = null;
  private removeTimer: number | null = null;
  private rootContainer: HTMLElement | null = null;
  private options: RootData = {};

  private init(options: RootData = {}) {
    this.destroy();
    this.app = createApp(LoadingBar);
    this.rootContainer = document.createElement("div");
    // 这个是为了获取组件实例,方便后面对组件变量动态操作
    this.vm = this.app.mount(this.rootContainer);
    const config: any = {
      ...this.options,
      ...options,
    };
    for (const key in config) {
      if (Object.prototype.hasOwnProperty.call(config, key)) {
        this.vm[key] = config[key];
      }
    }
    document.body.appendChild(this.rootContainer);
    return this;
  }

  private destroy() {
    this.app?.unmount();
    this.rootContainer?.remove();
    this.app = null;
    this.vm = null;
    this.rootContainer = null;
  }
  // 设置全局配置信息
  config(options: RootData) {
    if (isPlainObject(options)) {
      this.options = options;
    }
    return this;
  }

  private destroyTimer() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private destroyRemoveTimer() {
    if (this.removeTimer) {
      clearTimeout(this.removeTimer);
      this.removeTimer = null;
    }
  }

  // 显示加载进度条
  start(options: RootData) {
    this.init(options);
    this.timer = window.setInterval(() => {
      // 小于90的时候才进行前进
      if (this.vm.totalProgress < 90) {
        this.vm.totalProgress +=
          (this.vm.percentNum || Math.random()) * this.vm.speed;
      }
    }, 100);
    return this;
  }
  // 关闭加载进度条
  end() {
    if (!this.timer) {
      this.init();
    }
    // 先把总进度设置为100,让他走完
    this.vm.totalProgress = 100;
    this.destroyRemoveTimer();
    this.removeTimer = window.setTimeout(() => {
      this.destroyTimer();
      this.destroy();
    }, 200);
    return this;
  }

  // 显示错误进度条
  error() {
    this.end();
    this.vm.totalProgress = 100;
    this.vm.isError = true;
    return this;
  }
}
  • 使用方式
const instance = new LoadingBarConstructor();

instance.start();

setTimeout(() => {
  instance.end();
}, 3000);

createVNode + render

createVNoderender在官方文档中提及的比较少。通过阅读element-plus源码可以发现element是通过createVNoderender来实现 js 调用式组件。

createVNode(component,props):第一个参数为组件,第二个参数为组件的props

render(vm,rootContainer):第一个参数为VNode,第二个参数为组件的根元素

我们以实现一个loading组件为例,步骤如下:

  • 创建组件

因为我们需要访问或者修改组件的响应式数据,所以我们不能通过.vue文件来创建组件,createVNode是无法获取得到组件的实例

import { defineComponent, reactive, Transition } from "vue";

const data = reactive({
  // 加载文案
  text: "",
  // 是否全屏
  fullscreen: true,
  // 控制是否显示
  visible: false,
  // 背景
  background: "",
  // loading颜色
  loadingColor: "",
  // 文本颜色
  textColor: "",
});
const destroySelf = () => {
  // todo
};

const loadingComponent = defineComponent({
  setup() {
    return { data, destroySelf };
  },
  render() {
    return (
      <Transition name="fade" onAfterLeave={destroySelf}>
        {data.visible ? (
          <div
            class={["loading-mask", { "is-fullscreen": data.fullscreen }]}
            style={{ backgroundColor: data.background || "" }}
          >
            <div class="loading-content">
              <span
                class="loading-icon"
                style={{
                  "border-top-color": data.loadingColor || "",
                  "border-right-color": data.loadingColor || "",
                }}
              ></span>
              {data.text ? (
                <span
                  style={{ color: data.textColor || "" }}
                  class="loading-text"
                >
                  {data.text}
                </span>
              ) : null}
            </div>
          </div>
        ) : null}
      </Transition>
    );
  },
});
  • 生成并挂载 DOM
const div = document.createElement("div");
const vm = createVNode(loadingComponent);
render(vm, div);
document.body.appendChild(vm.el as HTMLElement);
// 或者
// document.body.appendChild(div);

render函数中,第二个参数不能是document.body

要注意的是vmVNode实例,并不是组件的实例

  • 销毁
document.body.removeChild(vm.el as HTMLElement);
render(null, div);
  • 最终效果

根据上面的步骤,我们进行一次封装,对外屏蔽一些实现的细节,最终代码如下:

import { pickObject } from "@packages/utils";
import {
  createVNode,
  defineComponent,
  reactive,
  render,
  Transition,
} from "vue";
import { Options } from "./types";

const createComponent = () => {
  const data = reactive({
    // 加载文案
    text: "",
    // 是否全屏
    fullscreen: true,
    // 控制是否显示
    visible: false,
    // 背景
    background: "",
    // loading颜色
    loadingColor: "",
    // 文本颜色
    textColor: "",
  });
  const div = document.createElement("div");
  const destroySelf = () => {
    document.body.removeChild(vm.el as HTMLElement);
    render(null, div);
  };

  const loadingComponent = defineComponent({
    setup() {
      return { data, destroySelf };
    },
    render() {
      return (
        <Transition name="fade" onAfterLeave={destroySelf}>
          {data.visible ? (
            <div
              class={["loading-mask", { "is-fullscreen": data.fullscreen }]}
              style={{ backgroundColor: data.background || "" }}
            >
              <div class="loading-content">
                <span
                  class="loading-icon"
                  style={{
                    "border-top-color": data.loadingColor || "",
                    "border-right-color": data.loadingColor || "",
                  }}
                ></span>
                {data.text ? (
                  <span
                    style={{ color: data.textColor || "" }}
                    class="loading-text"
                  >
                    {data.text}
                  </span>
                ) : null}
              </div>
            </div>
          ) : null}
        </Transition>
      );
    },
  });

  const vm = createVNode(loadingComponent);

  render(vm, div);

  const open = (options?: Options) => {
    if (!options) {
      return;
    }
    const object = pickObject(options, [
      "text",
      "fullscreen",
      "background",
      "loadingColor",
      "textColor",
      "fullscreen",
    ]);
    // 修改响应式数据
    Object.keys(object).forEach((key) => {
      (data as any)[key] = object[key as keyof Options];
    });

    // 已经是显示状态就不需要走下面了
    if (data.visible) {
      return;
    }
    // 添加上去
    document.body.appendChild(vm.el as HTMLElement);
    // 显示
    data.visible = true;
  };
  const close = () => {
    if (!data.visible) {
      return;
    }
    data.visible = false;
  };
  return {
    open,
    close,
  };
};
  • 使用
const instance = createComponent();

instance.open();

setTimeout(() => {
  instance.close();
}, 3000);

两种方式对比

如果只是实现一个js调用的组件,推荐使用createVNode+render的方式。因为createApp会生成一个app实例,会初始化一些无关要紧的东西,比如app.useapp.config.globalProperties等属性和函数,这些东西根本就用不上,每次都会进行一次初始化,会造成不必要的性能浪费

Logo

前往低代码交流专区

更多推荐