likes
comments
collection
share

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

作者站长头像
站长
· 阅读数 15

我正在参加「掘金·启航计划」

前言

前端技术发展至今,组件化已经成为比较常见的一个开发模式,组件化的最大特点就是可以减少重复工作,提高开发效率。目前业界主流实现组件化大多是基于框架实现,比如 DevUI、ElementUI 等,基于框架提供的能力,来构建适配框架的组件,这也间接说明了不同框架下开发出来的组件存在不能复用的问题。不妨设想一下如下场景:

蛋炒饭是一个前端开发者。在某次迭代中,业务 A 想要蛋炒饭提供一个 UI 组件来实现某个功能,蛋炒饭接到了需求,基于业务 A 的技术栈是 Vue,经过一段时间的开发,提供了一个 Vue 版本的 UI 组件。过了一段时间,业务 B 看到业务 A 使用的这个组件还不错,也想接入,但由于业务技术栈是 Angular,于是想要蛋炒饭提供一个 Angular 版本的 UI 组件。

如何解决不同技术栈的组件需求

按照一般思路,蛋炒饭这个时候有两个直白的选择:

(1)提供一个 Angular 版本的 UI 组件:

作为一个前端开发者,蛋炒饭自然不是很想因为框架选型的不同,为相似的需求再重写一版 UI 组件,因为这会让他感到组件的可复用性没有得到体现;同时蛋炒饭觉得,当 UI 组件需要变更时,还需要同时对两个版本进行修改,会提高后续的维护成本,如果以后有使用其他技术栈的业务也想要接入,还需要再重写一次。

(2)提供在 Angular 项目中使用 Vue 组件的技术支持:

之前在团队有看到过在 Angular 项目中使用 Vue,其中的几个关键步骤:引入 Vue 依赖,createApp,进行组件注册,mount 到指定节点。蛋炒饭觉得这种方式虽然能保证 Vue 组件的渲染,但是后续涉及到 Angular 组件和 Vue 组件的数据传递、插入自定义内容等场景,交互将会变得比较繁琐,会提高业务的使用成本。  

一顿分析下来,蛋炒饭比较难接受上面两种方案,脑海中顿时一想:有没有什么方法可以将 UI 组件封装成一个可以运用在各个框架的组件,实现真正的“组件化”。

这个时候就轮到我们今天的主角 Web Components 出场了。Web Components 旨在以浏览器能够理解的方式创建组件,最大可能的提高 HTML 复用程度。本文将介绍 Web Components 的基本概念和关键技术,并借助 vue3 提供的相关 API 支持,将 vue 组件转换为 Web Components 组件,在不同的技术栈中使用。

一、什么是 Web Components

Web Components 是一套 Web 原生的 API 的总称,它允许我们在其支持的环境,创建自定义元素,使用的时候只需要使用自定义元素的标签即可。需要注意的是 Web原生,这意味着 Web 组件可以在任何框架场景或者原生环境中使用!当我们面临的业务是不同技术栈时,Web 原生组件(下称 Web 组件)无疑会是一个值得考虑的选择。

简单理解一下概念,就是允许我们能够创建一个浏览器能够识别的新的 HTML 标签来使用 Web 组件。使用 Web 组件时,我们只需要简单的两个步骤:

(1)引入组件的相关资源

(2)在页面中以原生 HTML 标签的方式使用

举个简单的例子,比如我想要编写一个计数器组件,当我封装成 Web 组件,并将其定义为 my-count 标签,然后我就可以在页面中像使用原生标签一样这样使用:

// 引入组件资源
<script src="xxx.js"></script>

// 像div等HTML原生标签一样使用
<my-count></my-count>

二、Web Components 基础

1. 三项核心技术:

Custom element(自定义元素) :一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。

Shadow DOM(影子 DOM) :一组 JavaScript API,用于将封装的“影子” DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

HTML template(HTML 模板): <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

说说我的理解:

Custom element 允许我们能够将拥有一些业务逻辑的 Web 组件封装为一个新的浏览器能够识别的 HTML 标签,拥有交互行为,而不仅仅是一个单纯的 UI 组件。

