前言:这周完成了两场技术分享会,下周还有一场,就完成了这阶段的一个重大任务。分享会是关于 TS 的,我这两场分享会的主题分别是:
- TS 初级入门
- TS 高级语法
下周的主题是,如何在 React 中优雅的书写 TS。
我对技术也是有那么点喜欢,所以我平时喜欢学习些新技术,但是同时我认为最好的学习,应该是来自于实践,所以除了大量做项目,对刚学的技术最好的帮助就是分享,把别人给教会,而这也是一种能力的体现。所以我对分享并不是很排斥,反而有种强烈的喜欢。
而且分享还能打破封闭,对个人能力有很大的加成作用,不过难就难在跨出第一步,我刚开始分享也是有点慌,但等到第二场就开始驾轻就熟了,真的鼓励大家要不断的去尝试,不要重复自己,要敢于突破自己。
Web Components 这个技术是我在 「TS 高级语法」主题分享前给团队小伙伴的一个开胃小菜。
以下正文:
前端组件化
无论你用什么流行框架去写前端,本质上你都是在使用前端三剑客即: HTML、CSS 和 JavaScript。那这三剑客在自己的领域组件化/模块化
做的怎么样了呢?
- 对于 CSS,我们有
@impot
。 - 对于 JS 现在也有模块化方案。
那么对于 HTML 呢?我们知道样式和脚本都是集成到 HTML 中,所以所以单独的去做 HTML 模块化,没有任何意义。
既然如此,我们看看 HTML 在编程过程中遇到了什么问题。
- 因为 CSS 样式作用在全局,就会造成样式覆盖。
- 因为在页面中只有一个 DOM,任何地方都可以直接读取和修改 DOM。
可以看到我们的痛点就是解决 CSS 和 DOM 这两个阻碍组件化的因素,于是 Web Components 孕育而生。
Web Components
Web Components 由三项主要技术组成:
Web Components 整体知识点不多,内容也不复杂,我认为核心就是 Shadow DOM(影子 DOM),为什么我这么认为呢?看下 Shadow DOM 的作用你就明白了:
- 影子 DOM 中的元素对于整个网页是不可见的;
- 影子 DOM 的 CSS 不会影响到整个网页的 CSSOM,影子 DOM 内部的 CSS 只对内部的元素起作用。
看完,你发没发现它刚好解决了,我们开头前端组件遇到的问题,所以 Shadow DOM 才是 Web Components 的核心。
自定义元素(Custom elements)
如何自定义元素或叫如何自定义标签
自定义元素就像 Vue 和 React 中的类组件,首先我们需要使用 ES2015 语法来定义一个类,接着,使用浏览器原生的 customElements.define() 方法,告诉浏览器我要注册一个元素/标签 user-text
,(自定义元素的名称必须包含连词线,用与区别原生的 HTML 元素,就像 React 的自定义组件名使用时必须大写一样)。
class UserText extends HTMLElement {
constructor() {
super();
}
}
上面代码中,UserText 是自定义元素的类,这个类继承了 HTMLElement 父类。
我们现在把 user-text
作为标签使用,放到页面上去:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<user-text></user-text>
<script>
class UserText extends HTMLElement {
constructor () {
super();
this.innerHTML = "我是内容";
}
}
globalThis.customElements.define("user-text", UserText);
</script>
</body>
</html>
我们看到页面成功渲染:
组件会有生命周期,所以这个类还有些方法:
- connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用,俗称组件上树。
- disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用,俗称组件下树或组件消亡。
- adoptedCallback:当 custom element 被移动到新的文档时,被调用,这个 API 常和 document.adoptNode 配合使用。
- attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用,俗称组件更新。
模板 (Templates)
页面上的元素最终是要给用户呈现内容,在自定义组件里,我们通过字符串的方式来接受要展现给用户的内容,这种方式非常不利于组织我们的 HTML,我们需要一个写 HTML 的地方,这个技术就是模板 (Templates),非常像 Vue 的模版渲染,如果你熟悉 Vue ,完全可以无障碍切换。
我们随便来弄点数据组织下代码,在浏览器展示给用户:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<template id="user-text-template">
你好,我是模版!
</template>
<user-text></user-text>
<script>
class UserText extends HTMLElement {
constructor () {
super();
}
connectedCallback () {
const oldNode = document.getElementById("user-text-template").content;
const newNode = oldNode.cloneNode(true);
this.appendChild(newNode);
}
}
globalThis.customElements.define("user-text", UserText);
</script>
</body>
</html>
我们看到页面成功渲染:
如果,自定义元素需要动态传值给我们的自定义组件,可以使用插槽 slot,语法基本同 Vue,但是此时还无法演示,因为 slot 标签对标准的 DOM(更专业点叫 light DOM)无效,只对 shadow DOM 是有效的,看下使用示例。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<template id="user-text-template">
<style>
p {
color: red;
}
</style>
<p id="templateDOM">你好,我是模版!</p>
<p><slot>因为我是无效的,我也会默认展示</slot></p>
</template>
<user-text>
<p>light DOM 环境下,slot 标签没用</p>
</user-text>
<script>
class UserText extends HTMLElement {
constructor () {
super();
}
connectedCallback () {
const oldNode = document.getElementById("user-text-template").content;
const newNode = oldNode.cloneNode(true);
this.appendChild(newNode);
}
}
globalThis.customElements.define("user-text", UserText);
console.log(document.getElementById("templateDOM"));
</script>
</body>
</html>
看下页面加载显示:
除了,slot 无法使用,我们还观察到 template 元素及其内容不会在 DOM 中呈现,必须通过 JS 的方式去访问、style 标签内的样式是作用到全局的、template 里面的 DOM 也可以被全局访问。
影子 DOM(shadow DOM)
影子 DOM 是 Web Components 核心中的核心,可以一举解决我们前面提到的,CSS 和 DOM 作用全局的问题。
看下使用示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<template id="user-text-template">
<style>
p {
color: red;
}
</style>
<p id="templateDOM">你好,我是模版!</p>
<p><slot>因为我是无效的,我也会默认展示</slot></p>
</template>
<user-text>
<p>light DOM 环境下,slot 标签没用</p>
</user-text>
<p>测试 shadow DOM 样式不作用全局</p>
<script>
class UserText extends HTMLElement {
constructor () {
super();
}
connectedCallback () {
this.attachShadow({ mode: "open" });
const oldNode = document.getElementById("user-text-template").content;
const newNode = oldNode.cloneNode(true);
this.shadowRoot.appendChild(newNode);
}
}
globalThis.customElements.define("user-text", UserText);
console.log(document.getElementById("templateDOM"));
</script>
</body>
</html>
现在完成了,组件的样式应该与代码封装在一起,只对自定义元素生效,不影响外部的全局样式、DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部,同时 slot 也生效了,看下页面加载显示:
影子 DOM 的 mode 参数除了有 open,之外还有 closed,两者的区别在于此影子 DOM 是否能被访问外界访问,即是否能通过 JS 获取影子 DOM 读取 影子 DOM 里面的内容。
style 穿越 影子 DOM
任何项目为了统一风格,肯定需要有公共样式,而且为了方面是统一引入的,这就涉及到外部样式影响到内部样式,那怎么突破影子 DOM 呢?
CSS 变量
可以使用 CSS 变量来穿透 DOM:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS 变量样式穿透</title>
<style>
[type="primary"] {
--ui-button-border: 1px solid transparent;
--ui-button-background: deepskyblue;
--ui-button-color: #fff;
}
</style>
</head>
<body>
<template id="ui-button-template">
<style>
button {
cursor: pointer;
padding: 9px 1em;
border: var(--ui-button-border, 1px solid #ccc);
border-radius: var(--ui-button-radius, 4px);
background-color: var(--ui-button-background, #fff);
color: var(--ui-button-color, #333);
}
</style>
<button ><slot></slot></button>
</template>
<ui-button type="primary">按钮</ui-button>
<script>
class UiButton extends HTMLElement {
constructor () {
super();
}
connectedCallback () {
this.attachShadow( { mode: "open" });
const oldNode = document.getElementById("ui-button-template").content;
const newNode = oldNode.cloneNode(true);
this.shadowRoot.appendChild(newNode);
}
}
globalThis.customElements.define("ui-button", UiButton);
</script>
</body>
</html>
页面展示效果图:
::part 伪元素
::part 伪元素的用法有点像具名插槽 slot。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>::part 样式穿透</title>
<style>
[type="primary"]::part(button) {
cursor: pointer;
padding: 9px 1em;
border: 1px solid #ccc;
border-radius: 4px;
background-color: skyblue;;
color: #987;
}
</style>
</head>
<body>
<template id="ui-button-template">
<button part="button"><slot></slot></button>
</template>
<ui-button type="primary">按钮</ui-button>
<script>
class UiButton extends HTMLElement {
constructor () {
super();
}
connectedCallback () {
this.attachShadow( { mode: "open" });
const oldNode = document.getElementById("ui-button-template").content;
const newNode = oldNode.cloneNode(true);
this.shadowRoot.appendChild(newNode);
}
}
globalThis.customElements.define("ui-button", UiButton);
</script>
</body>
</html>
HTML 原生组件支持 Web Components
我们知道 HTML5 有很多的原生组件,例如:input,video,textarea,select,audio
等。
如果你审查元素会发现,这个组件并不是纯正的原生组件,而是基于 Web Components 来封装的。
如果你审查元素没有显示影子 DOM,请打开控制台,同时检查浏览器设置 Settings -> Preferences -> Elements
中把 Show user agent shadow DOM
打上勾。
落地应用有哪些?
首先,github 网址是完全基于 Web Components 来开发的,其次 Vue 和 小程序 也是基于 Web Components 来做组件化的,而且 Web Components 作为最底层的技术完全可配合 Vue 和 React 等框架,直接使用的。
光学不练那不是假把式吗,我来给大家整个 demo,自定义一个对话框,这个对话框只满足最基本的使用需求,先看下最终的成品。
源代码,可能比较难得两个思路:
- 数据更新,采用的是类的 get 和 set
- 关闭的回调事件,用的是自定义事件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自定义弹框</title>
</head>
<body>
<style>
.open-button {
cursor: pointer;
padding: 9px 1em;
border: 1px solid transparent;
border-radius: 4px;
background-color: deepskyblue;
color: #fff;
}
ul > li {
margin: 20px;
}
</style>
<section>
<ul>
<li><button id="launch-dialog-one" class="open-button">open-one</button>
<li><button id="launch-dialog-two" class="open-button">open-two</button></li>
<li><button id="launch-dialog-three" class="open-button">open-three</button></li></li>
</ul>
</section>
<shanshu-dialog title="title-one" id="shanshu-dialog-one">
<span slot="my-text">Let's have some different text!</span>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
</shanshu-dialog>
<shanshu-dialog title="title-two" id="shanshu-dialog-two">
<span slot="my-text">Let's have some different text!</span>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
</shanshu-dialog>
<shanshu-dialog title="title-three" id="shanshu-dialog-three">
<span slot="my-text">Let's have some different text!</span>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
<p>Some contents Some contents......</p>
</shanshu-dialog>
<template id="shanshu-dialog-template">
<style>
.wrapper {
opacity: 0;
transition: visibility 0s, opacity 0.25s ease-in;
}
.wrapper:not(.open) {
visibility: hidden;
}
.wrapper.open {
align-items: center;
display: flex;
justify-content: center;
height: 100vh;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 1;
visibility: visible;
}
.overlay {
background: rgba(0, 0, 0, 0.3);
height: 100%;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
}
.dialog {
background: #ffffff;
max-width: 600px;
min-width: 400px;
text-align: center;
padding: 1rem;
position: fixed;
border-radius: 4px;
}
button {
all: unset;
cursor: pointer;
font-size: 1.25rem;
position: absolute;
top: 1rem;
right: 1rem;
}
button:focus {
border: 1px solid skyblue;
}
h1 {
color: #4c5161;
}
.content {
color: #34495e;
position: relative;
}
.btn {
background: none;
outline: 0;
border: 0;
position: absolute;
right: 1em;
top: 1em;
width: 20px;
height: 20px;
padding: 0;
user-select: none;
cursor: unset;
}
.btn::before {
content: "";
display: block;
border: 1px solid green;
height: 20px;
width: 0;
border-radius: 2px;
/*transition: .1s;*/
transform: translate(9px) rotate(45deg);
background: #fff;
}
.btn::after {
content: "";
display: block;
border: 1px solid green;
height: 20px;
border-radius: 2px;
width: 0;
/*transition: .1s;*/
transform: translate(9px, -100%) rotate(-45deg);
background: #fff;
}
</style>
<div class="wrapper">
<div class="overlay"></div>
<div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
<button aria-label="Close" class="btn"></button>
<h1 id="title">Hello world</h1>
<div id="content" class="content">
<slot></slot>
<slot name="my-text"></slot>
</div>
</div>
</div>
</template>
<script type="text/javascript">
"use strict";
class ShanshuDialog extends HTMLElement {
static get observedAttributes() {
return ["open"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.close = this.close.bind(this);
}
connectedCallback() {
const { shadowRoot } = this;
const templateElem = document.getElementById("shanshu-dialog-template");
const oldNode = templateElem.content;
// const newNode = oldNode.cloneNode(true);
const newNode = document.importNode(oldNode, true);
shadowRoot.appendChild(newNode);
shadowRoot.getElementById("title").innerHTML = this.title;
shadowRoot.querySelector("button").addEventListener("click", this.close);
shadowRoot.querySelector(".overlay").addEventListener("click", this.close);
}
disconnectedCallback() {
this.shadowRoot.querySelector("button").removeEventListener("click", this.close);
this.shadowRoot.querySelector(".overlay").removeEventListener("click", this.close);
}
get open() {
return this.hasAttribute("open");
}
set open(isOpen) {
console.log("isOpen", isOpen);
const { shadowRoot } = this;
shadowRoot.querySelector(".wrapper").classList.toggle("open", isOpen);
shadowRoot.querySelector(".wrapper").setAttribute("aria-hidden", !isOpen);
if (isOpen) {
this._wasFocused = document.activeElement;
this.setAttribute("open", false);
this.focus();
shadowRoot.querySelector("button").focus();
} else {
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
this.removeAttribute("open");
this.close();
}
}
close() {
this.open !== false && (this.open = false);
const closeEvent = new CustomEvent("dialog-closed");
this.dispatchEvent(closeEvent);
}
}
customElements.define("shanshu-dialog", ShanshuDialog);
const buttonOneDOM = document.getElementById("launch-dialog-one");
const buttonTwoDOM = document.getElementById("launch-dialog-two");
const buttonThreeDOM = document.getElementById("launch-dialog-three");
const shanshuDialogOne = document.querySelector("#shanshu-dialog-one");
buttonOneDOM.addEventListener("click", () => {
document.querySelector("#shanshu-dialog-one").open = true;
});
shanshuDialogOne.addEventListener("dialog-closed", () => {
alert("对话框关闭回调函数");
});
buttonTwoDOM.addEventListener("click", () => {
document.querySelector("#shanshu-dialog-two").open = true;
});
buttonThreeDOM.addEventListener("click", () => {
document.querySelector("#shanshu-dialog-three").open = true;
});
</script>
</body>
</html>
组件库
当我们谈到在项目中如何应用,我们首先需要两个东西,选个 UI 组件库,同时有比较好的工具来操作这个 UI 库,我提供两个给你参考。