Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【bigo】web components和LitElement组件化方案 #77

Open
linliya opened this issue Oct 27, 2021 · 0 comments
Open

【bigo】web components和LitElement组件化方案 #77

linliya opened this issue Oct 27, 2021 · 0 comments

Comments

@linliya
Copy link

linliya commented Oct 27, 2021

背景:最近vite的风刮得很猛,于是我也着手自己负责的一个项目,从vue-cli迁移到了vite,迁移过程还是比较顺利的,开发构建速度真的是很快,当然这也得益于它使用了原生 ESM 导入功能,刚好最近我们团队在着手开发组件库的事情,这让我联想到了这篇文章想要讲的web components,同样也是利用原生功能实现的组件化解决方案。

简介

web components 是谷歌推出的一套用于编写可重用定制元素的一项技术,截止现在,Firefox(版本63)、Chrome和Opera都已默认支持,IE则需要一些polyfill才能使用(具体在下文详细说明)在我看来,组件的一个重要意义在于可复用性,我们在一个react项目里面写的一套组件,搬到一个vue项目中,是无法使用的,需要按照vue的规则重写一个,而且以后如果随着技术的发展,又出了个新的框架,又需要重新再写一遍,这无疑增大了我们的工作量,这时候如果使用web components,可以实现一次编写,处处运行,这多舒服呀~

前置知识

一、自定义元素(custom-element)

自定义元素是怎么创建的呢?我们需要使用 JavaScript 定义一个类,然后使用浏览器提供的customElements.define方法,将我们的BaseButton类与<base-button>元素相关联;

class BaseButton extends HTMLElement {
  constructor() {
    super();
  }
}
customElements.define('base-button', BaseButton)

浏览器提供的customElements.define方法还有第三个参数,但是一般不会用到,如果想了解的同学可以戳一下mdn查看;

为了与原生的html元素区别,我们编写的自定义组件必须使用连接符,例如<base-button>不能写做<basebutton>web components的使用方式跟普通的html元素一样,只要引入组件后,在页面插入下面代码,就会显示base-button

<base-button></base-button>

二、Shadow Dom

相信大家对这个词已经不陌生了,mdn上的这张图已经很形象的解释了shadow dom的隔离性,它可以将我们编写的组件的内部结构隐藏起来,保证内部代码和外部代码不会互相影响

shadow-dom

  1. Shadow host:一个常规 DOM节点,Shadow DOM 会被附加到这个节点上。
  2. Shadow tree:Shadow DOM内部的DOM树。
  3. Shadow boundary:Shadow DOM结束的地方,也是常规 DOM开始的地方。
  4. Shadow root: Shadow tree的根节点。

三、HTML模版(template)

使用过vue的同学应该对于<template>标签是很熟悉的,Web Components API 提供的<template>标签,跟vue里面的用法和作用是一样的,我们可以使用它在HTML里面定义DOM:

<template>
  <button>点我啊</button>
</template>

接下来就让我们通过实现下面这个最常见的button组件,来熟悉一下怎么一步步实现一个web component吧。

button

base-button(原生版本)

class BaseButton extends HTMLElement {
  constructor() {
    super();

    // 开启shadow dom
    let shadow = this.attachShadow({ mode: "open" });

    // 样式
    let style = document.createElement("style");
    style.textContent = `
      .btn {
        width: 120px;
        height: 30px;
        font-size: 14px;
        border-radius: 4px;
        outline: none;
        border: none;
        margin: 0;
        padding: 0;
        cursor: pointer;
      }
      .btn-primary {
        color: #fff;
        background: #1e80ff;
      }
      .btn-error {
        color: #fff;
        background: #fa5353;
      }
    `;

    // 定义模版
    let button = document.createElement("button");
    let slot = document.createElement("slot");

    button.setAttribute("class", "btn");
    button.appendChild(slot);
    shadow.appendChild(style);
    shadow.appendChild(button);
  }

  // 获取属性
  get type() {
    return this.getAttribute("type");
  }

  // 设置属性
  set type(newVal) {
    this.setAttribute("type", newVal);
  }

  // 监测属性变化
  static get observedAttributes() {
    return ["type"];
  }

  // 属性变化回调函数
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "type") {
      let shadow = this.shadowRoot;
      let btn = shadow.querySelector("button");
      if (newValue) {
        btn.classList.add(`btn-${newValue}`);
      }
    }
  }
}

customElements.define("base-button", BaseButton);

