鹿竹.png

> 这是第 145 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:Web Components-LitElement实践open in new window

前言

Google 在 2011 年首次正式提出 Web Components 组件化概念时,它主要依赖三个技术:Custom Element、Shadow Dom、HTML Templates。直到 2015 年 Google 才真正投入生产进行使用,那时其他浏览器厂商还没有大规模支持这个特性,应用起来存在很大的兼容问题。

在这期间,Angular、React 和 Vue 三大框架崛起,并且都有“组件化”这个功能,也形成了各自的生态圈,但都与框架强关联。由于这个原因,开发者对于 Web Components 的呼声一直是只增不减。

直到今天,由于各大浏览器厂商的支持并结合 polyfills,在使用 Web Components 时,兼容性已经不是问题,开发者开始积极探索并实践 Web Components 技术。

如何更好地应用 Web Components 技术呢?有轻便的框架可以简化原生较为复杂的写法吗?那么我们来看看 LitElement 做了什么,能不能让 Web Components 变得更好用些。

回顾

通过阅读上篇文章如何基于 Web Components 封装 UI 组件库open in new window,我们掌握了原生 Web Components 的一些内容,包括:

  1. 三要素和生命周期;
  2. 基本的组件通信,包括如何利用 observedAttributes 属性监听和 attributeChangedCallback 生命周期获取最新属性和通过 CustomEvent 抛出自定义事件来模拟实现状态的“双向绑定”;
  3. 如何设计组件库;
  4. 如何在原生、React 和 Vue 中优雅地使用我们封装的组件。

但使用 Web Components 的原生写法确实存在一些不简洁的地方:

  1. 属性监听:observedAttributes API 需要结合 attributeChangedCallback 生命周期,写起来代码量大;
  2. 组件通信时传入复杂数据类型:只能通过 stringify 后的 attribute 传递,特殊对象格式如 Date,Function 等传递起来会非常复杂,和现在的组件库能力上相比功能会比较弱,使用场景相对单一;
  3. 组件通信时双向绑定:需要结合自定义事件,写法会比较复杂。

为了更丰富的开发场景和更好的开发体验,LitElement 把以上问题进行了归纳转化,即:

  1. 如何响应 reactive properties的变化,并应用到UI上。
  2. 如何解决模板语法。

它用了两个核心库来解决这个问题,分别是 lit-element 和 lit-html。

LitElement介绍

基本内容

Lit 的核心是一个组件基类,它提供响应式、scoped 样式和一个小巧、快速且富有表现力的声明性模板系统,且支持 TypeScript 类型声明。Lit 在开发过程中不需要编译或构建,几乎可以在无工具的情况下使用。

我们知道 HTMLElement 是浏览器内置的类,LitElement 基类则是 HTMLElement 的子类,因此 Lit 组件继承了所有标准 HTMLElement 属性和方法。更具体来说,LitElement 继承自 ReactiveElement,后者实现了响应式属性,而后者又继承自 HTMLElement。

创建 Lit 组件还涉及许多概念,我们一一了解。

定义一个组件

Lit 组件作为 Custom Element 的实现,并在浏览器中注册。

原生的写法主要是继承 HTMLElement 类并重写它的方法。而 LitElement 框架则是基于 HTMLElement 类二次封装了 LitElement 类,它将很多的写法通过一些语法糖的封装变得更简单了,极大地简化了这些代码。开发者只需继承 LitElement 类开发自己的组件然后通过浏览器原生方法 customElements.define 注册即可。

export class LitButton extends LitElement { /* ... */  }
customElements.define('lit-button', LitButton);

当定义一个 Lit 组件时,就是定义了一个自定义 HTML 元素。因此,可以像使用任何内置元素一样使用新元素。

<lit-button type="primary"></lit-button>

渲染

组件具有 render 方法,该方法被调用以渲染组件的内容。

虽然 Lit 模板看起来像字符串插值,但 Lit 解析并创建一次静态 HTML,然后只更新表达式中需要更改的值。

export class LitButton extends LitElement {
	/* ... */
	
	render() {
    // 使用模板字符串,可以包含表达式
    return html`
      <div><slot name="btnText"></slot></div>
    `;
  }
}

通常,组件的 render() 方法返回单个 TemplateResult 对象(与 html 标记函数返回的类型相同)。

