面向 React 开发者的 Lit

Lit 简介

Lit 是 Google 提供的一组开源库,可帮助开发者构建快速、轻量且适用于任何框架的组件。借助 Lit,您可以构建可共享的组件、应用、设计系统等。

学习内容

如何将一些 React 概念转换为 Lit 概念,例如:

  • JSX 和模板
  • 组件和属性
  • 状态和生命周期
  • 钩子
  • 子元素
  • 引用
  • 调解状态

构建内容

此 Codelab 结束时,您应该能够将 React 组件的概念转换为 Lit 中的类似概念。

所需条件

  • 最新版本的 Chrome、Safari、Firefox 或 Edge。
  • 了解 HTML、CSS、JavaScript 和 Chrome 开发者工具
  • 了解 React
  • (高级)如果您想获得最佳的开发体验,请下载 VS Code。此外,您还需要 VS Code 的 lit-plugin 插件和 NPM

Lit 的核心概念和功能在很多方面与 React 类似,但 Lit 也与其存在一些重要的区别和差异:

体量小

Lit 非常小:经缩减大小和 gzip 压缩后可减至 5kb 左右,而 React + ReactDOM 的大小超过 40kb

经缩减大小和压缩后的软件包大小条形图,以 kb 为单位。代表 Lit 的条形为 5kb,代表 React + React DOM 的条形为 42.2kb

速度快

在对 Lit 的模板系统 lit-html 与 React 的 VDOM 进行对比的公开基准测试中,lit-html 在最糟糕的情况下比 React 快 8-10%,在比较常见的用例中快 50% 以上

LitElement(Lit 的组件基类)给 lit-html 增加的开销极低,但在内存用量、交互时间和启动用时等组件特性的对比中却比 React 的性能高 16-30%

Lit 与 React 性能对比分组条形图(以毫秒为单位,数值越低表示性能越高)

不需要构建

借助新的浏览器功能(例如 ES 模块和带标记模板字面量),Lit 不需要编译即可运行。也就是说,您可以使用脚本标记、浏览器和服务器设置开发环境,然后 Lit 便可正常运行。

借助 ES 模块以及 SkypackUNPKG 等现代 CDN,您甚至有可能无需 NPM 即可开始使用 Lit!

不过,如果您愿意,您仍可构建并优化 Lit 代码。近期围绕原生 ES 模块进行的开发者融合也对 Lit 颇有益处:Lit 就是常规 JavaScript,不需要框架专用的 CLI,也不需要构建处理

与框架无关

Lit 的组件以一组名为 Web Components 的网页标准为基础构建。这意味着,在 Lit 中构建组件可确保在当前和未来的框架中都能正常工作。如果它支持 HTML 元素,那么它就支持 Web Components。

框架互操作性方面的唯一问题是有些框架对 DOM 的支持有限制。React 就是其中一种框架,不过它通过引用提供了解决办法,但 React 中的引用并不是让人愉快的开发者体验。

Lit 团队一直在进行一项名为 @lit-labs/react 的实验性项目,它会自动解析您的 Lit 组件并生成 React 封装容器,让您无需使用引用。

此外,您还可以在 Custom Elements Everywhere 网站了解哪些框架和库能与自定义元素良好配合!

一流的 TypeScript 支持

虽然您可以使用 JavaScript 编写所有 Lit 代码,但 Lit 是使用 TypeScript 编写的,而且 Lit 团队也建议开发者使用 TypeScript!

Lit 团队一直与 Lit 社区合作来帮助维护项目,即使用 lit-analyzerlit-plugin 在开发中和构建时实现 TypeScript 类型检查和 Lit 模板智能感知

某 IDE 的屏幕截图,显示了针对将列出的布尔值设为数字的错误所进行的类型错误检查

某 IDE 的屏幕截图,显示了智能感知建议

开发者工具内置于浏览器中

Lit 组件就是 DOM 中的 HTML 元素。这意味着,即使为了检查组件,您也不需要为浏览器安装任何工具或扩展程序

只需打开开发者工具,选择某个元素,然后浏览其属性或状态即可。

,$0.value 返回 hello world,$0.outlined 返回 true,{$0} 显示属性扩展" class="l10n-relative-url-src" l10n-attrs-original-order="alt,src,class" src="https://codelabs.developers.google.com/codelabs/lit-2-for-react-devs/./img/browser-tools.png" />

在构建时考虑到了服务器端渲染 (SSR)

Lit 2 在构建时考虑到了 SSR 支持。在编写此 Codelab 时,Lit 团队尚未发布稳定版 SSR 工具,但 Lit 团队已在各种 Google 产品上部署服务器端渲染的组件。Lit 团队预计很快就能在 GitHub 上对外发布这些工具。

在此期间,您可在此处跟进 Lit 团队的进度。

投入低

使用 Lit 不需要大量的投入!您可以在 Lit 中构建组件并将其添加到现有项目中。如果不喜欢这些组件,那么您不必立刻转换整个应用,因为网页组件可在其他框架中工作!

您是否已用 Lit 构建了整个应用,并想更改为别的框架?您可以将当前的 Lit 应用放到新框架内,然后将您需要的任何内容迁移到新框架的组件中。

此外,许多现代框架都支持在网页组件中输出,因此,这意味着此类框架本身通常可以纳入 Lit 元素中。

您可以采用以下两种方式完成此 Codelab:

  • 在浏览器中完全在线完成
  • (高级)在本地计算机上使用 VS Code 完成

访问代码

此 Codelab 包含很多指向 Lit 游乐场的如下链接:

代码检查点

该游乐场是一个完全在浏览器中运行的代码沙盒。它可以编译并运行 TypeScript 和 JavaScript 文件,还可以自动解析节点模块的导入。例如:

// before
import './my-file.js';
import 'lit';

// after
import './my-file.js';
import 'https://cdn.skypack.dev/lit';

您可以从这些检查点着手,在 Lit 游乐场中完成整个教程。如果您使用 VS Code,那么您可以使用这些检查点下载任何步骤的起始代码并用其检查您的代码。

探索 Lit 游乐场界面

文件选择器标签页栏标为 Section 1,代码编辑部分标为 Section 2,输出预览标为 Section 3,预览重新加载按钮标为 Section 4

Lit 游乐场界面的屏幕截图突出显示了您在此 Codelab 中将要使用的部分。

  1. 文件选择器。请注意加号按钮…
  2. 文件编辑器。
  3. 代码预览。
  4. 重新加载按钮。
  5. 下载按钮。

VS Code 设置(高级)

使用此 VS Code 设置具有如下好处:

  • 模板类型检查
  • 模板智能感知和自动补全

