1. 简介
网络组件
网络组件是一系列网络标准,可让开发者使用自定义元素扩展 HTML。在此 Codelab 中,您将定义 <brick-viewer>
元素,该元素将能够显示砖块模型!
照亮元素
为帮助我们定义自定义元素 <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 文件可以将 Brick 模型拆分为多个单独的建筑物台阶。可以通过 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
属性指定的 bricks 文件。
5. 显示部分模型
现在,让我们将当前的构建步骤配置为可配置。我们希望能够指定 <brick-viewer step="5"></brick-viewer>
,并且应该可以看到砖块模型在第 5 个构建步骤中的显示效果。为此,我们使用 @property
修饰 step
属性,使其成为观察的属性。
装饰 step
属性:
export class BrickViewer extends LitElement {
@property({type: Number})
step?: number;
}
现在,我们将添加一个辅助方法,该方法会仅显示连接到当前构建步骤的积木。我们将在更新方法中调用帮助程序,以便每次 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>
。我们可以使用图标属性指定要使用的图标,如下所示:<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>
,和“forward”按钮:
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 repo。