上面这段代码是通过原生的形式编写,其中,attributeChangedCallback是原生提供生命周期回调函数,有兴趣可以参考mdn文档

上面那段代码直接对dom进行了操作,有种回到了写jq的感觉,当然我们可以使用<template>标签来构建html结构,但是对于一些复杂的页面构建,例如条件渲染和列表渲染,写起来还是力不从心的,那么,接下来我们就使用LitElement来实现这一个组件吧。

LitElement简单介绍

LitElement 是随着google的polymer框架的3.0版本一起推出的,它可用于创建快速、轻量级的 Web 组件,可在任何具有任何框架的网页中运行。LitElement 使用 lit-html 渲染成 shadow DOM,并添加 API 来管理属性和属性。默认情况下会监听属性,并且元素会在其属性更改时异步更新。虽然官网是英文版的,但是因为例子比较多,读起来也不算很费劲,下面简单讲一下主要的使用方法。

起步

  • 安装lit-element

    npm i lit-element
  • 如果需要兼容Edge和一些不支持web components的浏览器,可以安装一下WebComponents polyfills

    npm install --save-dev @webcomponents/webcomponentsjs

定义和渲染一个template

import { LitElement, html } from 'lit-element';

class MyElement extends LitElement {
  static get properties() {
    return {
      message: {type: String}
    }
  }

constructor() {
  super();
  this.message = 'world';
}
	
  render() {
    return html`<p>hello ${this.message}</p>`;
  }
}

customElements.define('my-element', MyElement);

通过定义一个类继承自LitElement,用html标签函数包裹html字符串的形式编写模版,通过get properties 定义属性类型,然后遵从web component的语法导出一个类,这就是最基本的LitElement组件了。当然也可以很简单地实现条件渲染和列表渲染,其他更多的使用方法可以查看官网

// 条件渲染
html`
  ${this.myBool?
    html`<p>Render some HTML if myBool is true</p>`:
    html`<p>Render some other HTML if myBool is false</p>`}
`;

// 列表渲染
html`<ul>
  ${this.myArray.map(i => html`<li>${i}</li>`)}
</ul>`;

properties和attributes

定义

  1. property是DOM中的属性,是JavaScript里的对象,attribute就是dom节点自带的属性,例如html中常用的id、class、title、align等,它的值只能够是字符串;
  2. property能够从attribute中得到同步;
  3. attribute不会同步property上的值;
  4. attributeproperty之间的数据绑定是单向的,attribute->property
  5. 更改propertyattribute上的任意值,都会将更新反映到HTML页面中;

详细解释可以参考https://qinzhen001.github.io/2017/12/17/Property和Attribute的区别-myblog/

声明properties

static get properties() {
  return { 
    propertyName: options
  };
}

// 如果使用ts或者有babel,可以使用装饰器进行定义
export class MyElement extends LitElement {
  @property(options) 
  propertyName;
}

property options属性

reflect:默认值为false,由定义2、3、4可以知道,property和attribute之间数据绑定是单向的,通过reflect我们可以设置property的值变化后是否同步到attribute,实现数据的双向绑定。

attribute: 默认值为true,用于设置当前property是否独立于attribute,即是否新增一个与property同样名字的attribute ,如果为false,converter,reflect,type都会被忽略。

type:reflect为true时,根据定义1,因为property是js对象类型,而attribute只会是字符串类型,lit-element 会根据默认的转换规则将property转换为attribute,具体的转换规则如下:

attribute to property:

  1. type为String类型时:当attribute被定义了,设置property为该值,不用转换;
  2. type为Number类型时:当attribute被定义了,将property设置为Number(attributeValue);
  3. type为Boolean类型时:当attribute 非空,设置property为true,如果为null或者undefined,设置property为false ;
  4. type为Object或者Array类型时:设置property为JSON.parse(attribueValue)

property to attribute:

  1. type为String类型时:当property为null,移除该attribute,当property为undefined,不修改attribute值,非null或者undefined,则设置该attribute为property的值;
  2. type为Number类型时:当property为null,移除该attribute,当property为undefined,不修改attribute值,非null或者undefined,则设置该attribute为property的值;
  3. type为Boolean类型时:当property为true,创建对应的attribute,为false,移除该attribute;
  4. type为Object或者Array类型时:当property为null或者undefined时,移除对应的attribute,不为null或者undefined时,设置attribute的值为JSON.stringify(propertyValue)

还有几个不太常用的options: converter、hasChanged、noAccessor ,有兴趣可以查看官网解释

