Web Components进阶篇(一)

1,328 阅读5分钟

可能有些同学没有看入门篇,由于莫名的缘分, 看到了这一篇。 所以先复习一些必要的前置知识点。防止懵圈。

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的优点:、

  1. 自定义组件样式私有化, 也就是保护内部元素不被外部样式入侵
  2. 可以同时保证我们新增的组件和原有的组件互不干扰
  3. 我们能在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>

所以我们拥有两种方式从外部样式影响组件内部的样式。

  1. css自定义变量
  2. 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自定义变量也存在一些问题:

  1. 没有类型安全检查, 意味着用户可以输入任何值, 尽管可能对该属性不起作用
  2. 很判断这些额外的css是否有效, 确保被正确的使用到默认值(比如: color: 'hawei';就是一个无效的规则, 应该使用默认值, 而不是就是使用 “hawei” )

所幸, 还是有解决方案的:registered properties, 效果类似于你给自定义变量设置了一些属性,

  1. Syntax: 如果不符合对应的语法, 则会选择默认值. 注意, 如果不设置这个属性,就会出现上面说的第二点, 因为自定义的值是无效的, 所以导致了浏览器根本不会去设置这个属性。
  2. Inherits: 字面意思, 就是是否能被继承, 像公共的属性, 例如字体类型这些就应该被继承, 其他没有必要继承的属性应该设置为false, 同时可以减少掉浏览器这部分的计算
  3. 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事件的时候, 重新执行一遍依赖函数。两个关键点:

  1. 使用static get properties()
  2. 在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个之间), 这样我们看问题的较多就不会单一。

引用