> TemplateResult对象:是 lit-html 接收模板字符串并经过它的 html 标记函数处理得到的一个纯值对象。

但是,它可以返回 Lit 可以渲染的任何内容,包括:

  • primitive 原始类型值,如字符串、数字或布尔值。
  • 由 html 函数创建的 TemplateResult 对象。
  • DOM 节点。
  • 任何受支持类型的数组或可迭代对象。

响应式properties

> DOM 中 property 与 attribute 的区别: > > - attribute 是HTML标签上的特性,可以理解为标签属性,它的值只能够是 String 类型,并且会自动添加同名 DOM 属性作为 property 的初始值; > - property 是 DOM 中的属性,是 JavaScript 里的对象,有同名 attribiute 标签属性的 property 属性值的改变也并不会同步引起 attribute 标签属性值的改变;

Lit 组件接收标签属性 attribute 并将其状态存储为 JavaScript 的 class 字段属性或 properties。响应式 properties 是可以在更改时触发响应式更新周期、重新渲染组件以及可选地读取或重新写入 attribute 的属性。每一个 properties 属性都可以配置它的选项对象。

export class LitButton extends LitElement {
	// 在静态属性类字段中声明属性,Lit会处理为响应式属性
  static properties = {
    type: {
      type: String,
      reflect: true,
      /*...其他选项属性...*/
    },
    other: {
      type: Object
    }
  };
  
  /* ... */
}

它的选项对象可以具有以下属性:

  • attribute:表示是否与 property 关联,或者 attribute 关联属性的自定义名称。默认值:true,表示 property 会与标签属性 attribute 进行关联。如果设置为 false,则下面的 converter 转换器、reflect 反射和 type 类型选项将被忽略。主要用来将 attribute 与 property 建立关联。
  • type:在将 String 类型的 attribute 转换为 property 时,Lit 的默认属性转换器会将 String 类型解析为给定的类型。将 property 反映到 attribute 时反之亦然。如果设置了 converter 转换器,则将此字段传递给转换器。如果未指定类型,则默认转换器将其视为 String 类型。
  • converter:用于在 attribute 和 property 之间转换的自定义转换器。如果未指定,则使用默认属性转换器。主要用来决定 attribute 与 property 确定建立关联后如何进行数据转换,毕竟 attribute 只能是 String 类型而 property 却是可以自定义的类型,默认属性转换器则是依据 property 配置的 type 选项进行目标类型的转换。上例中表示接受的 other 属性的 attribute 后会序列化为目标 Object 类型。
  • hasChanged:每当设置属性时调用的函数以确定属性是否已更改,并应触发更新。如果未指定,LitElement 将使用严格的不等式检查 (newValue !== oldValue) 来确定属性值是否已更改。
  • reflect:property 属性值是否反映回关联的 attribute 属性。默认值:false,即 property 的改变不会主动引起 attribute 的改变。上例中表示接收 type 组件属性 properties 的改动会同步到对应 attribute 标签属性上。
  • state:设置为 true 以将 property 属性声明为内部 state。内部 state 的改变也会触发更新,就像响应式属性 property,但 Lit 不会为其生成 attribute 属性,用户不应从组件外部访问它。这些属性应标记为 private 或 protected。还建议使用前导下划线 (_) 之类的约定来标识 JavaScript 用户的 private 或 protected 属性。可以为 state 内部状态指定的唯一选项是 hasChanged 函数。

省略选项对象或指定一个空的选项对象等效于为所有选项指定默认值。

另外,Lit 为每个响应式属性生成一个 getter/setter 对。当响应式属性发生变化时,组件会安排更新。Lit 也会自动应用 super 类声明的属性选项。除非需要更改选项,否则不需要重新声明该属性。

样式

组件模板被渲染到它的 shadow root。添加到组件的样式会自动作用于 shadow root,并且只会影响组件 shadow root 中的元素。

Shadow DOM 为样式提供了强大的封装。如果 Lit 没有使用 Shadow DOM,则必须非常小心不要意外地为组件之外的元素设置样式,无论是组件的父组件还是子组件。这可能涉及编写冗长而繁琐的类名。通过使用 Shadow DOM,Lit 确保编写的任何选择器仅适用于 Lit 组件的 shadow root 中的元素。

