1. 简介
网络组件
Web 组件是一组 Web 标准,可让开发者使用自定义元素扩展 HTML。在此 Codelab 中,您将定义 <brick-viewer>
元素,该元素将能够显示砖块模型!
lit-element
为帮助我们定义自定义元素 <brick-viewer>
,我们将使用 lit-element。lit-element 是一个轻量级基类,可在网络组件标准中添加一些语法糖。这样,我们就可以轻松上手使用自定义元素了。
开始使用
我们将在在线 Stackblitz 环境中编写代码,因此请在新窗口中打开以下链接:
stackblitz.com/edit/brick-viewer
让我们开始吧!
2. 定义自定义元素
类定义
如需定义自定义元素,请创建一个扩展 LitElement
的类,并使用 @customElement
对其进行修饰。@customElement
的参数将是自定义元素的名称。
在 brick-viewer.ts 中,添加以下代码:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}
现在,<brick-viewer></brick-viewer>
元素可以在 HTML 中使用了。但是,如果您尝试,则不会呈现任何内容。让我们解决这个问题。
呈现方法
要实现组件的视图,请定义一个名为 render 的方法。此方法应返回带有 html
函数标记的模板字面量。在标记的模板字面量中放入所需的任何 HTML。当您使用 <brick-viewer>
时,系统会渲染此内容。
添加 render
方法:
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick viewer</div>`;
}
}
3. 指定 LDraw 文件
定义媒体资源
如果 <brick-viewer>
的用户可以使用属性指定要显示的砖块模型,那就太棒了,如下所示:
<brick-viewer src="path/to/model.ldraw"></brick-viewer>
由于我们要构建 HTML 元素,因此可以利用声明式 API 并定义来源属性,就像 <img>
或 <video>
标记一样。有了 lit-element,它就像使用 @property
装饰类属性一样简单。借助 type
选项,您可以指定 lit-element 如何解析该属性以用作 HTML 属性。
定义 src
属性和特性:
export class BrickViewer extends LitElement {
@property({type: String})
src: string|null = null;
}
<brick-viewer>
现在具有 src
属性,我们可以在 HTML 中设置该属性!得益于 lit-element,我们已经可以在 BrickViewer
类中读取其值。
显示值
我们可以在呈现方法的模板字面量中使用 src
属性来显示其值。使用 ${value}
语法将值插入模板字面量中。
export class BrickViewer extends LitElement {
render() {
return html`<div>Brick model: ${this.src}</div>`;
}
}
现在,我们在窗口的 <brick-viewer>
元素中看到了 src 属性的值。请尝试执行以下操作:打开浏览器的开发者工具,然后手动更改 src 属性。试试吧...
...您是否注意到元素中的文本会自动更新?lit-element 会观察装饰有 @property
的类属性,并为您重新渲染视图!lit-element 会为您完成繁重工作,您无需操心。
4. 使用 Three.js 设定场景
灯光、摄像机、渲染!
我们的自定义元素将使用 three.js 来渲染 3D 积木模型。有些操作对于 <brick-viewer>
元素的每个实例来说只需执行一次,例如设置 Three.js 场景、相机和照明。我们会将这些内容添加到 BrickViewer 类的构造函数中。我们将保留一些对象作为类属性,以便稍后使用:相机、场景、控件和渲染程序。
添加 Three.js 场景设置:
export class BrickViewer extends LitElement {
private _camera: THREE.PerspectiveCamera;
private _scene: THREE.Scene;
private _controls: OrbitControls;
private _renderer: THREE.WebGLRenderer;
constructor() {
super();
this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
this._camera.position.set(150, 200, 250);
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0xdeebed);
const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
this._scene.add( ambientLight );
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-1000, 1200, 1500);
this._scene.add(directionalLight);
this._renderer = new THREE.WebGLRenderer({antialias: true});
this._renderer.setPixelRatio(window.devicePixelRatio);
this._renderer.setSize(this.offsetWidth, this.offsetHeight);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.addEventListener("change", () =>
requestAnimationFrame(this._animate)
);
this._animate();
const resizeObserver = new ResizeObserver(this._onResize);
resizeObserver.observe(this);
}
private _onResize = (entries: ResizeObserverEntry[]) => {
const { width, height } = entries[0].contentRect;
this._renderer.setSize(width, height);
this._camera.aspect = width / height;
this._camera.updateProjectionMatrix();
requestAnimationFrame(this._animate);
};
private _animate = () => {
this._renderer.render(this._scene, this._camera);
};
}
WebGLRenderer
对象提供了一个 DOM 元素,用于显示渲染的 three.js 场景。您可以通过 domElement
属性对其进行访问。我们可以使用 ${value}
语法将此值插入渲染模板字面量中。
移除模板中的 src
消息,并插入渲染程序的 DOM 元素:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
`;
}
}
为了允许完整显示渲染程序的 DOM 元素,我们还需要将 <brick-viewer>
元素本身设置为 display: block
。我们可以在名为 styles
的静态属性中提供样式,该属性设置为 css
模板字面量。
将此样式添加到类中:
export class BrickViewer extends LitElement {
static styles = css`
/* The :host selector styles the brick-viewer itself! */
:host {
display: block;
}
`;
}
现在,<brick-viewer>
显示的是渲染的 three.js 场景:
但是...是空的。我们来提供一个模型。
砖块装载机
我们将之前定义的 src
属性传递给随 three.js 一起提供的 LDrawLoader。
LDraw 文件可以将积木模型拆分为单独的构建步骤。您可以通过 LDrawLoader API 访问步骤总数和各个积木块的公开范围。
复制这些属性、新的 _loadModel
方法以及构造函数中的新行:
@customElement('brick-viewer')
export class BrickViewer extends LitElement {
private _loader = new LDrawLoader();
private _model: any;
private _numConstructionSteps?: number;
step?: number;
constructor() {
// ...
// Add this line right before this._animate();
(this._loader as any).separateObjects = true;
this._animate();
}
private _loadModel() {
if (this.src === null) {
return;
}
this._loader
.setPath('')
// Using our src property!
.load(this.src, (newModel) => {
if (this._model !== undefined) {
this._scene.remove(this._model);
this._model = undefined;
}
this._model = newModel;
// Convert from LDraw coordinates: rotate 180 degrees around OX
this._model.rotation.x = Math.PI;
this._scene.add(this._model);
this._numConstructionSteps = this._model.userData.numConstructionSteps;
this.step = this._numConstructionSteps!;
const bbox = new THREE.Box3().setFromObject(this._model);
this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
this._controls.update();
this._controls.saveState();
});
}
}
应在何时调用 _loadModel
?每次 src 属性发生更改时都需要调用它。通过使用 @property
修饰 src
属性,我们已选择将该属性加入 lit-element 更新生命周期。每当这些修饰的属性之一的值发生变化时,系统都会调用一系列方法,这些方法可以访问这些属性的新值和旧值。我们感兴趣的生命周期方法名为 update
。update
方法接受 PropertyValues
参数,该参数将包含有关刚刚更改的所有属性的信息。这里是致电 _loadModel
的理想去处。
添加 update
方法:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
super.update(changedProperties);
}
}
我们的 <brick-viewer>
元素现在可以显示使用 src
属性指定的砖块文件。
5. 显示部分模型
现在,让我们将当前的构建步骤配置为可配置。我们希望能够指定 <brick-viewer step="5"></brick-viewer>
,并且我们应该会在第 5 个构建步骤中看到砖块模型的外观。为此,我们将使用 @property
修饰 step
属性,使其成为被观察的属性。
装饰 step
属性:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
现在,我们将添加一个辅助方法,该方法会仅显示连接到当前构建步骤的积木。我们将在 update 方法中调用该帮助程序,以便在 step
属性每次发生变化时运行该程序。
更新 update
方法,并添加新的 _updateBricksVisibility
方法:
export class BrickViewer extends LitElement {
update(changedProperties: PropertyValues) {
if (changedProperties.has('src')) {
this._loadModel();
}
if (changedProperties.has('step')) {
this._updateBricksVisibility();
}
super.update(changedProperties);
}
private _updateBricksVisibility() {
this._model && this._model.traverse((c: any) => {
if (c.isGroup && this.step) {
c.visible = c.userData.constructionStep <= this.step;
}
});
requestAnimationFrame(this._animate);
}
}
好的,现在打开浏览器的 devtools 并检查 <brick-viewer>
元素。为该类添加 step
属性,如下所示:
观察渲染的模型会发生什么变化!我们可以使用 step
属性来控制显示的模型大小。将 step
属性设置为 "10"
时,应如下所示:
6. 砖块集导航
MWC 图标按钮
<brick-viewer>
的最终用户也应该能够通过界面浏览构建步骤。我们来添加用于执行下一步、上一步和第一步的按钮。为了简化操作,我们将使用 Material Design 的按钮网页组件。由于 @material/mwc-icon-button
已导入,我们可以将其添加到 <mwc-icon-button></mwc-icon-button>
中了。我们可以使用 icon 属性指定要使用的图标,如下所示:<mwc-icon-button icon="thumb_up"></mwc-icon-button>
。您可以点击以下网址查看所有可能的图标:material.io/resources/icons。
让我们为呈现方法添加一些图标按钮:
export class BrickViewer extends LitElement {
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button icon="replay"></mwc-icon-button>
<mwc-icon-button icon="navigate_before"></mwc-icon-button>
<mwc-icon-button icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
得益于 Web 组件,我们可以在网页上轻松使用 Material Design!
事件绑定
这些按钮应该能起到作用。“回复”按钮应将构建步骤重置为 1。“Navigate_before”按钮应减少构建步骤,“Navigate_next”按钮应增大该步骤。通过事件绑定,lit-element 可让您轻松添加此功能。在 HTML 模板字面量中,使用语法 @eventname=${eventHandler}
作为元素属性。现在,当系统在该元素上检测到 eventname
事件时,eventHandler
就会运行!例如,我们将点击事件处理脚本添加到三个图标按钮中:
export class BrickViewer extends LitElement {
private _restart() {
this.step! = 1;
}
private _stepBack() {
this.step! -= 1;
}
private _stepForward() {
this.step! += 1;
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
</div>
`;
}
}
现在,请尝试点击按钮。做得好!
样式
按钮可以正常使用,但外观不好看。他们都挤在底部。我们来为它们设置样式,以便将它们叠加在场景上。
为了给这些按钮应用样式,我们会返回 static styles
属性。这些样式的范围限定了,这意味着它们只会应用于此网络组件中的元素。这是编写网络组件的一大乐趣:选择器可以更简单,CSS 的读写也更简单。再见,BEM!
更新样式,使其如下所示:
export class BrickViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
#controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
}
`;
}
重置摄像头按钮
<brick-viewer>
的最终用户可以使用鼠标控件旋转场景。在添加按钮时,我们再添加一个用于将摄像头重置为默认位置的按钮。另一个带有点击事件绑定的 <mwc-icon-button>
即可完成此操作。
export class BrickViewer extends LitElement {
private _resetCamera() {
this._controls.reset();
}
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add this button: -->
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
导航更快捷
有些积木套装包含很多步骤。用户可能希望跳至特定步骤。添加带有步骤编号的滑块有助于快速导航。我们将使用 <mwc-slider>
元素来实现此目的。
MWC 滑块
滑块元素需要一些重要数据,例如最小和最大滑块值。最小滑块值始终为“1”。如果模型已加载,则最大滑块值应为 this._numConstructionSteps
。我们可以通过 <mwc-slider>
的属性告知这些值。如果未定义 _numConstructionSteps
属性,我们还可以使用 ifDefined
lit-html 指令来避免设置 max
属性。
在“返回”和“前进”按钮之间添加一个 <mwc-slider>
:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... backwards button -->
<!-- Add this slider: -->
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
></mwc-slider>
<!-- ... forwards button -->
</div>
`;
}
}
数据“向上”
当用户移动滑块时,当前的构建步骤应发生变化,并且模型的可见性也应相应更新。每当用户拖动滑块时,该滑块元素都会发出输入事件。在滑块本身上添加事件绑定,以捕获此事件并更改构建步骤。
添加事件绑定:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the @input event binding: -->
<mwc-slider
...
@input=${(e: CustomEvent) => this.step = e.detail.value}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
哇!我们可以使用滑块来更改显示哪个步骤。
数据“下降”
还有一件事。使用“返回”和“下一步”按钮更改步骤时,需要更新滑块手柄。将 <mwc-slider>
的 value 属性绑定到 this.step
。
添加 value
绑定:
export class BrickViewer extends LitElement {
render() {
return html`
<div id="controls">
<!-- ... -->
<!-- Add the value property binding: -->
<mwc-slider
...
value=${ifDefined(this.step)}
></mwc-slider>
<!-- ... -->
</div>
`;
}
}
滑块即将完成。添加 Flex 样式,使其与其他控件完美匹配:
export class BrickViewer extends LitElement {
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
}
此外,我们还需要对滑块元素本身调用 layout
。我们将在 firstUpdated
生命周期方法中执行此操作,该方法会在 DOM 首次布局后调用。query
修饰器可帮助我们获取对模板中滑块元素的引用。
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
}
以下是所有添加的滑块组件(在滑块上添加了额外的 pin
和 markers
属性,以使其看起来很酷):
export class BrickViewer extends LitElement {
@query('mwc-slider')
slider!: Slider|null;
static styles = css`
/* ... */
mwc-slider {
flex-grow: 1;
}
`;
async firstUpdated() {
if (this.slider) {
await this.slider.updateComplete
this.slider.layout();
}
}
render() {
return html`
${this._renderer.domElement}
<div id="controls">
<mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
<mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
<mwc-slider
step="1"
pin
markers
min="1"
max=${ifDefined(this._numConstructionSteps)}
?disabled=${this._numConstructionSteps === undefined}
value=${ifDefined(this.step)}
@input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
></mwc-slider>
<mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
<mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
</div>
`;
}
}
以下是最终产品!
7. 总结
我们学到了很多有关如何使用 lit-element 构建我们自己的 HTML 元素的信息。我们学习了如何:
- 定义自定义元素
- 声明属性 API
- 呈现自定义元素的视图
- 封装样式
- 使用事件和属性传递数据
如需详细了解 lit-element,请访问其官方网站。
您可以在 stackblitz.com/edit/brick-viewer-complete 上查看已完成的 brick-viewer 元素。
brick-viewer 也通过 NPM 分发,您可以在此处查看源代码:GitHub 代码库。