本文首发于政采云前端团队博客:Web Components-LitElement 实践
https://www.zoo.team/article/webcomponents
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 组件库》(https://juejin.cn/post/7096265630466670606),我们掌握了原生 Web Components 的一些内容,包括:
但使用 Web Components 的原生写法确实存在一些不简洁的地方:
为了更丰富的开发场景和更好的开发体验,LitElement 把以上问题进行了归纳转化,即:
它用了两个核心库来解决这个问题,分别是 lit-element 和 lit-html。
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 可以渲染的任何内容,包括:
“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
}
};
/* ... */
}
它的选项对象可以具有以下属性:
(newValue !== oldValue)
来确定属性值是否已更改。省略选项对象或指定一个空的选项对象等效于为所有选项指定默认值。
另外,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 类的生命周期,以保证父子组件生命周期的一致。
标准的自定义组件生命周期
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);
}
第二阶段:执行更新
第三阶段:完成更新
其他:
整个流程图示如下:
了解了基本的概念和内容,如果你做过任何现代的、基于组件的 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 开发方面有着很多比原生的优势,它具有以下特点:
结合这些点,基本可以满足项目开发中的大部分场景。
以上就是关于 LitElement 介绍的主要内容,更多内容可以前往官网学习了解,文中案例地址可以在此获得 (https://github.com/CYLpursuit/lit-element-ui),同时推荐安装 lit-plugin (https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin) 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 之间的关系。
如果你觉得这篇内容对你挺有启发,我想邀请你帮我两件小事
1.点个「在看」,让更多人也能看到这篇内容(点了「在看」,bug -1 😊)
政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 60 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。
如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com