Shadow DOM 允许我们使用的 Web 组件与正常的 DOM 隔离,在能够组织好自身的 HTML 结构、CSS 样式、JS 代码的同时,不影响正常文档的 DOM 节点,不会与正常的 DOM 冲突。

HTML template 允许 Web 组件具备模板/插槽的基本能力,能够将 DOM 模板/元素等自定义内容插入到标记位置,让 Web 组件灵活性更加强大、复用性更强。

2. 生命周期

与 Vue 等组件一样,Web 组件也具有生命周期这一概念,并提供了相应的钩子函数,允许我们在组件的生命周期不同阶段中执行一些自定义的业务操作,便于更好的控制和使用组件实例,以支持更多的场景:

connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用。

disconnectedCallback:当自定义元素与文档 DOM 断开连接时被调用。

adoptedCallback:当自定义元素被移动到新文档时被调用。

attributeChangedCallback:当自定义元素的一个属性被增加、移除或更改时被调用。

三、如何使用 Vue 创建 Web 组件

蛋炒饭这个时候已经有了一个 Vue 版本的组件,自然希望能够找到一种方法可以直接将 Vue 组件转换为 Web 组件,这样就可以最快捷最方便的解决问题。

碰巧的是,Vue3 刚好提供了这个能力,Vue3.2 版本提供了一个和定义一般 Vue 组件几乎完全一致的 defineCustomElement 方法来支持创建自定义元素。这就允许我们可以在不用额外学习 Web Components 语法的情况下,继续使用 Vue 框架开发 Vue 组件,最后调用 API 完成组件的转换。接下来我将以一个计数器组件为例,介绍如何使用 Vue 创建 Web 组件

1. 创建 Vue 组件(计数器为例)

需要注意的是,Vue 文件需要以 .ce.vue 结尾,默认情况下, .ce.vue 结尾的文件会开启 Vue 的自定义元素模式,会将样式暴露为组件的 styles 选项,以便在 defineCustomElement 时能够被提取和使用,组件初始化时能够将样式注入到 Web 组件的 shadow root 上,保证样式能够生效。

// src/MyCount/MyCount.ce.vue
<script setup lang="ts">
import { ref } from 'vue';

const count = ref(0);
const addCount = () => {
  count.value = count.value + 1;
}
</script> 

<template>
  <div class="container">
    <span class="title">我来自web组件:</span>
    <span>计数器值: {{ count }}</span>
    <button @click="addCount">点击增加</button>
  </div>
</template>

<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  background: #FFFFFF;
  padding: 24px;
  border-radius: 12px;
  box-shadow: 0 8px 16px 0 rgba(37, 43, 58, 0.16);
  width: 180px;
  gap: 16px;
  .title {
    font-weight: bold;
  }
}
</style>

2. 创建自定义元素

// src/MyCount/index.ts
import { defineCustomElement } from "vue";  // 引入API

import MyCount from "./MyCount.ce.vue";  // 引入计数器组件

const CustomElement = defineCustomElement(MyCount); 

// 为了便于业务自定义标签名称,导出名称注册方法,默认为my-conut
export function register(tagName: string = "my-count") {
  customElements.define(tagName, CustomElement);
  return CustomElement;
}

到现在为止,当我们引入 index.ts 组件时,Web 组件就已经创建好了,接下来介绍如何使用 Web 组件。

四、如何使用 Web 组件

1. Vue 项目中使用

来到 App.vue 或者任一 .vue 文件,读取 src/MyCount/index.ts 文件,调用其中注册方法,然后直接在模板中进行使用:

// App.vue
<script setup lang="ts">
import { register } from './MyCount/index';
register('my-count'); // 自定义标签取名为my-count
</script>

<template>
  // 像使用原生标签一样直接使用
  <my-count></my-count>
</template>

页面效果:

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

2. Angular 项目中使用

