使用 lit-element 建構 Brick 檢視器

1. 簡介

網頁元件

網頁元件是一組網路標準,可讓開發人員使用自訂元素擴充 HTML。在本程式碼研究室中,您將定義 <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 中設定!由於光學元素的關係,其值已經可以從我們的 BrickViewer 類別中讀取。

顯示值

我們可以在算繪方法的範本常值中使用 src 屬性的值,以便顯示該屬性的值。使用 ${value} 語法將值插入範本常值。

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

現在,我們看到視窗 <brick-viewer> 元素中的 src 屬性值。請試試這個方法:開啟瀏覽器的開發人員工具,然後手動變更 src 屬性。請繼續,試試看...

...您注意到元素中的文字會自動更新嗎?lit-element 會觀察以 @property 裝飾的類別屬性,然後重新算繪檢視畫面!光亮元素會處理繁重的作業,因此您不需要再費力。

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 元素,用於顯示算繪的 3.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> 會顯示經過轉譯的 3.js 場景:

磚塊檢視器元素顯示經過算繪但空白的場景。

但... 它是空的。讓我們提供模型。

磚塊載入器

我們會將先前定義的 src 屬性傳遞至 LDrawLoader,後者透過 3.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 屬性指定的磚塊檔案。

磚塊檢視器元素,顯示汽車模型。

5. 顯示部分模型

接下來,我們來讓目前的建構步驟可進行設定。我們希望能夠指定 <brick-viewer step="5"></brick-viewer>,並查看第 5 個建構步驟的積木模型外觀。為此,我們可以使用 @property 裝飾 step 屬性,使其成為觀察到的屬性。

修飾 step 屬性:

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

現在,我們要新增 Helper 方法,僅顯示目前建構步驟之前的積木。我們會在更新方法中呼叫輔助程式,讓它在每次變更 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 屬性,如下所示:

磚架檢視器元素的 HTML 程式碼,且步驟屬性設為 10。

看看算繪模型的結果!我們可以使用 step 屬性控制要顯示的模型大小。step 屬性設為 "10" 時,程式碼會如下所示:

僅建構十個步驟的積木模型。

6. 積木組合導覽

mwc-icon-button

<brick-viewer> 的使用者也應能透過 UI 瀏覽建構步驟。我們來新增前往下一個步驟、上一步和第一步的按鈕。我們會使用 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>
    `;
  }
}

在網頁上使用 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-slider

滑桿元素需要一些重要資料,例如最小值和最大值。滑桿的最小值一律可以設為「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> 的值屬性繫結至 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>
    `;
  }
}

我們即將完成滑桿。新增彈性樣式,讓樣式與其他控制項完美搭配:

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
  • 為自訂元素算繪檢視畫面
  • 封裝樣式
  • 使用事件和屬性來傳送資料

如要進一步瞭解光照元素,請造訪官方網站瞭解詳情。

如要查看完成的磚塊檢視器元素,請前往 stackblitz.com/edit/brick-viewer-complete

此外,實機觀眾也會在 NPM 上出貨,您可以前往 GitHub 存放區查看原始碼。