课程 7 - Web UI
在这节课中你将学习到以下内容:
- 使用 Lit 和 Shoelace 开发 Web UI
- 实现画布组件,监听页面宽高变换
- 实现缩放组件
使用 Lit 和 Shoelace 开发 Web UI
在选择组件库时,我不希望它绑定在某个具体的框架实现上。Web components 是不错的选择,Lit 为它添加了响应式状态、声明式模版、模块化样式等特性,让开发过程变得更便捷。Shoelace 是一个基于 Lit 开发的组件库。使用它们可以让我们的画布组件同时支持 React、Vue 和 Angular,做到真正的框架无关。
由于本教程静态站点使用 VitePress 编写,因此在示例页面中使用了 Vue 的接入方式,但组件中不会使用到 Vue 的语法。
接下来我们将目前已有的简单功能封装成 UI 组件。
画布组件
使用 Lit 声明一个画布组件,为了避免潜在的命名冲突,我们使用了 ic
作为组件前缀:
import { LitElement } from 'lit';
@customElement('ic-canvas')
export class InfiniteCanvas extends LitElement {}
首先通过装饰器定义画布组件的属性,这样可以通过 <ic-canvas renderer="webgl" />
使用它:
import { property } from 'lit/decorators.js';
export class InfiniteCanvas extends LitElement {
@property()
renderer = 'webgl';
}
我们希望画布跟随页面宽高变化,Shoelace 提供了开箱即用的 Resize Observer,当元素宽高改变时会抛出自定义事件 sl-resize
。我们在 Lit 提供的 render
生命周期中声明 HTML,通过 query 可以快捷地查询到实际的 HTMLCanvasElement:
export class InfiniteCanvas extends LitElement {
@query('canvas', true)
$canvas: HTMLCanvasElement;
render() {
return html`
<sl-resize-observer>
<canvas></canvas>
</sl-resize-observer>
`;
}
}
在 connectedCallback 生命周期中,监听尺寸变化,在 disconnectedCallback 生命周期中解除监听:
export class InfiniteCanvas extends LitElement {
connectedCallback() {
this.addEventListener('sl-resize', this.resize);
}
disconnectedCallback() {
this.removeEventListener('sl-resize', this.resize);
}
}
何时创建画布这个问题困扰了我一阵,在 connectedCallback 生命周期中尝试获取 <canvas>
将返回 undefined
,因为此时 CustomElement 还没有加入到文档中,自然也没法通过 DOM API 查询到。最后我发现 firstUpdated 是个不错的时机,创建画布后触发自定义事件 ic-ready
并在事件对象中带上画布实例,同时在每个 tick 中触发自定义事件 ic-frame
:
export class InfiniteCanvas extends LitElement {
async firstUpdated() {
this.#canvas = await new Canvas({
canvas: this.$canvas,
renderer: this.renderer as 'webgl' | 'webgpu',
}).initialized;
this.dispatchEvent(
new CustomEvent('ic-ready', { detail: this.#canvas }),
);
const animate = (time?: DOMHighResTimeStamp) => {
this.dispatchEvent(new CustomEvent('ic-frame', { detail: time }));
this.#canvas.render();
this.#rafHandle = window.requestAnimationFrame(animate);
};
animate();
}
}
这样我们的画布组件就编写完成了。Web components 的框架无关性让我们可以以一致的方式使用。以 Vue 和 React 为例:
<template>
<ic-canvas renderer="webgl"></ic-canvas>
</template>
<div>
<ic-canvas renderer="webgl"></ic-canvas>
</div>
画布加载完成后通过监听 ic-ready
自定义事件获取画布实例:
const $canvas = document.querySelector('ic-canvas');
$canvas.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
// 创建场景图
canvas.appendChild(circle);
});
下面我们来实现相机缩放组件。
相机缩放组件
下图是 Miro 的缩放组件:
我们创建一个 ic-zoom-toolbar
@customElement('ic-zoom-toolbar')
export class ZoomToolbar extends LitElement {}
在上一节的画布组件中添加:
<sl-resize-observer>
<canvas></canvas>
<ic-zoom-toolbar zoom="${this.zoom}"></ic-zoom-toolbar>
</sl-resize-observer>
这个组件内部结构如下,可以看到 Lit 使用的模版语法很接近 Vue:
<sl-button-group label="Zoom toolbar">
<sl-tooltip content="Zoom out">
<sl-icon-button
name="dash-lg"
label="Zoom out"
@click="${this.zoomOut}"
></sl-icon-button>
</sl-tooltip>
<span>${this.zoom}%</span>
<sl-tooltip content="Zoom in">
<sl-icon-button
name="plus-lg"
label="Zoom in"
@click="${this.zoomIn}"
></sl-icon-button>
</sl-tooltip>
</sl-button-group>
为了将画布组件中的画布实例传递到组件中,我们使用了 Lit Context,在实例化后保存到上下文中:
const canvasContext = createContext<Canvas>(Symbol('canvas'));
export class InfiniteCanvas extends LitElement {
#provider = new ContextProvider(this, { context: canvasContext });
async firstUpdated() {
this.#provider.setValue(this.#canvas);
}
}
然后就可以通过上下文在组件中消费了,由于画布的创建是异步的,需要通过 subscribe
让我们实时保持对上下文的跟踪:
export class ZoomToolbar extends LitElement {
@consume({ context: canvasContext, subscribe: true })
canvas: Canvas;
}
在相机上增加一个回调函数,每次相机或者投影矩阵发生变化时触发:
export class Camera {
onchange: () => void;
private updateViewProjectionMatrix() {
if (this.onchange) {
this.onchange();
}
}
}
回调触发时会修改响应式变量 zoom
this.#canvas.camera.onchange = () => {
this.zoom = Math.round(this.#canvas.camera.zoom * 100);
};
后续我们就不再详细介绍 UI 部分的实现了。