为了在 Angular 中使用,我们首先需要先把 Web 组件从 Vue 项目中打包出来,来到 Vue 项目中的 vite.config.ts 文件,将 src/MyCount/index.ts 打包出去。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/MyCount/index.ts', // 将打包入口更改为src/MyCount/index.ts,就可以单独将web组件打包出去
      formats: ['es'],
      fileName: () => 'my-count.js' // 打包js文件名
    }
  }
})

将打包之后的 dist/my-count.js 文件移到 Angular 项目中,Angular 项目支持 Web 组件需要在 app.module.ts 中引用 CUSTOM_ELEMENTS_SCHEMA

// app.module.ts
...
import { ..., CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
...

@NgModule({
  ...
  schemas: [
    CUSTOM_ELEMENTS_SCHEMA
  ],
  ...
})

然后来到 app.component.ts 中创建自定义元素:

// app.componnet.ts
...
import { register } from 'src/MyCount/my-count.js';
register('my-count');
...

接下来就可以在任一组件中,使用 Web 组件了:

 // ng-web-component.html
<h3>我是ng组件,下面是web组件:</h3>
<my-count></my-count>

页面效果如下:

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

3. 原生环境中使用

原生环境使用更加简单,引入 js 资源,调用注册方法,浏览器就能够识别到自定义标签

// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <script type="module">
    import { register } from './my-count.js';
    register('my-count');
  </script>
  <body>
    <div>
      <h3>我来自原生环境:</h3>
      <my-count />
    </div>
  </body>
</html>

页面如下:

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

五、Web 组件同样具备框架组件的基本能力和行为

讲到这里,相信有兴趣的同学,肯定会比较关心生成出来的 Web 组件,支不支持数据绑定、自定义插槽、事件监听等框架组件具备的基本能力。答案是肯定的,在一定程度上,使用 Web 组件,跟使用框架组件没有特别大的差别,同样可以使用框架语法,是能够携带业务逻辑的,下面我将以 Vue 项目(当然 Angular 项目也一样)为例,逐个介绍 Web 组件各个基本能力的使用。

1. 数据绑定

Vue 组件中接收一个 msg 字符串参数,并在页面中进行渲染;父组件中使用 Vue 的绑定语法,传入 msg,3 秒后更改msg的值,观察页面渲染的值是否会发生变化

// MyCount.ce.vue (子组件)
<script setup lang="ts">
const props = defineProps({
  msg: { type: String, default: '' }
});
...
</script> 
<template>
    ...
    <span class="bold-text">来自父组件的消息:{{ props.msg }}</span>
</template>

// App.vue (父组件)
<script setup lang="ts">
...
const msg = ref('我是第一条消息');
setTimeout(() => {
  msg.value = '我是第二条消息'; // 3s后更改msg的值
}, 3000)
</script>
<template>
  <my-count :msg="msg"></my-count>
</template>

页面效果:3s 后,Web 组件成功渲染最新的 info 的值

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

当然,除了可以绑定基本类型数据外,当然也支持传递引用数据类型,使用方式一样。下面采用 watch 监听的方式监听对象数据的变化:

// MyCount.ce.vue(子组件)
<script setup lang="ts">
...
const props = defineProps({
  info: { type: Array, default: [] }
});
...
watch(() => props.info, (newVal) => { // 监听info传参的变化
  console.log('info数据: ', newVal.name);
}, { immediate: true });
</script> 

// App.vue(父组件)
<script setup lang="ts">
...
const info = ref({ name: '变化前' });
setTimeout(() => {
  info.value = { name: '变化后' }; // 3s后更改info的值,看web组件是否能监听到变化
}, 3000);
</script>
<template>
  <my-count :info="info"></my-count>
</template>

具体效果如下:3s 后成功监听到变化,打印出 info 里面 name 的值

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

2. 插槽

插槽的使用与框架的有一些差别,Web 组件只支持原生的插槽语法,即使是通过 Vue 组件进行转换。这里主要介绍具名插槽的使用,首先需要在 Web 组件中设置 slot,然后在父组件中自定义插槽内容:

// MyCount.ce.vue(子组件)
<template>
  <div class="container">
    ...
    <slot name="customContent"></slot> // slot标签,设置名为customContent插槽
  </div>
</template>

// App.vue(父组件)
<my-count>
   <div slot="customContent" style="font-weight: bold">我来自插槽</div> // 注意slot属性,将div插入名为customContent插槽中
</my-count>

页面效果:

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

当然,原生的插槽语法也是能够支持插槽的多级传递的,假设 my-count 组件中使用了一个名为 my-time 的 Web 组件,那么在 my-count 中可以选择将插槽内容传递给 my-time 组件进行渲染。

限制: 由于 Web 组件仅支持原生的插槽语法,存在贪婪求值的性质,会阻碍组件之间的可组合性,导致了 Web 组件的插槽能力并不如框架,无法支持像 Vue 框架提供的作用域插槽等强大能力,插槽大多数场景下只能用于在 Web 组件中的标记位置插入自定义内容,而与 Web 组件本身的行为和逻辑没有太大关联。

3. 事件

Vue 组件通过 emits 对外发送事件,在使用 defineCustomElement 转换为 Web 组件时,Vue 组件原来的 emits 事件发送逻辑也能够保留,emit 触发的事件将会以 CustomEvents 的形式发出,事件参数将会暴露在CustomEvent 对象上的 detail 数组上。监听 Web 组件发出的事件一般有两种方式:

3.1 原生方法监听

// MyCount.ce.vue
<script setup lang="ts">
...
const emits = defineEmits(['sendMsg']);
const addCount = () => {
  count.value = count.value + 1;
  emits('sendMsg', `count的最新值为: ${count.value}`);  // 当计数器值增加时,发送sendMsg事件,参数为一个字符串
}
</script> 

// App.vue 
<script setup lang="ts">
...
const printMsg = (event: CustomEvent) => {
  console.log(event)
}
onMounted(() => {
  const countEle = document.getElementsByTagName('my-count')[0];
  countEle.addEventListener('sendMsg', printMsg);
});
</script>

<template>
  <my-count></my-count>
</template>

3.2 框架语法糖监听

// MyCount.ce.vue
<script setup lang="ts">
...
const emits = defineEmits(['sendMsg']);
const addCount = () => {
  count.value = count.value + 1;
  emits('sendMsg', `count的最新值为: ${count.value}`); // 当计数器值增加时,发送sendMsg事件,参数为一个字符串
}
</script> 

// App.vue 
<script setup lang="ts">
...
const printMsg = (event: CustomEvent) => {
  console.log(event)
}
</script>

<template>
  <my-count @sendMsg="printMsg"></my-count> // vue监听事件语法糖监听web组件的sendMsg事件
</template>

页面效果如下:

Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件 Web Components 初探:我终于学会了如何在其他框架中使用 Vue 组件

以上便是 Web 组件具备的基本能力和行为,跟常见的框架组件非常接近,细心的同学可能有疑问了,在我们平常的开发中,除了参数传递、事件监听等外,往往还需要调用组件内的方法或者变量来达到某种目的,在 Vue3 中,可以通过设置 defineExpose 将需要暴露的方法和变量挂载在 DOM 上来进行调用,那么在使用 Web 组件的情况下,能否拥有类似的机制来使用 Web 组件内的方法和变量?

遗憾的是,defineCustomElement 并没有提供这种机制,并不会将组件的方法和变量进行挂载,即使我们转换的 Vue组件使用了 defineExpose 将方法和变量暴露出去。不过,作为开发者,我们当然希望提供的web组件也能够拥有这种机制,让 Web 组件尽可能的具备框架组件的能力,来应对更多可能的场景,在下一个篇章中,我将介绍如何对 defineCustomElement 进行扩展来实现这种机制。

🔥 了解更多

欢迎大家关注 DevUI !

未来华为云前端开源社区也会将更多内部优秀工程实践开源出来,欢迎朋友们加入我们的社区,一起打造有竞争力的开源产品,营造有温度的开源社区,期待你的加入!

官网:devui.design

源码仓库:github.com/DevCloudFE

DevUI微信小助手:devui-official

文 / DevUI社区贡献者 蛋炒饭