可能有些同学没有看入门篇,由于莫名的缘分, 看到了这一篇。 所以先复习一些必要的前置知识点。防止懵圈。
shadow dom
复习一下, shadow dom. 我们可以通过shadow dom api来编写这些复杂的样式不被页面原有的样式所影响。下面是一个例子:
<style>
h1 {
color: teal;
}
</style>
<h1>
I am a teal-blue 🐳.
</h1>
<my-element>
#shadow-root
<style>
h1 {color: red;}
</style>
<h1>I am a golden 🌻</h1>
</my-element>
内部的h1标签不会被全局的h1样式影响。因为shadow dom 是独立于页面dom渲染的。所以外部的样式不会影响到shadow dom 内部的元素, 同时, shadow dom 内部的样式也不会对全局的元素有影响。但是实测, 字体类型啊这种是会作用到shadow dom的。所以这里可以总结一下shadow dom的优点:、
- 自定义组件样式私有化, 也就是保护内部元素不被外部样式入侵
- 可以同时保证我们新增的组件和原有的组件互不干扰
- 我们能在shadow dom使用与现有的html元素相同的属性(就是提供了对应的js接口, 可以直接操作对应的属性)
slot
我们可以通过插槽让我们的组件更加灵活, 先看个例子:
<my-element>
#shadow-root
<div>shadow-dom</div>
<slot></slot>
<slot name="last"></slot>
</my-element>
// 使用
<my-element>
default slot
<slot>last slot</slot>
</my-element>
插槽可以分命名插槽和默认插槽。当然slot给我们带来了众多的便利, 但也给我们带来了一些问题。插槽的设计可能会影响组件显示的设计规范。 因为有了你自定义的slot可能会和组件本身不是那么搭配, 我们对用户给的slot失去了控制权。关于这一点, 有个解决方案是使用::slotted(*) 伪类,去设置属性, 然后用css选择器只设置我们想要的slotted属性。
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
width: 36px;
height: 36px;
}
实际使用场景
在入门篇和进阶篇我们看到了。web component本身是一组较为低级的api. 如果我们要正常的进行业务开发。肯定不会直接去使用这些底层的api. 必定要做一些底层api的封装。在chromeDevSummit上就推荐了一个帮我们完成了底层封装的lib ---- lit-element
基本的使用如下:
我们可以使用lit-element封装好的各种api进行开发。最开始是一个css这个写法类似于styled-components这些对我们来说并不陌生。其次是定义properties这里让我比较神奇的是, 定义了这个方法后, 数据就可以响应了。说人话就是我们常说的数据双向绑定。接下来就是类似vue这种的模版语法, 去监听事件, 触发更新了。(这个只是一个例子, 后面还有讲解, 不要担心, 可以先往后看)
import { LitElement, html, css } from "lit-element";
export class HelloWorld extends LitElement {
static styles = css`
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
width: 36px;
height: 36px;
}
`;
static get properties() {
return {
label: { type: String },
value: { type: String },
};
}
handleChange(e) {
this.value = e.target.value;
this.dispatchEvent(
new CustomEvent("change", { bubbles: true, composed: true })
);
}
render() {
return html`
<label id="label">${this.value}</label>
<input
aria-labelledby="label"
value=${this.value}
@input=${this.handleChange}
/>
<span class="icon">
<slot name="icon"></slot>
</span>
`;
}
}
customElements.define("hello-world", HelloWorld);
结合css变量最佳实践
自定义变量
虽然大家一定比较熟悉了, 但是我还是科普一下定义: css自定义变量就是我们可以定义不同的css值然后给一个语意化的名字, 在我们写css规则的时候可以去使用。减少重复代码, 同时便于修改, 同时我们还可以在特定的规则中去覆盖变量的值, 达到自定义的效果。所以css自定义变量特别的时候用作设计token(就是设计师给出的一些样式的值, 例如: 字体大小, 长宽,间距) 下面是一个例子:
html {
--flower: goldenrod;
--large: 24rem;
}
h1 {
color: var(--flower);
font-size: var(--large, 8rem);
}
h1.rose {
--flower: red; // 还能这样?这个我是真的没想到
}
::part()选择器
使用part选择器, 在自定义组件设置了part的值以后, 外部就有机会通过part伪类选择器去修改内部样式。但这些改动都是主观的。作为组件的设计者, 完全可以不让用户修改, 不设置part值即可。下面是个例子:
<hello-world>
#shadow-root
<div part="hawei_label">
hawei
</div>
</hello-world>
<style>
hello-world::part(hawei_label) {
color: red;
}
</style>
当然, part其实也不是完全可控的。可能会影响到组件内部其他part名为“hawei_label”的元素。我认为, 滥用part很可能会导致你回到之前css混乱的状态。例如: 提供part初衷是想让用户修改字体的大小, 但是用户很可能会用于其他目的。其实也有解决方案: 从外部, 基于custom element设置自定义变量。
<hello-world>
#shadow-root
<header>
hawei
</header>
</hello-world>
<style>
hello-world {
--hw-header-font-size: 24px;
}
</style>
所以我们拥有两种方式从外部样式影响组件内部的样式。
- css自定义变量
- part选择器
区别在于, 如果使用了part那么就要小心的去做修改,做新增,需要更多的精力去维护, 而使用css自定义变量更为便捷和可控。接下来是一个例子:
import { LitElement, html, css } from "lit-element";
export class HelloWorld extends LitElement {
static styles = css`
label,
input {
color: var(--hw-primary, var(--hw-amber700, #d53600));
font-family: var(--hw-input-font-family, 'Boogaloo');
}
label {
font-size: var(--hw-input-label-font-size: 16px);
}
input {
font-size: var(--hw-input-label-font-size: 24px);
}
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
width: 36px;
height: 36px;
}
`;
}
render() {
return html`
<label id="label">label</label>
<input />
<span class="icon">
<slot name="icon"></slot>
</span>
`;
}
}
customElements.define("hello-world", HelloWorld);
我们可以通过, 自定义变量的默认值属性来设置默认值, 如果想让用户自定义样式,我们就定义不同的css自定义变量, 并且赋予默认值, 优先使用用户自定义的值。
css自定义变量与适配主题
我们可以使用媒体查询的 prefers-color-scheme属性来实现。以下是例子:
@media (perfers-color-scheme: dark) {
:host {
--hw-primary: var(--hw-amber200, #ff8503);
}
}
但是css自定义变量也存在一些问题:
- 没有类型安全检查, 意味着用户可以输入任何值, 尽管可能对该属性不起作用
- 很判断这些额外的css是否有效, 确保被正确的使用到默认值(比如: color: 'hawei';就是一个无效的规则, 应该使用默认值, 而不是就是使用 “hawei” )
所幸, 还是有解决方案的:registered properties, 效果类似于你给自定义变量设置了一些属性,
- Syntax: 如果不符合对应的语法, 则会选择默认值. 注意, 如果不设置这个属性,就会出现上面说的第二点, 因为自定义的值是无效的, 所以导致了浏览器根本不会去设置这个属性。
- Inherits: 字面意思, 就是是否能被继承, 像公共的属性, 例如字体类型这些就应该被继承, 其他没有必要继承的属性应该设置为false, 同时可以减少掉浏览器这部分的计算
- initialValue: 额 就字面意思, 你可以给一个初始值
<style>
@property --design-token {
syntax: '<color>';
inherits: true;
initial-value: goldenrod;
}
</style>
<script>
window.CSS.registerProperty({
name: '--design-token',
syntax: '<color>', // <link>, <number>, <image>
inherits: true,
initialValue: 'goldrenrod',
});
</script>
基于上述registered properties来改造一下现有的组件:
我们可以提供一个单独的css文件, 方便的定义不同的token的属性。
<style>
@property --hw-amber700 {
syntax: '<color>';
inherits: false;
initial-value: #d53600;
}
@property --hw-amber200 {
syntax: '<color>';
inherits: false;
initial-value: #ff8503;
}
@property --hw-primary {
syntax: '<color>';
inherits: true;
}
:root {
--hw-primary: var(--hw-amber700);
}
@media (perfers-color-scheme: dark) {
:host {
--hw-primary: var(--hw-amber200, #ff8503);
}
}
</style>
<script>
import { LitElement, html, css } from "lit-element";
export class HelloWorld extends LitElement {
static styles = css`
label,
input {
color: var(--hw-primary, var(--hw-amber700, #d53600));
font-family: var(--hw-input-font-family, 'Boogaloo');
}
label {
font-size: var(--hw-input-label-font-size: 16px);
}
input {
font-size: var(--hw-input-label-font-size: 24px);
}
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
width: 36px;
height: 36px;
}
`;
}
render() {
return html`
<label id="label">label</label>
<input />
<span class="icon">
<slot name="icon"></slot>
</span>
`;
}
}
customElements.define("hello-world", HelloWorld);
</script>
实战: 实现chromedevsummit官方的例子
先来看一下设计稿:
结构很简单, 就是一个带icon的input组件. 要求icon可换, 用户可自定义一些关键的css属性: 例如字体颜色, 边框颜色等. 那个小尾巴我现在没有切图, 就不打算去实现了, 有精力的小伙伴可以实现一下。
1. html结构
import { LitElement, html, css } from "lit-element";
export class QInput extends LitElement {
render() {
return html`
<section id="container">
<label id="label">label</label>
<input aria-labelledby="label" />
<span class="icon">
<slot name="icon"></slot>
</span>
</section>
`;
}
}
customElements.define("q-input", QInput);
2. 加入简单的样式
import { LitElement, html, css } from "lit-element";
export class QInput extends LitElement {
static styles = css`
label,
input {
color: var(--hw-primary, var(--hw-amber700, #555));
font-family: var(--hw-input-font-family, "Staatliches", cursive);
letter-spacing: 2px;
}
#container {
border: 0.3em solid #555;
border-radius: 10px;
padding: 1em;
display: flex;
position: relative;
background-color: #fff;
flex-flow: column;
min-height: 3.75em;
}
#inputContainer {
display: flex;
flex: 1;
justify-content: center;
}
#inputContainer input {
border: 0.1em solid #555;
border-radius: 8px;
flex: 1;
font-size: 2em;
}
#inputContainer label {
font-size: 18px;
font-weight: bolder;
position: absolute;
top: -15px;
left: 1em;
background-color: #fff;
height: 20px;
padding: 0 5px;
}
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
flex-shrink: 0;
width: 36px;
height: 100%;
margin-left: 0.5em;
justify-content: center;
align-items: center;
}
`;
render() {
return html`
<section id="container">
<section id="inputContainer">
<label id="label">Label</label>
<input aria-labelledby="label" />
<span class="icon">
<slot name="icon"></slot>
</span>
</section>
</section>
`;
}
}
customElements.define("q-input", QInput);
然后现在长这个样子:
有一点点像了...然后我们继续。
3. 事件监听 && 数据绑定
我们主要解决两件事, 1. 数据绑定 2. 事件监听 2.
数据绑定方式:
Lit-element的文档还没细看, 但是看代码里的注释大概是这样子。
/**
* The main LitElement module, which defines the [[`LitElement`]] base class and
* related APIs.
*
* LitElement components can define a template and a set of observed
* properties. Changing an observed property triggers a re-render of the
* element.
*
* Import [[`LitElement`]] and [[`html`]] from this module to create a
* component:
*
* ```js
* import {LitElement, html} from 'lit-element';
*
* class MyElement extends LitElement {
*
* // Declare observed properties
* static get properties() {
* return {
* adjective: {}
* }
* }
*
* constructor() {
* this.adjective = 'awesome';
* }
*
* // Define the element's template
* render() {
* return html`<p>your ${adjective} template here</p>`;
* }
* }
**/
解读一下, 大概意思是, 只要设定了 properties 里边的字段都可以被监听, 继而在改变的时候触发重渲染。原理大概是跟proxy拦截, 记录依赖, 然后在每次触发set事件的时候, 重新执行一遍依赖函数。两个关键点:
- 使用static get properties()
- 在constuctor中定义你需要的字段(否则你的初始值会成为"undefine")
解决了数据响应的方式后,我们来看一下事件部分。
这部分从api看, lit-element有参照了vue的指令系统设计的感觉。api都感觉很像。
<input
.value=${this.value}
@input=${this.onChangeHandler}
@change=${this.onChangeHandler}
/>
好的, 把上面的知识应用到我们的组件中。
import { LitElement, html, css } from "lit-element";
export class QInput extends LitElement {
static styles = css`
label,
input {
color: var(--hw-primary, var(--hw-amber700, #555));
font-family: var(--hw-input-font-family, "Staatliches", cursive);
letter-spacing: 2px;
}
#container {
border: 0.3em solid #555;
border-radius: 10px;
padding: 1em;
display: flex;
position: relative;
background-color: #fff;
flex-flow: column;
min-height: 3.75em;
}
#inputContainer {
display: flex;
flex: 1;
justify-content: center;
}
#inputContainer input {
border: 0.1em solid #555;
border-radius: 8px;
flex: 1;
font-size: 2em;
}
#inputContainer label {
font-size: 18px;
font-weight: bolder;
position: absolute;
top: -15px;
left: 1em;
background-color: #fff;
height: 20px;
padding: 0 5px;
}
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
flex-shrink: 0;
width: 36px;
height: 100%;
margin-left: 0.5em;
justify-content: center;
align-items: center;
}
`;
constructor() {
super();
this.value = "";
this.label = "Label";
}
// 很关键, 会决定能否数据响应
static get properties() {
return {
value: { type: String },
label: { type: String },
};
}
onChangeHandler(e) {
this.value = e.target.value;
this.dispatchEvent(
new CustomEvent("change", { bubbles: true, composed: true })
);
}
render() {
return html`
<section id="container">
<section id="inputContainer">
<label id="label">${this.label}</label>
<input
.value=${this.value}
@input=${this.onChangeHandler}
/>
<span class="icon">
<slot name="icon"></slot>
</span>
</section>
<span>${this.value}</span>
</section>
`;
}
}
customElements.define("q-input", QInput);
这部分代码也很简单, 就是监听input事件, 然后重新设置value的值。同时在事件监听那部分触发一下change事件, 至于为什么后面再说。这部分我们完成了数据响应视图渲染。
4. 抽离css变量
这一步主要是在css变量那一节有说过, 我们可以通过暴露part和css变量赋予外部样式修改组件内部样式的机会。(当然, 通过传参数也是可以的)我选择的是使用抽离css变量, 提供给用户, 让用户改特定的css变量, 达到定制化的目的。目前作为演示, 只抽离了--hw-primary用户可以通过外部的css方便的修改主题色。
@property --hw-color-black {
syntax : '<color>';
inherits : false;
initial-value: #555;
}
@property --hw-color-light {
syntax : '<color>';
inherits : false;
initial-value: #ff8100;
}
@property --hw-primary {
syntax : '<color>';
inherits: true;
}
:root {
--hw-primary: var(--hw-color-black);
}
@media (perfers-color-scheme: dark) {
:host {
--hw-primary: var(--hw-color-light, #ff8503);
}
}
import { LitElement, html, css } from "lit-element";
import "./styles.css";
export class QInput extends LitElement {
static styles = css`
label,
input {
color: var(--hw-primary);
font-family: var(--hw-input-font-family, "Staatliches", cursive);
letter-spacing: 2px;
}
#container {
border: 0.3em solid var(--hw-primary);
border-radius: 10px;
padding: 1em;
display: flex;
position: relative;
background-color: #fff;
flex-flow: column;
min-height: 3.5em;
}
#inputContainer {
display: flex;
flex: 1;
justify-content: center;
}
#inputContainer input {
border: 0.1em solid var(--hw-primary);
border-radius: 8px;
flex: 1;
font-size: 2em;
}
#inputContainer label {
font-size: 18px;
font-weight: bolder;
position: absolute;
top: -15px;
left: 1em;
background-color: #fff;
height: 20px;
padding: 0 5px;
}
.icon {
border: 0.1em solid var(--hw-primary);
}
slot[name="icon"]::slotted(*) {
display: none;
}
slot[name="icon"]::slotted(div) {
display: inline-flex;
flex-shrink: 0;
width: 36px;
height: 100%;
margin-left: 0.5em;
justify-content: center;
align-items: center;
}
`;
constructor(value = "value", label = "label") {
super();
debugger;
this.value = value;
this.label = label;
}
// 很关键, 会决定能否数据响应
static get properties() {
return {
value: { type: String },
label: { type: String },
};
}
onChangeHandler(e) {
this.value = e.target.value;
this.dispatchEvent(
new CustomEvent("change", { bubbles: true, composed: true })
);
}
render() {
return html`
<section id="container">
<section id="inputContainer">
<label id="label">${this.label}</label>
<input
aria-labelledby="label"
value=${this.value}
@input=${this.onChangeHandler}
/>
<span class="icon">
<slot name="icon"></slot>
</span>
</section>
<span>${this.value}</span>
</section>
`;
}
}
customElements.define("q-input", QInput);
5. work with react
我们通过一些代码就可以把现有的web component用于react. 下面是一个例子。在这里,我们其实没有做什么大的改动。只是把className传递一下。(react中class是关键字), 同时,通过给自定义的web component设置回调形式的ref来进行change事件挂载和卸载, 同时吧event事件往内部传递(每次传入新的onChange就要先卸载老的onChange否则会重复调用)。
import React, { useCallback, useRef } from "react";
export function QInputReact(props) {
const { className, onChange, ...otherProps } = props;
const ref = useRef(null);
const callback = useCallback(
(element) => {
debugger;
if (ref.current && onChange) {
ref.current.removeEventListener("change", onChange);
}
if (element && onChange) {
element.addEventListener("change", onChange);
}
ref.current = element;
},
[onChange]
);
return <q-input class={className} ref={callback} {...otherProps} />;
}
// use case
import React, { useState } from "react";
import { QInputReact } from "./Qinput";
const App = () => {
const [value, setValue] = useState("biu");
const handleChange = (e) => setValue(e.target.value);
return (
<>
<QInputReact label="hawei111" value={value} onChange={handleChange} />
<span>{value}</span>
</>
);
};
export default App;
暂时告一段落, 段落太长我怕写了前面忘了后面。 其实web components现在较大的厂商都在支持。 但是, 我觉得现在还撼动不了我们现有的三大框架。我猜哦, 更可能的发展方式是大家都尝试着, 去使用这些新的特性, 把自己需要并且当前已经稳定的特性加入自己的技术栈。至少我觉得shadow-dom对于样式的隔离就是一个很棒的尝试。
另外说一下最近的感悟。不要把自己的角色限定得太死。你当前主要的角色是Frontend Engineer但是你还是有很多的方向可以去学习, 去探索。我隐约记得查理芒格有这么一个思想, 就是我们要获得成功, 获得快乐的生活, 那么我们需要了解很多的模型(至少10到20个之间), 这样我们看问题的较多就不会单一。