总结一下:了解了上面的propertyattribute的定义和转换关系对于我们编写lit-element有什么用呢?主要的作用是为了做变化监听:

1、当通过this.setAttribut('myprop', 'hello world') 改变attribute的值时,我们可以通过attributeChangedCallback 获得变化的属性名,变化前的值和变化后的值,方便我们对视图进行更新,但是如果设置myProp: {attribute: false} ,attributeChangedCallback则不会监听到该属性的变化,需要注意的是,这里变化的属性名默认为小写格式,即myProp对应为myprop ,当然也可以通过myProp: {attribute: 'my-prop'}进行自定义。

2、我们一般比较常见的改变属性值的方式是通过this.xxx 进行修改,也就是修改property的值,这时如果我们也想监测变化的话,可以通过设置myProp: {reflect: true},将property的值同步到attribute,这样我们就可以通过attributeChangedCallback来获取变化,从而更新视图了。

事件

在react中,数据是单向流动的,数据只会由父级传给子级,如果子级触发了某个事件,想要通知上级,一般是通过父级传递一个方法进入子级,子级调用这个方法,从而达到数据的同步。

在lit-element中,我们可以转换下思维,在我们需要通知上层组件做一些更新操作时,我们可以dispatch一个事件,事件中可以携带信息,然后在父级监听对应的事件,从而达到数据的更新。

CustomEvent和Event

顾名思义,customEvent是指自定义事件,而Event则表示标准事件,如clicktouchStartkeydown等。

  • 触发一个CustomEvent
class MyElement extends LitElement {
  render() {
    return html`<div>Hello World</div>`;
  }
  // 生命周期函数,表示首次更新
  firstUpdated(changedProperties) {
    let event = new CustomEvent('my-event', {
      detail: {
	      message: 'Something important happened'
      }
    });
    this.dispatchEvent(event);
  }
}
  • 触发一个标准Event
class MyElementextends LitElement {
  render() {
    return html`<div>Hello World</div>`;
  }
  // 生命周期函数,表示组件更新
  updated(changedProperties) {
    let click = new Event('click');
    this.dispatchEvent(click);
  }
}

bubbles和composed

  • bubbles是冒泡的意思,可以设置当前事件是否一层层冒泡到上级组件。
  • composed:一般情况下,在shadow Dom 里面触发的一个冒泡事件,在到达shadow Dom边界就会停止冒泡,同时设置composedbubblestrue可以使得事件可以穿过shadow Dom边界。

生命周期

这里简单列举下常用的几个生命周期:

  • connectedCallback:组件被加载到dom时触发
  • disconnectedCallback:组件从dom中被移除时触发
  • attributeChangedCallback:组件的attribute发生变化时触发
  • firstUpdated: 组件被第一次更新时触发,在组件被创建后只会触发一次
  • updated: 组件视图更新或重新渲染时触发

那么,接下来就使用lit-element来编写一下我们的base-button组件吧~

base-button组件(LitElement版本)

import { LitElement, html, css } from "lit-element";

class BaseButton extends LitElement {
  static get styles() {
    return css`
      .btn {
        width: 120px;
        height: 30px;
        font-size: 14px;
        border-radius: 4px;
        outline: none;
        border: none;
        margin: 0;
        padding: 0;
        cursor: pointer;
      }
      .btn-primary {
        color: #fff;
        background: var(--primary-bg-color, #1e80ff);
      }
      .btn-error {
        color: #fff;
        background: var(--error-bg-color, #fa5353);
      }
    `;
  }

  static get properties() {
    return {
      type: { type: String },
    };
  }

  constructor() {
    super();
    this.type = "default";
  }

  render() {
    return html`
      <button class="btn btn-${this.type}" @click=${this._handleClick}><slot></slot></button>
    `;
  }

  _handleClick() {
    this.dispatchEvent(
      new CustomEvent("base-button-click", {
        bubbles: true,
      })
    );
  }
}

customElements.define("base-button", BaseButton);

export default BaseButton;

总结:web component 是个人觉得目前可以用于编写组件的一个较好的方案,不会受到项目所选的框架限制,但因为原生语法编写模版在一些复杂场景上(例如条件渲染和列表渲染)的力不从心,我们引入了lit-element,并对lit-element语法进行了简单的介绍,也实现了对应版本的base-button组件,我也改造了一个时间范围选择器组件,将vue版本转换为了lit-element版本,之后也会整理一下并上传到github。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant