一、需求背景
此需求涉及到两个项目,在 MA 项目 中需要复用 Push 项目 中的《高级设置模块》,Push 项目该模块已经开发完成上线,MA 项目正在开发中,需要复用该模块。
一开始的想法是,把这块的代码完全搬过去,但是由于 Push 项目中这个模块代码复杂,代码量大,与其他模块的耦合度高,依赖多,无法完全抽离出来 copy 到 MA 项目中进行使用。
提示:本文章会涉及到 Web Components 的相关知识,如果不了解的话,可以先去阅读该篇文章:# 探秘Web Component:前端开发的“魔法组件”。
二、解决方案
鉴于上面提到的痛点,采取的解决方案是:在 MA 项目中通过 Web Components 的方式嵌入 Push 项目的高级设置模块。
具体的实现思路如下:
-
基于 Push 项目创建消息通知页面,新建一个高级设置组件:
MaPushSetting.vue
,将对应的部分代码 copy 过去,然后把没用的代码删掉,同时要防止影响原页面组件的代码,但并不是所有代码都复制过去,只需保证有个入口组件即可,MaPushSetting.vue 仍会依赖 Push 项目里的很多代码以及组件; -
将推送高级设置模块抽离成一个单独的组件后,新建个入口文件将该组件暴露出去,使用
defineCustomElement
定义一个符合Web Components
规范的自定义元素; -
将指定入口文件打包成一个 JS 文件,在 MA 项目 中引入打包后的 JS,并使用自定义元素;
-
实现组件间的通信,确保在 MA 项目中能获取到自定义元素内部的数据。
三、实现细节
3.1 defineCustomElement
这里使用 Vue3 中的 defineCustomElement 方法来支持创建自定义元素,此方法接收的参数和 defineComponent 完全相同,它会返回一个继承自 HTMLElement 的自定义元素构造器,关于 defineCustomElement 更详细的介绍请阅读:cn.vuejs.org/guide/extra…
在 Push 项目中新建一个入口文件,使用 defineCustomElement 将 Vue 组件转换为 Web Component:
// src/components/MaPushSetting/index.ts
import { defineCustomElement } from 'vue';
import MaPushSetting from './MaPushSetting.vue';
// 将Vue组件转换为Web Component
const MaPushSettingElement = defineCustomElement(MaPushSetting);
// 注册自定义元素(防止重复注册)
if (!customElements.get('ma-push-setting')) {
customElements.define('ma-push-setting', MaPushSettingElement);
}
3.2 打包命令
以上面的入口文件为起点,执行打包命令:
"build-ma": "vue-cli-service build --target lib --inline-vue --name ma-push-setting --dest dist ./src/components/MaPushSetting/index.ts"
注意对 Vue 的依赖: 在使用库模式进行打包时,Vue 是外置的,这意味着包中不会有 Vue,即便你在代码中导入了 Vue,要避免此行为,可以在 build
命令中添加 --inline-vue
标志。
想了解更多关于 vue-cli + Web Components 的可以参考文档:cli.vuejs.org/zh/guide/bu…
打包之后的产物如下,这里主要用到两个文件:ma-push-setting.css、ma-push-setting.umd.min.js
3.3 使用自定义元素
在 MA 项目中加载 JS,并使用自定义的组件,注意:如果在打包的时候没有将 Vue 打包进去,还需要引入 Vue 的 JS,因为自定义组件 ma-push-setting 是基于 Vue 3 开发的,Vue 自定义元素依赖于 Vue 的运行时环境,如果没有引入 Vue,浏览器将无法识别 Vue 组件的语法和行为,导致组件无法正常工作。
<template>
<div class="push-setting" v-if="showPush">
<ma-push-setting></ma-push-setting>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
const showPush = ref(false);
const loadScripts = (url: string, flag?: boolean) => {
return new Promise((reslove) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
flag && (showPush.value = true);
reslove(true);
};
document.body.appendChild(script);
});
};
const loadResource = () => {
// 先引入Vue
loadScripts('https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js').then(() => {
loadScripts('/ma-push-setting.umd.min.js', true);
});
};
onMounted(loadResource);
</script>
3.4 组件间通信
涉及到两个组件间的通信,父组件、自定义元素(也就是子组件),由于自定义元素是完全隔离开来的,因此和常规组件的通信手段不完全一样。
3.4.1 属性传递
官方文档有详细的介绍:使用 Vue 构建自定义元素
传值和普通的 Vue 组件其实差不多,比如下面这样的 props 声明:
props: {
selected: Boolean,
index: Number
}
并以下面这样的方式使用自定义元素:
<ma-push-setting selected index="1"></ma-push-setting>
在组件中,selected
会被转换为 true (boolean) 而 index
会被转换为 1 (number)。
3.4.2 事件触发
在组件间触发事件,需要使用到 CustomEvents,比如我想要在父组件中调用子组件(自定义元素)上的方法,获取数据 data,实现思路为:
父组件创建一个自定义事件 A 并触发,子组件(自定义元素)监听事件 A,子组件监听到后创建一个自定义事件 B 并触发(传递数据 data),父组件监听事件 B 获取数据,具体的实现代码如下:
父组件:
// 父组件
const onTrigger = () => {
const event = new CustomEvent('PushEvent-Trigger');
document.dispatchEvent(event);
};
const getPushData = (event: any) => {
const data = event.detail.data;
console.log(data)
};
onMounted(() => {
document.addEventListener('PushEvent-GetData', getPushData);
});
onUnmounted(() => {
document.removeEventListener('PushEvent-GetData', getPushData);
});
子组件(自定义元素):
const emitCustomEvent = async () => {
const data = await getSendData();
const event = new CustomEvent('PushEvent-GetData', {
detail: {
data
}
});
document.dispatchEvent(event);
};
onMounted(() => {
document.addEventListener('PushEvent-Trigger', emitCustomEvent);
});
onUnmounted(() => {
document.removeEventListener('PushEvent-Trigger', emitCustomEvent);
});
当然事件触发可能还有其他的实现方式,试过其他的一些方式不大行,大家可以评论一起交流下。
四、遇到的一些问题
4.1 样式丢失
按照上面的步骤完成后,启动项目,组件已经能够在页面上显示出来了,但是所有样式都丢失了。
样式丢失的原因是:
在使用 defineCustomElement 将 Vue 组件转换为 Web Component 时,Vue 默认会将组件的样式封装在 Shadow DOM 中。Shadow DOM 是一种隔离 DOM 和样式的机制,它使得组件的样式不会影响到外部文档,同时外部文档的样式也不会影响到组件内部。因此,如果你在 Vue 组件中定义的样式没有正确处理,它们将不会应用到 Shadow DOM 中的元素。
那么该如何处理样式丢失的问题呢?
4.1.1 组件命名为 .ce.vue
在 Vue3 官方文档中,我们也许能够找到答案:cn.vuejs.org/guide/extra…
要开启这个模式,只需要将你的组件文件以 .ce.vue 结尾即可:
import { defineCustomElement } from 'vue';
import MaPushSetting from './MaPushSetting.ce.vue';
// 将Vue组件转换为Web Component
const MaPushSettingElement = defineCustomElement(MaPushSetting);
// 注册自定义元素(防止重复注册)
if (!customElements.get('ma-push-setting')) {
customElements.define('ma-push-setting', MaPushSettingElement);
}
这种方式会在 shadow-root 下面插入一个 style 标签,使得样式能够生效:
能够发现部分样式已经生效了,但是大部分元素的样式还是丢失了,通过观察能够得出结论:
主组件(根组件)中的样式被加载,但主组件中引入的其他组件(子组件)中定义的样式不会应用,也就是这些组件的样式不会注入到自定义元素的影子根中,在网上没有找到解决方案,因此只能放弃这种方式。
4.1.2 动态插入style标签
受第一种方式的启发,考虑将打包生成的 CSS文件内容,动态生成一个 style 标签,插入到 shadow-root 下面,因为打包后 CSS 文件含有所有组件的样式,因此能够保证所有样式均能正常生效。
const loadCssAndSetStyle = async () => {
const response = await fetch('/ma-push-setting.css');
const styleContent = await response.text();
const shadowHost = document.querySelector('ma-push-setting');
if (!shadowHost || !shadowHost.shadowRoot) return;
let style = shadowHost.shadowRoot.querySelector('style');
if (!style) {
style = document.createElement('style');
shadowHost.shadowRoot.appendChild(style);
}
style.textContent = styleContent;
};
4.2 iconfont图标渲染不出来
自定义元素内部使用的 iconfont 图标渲染不出来,归根到底还是样式作用域问题,Web Components 内部与外界是完全隔离开的,全局的字体图标样式无法作用于自定义元素。
解决方法:借鉴上面处理样式丢失的做法,将字体图标的样式添加到 shadow-root 内部的 style 标签上。
const loadCss = async () => {
const response = await fetch('/ma-push-setting.css');
const cssContent = await response.text();
const iconfontContent = `
@font-face {
font-family: "iconfont";
src: url('iconfont.woff2?t=xxxxx') format('woff2'),
url('iconfont.woff?t=xxxxxxx') format('woff'),
url('iconfont.ttf?t=xxxxxxxxx') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ec-delete:before {
content: "\e71e";
}
.ec-preview:before {
content: "\e71d";
}
`;
const shadowHost = document.querySelector('ma-push-setting');
if (!shadowHost || !shadowHost.shadowRoot) return;
let style = shadowHost.shadowRoot.querySelector('style');
if (!style) {
style = document.createElement('style');
shadowHost.shadowRoot.appendChild(style);
}
style.textContent = = cssContent + iconfontContent;
};
4.3 Antd组件前缀prefixCls丢失
Ant-design-vue 组件允许开发者设置组件类名的前缀,前提是使用全局配置组件 ConfigProvider,并设置 prefixCls 属性,我们一般会在 App.vue 中进行配置:
<a-config-provider :prefixCls="'jant'">
<main></main>
</a-config-provider>
由于打包入口,并没有从 App.vue 出发,因此自定义元素内部的 Ant-design-vue 组件的类名前缀就会丢失,而且两个项目都设置了 prefixCls 属性,这样也会导致很多样式丢失的问题。
解决方法:在 MaPushSetting.vue 组件的最外层套上 a-config-provider 组件即可。
4.4 打包后图片资源的路径问题
在打包后的源码中,图标的路径显示类似为:xiaomi.9cdcc6de.svg,这样在 MA项目使用自定义元素时,就会从当前项目中去查找,找不到就会 404。
解决方法其实很多,我这里采取的方案是,在打包后的源码中给图片的路径添加上 CDN 地址,只需设置 publicPath 属性即可,然后将图片等资源上传到 CDN。
vue.config.js 配置如下,图片路径添加上 maPubilcPath:
module.exports = {
......
chainWebpack: (config) => {
const fileRule = config.module.rule('file');
fileRule.uses.clear();
fileRule
.test(/.svg$/)
.exclude.add(path.resolve(__dirname, './src/icons'))
.end()
.use('file-loader')
.loader('file-loader')
.tap(() => ({
name: '[name].[hash:8].[ext]',
quality: 85,
limit: 0,
esModule: false,
publicPath: isMa ? maPubilcPath : undefined
}));
config.module
.rule('images')
.use('url-loader')
.tap(() => ({
name: '[name].[hash:8].[ext]',
quality: 85,
limit: 0,
esModule: false,
publicPath: isMa ? maPubilcPath : undefined
}));
config.plugin('monaco').use(new MonacoWebpackPlugin());
},
};
然后执行自己项目中上传 CDN 的脚本即可。
4.5 globalProperties属性注入
在 Push 项目 MaPushSetting.vue 组件内部用到一个全局挂载的方法 $url,挂载代码位于 main.ts 中:
import AppVue from './App.vue';
const app = createApp(AppVue);
app.config.globalProperties.$url = getPublicPath;
在使用打包后生成的自定义元素 时会报错:
TypeError: e.$url is not a function
原因:自定义元素内部与外层是完全隔离的,所处的上下文环境不一致,因此访问不到 globalProperties 上定义的属性以及方法。尝试过以下三种方法还是没能够解决报错:
1.将 Vue 也打包到 JS 中
2.将 $url 作为属性传递给自定义元素
3.在 MA 项目 使用 ma-push-setting 组件时,通过 createApp 创建一个实例,再挂载到 globalProperties 上
解决这个问题的核心:在创建一个自定义元素的同时,如何在该元素的 globalProperties 属性上挂载属性或者方法?因此,需要在入口文件使用 defineCustomElement 时做一些处理,最终的解决代码如下:
import { defineCustomElement } from 'vue';
import MaPushSetting from './index.vue';
import maConfig from './config';
const defineCustomElementForMa = (component) => {
const element = defineCustomElement(component);
return class CustomElementWithStyle extends element {
constructor(props) {
super(props);
}
_createVNode() {
const node = super._createVNode();
node.appContext = {
app: null,
config: {
isNativeTag: function () {
return false;
},
performance: false,
globalProperties: {},
optionMergeStrategies: {},
errorHandler: undefined,
warnHandler: undefined,
compilerOptions: {}
},
mixins: [],
components: {},
directives: {},
provides: Object.create(null),
optionsCache: new WeakMap(),
propsCache: new WeakMap(),
emitsCache: new WeakMap()
};
node.appContext.config.globalProperties.$url = (url) => url;
return node;
}
};
};
const MaPushSettingElement = defineCustomElementForMa(MaPushSetting);
if (!customElements.get('ma-push-setting')) {
customElements.define('ma-push-setting', MaPushSettingElement);
}
需要注意的是,要把入口文件定义为 JS 才行,TS 文件会报一些类型错误,导致打包失败。