可以使用标记的模板 css 函数在静态 styles 类字段中定义 scoped 样式。

export class LitButton extends LitElement {
	// 使用纯CSS为组件定义scoped样式
  static styles = css`
    .lit-button {
      display: inline-block;
      padding: 4px 20px;
      font-size: 14px;
      line-height: 1.5715;
      font-weight: 400;
      border: 1px solid #1890ff;
      border-radius: 2px;
      background-color: #1890ff;
      color: #fff;
      box-shadow: 0 2px #00000004;
      cursor: pointer;
    }
  `;
  
  /* ... */
}

如图同样应用了 lit-button 样式,但样式只对 shodow root 中的部分起作用。

静态 styles 类字段的值可以是:

  • 单个标记的模板文字。

    static styles = css`...`;
    
  • 一组标记的模板文字。

    static styles = [ css`...`, css`...`];
    

此外,styles 也支持在样式中使用表达式、使用语句、继承父类样式、共享样式、使用 unicode escapes 以及在模板 template 中使用样式等功能。Lit 也提供了两个指令,classMap 和 styleMap,可以方便地在 HTML 模板中条件式的应用 class 和 style。

import {LitElement, html, css} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-map.js';

export class LitButton extends LitElement {
  static properties = {
    classes: {},
    styles: {},
  };
  static styles = css`
  	.lit-button {
      display: inline-block;
      padding: 4px 20px;
      font-size: 14px;
      line-height: 1.5715;
      font-weight: 400;
      border: 1px solid #1890ff;
      border-radius: 2px;
      background-color: #1890ff;
      color: #fff;
      box-shadow: 0 2px #00000004;
      cursor: pointer;
    }
    .someclass {
      color: #000;
    }
    .anotherclass {
      font-size: 16px;
    }
  `;

  constructor() {
    super();
    this.classes = {'lit-button': true, someclass: true, anotherclass: true};
    this.styles = {fontFamily: 'Roboto'};
  }
  render() {
    return html`
      <div class=${classMap(this.classes)} style=${styleMap(this.styles)}>
        <slot name="btnText"></slot>
      </div>
    `;
  }
}
customElements.define('lit-button', LitButton);

生命周期

Lit 组件可以继承原生的自定义元素生命周期方法。但如果需要使用自定义元素生命周期方法,确保调用 super 类的生命周期,以保证父子组件生命周期的一致。

标准的自定义组件生命周期

  • constructor():创建元素时调用。适用于执行必须在第一次更新之前完成的一次性初始化任务。
  • connectedCallback():在将组件添加到文档的 DOM 时调用。适用于仅在元素连接到文档时才发生的任务。其中最常见的是将事件侦听器添加到元素节点。
  • disconnectedCallback():当组件从文档的 DOM 中移除时调用,用于移除对元素的引用。比如移除添加到元素节点的事件侦听器。
  • attributeChangedCallback():当元素的 observedAttributes 之一更改时调用。
  • adoptedCallback():当组件移动到新文档时调用。
connectedCallback() {
  super.connectedCallback()
  addEventListener('keydown', this._handleKeydown);
}

disconnectedCallback() {
  super.disconnectedCallback()
  window.removeEventListener('keydown', this._handleKeydown);
}

除了标准的自定义元素生命周期之外,Lit 组件还实现了响应式更新周期。Lit 异步执行更新,因此属性更改是批处理的,如果在请求更新后但在更新开始之前发生了更多属性更改,则所有更改都将在同一个更新中进行。当响应式 prpperties 属性发生变化或显式调用 requestUpdate() 方法时,将触发响应更新周期,它会将更改呈现给 DOM。

响应式更新周期

第一阶段:触发更新

  • haschanged():在设置响应式属性时隐式调用。默认情况下 hasChanged() 会进行严格的相等性检查,如果返回 true,则会安排更新。

  • requestUpdate():调用 requestUpdate() 来安排显式更新。如果需要在与属性无关的内容发生更改时更新和呈现元素,将很有用。

    connectedCallback() {
      super.connectedCallback();
      this._timerInterval = setInterval(() => this.requestUpdate(), 1000);
    }
    
    disconnectedCallback() {
      super.disconnectedCallback();
      clearInterval(this._timerInterval);
    }
    
    

