近期的工作用到了 Web Components 的能力,但在学习的过程中还是踩了不少的坑。但学完之后发现其和 Vue、React 有诸多相似之处,所以通过对比 React、Vue 与 Web Components 的相似点和不同点,希望大家在需要的时候能够快速掌握 Web Components 核心知识,减少学习的时间和少踩坑。
目录
核心概念
Web Components 给我们给提供了自定义 html 元素的能力,类似 Vue 或者 React 组件,但和他们不同的是,Web Components 定义的组件,就是普通的 html 元素,为什么这样说呢?我们且看下面一个小示例:
<head>
<style>
/* 3.设置样式 */
hello-world {
color: red;
font-size: 100px;
border: 1px solid #eee;
display: inline-block;
}
</style>
</head>
<body>
<!-- 2.使用组件 -->
<hello-world />
<script>
// 1.定义 Web Components 标签
customElements.define('hello-world', class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<div>hello world</div><div>你好,世界</div>`
}
})
// 4.定义事件监听
const helloWorld = document.querySelector('hello-world')
helloWorld.addEventListener('mouseover', (e) => {
console.log('hover', e)
})
</script>
</body>
</html>
复制代码
我们看到上述代码,无论是样式设置还是事件监听都和原生标签无异。Vue 或者 React 组件只是逻辑上的组合,并不是真正的 HTML 标签。
定义和注册
Web Components 的定义和 React class 模式的定义很类似,都是继承基类:
// react 组件定义方式
class HelloWorld extends React.Component {
constructor() {
super();
}
render() {
return <div>hello world</div>;
}
}
// Web Components 组件定义
class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<div>hello world</div>`;
}
}
复制代码
其注册方式也很简单,类似 Vue 的全局组件,注册后就可以任意处使用(没注册也可以先写上 html 标签):
// vue2 组件全局组件注册
Vue.component("hello-world", HelloWorld);
// Web Components 组件注册
customElements.define("hello-world", HelloWorld);
复制代码
这里需要注意的一点是,Web Components 不仅可以继承基础的 HTMLElement 还可以继承任意标签,例如:
<script>
// 1、继承 HTMLVideoElement
class VipVideo extends HTMLVideoElement {
constructor() {
super();
this.addEventListener("play", () => {
setTimeout(() => {
this.pause();
alert("请充值 SVIP 会员");
}, 3000);
});
}
}
// 2、这里需要 extends: video
customElements.define("vip-video", VipVideo, { extends: "video" });
</script>
<!-- 3、使用 is 表明其真实身份 -->
<video
is="vip-video"
autoplay="autoplay"
src="https://www.w3school.com.cn/i/movie.ogg"
controls="controls"
></video>
复制代码
继承其他标签从定义到注册到使用都和默认的方式稍有区别,要注意哦。
节点和模板
无论是 React 的 jsx 还是 Vue template 都是虚拟 DOM,但是 Web Components 则是真实 DOM,我们上面 HelloWorld 定义采用了最简单的 innerHTML 的方式,其还可以用原生的 DOM API 进行创建,例如:
class HelloWorld extends HTMLElement {
constructor() {
super();
// DOM API 创建 div 节点
const div = document.createElement("div");
div.textContent = "hello world";
// 创建 style 节点,添加内部的样式
const style = document.createElement("style");
style.textContent = "div { font-size: 30px; color: red; }";
// 添加到当前节点
this.appendChild(style);
this.appendChild(div);
}
}
复制代码
当然上面的定义方式在一些较大组件的定义时,显然会让人发疯,所以我们可以通过更为简单的模板克隆,可以先将结构定义好,然后再填充数据,和 Vue 的模板差不多,但是数据填充还是要通过 DOM API 进行操作:
<template id="user-info-template">
<style>
.container {
color: red;
font-size: 100px;
}
</style>
<div class="container">
<div>用户名:<span id="user-name"></span></div>
<div>年龄:<span id="user-age"></span></div>
</div>
</template>
<user-info></user-info>
<script>
class UserInfo extends HTMLElement {
constructor() {
super();
// 获取模板
const templateElem = document.getElementById("user-info-template");
// 深度克隆
const deepClonedElem = templateElem.content.cloneNode(true);
// 填充数据
deepClonedElem.querySelector("#user-name").textContent = "zhang";
deepClonedElem.querySelector("#user-age").textContent = 18;
// 添加节点
this.appendChild(deepClonedElem);
}
}
window.customElements.define("user-info", UserInfo);
</script>
复制代码
查询
Web Components 组件与 Vue 全局组件不同的是,组件一旦定义,是不能被覆盖的,如果强行重定义,是抛异常的,例如: 这里报错就是告知我们 user-info
组件已经被定义过了。
为了防止这种报错,我们就需要判断其是否被定义过了,API 为:
customElements.get("user-info");
复制代码
声明周期
无论是 Vue 还是 React 都是自己的生命周期钩子函数让我们做一些事情,例如组件节点渲染前获取数据,组件销毁前做一些清理定时任务或者事件等,同样的 Web Components 也考虑到了这类场景,提供了一下钩子函数:
- connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用,作用同 Vue3 的 onMounted 或者 React Class 模式的 componentDidMount、React Function 模式的 useEffect 初次调用。
- disconnectedCallback: 当自定义元素与文档 DOM 断开连接时被调用,作用同 **Vue3 的 onUnmounted **或者 React Class 的 componentWillUnmount、React Function 模式的 useEffect 返回函数。
- attributeChangedCallback: 当自定义元素增加、删除、修改自身属性时,被调用,类似 **Vue3 的 onUpdated **或者 **React Class ** 的 componentDidUpdate(props 属性小节会再提到)。
我们从上面看似乎 Web Components 组件提供的钩子相对于 Vue 或者 React 少很多,但其实其他的钩子都是可以模拟出来的,想要了解更多的参考github.com/yyx990803/v… 里面有关于使用 Web Components 模拟出 Vue3 生成周期方法。
props 属性
Web Components 属性与 Vue 或 React 属性有三点不同:
- 属性不能为引用类型,也就是不能是函数、对象;
- 响应式属性必须提前声明,否则无法触发 attributeChangedCallback;
- 如果有响应式属性,其先触发 n 次(n = 定义响应式属性个数)attributeChangedCallback,然后再调用 connectedCallback。
属性不能传引用类型 原因: 属性不能是引用类型比较好理解,我们没见过哪个 HTML 标签属性是传对象进去的,而自定义组件也是普通 HTML 标签,所以也遵循了这个基本原则。 解决方案:
- 明修栈道暗度陈仓:将传递的先保存到一个全局变量(或模块变量),然后返回一个字符串标识,在组件内部通过标识拿到真正的值。具体代码可以参考:magic-microservices;
- 发布订阅:既然限制这么厉害,我们干脆就不走它的属性传递方式,而是采用发布订阅模式在外部传递数据,内部接受数据;
响应式属性必须提前声明与触发时机演示
以下是具体代码:
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<input id="user-name" type="text" />
<input id="user-age" type="numer" />
<input id="user-gender" type="text" />
<user-info name="zhang" age="19" gender="man"></user-info>
<script>
class UserInfo extends HTMLElement {
// 1.定义了 2 个响应式属性
static get observedAttributes() {
return ["name", "age"];
}
constructor() {
super();
const content = `
<div>username: <span id='info-name'>${this.getAttribute(
"name"
)}</span></div>
<div>age: <span id='info-age'>${this.getAttribute("age")}</span></div>
<div>gender: <span id='info-gender'>${this.getAttribute(
"gender"
)}</span></div>
`;
this.innerHTML = content;
}
// 3.然后触发此钩子
connectedCallback() {
console.log("connectedCallback");
}
// 2.先触发 2 次
attributeChangedCallback(name, oldValue, newValue) {
console.count("attributeChangedCallback");
console.log(name, oldValue, newValue);
this.update(name, newValue);
}
update(name, value) {
const el = this.querySelector(`#info-${name}`);
el.textContent = value;
}
}
window.customElements.define("user-info", UserInfo);
</script>
<script>
const userInfo = document.querySelector("user-info");
document.querySelector("#user-name").addEventListener("input", (e) => {
userInfo.setAttribute("name", e.target.value);
});
document.querySelector("#user-age").addEventListener("input", (e) => {
userInfo.setAttribute("age", e.target.value);
});
// 4.就算更新属性,也不会触发 attributeChangedCallback
document.querySelector("#user-gender").addEventListener("input", (e) => {
userInfo.setAttribute("gender", e.target.value);
});
</script>
</body>
</html>
复制代码
当然想要实现任意属性的响应式只能采用发布订阅的模式,而不是走 Web Components 的属性体系。
CSS 和 Shadow DOM
首先需要澄清一个我自己一直以来的认知误区,既 Web Components 很安全,有沙箱功能,能隔离 JS。这句话里能隔离 JS 是不对的,Web Components 不对 JS 做隔离的,但能做到 HTML 和 CSS 的隔离。
默认情况:html、css 不做隔离
<body>
<div>out: hello world</div>
<style>
div {
color: red;
}
</style>
<hello-world></hello-world>
<script>
class HelloWorld extends HTMLElement {
constructor() {
super();
const content = `
<div>inner: hello world</div>
<style>
div {
font-size: 100px;
}
</style>
`;
this.innerHTML = content;
}
}
window.customElements.define("hello-world", HelloWorld);
</script>
<script>
const helloWorld = document.querySelector("hello-world");
console.log(helloWorld.innerHTML);
</script>
</body>
复制代码
Shadow DOM mode 为 open:样式隔离、DOM 隐藏
- this.innerHTML = content
+ const shadowDOM = this.attachShadow({ mode: 'open' }) // 开启 Shadow DOM
+ shadowDOM.innerHTML = content
复制代码
Shadow DOM mode 为 closed:样式隔离、DOM 隐藏
closed 时,不仅无法获取 HTML,连路径也无法获取,这里不做演示,感兴趣可以对比一下两者当点击时获取的路径 。
document.querySelector("html").addEventListener("click", (e) => {
console.log(e.composed);
console.log(e.composedPath());
});
复制代码
插槽
Web Components 的插槽功能和 Vue2.5 之前的可以说是一模一样了(当然 web compnents 是没有作用域插槽功能的),或者说 Vue2 当初设计时就参考了 Web Components 的规范。
// 1、vue 插槽定义
<template>
<div class="layout">
<!-- 具名插槽定义 & 支持插槽默认值 -->
<slot name="header"><h1>header(插槽默认内容)</h1></slot>
<!-- 默认插槽定义 -->
<slot></slot>
<!-- 具名插槽定义 -->
<slot name="footer">footer</slot>
</div>
</template>
// 2、使用组件和插槽
<template>
<base-layout>
<!-- 覆盖具名插槽 -->
<h2 slot="header">自定义 header</h2>
<!-- 覆盖默认插槽内容 -->
<div>覆盖默认插槽内容</div>
</base-layout>
</template>
复制代码
<!-- 1.模板定义插槽 -->
<template id="base-layout-temp">
<div class="layout">
<!-- 具名插槽定义 & 支持插槽默认值 -->
<slot name="header">
<h1>header(插槽默认内容)</h1>
</slot>
<!-- 默认插槽定义 -->
<slot></slot>
<!-- 具名插槽定义 -->
<slot name="footer">footer</slot>
</div>
</template>
<!-- 3.使用组件和插槽 -->
<base-layout>
<h2 slot="header">自定义 header</h2>
<div>覆盖默认插槽内容</div>
</base-layout>
<script>
// 2.注册组件
class BaseLayout extends HTMLElement {
constructor() {
super();
const temp = document.querySelector("#base-layout-temp");
// 必须是 Shadow DOM 模式 !!!
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(temp.content.cloneNode(true));
}
}
customElements.define("base-layout", BaseLayout);
</script>
复制代码
从上我们看到 Vue 在插槽的定义上确实和 Web Components 一模一样,在使用上也没啥差别,需要注意一个坑就是的是插槽只有在 Shadow DOM 模式下才生效。
事件和综合示例
Vue 通过 $emit
抛出事件,v-bind
接受事件;而 React 则是通过传递函数,组件内部调用。Web Components 更像是 Vue 的模式,简单而言就是内部可以抛出自定义事件,外部通过 addEventListener
进行监听。 这个自定义事件能力详见 MDN,我们结合上述所有章节给一个综合的示例:
<template id="fixed-overlay-temp">
<style>
.fixed-overlay-background {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
display: flex;
background: #00000088;
justify-content: center;
align-items: center;
}
.fixed-overlay {
position: relative;
z-index: 1000;
pointer-events: auto;
}
</style>
<div class="fixed-overlay-background">
<div class="fixed-overlay">
<slot></slot>
</div>
</div>
</template>
<div class="main">
<fixed-overlay visible="false">
<div
style="width: 200px; height: 200px; background: skyblue; display: flex;align-items: center;justify-content: center;"
>
你好,世界
</div>
</fixed-overlay>
<button id="toggle-btn">切换</button>
</div>
<script>
customElements.define(
"fixed-overlay",
class extends HTMLElement {
static get observedAttributes() {
return ["visible"];
}
constructor() {
super();
// 获取模板
const temp = document.querySelector("#fixed-overlay-temp");
const dom = temp.content.cloneNode(true);
// 默认隐藏
const overlay = dom.querySelector(".fixed-overlay-background");
overlay.style.display = "none";
// 添加 DOM
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(dom);
// 添加监听事件
this.startListen();
}
// 开始监听
startListen() {
// 点击背景,抛出自定义事件
const overlayBG = this.shadowRoot.querySelector(
".fixed-overlay-background"
);
overlayBG.addEventListener("click", () => {
this.emit("visible", false);
});
// 防止内部点击
const overlay = this.shadowRoot.querySelector(".fixed-overlay");
overlay.addEventListener("click", (e) => {
e.stopPropagation();
});
}
// 模仿 Vue emit(1、重点!!!)
emit(evetName, data) {
const event = new CustomEvent(evetName, { detail: data });
this.dispatchEvent(event);
}
// 属性变化回调
attributeChangedCallback(attrName, oldValue, newValue) {
// 实际上,监听的属性就这一个,可以不做这一步
if (attrName === "visible") {
this.toggleVisible(newValue);
}
}
// 切换显示
toggleVisible(visible) {
const overlay = this.shadowRoot.querySelector(
".fixed-overlay-background"
);
overlay.style.display = visible === "false" ? "none" : "flex";
}
}
);
const overlayInstance = document.querySelector("fixed-overlay");
// 切换显示
const toggleVisible = (visible) => {
overlayInstance.setAttribute("visible", visible);
};
// 监听自定义的 visible 事件(2、重点!!!)
overlayInstance.addEventListener("visible", (e) => {
toggleVisible(e.detail);
});
// 监听 btn 的切换事件
const btn = document.querySelector("#toggle-btn");
btn.addEventListener("click", () => {
const oldVisible = overlayInstance.getAttribute("visible");
const newVisible = oldVisible === "false" ? true : false;
toggleVisible(newVisible);
});
</script>
复制代码
生态和工具
- lit:目前最流行的、谷歌开源的,用于快速构建 Web Components 的库/工具
- omi:腾讯开源的,用于快速构建 Web Components 的库/工具
- webcomponentsjs:Web Components IE 兼容方案
- magic-microservices:字节跳动开源的,基于 Web Components 的轻量级微组件解决方案
- micro-app:京东零售团队开源的,基于 Web Components 的轻量、高效、功能强大的微前端解决方案
- omiu:基于 omi 开发的 Web Components 组件库
- @shoelace-style/shoelace:Web Components 开发的组件库
- LuLu UI:张鑫旭大佬的作品,Web Components 开发的组件库
学习资料
后续
本篇算是入门篇,后续如果有时间可以再搞两篇:
- Web Components 引用类型属性传递解决方案
- 100 行代码实现 magic-microservices
写作很辛苦,如果你有所收获,请帮忙点个赞,如果暂时没有应用场景,也可以先收藏。