如果您已安装 NPM 和 VS Code(带有 lit-plugin 插件),并了解如何使用该环境,那么您只需执行以下操作来下载和启动这些项目:

  • 按下载按钮
  • 将 tar 文件的内容提取到目录中
  • (如果使用 TS)设置 quick tsconfig 来输出 es 模块和 es2015+
  • 安装可解析裸模块说明符的开发服务器(Lit 团队建议使用 @web/dev-server
  • 运行开发服务器并打开浏览器(如果您使用的是 @web/dev-server,可以使用 web-dev-server --node-resolve --watch --open

在这一部分中,您将了解 Lit 中的模板基础知识。

JSX 和 Lit 模板

JSX 是 JavaScript 的一种扩展语法,可以让 React 用户轻松地使用 JavaScript 代码编写模板。Lit 模板起着类似的作用:将组件的界面表示为其状态的函数。

基本语法

代码检查点

在 React 中,您会编写如下代码来渲染 JSX hello world:

import 'react';
import ReactDOM from 'react-dom';

const name = 'Josh Perez';
const element = (
  <>
    <h1>Hello, {name}</h1>
    <div>How are you?</div>
  </>
);

ReactDOM.render(
  element,
  mountNode
);

上面的示例中有两个元素,并包含一个“name”变量。在 Lit 中,您会编写如下代码:

import {html, render} from 'lit';

const name = 'Josh Perez';
const element = html`
  <h1>Hello, ${name}</h1>
  <div>How are you?</div>`;

render(
  element,
  mountNode
);

请注意,Lit 模板不需要使用 React Fragment 在其模板中对多个元素进行分组。

在 Lit 中,模板是用 html 带标记模板字面量封装的,字面量的英文是 LITeral,Lit 正是因此而得名!

模板值

Lit 模板可以接受其他 Lit 模板(称为 TemplateResult)。例如,将 name 封装在斜体 (<i>) 标记中,然后使用带标记模板字面量 N.B 对其进行封装。请务必使用反引号字符 (`),而不要使用单引号字符 (')。

import {html, render} from 'lit';

const name = html`<i>Josh Perez</i>`;
const element = html`
  <h1>Hello, ${name}</h1>
  <div>How are you?</div>`;

render(
  element,
  mountNode
);

Lit TemplateResult 可以接受数组、字符串、其他 TemplateResult 以及指令。

代码检查点

作为练习,请尝试将以下 React 代码转换为 Lit 代码:

const itemsToBuy = [
  <li>Bananas</li>,
  <li>oranges</li>,
  <li>apples</li>,
  <li>grapes</li>
];
const element = (
  <>
    <h1>Things to buy:</h1>
    <ol>
      {itemsToBuy}
    </ol>
  </>);

ReactDOM.render(
  element,
  mountNode
);

答案:

import {html, render} from 'lit';

const itemsToBuy = [
  html`<li>Bananas</li>`,
  html`<li>oranges</li>`,
  html`<li>apples</li>`,
  html`<li>grapes</li>`
];
const element = html`
  <h1>Things to buy:</h1>
  <ol>
    ${itemsToBuy}
  </ol>`;

render(
  element,
  mountNode
);

传递和设置属性

代码检查点

JSX 和 Lit 的语法之间最大的区别之一是数据绑定语法。以下面带有绑定的 React 输入为例:

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
  <input
      disabled={disabled}
      className={`static-class ${myClass}`}
      defaultValue={value}/>;

ReactDOM.render(
  element,
  mountNode
);

在上面的示例中,定义了一个输入来执行以下操作:

  • 将 disabled 设置为定义的变量(在本例中为 false)
  • 将 class 设置为 static-class 加一个变量(在本例中为 "static-class my-class"
  • 设置一个默认值

在 Lit 中,您会编写如下代码:

import {html, render} from 'lit';

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
  <input
      ?disabled=${disabled}
      class="static-class ${myClass}"
      .value=${value}>`;

render(
  element,
  mountNode
);

在 Lit 示例中,添加了一个布尔值绑定来切换 disabled 属性。

接下来,有一个直接绑定到 class 属性而不是 className 的绑定。您可以向 class 属性添加多个绑定,除非您使用的是 classMap 指令(一种用于切换类的声明式辅助指令)。

最后,对输入设置 value 属性。与 React 不同,这不会将输入元素设置为只读,因为它遵循输入的原生实现和行为。

Lit 属性绑定语法

html`<my-element ?attribute-name=${booleanVar}>`;
  • ? 前缀是用于切换元素属性的绑定语法
  • 相当于 inputRef.toggleAttribute('attribute-name', booleanVar)
  • 对使用 disabled 的元素很实用,原因在于 DOM 仍会将 disabled="false" 读取为 true,因为 inputElement.hasAttribute('disabled') === true
html`<my-element .property-name=${anyVar}>`;
  • . 前缀是用于设置元素属性的绑定语法
  • 相当于 inputRef.propertyName = anyVar
  • 适用于传递对象、数组或类等复杂数据
html`<my-element attribute-name=${stringVar}>`;
  • 绑定到元素的属性
  • 相当于 inputRef.setAttribute('attribute-name', stringVar)
  • 适用于基本值、样式规则选择器和 querySelector

传递处理程序

代码检查点

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
  <input
      onClick={() => console.log('click')}
      onChange={e => console.log(e.target.value)} />;

ReactDOM.render(
  element,
  mountNode
);

在上面的示例中,定义了一个输入来执行以下操作:

  • 在用户点击该输入时记录“click”一词
  • 在用户键入字符时记录输入的值

在 Lit 中,您会编写如下代码:

import {html, render} from 'lit';

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
  <input
      @click=${() => console.log('click')}
      @input=${e => console.log(e.target.value)}>`;

render(
  element,
  mountNode
);

在 Lit 示例中,使用 @clickclick 事件添加了一个监听器。

接下来,没有使用 onChange 而是绑定到 <input> 的原生 input 事件,因为原生 change 事件只在 blur 时触发(React 会抽象出这些事件)。

Lit 事件处理程序语法

html`<my-element @event-name=${() => {...}}></my-element>`;
  • @ 前缀是事件监听器的绑定语法
  • 相当于 inputRef.addEventListener('event-name', ...)
  • 使用原生 DOM 事件名称

在这一部分中,您将了解 Lit 的类组件和函数。后面几部分将详细介绍状态和钩子。

类组件和 LitElement

代码检查点 (TS)代码检查点 (JS)

Lit 中的 LitElement 相当于 React 的类组件,而 Lit 中的“响应式属性”概念则将 React 中的属性和状态合二为一。例如:

import React from 'react';
import ReactDOM from 'react-dom';

class Welcome extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: ''};
  }

  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

const element = <Welcome name="Elliott"/>
ReactDOM.render(
  element,
  mountNode
);

在上面的示例中,有一个执行以下操作的 React 组件:

  • 渲染 name
  • name 的默认值设置为空字符串 ("")
  • name 重新赋值给 "Elliott"

而在 LitElement 中,您会如下所示地编写代码

使用 TypeScript:

import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
  @property({type: String})
  name = '';

  render() {
    return html`<h1>Hello, ${this.name}</h1>`
  }
}

使用 JavaScript:

import {LitElement, html} from 'lit';

class WelcomeBanner extends LitElement {
  static get properties() {
    return {
      name: {type: String}
    }
  }

  constructor() {
    super();
    this.name = '';
  }

  render() {
    return html`<h1>Hello, ${this.name}</h1>`
  }
}

customElements.define('welcome-banner', WelcomeBanner);

以及在 HTML 文件中:

<!-- index.html -->
<head>
  <script type="module" src="./index.js"></script>
</head>
<body>
  <welcome-banner name="Elliott"></welcome-banner>
</body>

我们来回顾一下上面的示例所完成的操作:

@property({type: String})
name = '';
  • 定义一个公共响应式属性,它是组件的公共 API 的一部分
  • 公开一个属性(默认)和一个组件属性
  • 定义如何将组件的属性(字符串)转换为值
static get properties() {
  return {
    name: {type: String}
  }
}
  • 此元素与 @property TS 修饰器作用相同,但直接以 JavaScript 原生方式运行
render() {
  return html`<h1>Hello, ${this.name}</h1>`
}
  • 只要任何响应式属性发生更改,就会调用此元素
@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
  ...
}
  • 此元素将一个 HTML 元素标记名称与一个类定义相关联
  • 根据 Custom Elements 标准,标记名称必须包含连字符 (-)
  • LitElement 中的 this 会引用自定义元素的实例(在本例中为 <welcome-banner>
customElements.define('welcome-banner', WelcomeBanner);
  • 此元素在 JavaScript 中相当于 @customElement TS 修饰器
<head>
  <script type="module" src="./index.js"></script>
</head>
  • 导入自定义元素定义
<body>
  <welcome-banner name="Elliott"></welcome-banner>
</body>
  • 将自定义元素添加到页面
  • name 属性设置为 'Elliott'

函数组件

代码检查点

Lit 中没有与函数组件一一对应的解释,因为它不使用 JSX 或预处理器。不过,编写一个函数来接受属性并根据这些属性渲染 DOM 也相当简单。例如:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

const element = <Welcome name="Elliott"/>
ReactDOM.render(
  element,
  mountNode
);

在 Lit 中,等效代码如下:

import {html, render} from 'lit';

function Welcome(props) {
  return html`<h1>Hello, ${props.name}</h1>`;
}

render(
  Welcome({name: 'Elliott'}),
  document.body.querySelector('#root')
);

在这一部分中,您将了解 Lit 的状态和生命周期。

状态

Lit 中的“响应式属性”概念将 React 中的状态和属性合二为一。响应式属性发生更改时,可触发组件生命周期。响应式属性有两种变体:

公共响应式属性

// React
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props)
    this.state = {name: 'there'}
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.name !== nextProps.name) {
      this.setState({name: nextProps.name})
    }
  }
}

// Lit (TS)
import {LitElement} from 'lit';
import {property} from 'lit/decorators.js';

class MyEl extends LitElement {
  @property() name = cs  'there';
}
  • 通过 @property 定义
  • 与 React 的属性和状态相似,但可变
  • 是可由组件使用方访问和设置的公共 API

内部响应式状态

// React
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props)
    this.state = {name: 'there'}
  }
}

// Lit (TS)
import {LitElement} from 'lit';
import {state} from 'lit/decorators.js';

class MyEl extends LitElement {
  @state() name = 'there';
}
  • 通过 @state 定义
  • 与 React 的状态相似,但可变
  • 是通常可从组件或子类中访问的不公开内部状态

生命周期

Lit 的生命周期与 React 的生命周期颇为相似,但又存在一些显著区别。

constructor

// React (js)
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
    this._privateProp = 'private';
  }
}

// Lit (ts)
class MyEl extends LitElement {
  @property({type: Number}) counter = 0;
  private _privateProp = 'private';
}

// Lit (js)
class MyEl extends LitElement {
  static get properties() {
    return { counter: {type: Number} }
  }
  constructor() {
    this.counter = 0;
    this._privateProp = 'private';
  }
}
  • Lit 中的等效元素也是 constructor
  • 无需向 super 调用传递任何内容
  • 调用方如下(并非详尽无遗):
    • document.createElement
    • document.innerHTML
    • new ComponentClass()
    • 如果页面上包含未升级的标记名称,并且系统会使用 @customElementcustomElements.define 加载并注册定义
  • 在功能上与 React 的 constructor 相似

render

// React
render() {
  return <div>Hello World</div>
}

// Lit
render() {
  return html`<div>Hello World</div>`;
}
  • Lit 中的等效元素也是 render
  • 可以返回任何可渲染结果,例如 TemplateResultstring
  • 与 React 相似,render() 也应为纯函数
  • 将渲染到 createRenderRoot() 返回的任一节点(默认为 ShadowRoot

componentDidMount

componentDidMount 类似于 Lit 的 firstUpdatedconnectedCallback 生命周期回调二者相结合。

firstUpdated

import Chart from 'chart.js';

// React
componentDidMount() {
  this._chart = new Chart(this.chartElRef.current, {...});
}

// Lit
firstUpdated() {
  this._chart = new Chart(this.chartEl, {...});
}
  • 首次将组件的模板渲染到组件的根节点中时调用
  • 仅在元素已连接时调用,例如,在相应节点被附加到 DOM 树中之前不会通过 document.createElement('my-component') 进行调用
  • 非常适合在其中执行需要用组件渲染 DOM 的组件设置
  • 与 React 的 componentDidMount 不同,更改 firstUpdated 中的响应式属性会导致重新渲染,不过浏览器通常会在同一帧中批量处理更改。如果这些更改不需要访问根节点的 DOM,那么它们通常应放入 willUpdate

connectedCallback

// React
componentDidMount() {
  this.window.addEventListener('resize', this.boundOnResize);
}

// Lit
connectedCallback() {
  super.connectedCallback();
  this.window.addEventListener('resize', this.boundOnResize);
}
  • 每当将自定义元素插入 DOM 树中时调用
  • 与 React 组件不同,自定义元素与 DOM 分离时不会被销毁,因此可以“连接”多次
  • 适用于重新初始化 DOM 或重新附加在连接断开时被清理的事件监听器
  • 注意:connectedCallback 可能会在 firstUpdated 之前调用,因此在首次调用时,DOM 可能不可用

componentDidUpdate

// React
componentDidUpdate(prevProps) {
  if (this.props.title !== prevProps.title) {
    this._chart.setTitle(this.props.title);
  }
}

// Lit (ts)
updated(prevProps: PropertyValues<this>) {
  if (prevProps.has('title')) {
    this._chart.setTitle(this.title);
  }
}
  • Lit 中的等效元素是 updated(使用英文单词“update”的过去式)
  • 与 React 不同,updated 也是在初始渲染时调用
  • 在功能上与 React 的 componentDidUpdate 相似

componentWillUnmount

// React
componentWillUnmount() {
  this.window.removeEventListener('resize', this.boundOnResize);
}

// Lit
disconnectedCallback() {
  super.disconnectedCallback();
  this.window.removeEventListener('resize', this.boundOnResize);
}
  • Lit 中的等效元素与 disconnectedCallback 相似
  • 与 React 组件不同,当自定义元素与 DOM 分离时,该组件不会被销毁
  • componentWillUnmount 不同,disconnectedCallback 在元素从树中移除之后调用
  • 根节点内的 DOM 仍然附加到根节点的子树中
  • 适用于清理事件监听器和易造成内存泄漏的引用,以便浏览器可对组件进行垃圾回收

练习

代码检查点 (TS)代码检查点 (JS)

import React from 'react';
import ReactDOM from 'react-dom';

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

在上面的示例中,有一个执行以下操作的简单时钟:

  • 渲染“Hello World! It is”,然后显示时间
  • 每秒更新一次时钟
  • 在卸除时清除调用 tick 的时间间隔

首先,声明组件的类:

// Lit (TS)
// some imports here are imported in advance
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';

@customElement('lit-clock')
class LitClock extends LitElement {
}

// Lit (JS)
// `html` is imported in advance
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
}

customElements.define('lit-clock', LitClock);

接下来,初始化 date 并使用 @state 声明其为内部响应式属性,因为使用此组件的用户不会直接设置 date

// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';

@customElement('lit-clock')
class LitClock extends LitElement {
  @state() // declares internal reactive prop
  private date = new Date(); // initialization
}

// Lit (JS)
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
  static get properties() {
    return {
      // declares internal reactive prop
      date: {state: true}
    }
  }

  constructor() {
    super();
    // initialization
    this.date = new Date();
  }
}

customElements.define('lit-clock', LitClock);

接下来,渲染模板。

// Lit (JS & TS)
render() {
  return html`
    <div>
      <h1>Hello, World!</h1>
      <h2>It is ${this.date.toLocaleTimeString()}.</h2>
    </div>
  `;
}

现在,实现 tick 方法。

tick() {
  this.date = new Date();
}

接下来,实现 componentDidMount。同样,在 Lit 中结合使用 firstUpdatedconnectedCallback 可达到类似效果。对于此组件而言,使用 setInterval 调用 tick 并不需要访问根节点内的 DOM。此外,时间间隔会在相应元素从文档树中移除时被清除,因此如果重新附加该元素,时间间隔也需要重新开始。由此可见,connectedCallback 在此处是更好的选择。

// Lit (TS)
@customElement('lit-clock')
class LitClock extends LitElement {
  @state()
  private date = new Date();
  private timerId = -1; // initialize timerId for TS

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  ...
}

// Lit (JS)
constructor() {
  super();
  // initialization
  this.date = new Date();
  this.timerId = -1; // initialize timerId for JS
}

connectedCallback() {
  super.connectedCallback();
  this.timerId = setInterval(
    () => this.tick(),
    1000
  );
}

最后,清理时间间隔,使其在元素与文档树断开连接后不执行 tick。

// Lit (TS & JS)
disconnectedCallback() {
  super.disconnectedCallback();
  clearInterval(this.timerId);
}

综上,完整代码应如下所示:

// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';

@customElement('lit-clock')
class LitClock extends LitElement {
  @state()
  private date = new Date();
  private timerId = -1;

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  tick() {
    this.date = new Date();
  }

  render() {
    return html`
      <div>
        <h1>Hello, World!</h1>
        <h2>It is ${this.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearInterval(this.timerId);
  }
}

// Lit (JS)
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
  static get properties() {
    return {
      date: {state: true}
    }
  }

  constructor() {
    super();
    this.date = new Date();
  }

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  tick() {
    this.date = new Date();
  }

  render() {
    return html`
      <div>
        <h1>Hello, World!</h1>
        <h2>It is ${this.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearInterval(this.timerId);
  }
}

customElements.define('lit-clock', LitClock);

在这一部分中,您将了解如何将 React 的钩子概念转换为 Lit 的概念。

React 的钩子概念

React 钩子提供了一种可供函数组件与状态“挂钩”的方式。这可以带来几点好处。

  • 让有状态逻辑的重用得到简化
  • 有助于将组件拆分为更小的函数

此外,侧重使用基于函数的组件还能解决 React 基于类的语法中存在的一些问题,例如:

  • 必须将 propsconstructor 传递到 super
  • constructor 中的属性初始化不简洁
    • 这是 React 团队当时提出的一个理由,但 ES2019 解决了这个问题
  • this 不再引用组件而导致的问题

Lit 中与 React 钩子相当的概念

组件和属性部分所述,Lit 不提供通过函数创建自定义元素的方式,但 LitElement 确实解决了 React 类组件的大多数主要问题。例如:

// React (at the time of making hooks)
import React from 'react';
import ReactDOM from 'react-dom';

class MyEl extends React.Component {
  constructor(props) {
    super(props); // Leaky implementation
    this.state = {count: 0};
    this._chart = null; // Deemed messy
  }

  render() {
    return (
      <>
        <div>Num times clicked {count}</div>
        <button onClick={this.clickCallback}>click me</button>
      </>
    );
  }

  clickCallback() {
    // Errors because `this` no longer refers to the component
    this.setState({count: this.count + 1});
  }
}

// Lit (ts)
class MyEl extends LitElement {
  @property({type: Number}) count = 0; // No need for constructor to set state
  private _chart = null; // Public class fields introduced to JS in 2019

  render() {
    return html`
        <div>Num times clicked ${count}</div>
        <button @click=${this.clickCallback}>click me</button>`;
  }

  private clickCallback() {
    // No error because `this` refers to component
    this.count++;
  }
}

Lit 如何解决这些问题?

  • constructor 不接受参数
  • 所有 @event 绑定都自动绑定到 this
  • this 在绝大多数情况下都引用自定义元素的引用
  • 类属性现在可实例化为类成员。这将清理基于构造函数的实现

响应式控制器

代码检查点 (TS)代码检查点 (JS)

钩子背后的主要概念在 Lit 中以响应式控制器的形式存在。使用响应式控制器模式可以共享有状态逻辑,将组件拆分为更小、更加模块化的位,并可与元素的更新生命周期挂钩。

响应式控制器是一种对象接口,可与 LitElement 等控制器托管组件的更新生命周期挂钩。

ReactiveControllerreactiveControllerHost 的生命周期如下:

interface ReactiveController {
  hostConnected(): void;
  hostUpdate(): void;
  hostUpdated(): void;
  hostDisconnected(): void;
}
interface ReactiveControllerHost {
  addController(controller: ReactiveController): void;
  removeController(controller: ReactiveController): void;
  requestUpdate(): void;
  readonly updateComplete: Promise<boolean>;
}

通过构造响应式控制器并使用 addController 将其附加到托管组件,该控制器的生命周期将与托管组件的生命周期一起被调用。例如,回顾一下状态和生命周期部分中的时钟示例:

import React from 'react';
import ReactDOM from 'react-dom';

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

在上面的示例中,有一个执行以下操作的简单时钟:

  • 渲染“Hello World! It is”,然后显示时间
  • 每秒更新一次时钟
  • 在卸除时清除调用 tick 的时间间隔

构建组件基架

首先,声明组件的类并添加 render 函数。

// Lit (TS) - index.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
class MyElement extends LitElement {
  render() {
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${'time to get Lit'}.</h2>
      </div>
    `;
  }
}

// Lit (JS) - index.js
import {LitElement, html} from 'lit';

class MyElement extends LitElement {
  render() {
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${'time to get Lit'}.</h2>
      </div>
    `;
  }
}

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

构建控制器

现在,切换到 clock.ts,为 ClockController 创建一个类并设置 constructor

// Lit (TS) - clock.ts
import {ReactiveController, ReactiveControllerHost} from 'lit';

export class ClockController implements ReactiveController {
  private readonly host: ReactiveControllerHost;

  constructor(host: ReactiveControllerHost) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
  }

  private tick() {
  }

  hostDisconnected() {
  }

  // Will not be used but needed for TS compilation
  hostUpdate() {};
  hostUpdated() {};
}

// Lit (JS) - clock.js
export class ClockController {
  constructor(host) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
  }

  tick() {
  }

  hostDisconnected() {
  }
}

您可以采用任意方式构建响应式控制器,只要它共享 ReactiveController 接口即可。不过,Lit 团队倾向于在大多数基本情况下使用以下模式:使用的类具有可以接受 ReactiveControllerHost 接口以及初始化控制器所需的任何其他属性的 constructor

现在,您需要将 React 生命周期回调转换为控制器回调。简而言之:

  • componentDidMount
    • 转换为 LitElement 的 connectedCallback
    • 转换为控制器的 hostConnected
  • ComponentWillUnmount
    • 转换为 LitElement 的 disconnectedCallback
    • 转换为控制器的 hostDisconnected

如需详细了解如何将 React 生命周期转换为 Lit 生命周期,请参阅状态和生命周期部分。

接下来,实现 hostConnected 回调和 tick 方法,并清理 hostDisconnected 中的时间间隔,如状态和生命周期部分中的示例所完成的那样。

// Lit (TS) - clock.ts
export class ClockController implements ReactiveController {
  private readonly host: ReactiveControllerHost;
  private interval = 0;
  date = new Date();

  constructor(host: ReactiveControllerHost) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
    this.interval = setInterval(() => this.tick(), 1000);
  }

  private tick() {
    this.date = new Date();
  }

  hostDisconnected() {
    clearInterval(this.interval);
  }

  hostUpdate() {};
  hostUpdated() {};
}

// Lit (JS) - clock.js
export class ClockController {
  interval = 0;
  host;
  date = new Date();

  constructor(host) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
    this.interval = setInterval(() => this.tick(), 1000);
  }

  tick() {
    this.date = new Date();
  }

  hostDisconnected() {
    clearInterval(this.interval);
  }
}

使用控制器

如需使用时钟控制器,请导入该控制器,并更新 index.tsindex.js 中的组件。

// Lit (TS) - index.ts
import {LitElement, html, ReactiveController, ReactiveControllerHost} from 'lit';
import {customElement} from 'lit/decorators.js';
import {ClockController} from './clock.js';

@customElement('my-element')
class MyElement extends LitElement {
  private readonly clock = new ClockController(this); // Instantiate

  render() {
    // Use controller
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }
}

// Lit (JS) - index.js
import {LitElement, html} from 'lit';
import {ClockController} from './clock.js';

class MyElement extends LitElement {
  clock = new ClockController(this); // Instantiate

  render() {
    // Use controller
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }
}

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

如需使用该控制器,您需要传入对控制器托管组件(即 <my-element> 组件)的引用以实例化控制器,然后在 render 方法中使用该控制器。

在控制器中触发重新渲染

请注意,它将显示时间,但时间没有更新。这是因为虽然控制器每秒会设置一次日期,但托管组件并未更新。之所以如此,原因在于 dateClockController 类中更改而不再在组件中更改。这意味着对控制器设置 date 后,需要使用 host.requestUpdate() 告知托管组件运行更新生命周期。

// Lit (TS & JS) - clock.ts / clock.js
private tick() {
  this.date = new Date();
  this.host.requestUpdate();
}

现在,时钟应该开始运行了!

如需了解对常见钩子用例更深入的比较,请参阅高级主题 - 钩子部分。

在这一部分中,您将了解如何在 Lit 中使用插槽管理子元素。

插槽和子元素

代码检查点

借助插槽,您可以嵌套组件,从而实现组合。

在 React 中,通过属性继承子元素。默认插槽为 props.childrenrender 函数会定义默认插槽的位置。例如:

const MyArticle = (props) => {
 return <article>{props.children}</article>;
};

请注意,props.children 是 React 组件而不是 HTML 元素。

在 Lit 中,使用 slot 元素在 render 函数中编写子元素。请注意,子元素的继承方式与 React 不同。在 Lit 中,子元素是附加到插槽的 HTMLElement。这种附加称为投影

@customElement("my-article")
export class MyArticle extends LitElement {
  render() {
    return html`
      <article>
        <slot></slot>
      </article>
   `;
  }
}

多个插槽

代码检查点

在 React 中,添加多个插槽本质上与继承更多属性相同。

const MyArticle = (props) => {
  return (
    <article>
      <header>
        {props.headerChildren}
      </header>
      <section>
        {props.sectionChildren}
      </section>
    </article>
  );
};

同样,添加更多 <slot> 元素会在 Lit 中创建更多插槽。使用 name 属性定义多个插槽:<slot name="slot-name">。如此一来,子元素就可以声明将为其分配的插槽。

@customElement("my-article")
export class MyArticle extends LitElement {
  render() {
    return html`
      <article>
        <header>
          <slot name="headerChildren"></slot>
        </header>
        <section>
          <slot name="sectionChildren"></slot>
        </section>
      </article>
   `;
  }
}

默认插槽内容

在没有节点投影到插槽时,该插槽会显示其子树。而当节点投影到插槽后,该插槽就不会显示其子树,而是显示投影的节点。

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    return html`
      <section>
        <div>
          <slot name="slotWithDefault">
            <p>
             This message will not be rendered when children are attached to this slot!
            <p>
          </slot>
        </div>
      </section>
   `;
  }
}

将子元素分配到插槽

代码检查点

在 React 中,通过组件的属性将子元素分配到插槽。在以下示例中,React 元素被传递到 headerChildrensectionChildren 属性。

const MyNewsArticle = () => {
 return (
   <MyArticle
     headerChildren={<h3>Extry, Extry! Read all about it!</h3>}
     sectionChildren={<p>Children are props in React!</p>}
   />
 );
};

在 Lit 中,使用 slot 属性将子元素分配到插槽。

@customElement("my-news-article")
export class MyNewsArticle extends LitElement {
  render() {
    return html`
      <my-article>
        <h3 slot="headerChildren">
          Extry, Extry! Read all about it!
        </h3>
        <p slot="sectionChildren">
          Children are composed with slots in Lit!
        </p>
      </my-article>
   `;
  }
}

如果没有默认插槽(例如 <slot>),而所有具有 name 属性的插槽(例如 <slot name="foo">)与自定义元素的子元素(例如 <div slot="foo">)的 slot 属性都不匹配,那么就不会投影也不会显示该节点。

有时,开发者可能需要访问 HTMLElement 的 API。

在这一部分中,您将了解如何在 Lit 中获取元素引用。

React 引用

代码检查点 (TS)代码检查点 (JS)

React 组件会被源到源编译为一系列函数调用,这些函数调用在被调用时,将创建一个虚拟 DOM。此虚拟 DOM 由 ReactDOM 解释并渲染 HTMLElement。

在 React 中,引用是内存中包含生成的 HTMLElement 的空间。

const RefsExample = (props) => {
 const inputRef = React.useRef(null);
 const onButtonClick = React.useCallback(() => {
   inputRef.current?.focus();
 }, [inputRef]);

 return (
   <div>
     <input type={"text"} ref={inputRef} />
     <br />
     <button onClick={onButtonClick}>
       Click to focus on the input above!
     </button>
   </div>
 );
};

在上面的示例中,React 组件将执行以下操作:

  • 渲染一个空文本输入和一个带文本的按钮
  • 在用户点击按钮时聚焦输入

初始渲染之后,React 会通过 ref 属性将 inputRef.current 设置为生成的 HTMLInputElement

使用 @query 的 Lit“引用”

Lit 与浏览器高度集成,并对原生浏览器功能创建了非常浅的抽象。

Lit 中由 @query@queryAll 修饰器返回的 HTMLElement 相当于 React 中的 refs

@customElement("my-element")
export class MyElement extends LitElement {
  @query('input') // Define the query
  inputEl!: HTMLInputElement; // Declare the prop

  // Declare the click event listener
  onButtonClick() {
    // Use the query to focus
    this.inputEl.focus();
  }

  render() {
    return html`
      <input type="text"></input>
      <br />
      <!-- Bind the click listener -->
      <button @click=${this.onButtonClick}>
        Click to focus on the input above!
      </button>
   `;
  }
}

在上面的示例中,Lit 组件执行以下操作:

  • 使用 @query 修饰器在 MyElement 上定义一个属性(为 HTMLInputElement 创建 getter)。
  • 声明并附加名为 onButtonClick 的点击事件回调。
  • 在点击按钮时聚焦输入

在 JavaScript 中,@query@queryAll 修饰器分别执行 querySelectorquerySelectorAll。以下是相当于 @query('input') inputEl!: HTMLInputElement; 的 JavaScript 代码

get inputEl() {
  return this.renderRoot.querySelector('input');
}

Lit 组件将 render 方法的模板提交到 my-element 的根节点后,@query 修饰器现在允许 inputEl 返回在渲染根节点中找到的第一个 input 元素。如果 @query 找不到指定的元素,它将返回 null

如果渲染根节点中存在多个 input 元素,@queryAll 会返回一个节点列表。

在这一部分中,您将了解如何在 Lit 中的组件之间调解状态。

可重用的组件

代码检查点

React 通过数据自上而下的流动来模拟函数式渲染流水线。父元素通过属性向子元素提供状态。子元素通过在属性中找到的回调与父元素通信。

const CounterButton = (props) => {
  const label = props.step < 0
    ? `- ${-1 * props.step}`
    : `+ ${props.step}`;

  return (
    <button
      onClick={() =>
        props.addToCounter(props.step)}>{label}</button>
  );
};

在上面的示例中,React 组件将执行以下操作:

  • 根据值 props.step 创建一个标签。
  • 以 +step 或 -step 为标签渲染按钮
  • 在点击时,通过调用 props.addToCounter 并以 props.step 为参数来更新父组件

尽管可以在 Lit 中传递回调,但常见模式却不同。上面示例中的 React 组件可以写成下面示例中的 Lit 组件:

@customElement('counter-button')
export class CounterButton extends LitElement {
  @property({type: Number}) step: number = 0;

  onClick() {
    const event = new CustomEvent('update-counter', {
      bubbles: true,
      detail: {
        step: this.step,
      }
    });

    this.dispatchEvent(event);
  }

  render() {
    const label = this.step < 0
      ? `- ${-1 * this.step}`  // "- 1"
      : `+ ${this.step}`;      // "+ 1"

    return html`
      <button @click=${this.onClick}>${label}</button>
    `;
  }
}

在上面的示例中,Lit 组件将执行以下操作:

  • 创建响应式属性 step
  • 在点击时,分派名为 update-counter 并携带元素的 step 值的自定义事件

浏览器事件从子元素向上冒泡到父元素。事件允许子元素广播互动事件和状态更改。从根本上而言,React 是按相反的方向传递状态,因此 React 组件使用与 Lit 组件相同的方式分派和监听事件的情况并不多见。

有状态组件

代码检查点

在 React 中,通常使用钩子管理状态。您可以通过重用 CounterButton 组件来创建 MyCounter 组件。请注意 addToCounter 是如何传递到 CounterButton 的两个实例的。

const MyCounter = (props) => {
 const [counterSum, setCounterSum] = React.useState(0);
 const addToCounter = useCallback(
   (step) => {
     setCounterSum(counterSum + step);
   },
   [counterSum, setCounterSum]
 );

 return (
   <div>
     <h3>&Sigma;: {counterSum}</h3>
     <CounterButton
       step={-1}
       addToCounter={addToCounter} />
     <CounterButton
       step={1}
       addToCounter={addToCounter} />
   </div>
 );
};

上面的示例执行以下操作:

  • 创建一个 count 状态。
  • 创建一个回调,用于为 count 状态加一个数字。
  • CounterButton 在每次点击时使用 addToCounter 按照 step 的值更新 count

在 Lit 中,可以实现与 MyCounter 类似的实现。请注意,addToCounter 并未传递到 counter-button,而是在父元素上将该回调作为事件监听器绑定到 @update-counter 事件。

@customElement("my-counter")
export class MyCounter extends LitElement {
  @property({type: Number}) count = 0;

  addToCounter(e: CustomEvent<{step: number}>) {
    // Get step from detail of event or via @query
    this.count += e.detail.step;
  }

  render() {
    return html`
      <div @update-counter="${this.addToCounter}">
        <h3>&Sigma; ${this.count}</h3>
        <counter-button step="-1"></counter-button>
        <counter-button step="1"></counter-button>
      </div>
    `;
  }
}

上面的示例执行以下操作:

  • 创建一个名为 count 的响应式属性,用于在值发生更改时更新组件
  • addToCounter 回调绑定到 @update-counter 事件监听器
  • 通过增加在 update-counter 事件的 detail.step 中找到的值来更新 count
  • 通过 step 属性设置 counter-buttonstep

在 Lit 中,使用响应式属性将更改从父元素广播到子元素的情况更常见。同样,使用浏览器的事件系统从下向上冒泡详细信息也是一种很好的做法。

此方法既遵循最佳做法,又符合 Lit 为网页组件提供跨平台支持的目标。

在这一部分中,您将了解 Lit 中的样式。

样式

Lit 提供多种设置元素样式的方式以及一个内置的解决方案。

内嵌样式

代码检查点

Lit 支持内嵌样式以及绑定到内嵌样式。

import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
class MyElement extends LitElement {
  render() {
    return html`
      <div>
        <h1 style="color:orange;">This text is orange</h1>
        <h1 style="color:rebeccapurple;">This text is rebeccapurple</h1>
      </div>
    `;
  }
}

上面的示例中有 2 个标题,并且各包含一个内嵌样式。

现在,尝试将 border: 1px solid black 绑定到橙色文本:

<h1 style="color:orange;${'border: 1px solid black;'}">This text is orange</h1>

每次都必须计算样式字符串可能会令人厌烦,因此 Lit 提供了一条指令来帮助解决此问题。

styleMap

借助 styleMap 指令,您可以更轻松地使用 JavaScript 设置内嵌样式。例如:

代码检查点

import {LitElement, html, css} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map';

@customElement('my-element')
class MyElement extends LitElement {
  @property({type: String})
  color = '#000'

  render() {
    // Define the styleMap
    const headerStyle = styleMap({
      'border-color': this.color,
    });

    return html`
      <div>
        <h1
          style="border-style:solid;
          <!-- Use the styleMap -->
          border-width:2px;${headerStyle}">
          This div has a border color of ${this.color}
        </h1>
        <input
          type="color"
          @input=${e => (this.color = e.target.value)}
          value="#000">
      </div>
    `;
  }
}

上面的示例会执行以下操作:

  • 显示带有边框和颜色选择器的 h1
  • border-color 更改为颜色选择器中的值

此外,还有用于设置 h1 样式的 styleMapstyleMap 遵循的语法与 React 的 style 属性绑定语法相似。

CSSResult

代码检查点

如果要为组件设置样式,建议使用 css 带标记模板字面量。

import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';

const ORANGE = css`orange`;

@customElement('my-element')
class MyElement extends LitElement {
  static styles = [
    css`
      #orange {
        color: ${ORANGE};
      }

      #purple {
        color: rebeccapurple;
      }
    `
  ];

  render() {
    return html`
      <div>
    <h1 id="orange">This text is orange</h1>
        <h1 id="purple">This text is rebeccapurple</h1>
      </div>
    `;
  }
}

上面的示例会执行以下操作:

  • 使用绑定声明 CSS 带标记模板字面量
  • 使用 ID 设置两个 h1 的颜色

使用 css 模板标记的优势:

  • 每个类解析一次与每个实例解析一次
  • 在实现时考虑到了模块的可重用性
  • 可以轻松地将样式分到其自己的文件中
  • 与 CSS 自定义属性 polyfill 兼容

此外,请注意 index.html 中的 <style> 标记:

<!-- index.html -->
<style>
  h1 {
    color: red !important;
  }
</style>

Lit 会将组件样式的作用域限定为其根节点。这意味着样式不会泄露和渗入。如需将样式传递给组件,Lit 团队建议使用 CSS 自定义属性,因为此类属性可以穿透 Lit 样式作用域。

样式标记

您还可以直接在模板中内嵌 <style> 标记。浏览器会删除重复的此类样式标记,不过将其放在模板中就能按组件实例对其进行解析,而不是像使用 css 带标记模板那样按类进行解析。此外,浏览器删除重复 CSSResult 的速度也快得多。

虽然您还可以在模板中对样式使用 <link rel="stylesheet">,但我们也建议不要这样做,因为它可能会导致最初的内容样式短暂失效 (FOUC)。

JSX 和模板

Lit 和虚拟 DOM

lit-html 不包含对每个节点执行 diff 算法的传统虚拟 DOM,而是利用 ES2015 带标记模板字面量规范本质的性能特性。带标记模板字面量是附加了 tag 函数的模板字面量字符串。

以下是模板字面量的一个示例:

const str = 'string';
console.log(`This is a template literal ${str}`);

以下是带标记模板字面量的一个示例:

const tag = (strings, ...values) => ({strings, values});
const f = (x) => tag`hello ${x} how are you`;
console.log(f('world')); // {strings: ["hello ", " how are you"], values: ["world"]}
console.log(f('world').strings === f(1 + 2).strings); // true

在上面的示例中,标记是 tag 函数,而 f 函数将返回带标记模板字面量的调用。

Lit 在性能上的许多神奇之处都源于传入 tag 函数的字符串数组具有相同的指针(如第二个 console.log 中所示)。浏览器不会在每次调用 tag 函数时重新创建新的 strings 数组,因为它使用的是同一个模板字面量(即,位于 AST 中的相同位置)。因此,Lit 的绑定、解析和模板缓存可以充分利用这些特性,而不会产生太多运行时 diff 算法开销。

带标记模板字面量的这种内置浏览器行为赋予 Lit 相当大的性能优势。大多数传统虚拟 DOM 使用 JavaScript 完成大部分工作。但是,带标记模板字面量在浏览器中使用 C++ 执行大多数 diff 算法。

如果您想通过 React 或 Preact 开始使用 HTML 带标记模板字面量,Lit 团队建议您使用 htm

不过,您会发现带标记模板字面量的语法突出显示不是很常见,Google Codelab 网站和一些在线代码编辑器的情况往往都是如此。有些 IDE 和文本编辑器默认支持此类语法突出显示,例如 Atom 和 GitHub 的代码块高亮器。Lit 团队也与社区紧密合作,共同维护一些项目,例如为 Lit 项目添加语法突出显示、类型检查和智能感知功能的 VS Code 插件 lit-plugin

Lit 和 JSX + React DOM

JSX 不在浏览器中运行,而是使用预处理器将 JSX 转换为 JavaScript 函数调用(通常通过 Babel)。

例如,Babel 会将以下代码:

const element = <div className="title">Hello World!</div>;
ReactDOM.render(element, mountNode);

转换为以下代码:

const element = React.createElement('div', {className: 'title'}, 'Hello World!');
ReactDOM.render(element, mountNode);

然后,React DOM 接受 React 输出并将其转换为实际 DOM(属性、事件监听器等)。

lit-html 使用的带标记模板字面量无需源到源编译或预处理器即可在浏览器中运行。也就是说,要想开始使用 Lit,您只需要一个 HTML 文件、一个 ES 模块脚本和一台服务器。下面就是一个可完全在浏览器中运行的脚本:

<!DOCTYPE html>
<html>
  <head>
    <script type="module">
      import {html, render} from 'https://cdn.skypack.dev/lit';

      render(
        html`<div>Hello World!</div>`,
        document.querySelector('.root')
      )
    </script>
  </head>
  <body>
    <div class="root"></div>
  </body>
</html>

此外,由于 Lit 模板系统 lit-html 不使用传统虚拟 DOM 而是直接使用 DOM API,因此 Lit 2 的大小经缩减大小和 gzip 压缩后不到 5kb,而经过缩减大小和 gzip 压缩的 React (2.8kb) + react-dom (39.4kb) 大小超过 40kb。

事件

React 使用合成事件系统。这意味着 react-dom 必须定义将在每个组件上使用的每个事件,并为每种类型的节点提供一个等效的驼峰式大小写 (camelCase) 事件监听器。因此,JSX 没有为自定义事件定义事件监听器的方法,开发者必须使用 ref,然后以命令方式应用监听器。如果集成的库没有考虑 React 因而导致开发者不得不编写 React 专用的封装容器,这一点会使开发者体验欠佳。

lit-html 可直接访问 DOM 并使用原生事件,因此添加事件监听器就是编写一行 @event-name=${eventNameListener} 这么简单。这意味着添加事件监听器和触发事件所需的运行时解析更少。

组件和属性

React 组件和自定义元素

在后台,LitElement 使用自定义元素打包其组件。在组件化方面,自定义元素对不同 React 组件进行了权衡(状态和生命周期部分进一步介绍了状态和生命周期)。

自定义元素作为组件系统具有如下优势:

  • 浏览器原生,无需任何工具
  • innerHTMLdocument.createElementquerySelector,适用于所有浏览器 API
  • 通常可以跨框架使用
  • 可以使用 customElements.define 和“hydrate”DOM 延迟注册

与 React 组件相比,自定义元素也有一些劣势:

  • 无法在不定义类的情况下创建自定义元素(因此也就不会有类似 JSX 的功能组件)
  • 必须包含一个结束标记
    • 注意:尽管开发者很方便,但浏览器供应商往往对自闭合标记规范感到后悔,这也正是较新的规范倾向于不包含自闭合标记的原因
  • 在 DOM 树中引入了一个额外的节点,这可能会导致布局问题
  • 必须通过 JavaScript 注册

Lit 采用自定义元素而非定制元素系统,因为自定义元素内置于浏览器中,而且 Lit 团队认为跨框架的优势胜于组件抽象层的优势。事实上,Lit 团队在 lit-ssr 领域的努力已经解决了 JavaScript 注册的主要问题。此外,GitHub 等一些公司也在利用自定义元素的延迟注册,通过可选功能渐进增强页面。

将数据传递给自定义元素

对自定义元素的一个常见误解是,只能以字符串形式传入数据。造成这种误解的原因可能是元素属性只能编写为字符串。虽然 Lit 确实会将字符串属性转换为其定义的类型,但自定义元素也可以接受复杂的数据作为属性。

以下面的 LitElement 定义为例:

代码

// data-test.ts
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('data-test')
class DataTest extends LitElement {
  @property({type: Number})
  num = 0;

  @property({attribute: false})
  data = {a: 0, b: null, c: [html`<div>hello</div>`, html`<div>world</div>`]}

  render() {
    return html`
      <div>num + 1 = ${this.num + 1}</div>
      <div>data.a = ${this.data.a}</div>
      <div>data.b = ${this.data.b}</div>
      <div>data.c = ${this.data.c}</div>`;
  }
}

上例定义了一个基元响应式属性 num,它会将属性的字符串值转换为 number,然后使用 attribute:false 禁用 Lit 的属性处理,从而引入复杂的数据结构。

下面展示了如何将数据传递到此自定义元素:

<head>
  <script type="module">
    import './data-test.js'; // loads element definition
    import {html} from './data-test.js';

    const el = document.querySelector('data-test');
    el.data = {
      a: 5,
      b: null,
      c: [html`<div>foo</div>`,html`<div>bar</div>`]
    };
  </script>
</head>
<body>
  <data-test num="5"></data-test>
</body>

状态和生命周期

其他 React 生命周期回调

static getDerivedStateFromProps

Lit 中没有等效元素,因为 props 和 state 二者是相同的类属性

shouldComponentUpdate

  • Lit 中的等效元素是 shouldUpdate
  • 在首次渲染时调用,这与 React 不同
  • 在功能上与 React 的 shouldComponentUpdate 相似

getSnapshotBeforeUpdate

在 Lit 中,getSnapshotBeforeUpdateupdatewillUpdate 都很相似

willUpdate

  • update 之前调用
  • getSnapshotBeforeUpdate 不同,willUpdaterender 之前调用
  • willUpdate 中响应式属性的更改不会重新触发更新周期
  • 非常适合用来计算依赖于其他属性并在更新过程其余环节中使用的属性值
  • 此方法在服务器上的 SSR 中调用,因此,建议不要在此处访问 DOM

update

  • willUpdate 之后调用
  • getSnapshotBeforeUpdate 不同,updaterender 之前调用
  • update 中响应式属性的更改如果发生在调用 super.update 之前,就不会重新触发更新周期
  • 在将渲染的输出提交到 DOM 之前,适合使用此方法从 DOM 捕获有关组件的信息
  • 此方法不会在服务器上的 SSR 中调用

其他 Lit 生命周期回调

还有一些生命周期回调在上一部分中并未提及,因为 React 中没有与其类似的元素。它们是:

attributeChangedCallback

在元素的其中一个 observedAttributes 发生更改时调用。observedAttributesattributeChangedCallback 都是自定义元素规范的一部分,并由 Lit 在后台实现,以便为 Lit 元素提供属性 API。

adoptedCallback

将组件移至新文档中(例如从 HTMLTemplateElementdocumentFragment 移至主 document)时调用。此回调也是自定义元素规范的一部分,并且只应当用于组件更改了文档的高级用例。

其他生命周期方法和属性

以下方法和属性是您可以调用、替换或等待的类成员,可帮助您操控生命周期过程。

updateComplete

这是一个 Promise,它在元素完成更新时解析,因为更新和渲染生命周期是异步的。示例:

async nextButtonClicked() {
  this.step++;
  // Wait for the next "step" state to render
  await this.updateComplete;
  this.dispatchEvent(new Event('step-rendered'));
}

getUpdateComplete

updateComplete 解析时,应替换此方法以进行自定义。当某个组件渲染子组件且其渲染周期必须同步时,常会发生这种情况。例如:

class MyElement extends LitElement {
  ...
  async getUpdateComplete() {
    await super.getUpdateComplete();
    await this.myChild.updateComplete;
  }
}

performUpdate

此方法会调用更新生命周期回调。除非是在必须同步完成更新的极少数情况下或者为了进行自定义调度,否则通常应该不需要使用此方法。

hasUpdated

如果组件至少已更新过一次,那么此属性的值为 true

isConnected

作为自定义元素规范的一部分,此属性在元素当前附加到主文档树的情况下将为 true

直观展示 Lit 更新生命周期

更新生命周期分为 3 个部分:

  • 更新前
  • 更新
  • 更新后

更新前

带有回调名称的节点有向无环图。箭头从 constructor 指向 requestUpdate。从 @property 指向 Property Setter。从 attributeChangedCallback 指向 Property Setter。从 Property Setter 指向 hasChanged。从 hasChanged 指向 requestUpdate。从 requestUpdate 指向下一张更新生命周期图。

requestUpdate 之后,有一个安排的更新等待完成。

更新

带有回调名称的节点有向无环图。箭头从上一张更新前生命周期图指向 performUpdate。从 performUpdate 指向 shouldUpdate。从 shouldUpdate 指向“complete update if false”和 willUpdate。从 willUpdate 指向 update。从 update 指向 render 和下一张更新后生命周期图。render 也指向下一张更新后生命周期图。

更新后

带有回调名称的节点有向无环图。箭头从上一张更新生命周期图指向 firstUpdated。从 firstUpdated 指向 updated。从 updated 指向 updateComplete。

钩子

为何要使用钩子

React 中针对需要状态的简单函数组件用例引入了钩子。在许多简单用例中,带有钩子的函数组件往往比同类的类组件简单得多,也更易读懂。然而,当引入异步状态更新以及在钩子或副作用之间传递数据时,钩子模式往往就不够了,而响应式控制器等基于类的解决方案常常表现突出。

API 请求钩子和控制器

编写一个钩子向 API 请求数据的情况很常见。以执行下列操作/请求的 React 函数组件为例:

  • index.tsx
    • 渲染文本
    • 渲染 useAPI 的响应
      • 用户 ID + 用户名
      • 错误消息
        • 到达用户 11(按设计)时的 404 消息
        • API 提取中止时的中止错误消息
      • 加载消息
    • 渲染操作按钮
      • 下一个用户:为下一个用户提取 API
      • 取消:中止 API 提取并显示错误
  • useApi.tsx
    • 定义一个 useApi 自定义钩子
    • 将从某个 API 异步提取用户对象
    • 发出:
      • 用户名
      • 提取是否正在加载
      • 任意错误消息
      • 中止提取的回调
    • 在卸除后中止正在进行的提取

这是 Lit + Reactive 控制器的实现

知识要点:

  • 响应式控制器与自定义钩子最为相似
  • 在回调和副作用之间传递不可渲染的数据
    • React 使用 useRefuseEffectuseCallback 之间传递数据
    • Lit 使用不公开的类属性
    • React 本质上是在模拟不公开的类属性的行为

子元素

默认插槽

HTML 元素没有指定 slot 属性时,会被分配到默认的未命名插槽。在以下示例中,MyApp 会将一个段落插入一个命名插槽中。另一个段落将默认插入未命名插槽中。

游乐场

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    return html`
      <section>
        <div>
          <slot></slot>
        </div>
        <div>
          <slot name="custom-slot"></slot>
        </div>
      </section>
   `;
  }
}

@customElement("my-app")
export class MyApp extends LitElement {
  render() {
    return html`
      <my-element>
        <p slot="custom-slot">
          This paragraph will be placed in the custom-slot!
        </p>
        <p>
          This paragraph will be placed in the unnamed default slot!
        </p>
      </my-element>
   `;
  }
}

插槽更新

当插槽后代的结构发生更改时,会触发 slotchange 事件。Lit 组件可以将事件监听器绑定到 slotchange 事件。在以下示例中,shadowRoot 中第一个插槽的 assignedNodes 会在 slotchange 时记录到控制台。

@customElement("my-element")
export class MyElement extends LitElement {
  onSlotChange(e: Event) {
    const slot = this.shadowRoot.querySelector('slot');
    console.log(slot.assignedNodes({flatten: true}));
  }

  render() {
    return html`
      <section>
        <div>
          <slot @slotchange="{this.onSlotChange}"></slot>
        </div>
      </section>
   `;
  }
}

引用

引用的生成

在调用 render 函数后,Lit 和 React 都会公开对 HTMLElement 的引用。不过,我们有必要回顾一下 React 和 Lit 如何编写稍后会通过 Lit @query 修饰器或 React 引用返回的 DOM。

React 是一种函数式流水线,创建 React 组件而非 HTMLElement。由于在渲染 HTMLElement 前会声明一个引用,因此会分配内存空间。这正是引用的初始值为 null 的原因,因为实际的 DOM 元素尚未创建(或渲染),即 useRef(null)

当 ReactDOM 将 React 组件转换为 HTMLElement 后,它会在 ReactComponent 中查找名为 ref 的属性。如果有,ReactDOM 就会将 HTMLElement 的引用放入 ref.current

LitElement 使用 lit-html 中的 html 模板标记函数在后台编写模板元素。在渲染后,LitElement 会将模板的内容贴到自定义元素的 shadow DOM 中。shadow DOM 是由影子根封装的带作用域 DOM 树。然后,@query 修饰器会为属性创建 getter,它实质上会对带作用域的根执行 this.shadowRoot.querySelector

查询多个元素

在以下示例中,@queryAll 修饰器会以 NodeList 的形式返回影子根中的两个段落。

@customElement("my-element")
export class MyElement extends LitElement {
  @queryAll('p')
  paragraphs!: NodeList;

  render() {
    return html`
      <p>Hello, world!</p>
      <p>How are you?</p>
   `;
  }
}

实质上,@queryAll 会为 paragraphs 创建一个 getter,用于返回 this.shadowRoot.querySelectorAll() 的结果。在 JavaScript 中,可以声明一个 getter 来达到相同的目的:

get paragraphs() {
  return this.renderRoot.querySelectorAll('p');
}

查询有变化的元素

@queryAsync 修饰器更适合处理可以根据其他元素属性的状态发生更改的节点。

在以下示例中,@queryAsync 将查找第一个段落元素。不过,只有当 renderParagraph 随机生成奇数时,才会渲染段落元素。@queryAsync 指令将返回一个 promise,它将在第一个段落可用时解析。

@customElement("my-dissappearing-paragraph")
export class MyDisapppearingParagraph extends LitElement {
  @queryAsync('p')
  paragraph!: Promise<HTMLElement>;

  renderParagraph() {
    const randomNumber = Math.floor(Math.random() * 10)
    if (randomNumber % 2 === 0) {
      return "";
    }

    return html`<p>This checkbox is checked!`
  }

  render() {
    return html`
      ${this.renderParagraph()}
   `;
  }
}

调解状态

在 React 中,通常会使用回调,因为状态由 React 本身调解。React 会尽量不依赖元素提供的状态。DOM 只是渲染过程的效果。

外部状态

您可以将 Redux、MobX 或任何其他状态管理库与 Lit 一起使用。

Lit 组件的创建不会超出浏览器的作用域。因此,同样存在于浏览器作用域内的任何库都可以供 Lit 使用。现在已经构建了许多表现出色的库来利用 Lit 中的现有状态管理系统。

此处是 Vaadin 的系列文章,介绍了如何在 Lit 组件中利用 Redux。

了解一下 Adobe 的 lit-mobx,看看大型网站如何在 Lit 中利用 MobX。

此外,请查阅 Apollo Elements,了解开发者如何在其网页组件中包含 GraphQL。

Lit 可与原生浏览器功能结合使用,而浏览器作用域内的大多数状态管理解决方案也都可用于 Lit 组件。

样式

Shadow DOM

为了在自定义元素内原生封装样式和 DOM,Lit 会使用 Shadow DOM。影子根会生成一个独立于主文档树的影子树。这意味着大多数样式的作用域就是此文档。但某些样式确实会通过颜色和其他字体相关样式泄露。

Shadow DOM 还为 CSS 规范引入了新的概念和选择器:

:host, :host(:hover), :host([hover]) {
  /* Styles the element in which the shadow root is attached to */
}

slot[name="title"]::slotted(*), slot::slotted(:hover), slot::slotted([hover]) {
  /*
   * Styles the elements projected into a slot element. NOTE: the spec only allows
   * styling the direcly slotted elements. Children of those elements are not stylable.
   */
}

共享样式

使用 Lit,您可以轻松地以 CSSTemplateResults 的形式通过 css 模板标记在组件之间共享样式。例如:

// typography.ts
export const body1 = css`
  .body1 {
    ...
  }
`;

// my-el.ts
import {body1} from './typography.ts';

@customElement('my-el')
class MyEl Extends {
  static get styles = [
    body1,
    css`/* local styles come after so they will override bod1 */`
  ]

  render() {
    return html`<div class="body1">...</div>`
  }
}

主题

影子根给传统主题带来了一定的挑战,因为传统主题通常是自上而下样式标记方法。Web Components 使用 Shadow DOM 处理主题的传统方式是通过 CSS 自定义属性公开样式 API。例如,以下就是 Material Design 使用的模式:

.mdc-textfield-outline {
  border-color: var(--mdc-theme-primary, /* default value */ #...);
}
.mdc-textfield--input {
  caret-color: var(--mdc-theme-primary, #...);
}

然后,用户通过应用自定义属性值来更改网站的主题:

html {
  --mdc-theme-primary: #F00;
}
html[dark] {
  --mdc-theme-primary: #F88;
}

如果必须采用自上而下主题而且您无法公开样式,您随时可以通过以下方式停用 Shadow DOM:替换 createRenderRoot 以返回 this,这样会将组件的模板渲染到自定义元素本身而不是附加到自定义元素的影子根。不过,这样做会牺牲样式封装、DOM 封装和插槽。

生产环境

IE 11

如果您需要支持 IE 11 等旧版浏览器,必须加载一些 polyfill,大约会增加 33kb 的大小。如需了解详情,请点击此处

基于条件的软件包

Lit 团队建议提供两个不同的软件包,一个用于 IE 11,一个用于现代浏览器。这样做有几点好处:

  • 提供 ES 6 的速度更快,并可满足大部分客户端的需求
  • 经过源到源编译的 ES 5 显著增加了软件包的大小
  • 基于条件的软件包兼具二者的优势
    • 支持 IE 11
    • 在现代浏览器中不会降速

如需详细了解如何构建基于条件提供的软件包,请点击此处访问我们的文档网站。