第二阶段:执行更新

  • shouldUpdate():调用以确定是否需要更新周期。
  • willUpdate():在 update() 之前调用以计算更新期间所需的值。
  • update():调用以更新组件的 DOM。
  • render():由 update() 调用,并应实现返回用于渲染组件 DOM 的可渲染结果(例如 TemplateResult)。

第三阶段:完成更新

  • firstUpdated():在组件的 DOM 第一次更新后调用,紧接在调用 updated() 之前。
  • updated():每当组件的更新完成并且元素的 DOM 已更新和呈现时调用。
  • updateComplete():updateComplete Promise 在元素完成更新时更新为 resolved 状态。

其他:

  • performUpdate():调用 performUpdate() 以立即处理挂起的更新。这通常不需要,但在需要同步更新的极少数情况下可以这样做。
  • hasUpdated():如果组件至少更新过一次,则 hasUpdated 属性返回 true。仅当组件尚未更新时,才可以在任何生命周期方法中使用 hasUpdated 来执行工作。
  • getUpdateComplete():在执行 updateComplete 之前等待其他条件执行完成。

整个流程图示如下:

了解了基本的概念和内容,如果你做过任何现代的、基于组件的 Web 开发,你应该对 Lit 的系列概念和用法感到似曾相识并且容易上手。下面通过一些案例了解 LitElement 的其他特性。

传入复杂数据类型

对于复杂数据的处理,为什么会存在这个问题,根本原因还是因为 attribute 标签属性值只能是 String 类型,其他类型需要进行序列化。在 LitElement 中,只需要在父组件模板的属性值前使用(.)操作符,这样子组件内部 properties 就可以正确序列化为目标类型。

/**
 * 父组件-复杂数据类型
 */
 import { html, LitElement } from 'lit';
 import './person';

 class LitComplex extends LitElement {

  constructor() {
    super();
    this.person= {'name':'cai'};
    this.friends = [{'name':'zheng'},{'name':'yun'}];
  }

   render() {
     return html`
     <div>复杂数据类型</div>
     <lit-person .person=${this.person} .friends=${this.friends}></lit-person>
     `
   }
 }

 customElements.define('lit-complex', LitComplex);

 export default LitComplex;
/**
 * 基础组件
 */
 import { html, LitElement } from 'lit';

 class LitPerson extends LitElement {
   static properties = {
     person: {
       type: Object
     },
     friends: {
       type: Array,
     },
     date: {
       type: Date,
     }
   }

   firstUpdated() {
     console.log(this.person instanceof Object, this.friends instanceof Array, this.date instanceof Date); 
     // true true true
   }

   render() {
     return html`
     <div>${this.person.name}${this.friends.length}个朋友</div>
     `
   }
 }

 customElements.define('lit-person', LitPerson);

 export default LitPerson;

这样可以支持各种类型数据的传递使用。

数据的双向绑定

/**
 * 数据绑定-father
 */
 import { html, LitElement } from 'lit';
 import './lit-input';

class LitInputFather extends LitElement {
  static properties = {
    data: {
      type: String
    }
  }

  constructor() {
    super();
    this.data = 'default';
  }

  render() {
    return html`
    <lit-input value=${this.data}></lit-input>
    `;
  }
}

customElements.define('lit-input-father', LitInputFather);

 export default LitInputFather;
/**
 * 数据绑定
 */
 import { html, LitElement } from 'lit';

 class LitInput extends LitElement {
   static properties = {
     value: {
       type: String,
       reflect: true
     }
   }

   change = (e) => {
     this.value = e.target.value;
   }

   render() {
     return html`
     <div>输入:<input value=${this.value} @input=${this.change}/></div>
     `
   }
 }

 customElements.define('lit-input', LitInput);

 export default LitInput;

这里子组件接收了父组件的 value 属性,默认值设为了'default',在子组件内通过监听输入事件更新了 value 值,因为 value 属性配置了 reflect 为 true,即可将属性值的改变反映回关联的 attribute 属性。

如图:input 组件默认值为'default'并在紧接着输入'123'后,组件的标签属性 value 同时发生了变化。

这时在父组件通过获取子组件的 attribute 即可获得子组件同步改动的值。以此实现数据的双向绑定,但 LitElement 本身是单向的数据流。

指令使用

