一、技术点引入
前五篇文章已经将使用defineCustomElement方法定义自定义元素实现富文本组件的难点和坑点都梳理了一遍,目前来看解决问题的手段能够解决目前遇到的90%的问题,当然有些问题我有可能没有遇到,欢迎各位大佬在评论区提出,感谢!
这篇文章和第七篇文章都是为了增强自定义元素的拓展性,其目的都是解决shadow dom带来的css样式强隔离问题。
- 实现目标
我们的目标是实现一个tabs,它能够实现动态的增删tab,每个tab内部都是一个富文本,它实现后应该长这样:
- 前期准备
我这里已经有一个自己实现的CustomSwiper.vue组件,他接受一个数组,然后判断需不需要无限滚动来决定是否将数组头元素复制到尾部,将尾部元素复制到头部,最后通过作用域插槽向外抛出处理后的数组,用户再通过作用域插槽实现内容的渲染模板,他具体是这样:
js
<script setup lang="ts">
import {
computed,
onMounted,
onBeforeUnmount,
ref,
watch,
type StyleValue,
onBeforeMount
} from 'vue'
import { generateRandomString } from '@/utils/utils'
const props = withDefaults(
defineProps<{
data: any[]
activeFooter?: number
autoplay?: boolean
intervals?: number
direction?: 'horizontal' | 'vertical'
loop?: boolean
scrollDirection?: -1 | 1
defaultFooter?: number
duration?: string
showDoc?: boolean
}>(),
{
autoplay: true,
intervals: 1000,
direction: 'horizontal',
loop: true,
scrollDirection: -1,
duration: '.3s',
showDoc: true
}
)
let stepper: number | undefined
const swiper = ref<HTMLElement | null>(null)
const container = ref<HTMLElement | null>(null)
const footer = ref(0)
const virtualFooter = ref(0)
// 计算后的数据类型
const computedData = computed(() => {
if (!props.data.length || !Array.isArray(props.data)) return []
const data = [...props.data]
if (props.loop) {
const firstData = data[0]
const lastData = data[data.length - 1]
data.unshift(lastData)
data.push(firstData)
}
data.forEach((item) => {
!item.__key && (item.__key = generateRandomString(8))
})
return data
})
// 计算后的container高度和宽度
const computedStyle = computed(() => {
const styles = {
width: '',
height: '',
flexDirection: '',
left: '0',
top: '0'
}
if (props.direction === 'horizontal') {
styles.width = `${computedData.value.length * 100}%`
styles.flexDirection = 'row'
styles.height = '100%'
styles.left = `${virtualFooter.value * 100}%`
} else {
styles.width = '100%'
styles.height = `${computedData.value.length * 100}%`
styles.flexDirection = 'column'
styles.top = `${virtualFooter.value * 100}%`
}
return styles
})
const computedTransition = computed(() => {
return `${props.duration} ${props.direction === 'horizontal' ? 'left' : 'top'}`
})
watch(computedData, async () => {
clearTimeout(stepper)
setDefaultFooter()
await 1
props.autoplay && (stepper = setTimeout(stepperFn, props.intervals))
})
function stepperFn() {
footer.value -= props.scrollDirection
if (footer.value >= props.data.length) {
footer.value = 0
}
if (footer.value < 0) {
footer.value = props.data.length - 1
}
container.value!.style.transition = computedTransition.value
if (!props.loop) {
virtualFooter.value = -1 * footer.value
} else {
virtualFooter.value += props.scrollDirection
}
}
const setDefaultFooter = () => {
if (typeof props.activeFooter === 'number') {
footer.value = props.activeFooter
virtualFooter.value = (footer.value + +props.loop) * -1
} else {
if (
props.defaultFooter &&
Number.isInteger(props.defaultFooter) &&
props.defaultFooter < props.data.length &&
props.defaultFooter >= 0
) {
footer.value = props.defaultFooter
virtualFooter.value = (footer.value + +props.loop) * -1
} else {
if (props.scrollDirection > 0) {
footer.value = props.data.length - 1
virtualFooter.value = 1 + +props.loop - computedData.value.length
} else {
footer.value = 0
virtualFooter.value = -1 * +props.loop
}
}
}
}
const setActiveFooter = (num: number) => {
clearTimeout(stepper)
container.value!.style.transition = computedTransition.value
footer.value = num
virtualFooter.value = (footer.value + +props.loop) * -1
props.autoplay && (stepper = setTimeout(stepperFn, props.intervals))
}
if (Number.isInteger(props.activeFooter)) {
watch(
() => props.activeFooter,
(newVal) => {
typeof newVal === 'number' && setActiveFooter(newVal)
}
)
}
const getFooter = () => {
return { footer: footer.value, virtualFooter: virtualFooter.value }
}
onBeforeMount(() => {
setDefaultFooter()
})
onMounted(() => {
if (container.value) {
container.value.addEventListener('transitionend', () => {
clearTimeout(stepper)
container.value!.style.transition = 'none'
// 循环类型延迟判断,非循环类型判断再做动画
if (props.loop) {
if (virtualFooter.value * -1 >= computedData.value.length - 1) {
virtualFooter.value = -1
} else if (virtualFooter.value >= 0) {
virtualFooter.value = 2 - computedData.value.length
}
}
props.autoplay && (stepper = setTimeout(stepperFn, props.intervals))
})
props.autoplay && (stepper = setTimeout(stepperFn, props.intervals))
}
})
onBeforeUnmount(() => {
clearTimeout(stepper)
})
defineExpose({
setActiveFooter,
getFooter
})
</script>
<template>
<div class="swiper" ref="swiper" v-if="computedData && computedData.length">
<div class="swiper-container" :style="computedStyle as StyleValue" ref="container">
<div
class="swiper-items"
:style="{ height: direction === 'horizontal' ? '100%' : 'auto' }"
v-for="(item, index) of computedData"
:key="item.__key"
@dragstart="() => false"
>
<slot :data="item" :index="index"></slot>
</div>
</div>
<div class="doc-container" v-if="showDoc">
<div
class="doc"
:class="{ 'doc-active': item.__key === data[footer].__key }"
v-for="(item, index) of data"
:key="item.__key"
@click="setActiveFooter(index)"
></div>
</div>
</div>
</template>
<script lang="ts">
export default { customElement: true }
</script>
<style lang="less" scoped>
.swiper {
position: relative;
overflow: hidden;
&-container {
position: absolute;
will-change: auto;
display: flex;
}
&-items {
flex: 1;
overflow: hidden;
}
.doc-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(128, 128, 128, 0.85);
padding: 5px 10px;
border-radius: 50px;
display: flex;
.doc {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #fff;
margin: 0 2px;
transition: 0.3s;
}
.doc-active {
background-color: blue;
}
}
}
</style>
他的使用方式为:
js
<CustomSwiper style="height: 500px;" :data="[{ src: 'https://pic3.zhimg.com/v2-ec4ca0efcc1d7c061e0f4a1088fe6b1a_r.jpg' },
{ src: 'https://img95.699pic.com/photo/50070/5999.jpg_wh860.jpg' }]">
<template #default="{ data }">
<img :src="data.src" />
</template>
</CustomSwiper>
展示的效果为:
我的tabs要复用swiper组件的能力,来实现这个功能,他的具体代码为:
js
<script setup lang="ts">
import CustomSwiper from '@/components/CustomSwiper.vue'
import { watch, onMounted, reactive, ref, getCurrentInstance } from 'vue'
import { RichText } from '@/utils/richText/RichText'
import { type RichTextVirtualDOM } from '@/types/customComponent'
const props = withDefaults(
defineProps<{
data: Array<{ title: string; content: RichTextVirtualDOM }>
mode: 'edit' | 'show'
}>(),
{
mode: 'edit'
}
)
const instance = getCurrentInstance()
// 这个用来同步页数和title
const editData = reactive(
props.data.map((item) => {
return { title: item.title }
})
)
const swiper: any = ref(null)
const footer = ref(0)
const addTab = () => {
editData.push(getDefaultTab())
footer.value = editData.length - 1
}
const removeTab = async (index: number) => {
editData.splice(index, 1)
!editData.length && addTab()
// 新建一个宏任务来延迟执行
setTimeout(() => {
footer.value = index ? index - 1 : index
})
}
function getDefaultTab() {
return { title: '' }
}
const getData = () => {
return editData.map((item) => ({
content: RichText.jsonize(instance?.refs?.[item.__key] as HTMLElement),
title: item.title
}))
}
watch(footer, (newVal) => {
console.log('tab--->', newVal)
})
onMounted(() => {
if (!editData.length) {
editData.push(getDefaultTab())
} else {
editData.forEach((item, index) => {
const containerEl = instance?.refs[item.__key]
const defaultData = props.data?.[index]
if (containerEl && defaultData?.content) {
const defaultContainer = (containerEl as HTMLElement).querySelector('.default-container')
if (typeof defaultData.content === 'string') {
defaultContainer &&
defaultContainer.appendChild(document.createTextNode(defaultData.content))
} else {
defaultContainer && (containerEl as HTMLElement).removeChild(defaultContainer);
(containerEl as HTMLElement).appendChild(RichText.parse2DOM(defaultData.content))
}
}
})
}
})
defineExpose({
data: getData
})
</script>
<template>
<div class="tabs">
<ul class="tabs-container">
<li :class="{ 'tab-active': index === footer }" v-for="(item, index) of editData" :key="index"
@click="footer = index">
<span v-if="mode === 'edit'">
<input v-model="item.title" placeholder="请输入标签名" />
<button type="button" @click="removeTab(index)">×</button>
</span>
<span v-else>{{ item.title ?? '--' }}</span>
</li>
<li class="add-tab" v-if="mode === 'edit'">
<button type="button" @click="addTab">+</button>
</li>
</ul>
<CustomSwiper class="swiper" :data="editData" :autoplay="false" :showDoc="false" :loop="false" :activeFooter="footer"
ref="swiper">
<template #default="{ data }">
<div :key="data.__key" class="swiper-item" :ref="data.__key">
<div class="default-container" :contenteditable="mode === 'edit'"></div>
</div>
</template>
</CustomSwiper>
</div>
</template>
<script lang="ts">
export default {
name: 'custom-tabs',
type: 'block',
nestable: true
}
</script>
<style lang="less">
.tabs {
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 15px 40px;
&-container {
display: flex;
list-style: none;
padding: 0;
li {
flex: 1;
line-height: 28px;
text-align: center;
box-sizing: border-box;
cursor: pointer;
}
.add-tab {
flex: 0 0 28px;
}
.tab-active {
color: #1677ff;
border-bottom: 2px solid #1677ff;
}
}
.swiper {
min-height: 300px;
}
.swiper-item {
height: 100%;
box-sizing: border-box;
padding: 15px;
border: 1px solid black;
.default-container {
height: 100%;
overflow: auto;
}
}
}
</style>
这个组件能够接受一个data,里面是一个虚拟dom的数组,然后在onMounted生命钩子中将这些虚拟dom转换为真实dom,并在defineExpose中抛出了一个获取data的方法getData,这样就能够实现内容的数据保存和复现。但是当我们尝试新建一个tabs的时候,我们会看到:
可以看到他的样式出了问题。打开控制台,我们可以看到:
虽然挂上了样式类名,但是样式却丢失了。这是因为CustomSwiper.vue 这个组件没有以.ce.vue结尾,所以他没有导出自己的styles,并且就算导出了也要自己手动将swiper的styles添加到tabs组件的styles中,耦合度很高。
- 问题解决思路
既然style会丢失,那么该如何无感的去拿到styles并放到引入的自定义元素中呢,这里我采用的方法是利用vite插件的能力来解决这个问题:
- 首先要将enforce设置为'true',这样就能在vue打包之前来处理,可以在transform中拿到原始文件
- 然后在transform中对文件名通过正则拿到.ce.vue结尾的文件,这代表他是一个自定义元素
- 之后定义getImportedComponents函数通过正则来匹配到这个自定义组件引入的文件路径,并返回一个路径数组
- 接着遍历这个路径数组,通过定义的getAbsFilePath函数来获取所有文件的绝对路径,拿到路径后用fs包读取这个组件
- 最后通过正则来匹配到被引入组件的所有style标签,并将它们全部添加到code中,然后return 添加完样式标签的code
二、代码实现
下面是这个插件的代码,一些细节处理我用注释展示:
js
// 这个插件可以将自定义元素中引用vue组件(也就是文件名后缀不带.ce)导致的样式丢失进行修复
import type { Alias, PluginOption } from 'vite'
import path from 'path'
import fs from 'fs'
export default function vitePluginVueComponentInlineCssLoader(): PluginOption {
let alias: Alias[] = []
return {
name: 'vite-plugin-vue-component-inline-css-loader',
// 在vue打包之前来处理,这样就能在transform中拿到未被编译的文本
enforce: 'pre',
transform(code, id) {
if (/\.ce.vue$/.test(id)) {
// 获取引入的组件名
const importedComponents = getImportedComponents(code)
// 遍历组件名,生成引入的字符串
importedComponents.forEach(comp => {
// 获取文件绝对路径
const filePath = getAbsFilePath(comp, id, alias)
// 读取组件
const component = fs.readFileSync(filePath, 'utf8')
// 用正则获取style
const styles = getStyleAll(component)
// 将获得的styles加到组件的末尾
styles.forEach(style => {
code = `${code}${style}`
})
})
return code
}
},
configResolved(config) {
// 当config加载完成就初始化alias
// 因为这个钩子比transform先触发,所以不担心transform访问出现问题
alias = config.resolve.alias
}
}
}
// 获取组件引入的组件的组件名数组
function getImportedComponents(str: string) {
// 匹配所有的<script>标签
const scriptRegex = /<script[^>]*>((.|\n|\r)*?)<\/script>/g
const scriptMatches = str.match(scriptRegex)
const vueImports: string[] = []
if (scriptMatches) {
scriptMatches.forEach((scriptStr) => {
// 移除所有的多行注释
const noMultilineComments = scriptStr.replace(/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g, '')
// 移除所有的单行注释
const noComments = noMultilineComments.replace(/\/\/.*/g, '')
// 匹配所有.vue的引入
const importRegex = /import\s+\w+\s+from\s+['"][^'"]+\.vue['"]/g
const importMatches = noComments.match(importRegex)
if (importMatches) {
vueImports.push(...importMatches)
}
});
}
const components: string[] = []
vueImports.forEach(comp => {
const path = comp.match(/from (['"])(.+)['"]/)
if (path) {
components.push(path[2])
}
})
return components
}
function getAbsFilePath(comp: string, id: string, alias: Alias[]): string {
// 如果是绝对路径直接返回
if (path.isAbsolute(comp)) {
return comp
}
// 判断有没有别名,如果有的话就用别名路径
const al = alias.find((al) => {
if (typeof al.find === 'string') {
return comp.indexOf(al.find) === 0
}
// 如果为正则直接返回false
return false
})
if (al) {
return comp.replace(al.find, al.replacement)
} else {
// 如果是相对路径则通过path来resolve
const parentDir = path.dirname(id)
return path.resolve(parentDir, comp)
}
}
function getStyleAll(str: string): string[] {
const regex = /<style[^>]*>([^<]+)<\/style>/g
let match
const styles = []
while ((match = regex.exec(str)) !== null) {
let m = match[0]
// 将style标签中的scoped去除,不然会触发data-v-xxx选择器
// 而自定义元素中这个属性设置是有问题的,会导致属性选择器无法选中
m = m.replace(/(<style[^>]*?)\bscoped\b([^>]*?>)/g, '$1$2')
styles.push(m)
}
return styles
}
这样通过一系列的字符串操作实现了插件自动将样式字符串拼接到原本的组件后面,使用的方式也很简单:
js
import componentInlineCssLoader from './plugins/componentInlineCssLoader'
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment'
},
plugins: [
vue(),
vueJsx(),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false, // css in js
}),
],
}),
// antdvCssLoader(),
componentInlineCssLoader(),
],
})
做完这一切后保存刷新页面,再新建tabs,我们可以看到:
我们已经成功的将CustomSwiper.vue 组件中的样式放到CustomTabs.ce.vue自定义元素中去了。
三、总结
这篇文章主要讲解了如何通过vite插件来实现自动将被引入的组件样式打包到自定义元素中;我的实现方式比较的粗犷,单纯的使用正则表达式来匹配,可能有很多边界条件没有判断到,更为细致的做法肯定是通过ast语法树来进行判断,不过我的办法也能够解决80%遇到的组件引入导致的样式丢失问题。
下一篇文章我将对自定义元素引用组件库的组件引发的样式问题提出探索性的解决方案,以antdv为例,并谈谈为什么组件库为什么不愿意给webcomponent做样式适配的一些个人见解。