使用 lit-element 构建 Brick Viewer

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 场景:

显示已渲染但空白的场景的 brick-viewer 元素。

但是...是空的。我们来提供一个模型。

砖块装载机

我们将之前定义的 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 更新生命周期。每当这些修饰的属性之一的值发生变化时,系统都会调用一系列方法,这些方法可以访问这些属性的新值和旧值。我们感兴趣的生命周期方法名为 updateupdate 方法接受 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 属性,如下所示:

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

观察渲染的模型会发生什么变化!我们可以使用 step 属性来控制显示的模型大小。将 step 属性设置为 "10" 时,应如下所示:

仅有 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();
    }
  }
}

以下是所有添加的滑块组件(在滑块上添加了额外的 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>
   `;
 }
}

以下是最终产品!

使用砖块查看器元素浏览汽车砖块模型

7. 总结

我们学到了很多有关如何使用 lit-element 构建我们自己的 HTML 元素的信息。我们学习了如何:

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

如需详细了解 lit-element,请访问其官方网站

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

brick-viewer 也通过 NPM 分发,您可以在此处查看源代码:GitHub 代码库