指令是可以通过自定义表达式呈现方式来扩展 Lit 的函数。 Lit 包含许多内置指令,可帮助满足各种渲染需求:以组件缓存为例。

在更改模板而不是丢弃 DOM 时缓存渲染的 DOM。在大型模板之间频繁切换时,可以使用此指令优化渲染性能。

/**
 * cache内置指令使用
 */
 import {LitElement, html} from 'lit';
 import {cache} from 'lit/directives/cache.js';

 class LitCache extends LitElement {
  static properties = {
    show: false,
    data: {},
  };

  constructor() {
    super();
    this.data = {
      detail: 'detail',
      sumary: 'sumary'
    };
  }

  detailView = (data) => html`<div>${data.detail}</div>`;

  summaryView = (data) => html`<div>${data.sumary}</div>`

  changeTab = () => {
    this.show = !this.show;
  }

  render() {
    return html`${cache(this.show
      ? this.detailView(this.data)
      : this.summaryView(this.data)
    )}
    <button @click=${this.changeTab}>切换</button>
    `;
  }
}
customElements.define('lit-cache', LitCache);

这个例子在模板中使用了语句表达式,再通过 click 事件切换组件时展示不同的模板内容;引入了 cache 指令函数,实现了 DOM 的缓存。

LitElement 内置了大量的指令函数可以使用。

此外,它还有丰富的 Mixins 和 Decoratrs 等内容值得细细学习,在此不再做过多展开。

总结

总的来说,LitElement 在 Web Components 开发方面有着很多比原生的优势,它具有以下特点:

  • 简单:在 Web Components 标准之上构建,Lit 添加了响应式、声明性模板和一些周到的功能,减少了模板文件。
  • 快速:更新速度很快,因为 Lit 会跟踪 UI 的动态部分,并且只在底层状态发生变化时更新那些部分——无需重建整个虚拟树并将其与 DOM 的当前状态进行比较。
  • 轻便: Lit 的压缩后大小约为 5 KB,有助于保持较小的包大小并缩短加载时间。
  • 高扩展性:lit-html 基于标记的 template,它结合了 ES6 中的模板字符串语法,使得它无需预编译、预处理,就能获得浏览器原生支持,并且扩展能力强。
  • 兼容良好:对浏览器兼容性非常好,对主流浏览器都能有非常好的支持。

结合这些点,基本可以满足项目开发中的大部分场景。

以上就是关于 LitElement 介绍的主要内容,更多内容可以前往官网学习了解,文中案例地址可以在此获得open in new window,同时推荐安装 lit-pluginopen in new window VS Code 插件来更好的预览和改动代码。

尾声

我们知道,W3C 仿照 jQuery 的 $ 函数,实现了 querySelector()querySelectorAll() 方法并逐渐取代了 jQuery 快速选择 DOM 元素的功能,加速了 jQuery 的没落,带着前端迈向了新的阶段。那么随着 Web Components 的不断发展,它会取代现有的前端框架吗?

现阶段来看,还并不会,因为 Web Components 与各前端框架之间的关系是“共存”而非互斥,两者可以完美的互补。虽然前端框架 React 和 Vue 中组件化是其中非常重要的功能,但它们还有页面路由,数据绑定,模块化,CSS 预处理器,虚拟 DOM,Diff 算法以及各种庞大的生态等功能。而 Web components 所解决的仅仅是组件化这么一项功能。不论是 React 还是 Vue,从它们的官方文档有关于Web Components 的说明中,都可以更好帮助我们理解它们与 Web Components 之间的关系。

UI组件库

参考资料

推荐阅读

模块联邦浅析open in new window

性能优化——图片压缩、加载和格式选择open in new window

[如何基于 WebComponents 封装 UI 组件库](https://juejin.cn/post/7096265630466670606 "# 如何基于 WebComponents 封装 UI 组件库")

Web Workeropen in new window

如何落地一个智能机器人open in new window

开源作品

  • 政采云前端小报

开源地址 www.zoo.team/openweekly/open in new window (小报官网首页有微信交流群)

  • 商品选择 sku 插件

开源地址 https://github.com/zcy-inc/skuPathFinder-back/open in new window

招贤纳士

政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 60 余个前端小伙伴,平均年龄 27 岁,近 4 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com