使用 lit-element 构建 Brick Viewer

1. 简介

Web 组件

Web 组件是一组 Web 标准,开发者可以使用这些标准通过自定义元素扩展 HTML。在此 Codelab 中,您将定义 <brick-viewer> 元素,该元素能够显示积木模型!

lit-element

为了帮助我们定义自定义元素 <brick-viewer>,我们将使用 lit-element。lit-element 是一个轻量级基类,它为 Web 组件标准添加了一些语法糖。这样,我们就可以轻松上手并使用自定义元素。

开始使用

我们将在 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 类中读取其值。

显示值

我们可以在 render 方法的模板字面量中使用 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 场景:

一个 brick-viewer 元素,用于显示已渲染但为空的场景。

但是…它是空的。让我们为其提供一个模型。

积木加载器

我们将之前定义的 src 属性传递给 LDrawLoader,该加载器随 three.js 一起提供。

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 更新生命周期。每当其中一个修饰属性的值发生变化时,系统都会调用一系列方法,这些方法可以访问属性的新值和旧值。我们感兴趣的生命周期方法称为 updateupdate 方法接受一个 PropertyValues 实参,该实参将包含有关刚刚发生变化的任何属性的信息。这是调用 _loadModel 的理想位置。

添加 update 方法:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

现在,我们的 <brick-viewer> 元素可以显示使用 src 属性指定的积木文件。

一个显示汽车模型的 brick-viewer 元素。

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 属性,如下所示:

brick-viewer 元素的 HTML 代码,其中 step 属性设置为 10。

观看呈现的模型会发生什么变化!我们可以使用 step 属性来控制显示的模型量。以下是将 step 属性设置为 "10" 时的外观:

一个仅包含 10 个拼搭步骤的积木模型。

6. 积木集导航

mwc-icon-button

<brick-viewer> 的最终用户还应该能够通过界面浏览构建步骤。让我们添加用于转到下一步、上一步和第一步的按钮。我们将使用 Material Design 的按钮 Web 组件,以便轻松实现此目的。由于 @material/mwc-icon-button 已导入,因此我们可以直接添加 <mwc-icon-button></mwc-icon-button>。我们可以使用 icon 属性指定要使用的图标,如下所示:<mwc-icon-button icon="thumb_up"></mwc-icon-button>。您可以在material.io/resources/icons中找到所有可能的图标。

让我们向 render 方法添加一些图标按钮:

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,您可以轻松添加此功能,并使用事件绑定。“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 属性。这些样式是限定范围的,这意味着它们仅适用于此 Web 组件中的元素。这是编写 Web 组件的乐趣之一:选择器可以更简单,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 元素。

重置相机按钮

<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-slider

滑块元素需要一些重要数据,例如滑块的最小值和最大值。滑块的最小值始终可以是“1”。如果模型已加载,则滑块的最大值应为 this._numConstructionSteps。我们可以通过 <mwc-slider> 的属性告知这些值。我们还可以使用 ifDefined lit-html 指令来避免在尚未定义 _numConstructionSteps 属性的情况下设置 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();
    }
  }
}

以下是所有滑块添加内容(滑块上还有额外的 pinmarkers 属性,使其看起来很酷):

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>
   `;
 }
}

这是最终产品!

使用 brick-viewer 元素浏览汽车积木模型

7. 总结

我们详细了解了如何使用 lit-element 构建自己的 HTML 元素。我们学习了如何:

  • 定义自定义元素
  • 声明属性 API
  • 为自定义元素呈现视图
  • 封装样式
  • 使用事件和属性传递数据

如果您想详细了解 lit-element,可以在其官方网站上阅读更多内容。

您可以在 stackblitz.com/edit/brick-viewer-complete 中查看已完成的 brick-viewer 元素。

brick-viewer 也随附在 NPM 中,您可以在此处查看源代码:GitHub 代码库