自定义标签
自定义标签通过扩展一个HTMLElement或HTMLElement的子类来定义一个新的html标签,是通过原生js实现的组件化。
自定义标签通过window.customElements.define来定义,
- 第一个参数是标签的名字,必须带有一个短横线
-且全是小写字母 - 第二个参数是标签的构造函数,就是上面提到的继承自
HTMLElement的类 - 第三个参数接收一个对象,当前只有一个
extends属性可以配置,如果构造函数是继承自HTMLElement的子类,如HTMLDivElement就需要指定extends:"div"
在定义好自定义元素后就可以直接在html中使用自定义的元素了,如果自定义元素继承自其它元素,需要使用原来的标签加上is属性指定自定义标签的名字
<!-- 继承自HTMLElement -->
<ce-myelement></ce-myelement>
<!-- 继承自p标签 -->
<p is="ce-my-p-element"></p>
下面是一个简单例子,点击元素后这个元素会打印出自己
class CopyCode extends HTMLElement {
constructor() {
super();
this.onclick = (e) => {
if (e.target != this) return;
console.log(e.target);
};
}
}
window.customElements.define("ce-myelement", CopyCode);
影子DOM
创建
前面的自定义标签只是定义了自己的一些特别的通用方法,也能插入子元素,已经拥有了组件化的方法,但和复杂的组件相比是完全不够用的,它应该配合另一个特性Shadow DOM一起使用
Shadow DOM能封闭内部,让js和css都无法选择到内部元素(只是无法选择,还是会显示到页面上),里面可以定义<stype>标签且只会影响到内部样式
通过下面方法就能将一个普通元素接管为影子DOM
const innerNode = document.createElement("p");
innerNode.innerText = "inner";
const div = document.querySelector("div");
const shadow = div.attachShadow({ mode: "closed" });
shadow.appendChild(div.appendChild(innerNode));
将一个元素设置为shadow DOM后,它的所有子元素都会被页面隐藏,shadow DOM中的元素会出现在屏幕上
通过原来的元素的shadowRoot属性能获得其中的影子DOM,如果创建时mode属性为closed则不能获得影子DOM,这意味着这个元素是完全封闭的,外部无法更改它
const shadow = div.attachShadow({ mode: "closed" });
console.log(div.shadowRoot); // null
const shadow = div.attachShadow({ mode: "open" });
div.shadowRoot == shadow; // true
添加样式
通过上面方法已经给div创建了shadowDOM,现在就能向其中添加元素和样式了,样式和普通的页面一样创建
- 通过创建
<style>标签使用innerText手动写 - 通过
css的import url()方法引入外部样式 - 通过
<link>标签引入外部样式
插槽
通过影子dom接管了普通元素的内部内容,元素中原来的内容都会被隐藏起来,这时可以通过插槽元素<slot>来将外部元素引入影子dom,让它在适当的地方显示出来
一个简单的例子,让div中的文字换成红色的h1大小的文字
const div = document.querySelector("div");
const shadow = div.attachShadow({ mode: "open" });
const h1 = document.createElement("h1");
h1.style.color = "red";
h1.appendChild(slot);
shadow.appendChild(h1);
插槽也支持命名插槽,通过在<slot>上定义name属性指定名字,在普通元素上使用slot属性指定同名的插槽,就会把普通元素替换到影子中,同时<slot>中也可以放入默认的元素
const div = document.querySelector("div");
const shadow = div.attachShadow({ mode: "open" });
const slot = document.createElement("slot");
slot.setAttribute("name", "h1");
const h1 = document.createElement("h1");
h1.style.color = "red";
h1.appendChild(slot);
shadow.appendChild(h1); // 在影子中加入一个含插槽的元素
const text = document.createElement("div");
text.innerText = "h1";
text.setAttribute("slot", "h1");
div.appendChild(text); // 将指定了插槽的元素放入原来的元素中
模板
上面例子中一直使用代码构建dom树,其实可以使用<templates>标签来构造模板,和普通标签不同,<templates>标签中的内容不会显示到页面上,同时也和影子DOM一样有css的作用域
将上面的代码改写成模板的形式:
<div>aaa</div>
<template id="text">
<b slot="h1">text</b>
</template>
<template id="temp">
<style>
h1 {
color: red;
}
</style>
<h1>
<slot name="h1"></slot>
</h1>
</template>
<script src="./index.js" type="module"></script>
const div = document.querySelector("div");
const shadow = div.attachShadow({ mode: "open" });
shadow.appendChild(document.querySelector("#temp").content.cloneNode(true));
const text = document.querySelector("#text").content;
div.appendChild(text);
组件
这样,结合上面的自定义标签,就可以制作一个组件了
<body>
<ce-red-h1 data-text="abc">
<b slot="h1">b</b>
</ce-red-h1>
<template id="temp">
<style>
h1 {
color: red;
}
</style>
<h1>
<slot name="h1"></slot>
</h1>
</template>
<script src="./index.js" type="module"></script>
</body>
class RedH1 extends HTMLElement {
text;
constructor() {
super();
const template = document.querySelector("#temp");
this.attachShadow({ mode: "open" }).appendChild(template.content.cloneNode(true));
this.text = this.dataset.text;
const p = document.createElement("p");
p.innerText = this.text;
this.shadowRoot.appendChild(p);
}
}
window.customElements.define("ce-red-h1", RedH1);
虽然自定义标签也能通过
data-来传递数据,但只能是字符串
Vue中使用
vue中提供了一个defineCustomElement来创建一个自定义标签的构造函数,它接收defineComponent相同的参数,返回的类需要使用window.customElements.define来注册,因为是使用原生的方法注册,这样的组件不需要挂载为全局组件就能全局使用,通过vue模板来创建的自定义标签能支持传递对象等复杂数据
在vue中使用自定义标签得先配置loader,否则会有警告提示标签不是vue组件
// vite
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith("ce-"), // 自定义标签开头用`ce-`这样就不会抛错
},
},
})
// vuecli
module.exports = {
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => ({
...options,
compilerOptions: {
// 将所有以 ion- 开头的标签作为自定义元素处理
isCustomElement: tag => tag.startsWith('ion-')
}
}))
}
}
为了防止打包时将样式单独打包到外部,需要将vue文件后缀名改为.ce.vue
通过单文件组件定义的内容全都放入了自定义元素的影子DOM中
<template>
<h1 class="redH1">
<slot name="h1"></slot>
</h1>
<p>{{ text.value }}</p>
</template>
<script setup lang="ts">
interface Props {
text: {
value?: string;
};
}
withDefaults(defineProps<Props>(), {
text: () => ({
value: "a",
}),
});
</script>
<style>
// 因为不会被打包到外部且样子只会应用于影子中,所以不用加scoped
h1 {
color: red;
}
</style>
// index.ts
import { defineCustomElement } from "vue";
import RedH1 from "./RedH1.ce.vue";
window.customElements.define("ce-red-h1", defineCustomElement(RedH1));
然后在main中通过副作用引入index就能在全局使用了
<template>
<ce-red-h1 .text="text">
<b slot="h1">b</b>
</ce-red-h1>
</template>
<script setup lang="ts">
import { reactive } from "@vue/reactivity";
const text = reactive({
value: "abc",
});
</script>
注意,如果是传递对象,数组等数据,不是使用
v-bind:text,而是v-bind:text.prop,简写.
使用单文件时会打包更多的代码进去,如果只是使用简单的功能组件更推荐使用原生写法
使用场景
如果需要扩展从外部获取的html并添加比较复杂的功能,自定义标签就是个很好的选择,比如我的博客的文章通过markdown解析为html,只需要在解析出的html文本的代码片段的右上角的复制按钮就是一个自定义标签,通过自定义点击事件直接将父元素中的innerText复制进剪贴板,就不用像思否的粘贴按钮一样单独设置每个代码片段的粘贴内容