Tạo Trình xem khối hình bằng phần tử lit

1. Giới thiệu

Thành phần web

Thành phần web là một tập hợp các tiêu chuẩn web cho phép nhà phát triển mở rộng HTML bằng các phần tử tuỳ chỉnh. Trong lớp học lập trình này, bạn sẽ xác định phần tử <brick-viewer>, phần tử này có thể hiển thị các mô hình gạch!

lit-element

Để giúp chúng ta xác định phần tử tuỳ chỉnh <brick-viewer>, chúng ta sẽ sử dụng lit-element. lit-element là một lớp cơ sở gọn nhẹ giúp bổ sung một số cú pháp dễ hiểu cho tiêu chuẩn thành phần web. Điều này sẽ giúp chúng ta dễ dàng bắt đầu và chạy với phần tử tuỳ chỉnh.

Bắt đầu

Chúng ta sẽ viết mã trong môi trường Stackblitz trực tuyến, vì vậy, hãy mở đường liên kết này trong một cửa sổ mới:

stackblitz.com/edit/brick-viewer

Hãy bắt đầu!

2. Xác định phần tử tuỳ chỉnh

Xác định lớp

Để xác định một phần tử tuỳ chỉnh, hãy tạo một lớp mở rộng LitElement và trang trí lớp đó bằng @customElement. Đối số cho @customElement sẽ là tên của phần tử tuỳ chỉnh.

Trong brick-viewer.ts, hãy đặt:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

Giờ đây, bạn có thể sử dụng phần tử <brick-viewer></brick-viewer> trong HTML. Tuy nhiên, nếu bạn thử, sẽ không có gì hiển thị. Hãy khắc phục vấn đề đó.

Phương thức hiển thị

Để triển khai khung hiển thị của thành phần, hãy xác định một phương thức có tên là render. Phương thức này sẽ trả về một chuỗi mẫu theo nghĩa đen được gắn thẻ bằng hàm html. Đặt bất kỳ HTML nào bạn muốn vào chuỗi mẫu được gắn thẻ. Thao tác này sẽ hiển thị khi bạn sử dụng <brick-viewer>.

Thêm phương thức render:

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. Chỉ định tệp LDraw

Xác định một thuộc tính

Sẽ rất hữu ích nếu người dùng <brick-viewer> có thể chỉ định mô hình gạch nào cần hiển thị bằng một thuộc tính, như sau:

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

Vì đang xây dựng một phần tử HTML, nên chúng ta có thể tận dụng API khai báo và xác định một thuộc tính nguồn, giống như thẻ <img> hoặc <video>. Với lit-element, bạn chỉ cần trang trí một thuộc tính lớp bằng @property. Tuỳ chọn type cho phép bạn chỉ định cách lit-element phân tích cú pháp thuộc tính để sử dụng làm thuộc tính HTML.

Xác định thuộc tính và thuộc tính src:

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

<brick-viewer> hiện có một thuộc tính src mà chúng ta có thể đặt trong HTML! Giá trị của thuộc tính này đã có thể đọc được trong lớp BrickViewer nhờ lit-element.

Hiển thị giá trị

Chúng ta có thể hiển thị giá trị của thuộc tính src bằng cách sử dụng thuộc tính này trong chuỗi mẫu của phương thức hiển thị. Nội suy các giá trị vào chuỗi mẫu bằng cú pháp ${value}.

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

Giờ đây, chúng ta thấy giá trị của thuộc tính src trong phần tử <brick-viewer> trong cửa sổ. Hãy thử làm như sau: mở công cụ dành cho nhà phát triển của trình duyệt và thay đổi thuộc tính src theo cách thủ công. Hãy thử xem...

...Bạn có nhận thấy rằng văn bản trong phần tử tự động cập nhật không? lit-element quan sát các thuộc tính lớp được trang trí bằng @property và hiển thị lại khung hiển thị cho bạn! lit-element thực hiện công việc nặng nhọc để bạn không phải làm.

4. Thiết lập cảnh bằng Three.js

Đèn, máy quay, kết xuất!

Phần tử tuỳ chỉnh của chúng ta sẽ sử dụng three.js để hiển thị các mô hình gạch 3D. Có một số việc chúng ta chỉ muốn thực hiện một lần cho mỗi thực thể của phần tử <brick-viewer>, chẳng hạn như thiết lập cảnh, máy quay và ánh sáng three.js. Chúng ta sẽ thêm các thành phần này vào hàm khởi tạo lớp BrickViewer. Chúng ta sẽ giữ một số đối tượng làm thuộc tính lớp để có thể sử dụng sau này: máy quay, cảnh, bộ điều khiển và trình kết xuất.

Thêm vào phần thiết lập cảnh 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);
  };
}

Đối tượng WebGLRenderer cung cấp một phần tử DOM hiển thị cảnh three.js được kết xuất. Bạn có thể truy cập vào đối tượng này thông qua thuộc tính domElement. Chúng ta có thể nội suy giá trị này vào chuỗi mẫu hiển thị bằng cú pháp ${value}.

Xoá thông báo src mà chúng ta có trong mẫu và chèn phần tử DOM của trình kết xuất:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

Để cho phép hiển thị toàn bộ phần tử DOM của trình kết xuất, chúng ta cũng cần đặt chính phần tử <brick-viewer> thành display: block. Chúng ta có thể cung cấp các kiểu trong một thuộc tính tĩnh có tên là styles, được đặt thành một chuỗi mẫu css.

Thêm kiểu này vào lớp:

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

Giờ đây, <brick-viewer> đang hiển thị một cảnh three.js được kết xuất:

Một phần tử brick-viewer hiển thị một cảnh được kết xuất nhưng trống.

Nhưng... cảnh này trống. Hãy cung cấp cho cảnh này một mô hình.

Trình tải gạch

Chúng ta sẽ truyền thuộc tính src mà chúng ta đã xác định trước đó cho LDrawLoader, thuộc tính này được vận chuyển cùng với three.js.

Tệp LDraw có thể tách mô hình Gạch thành các bước xây dựng riêng biệt. Bạn có thể truy cập vào tổng số bước và khả năng hiển thị của từng viên gạch thông qua LDrawLoader API.

Sao chép các thuộc tính này, phương thức _loadModel mới và dòng mới trong hàm khởi tạo:

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

Khi nào nên gọi _loadModel? Bạn cần gọi phương thức này mỗi khi thuộc tính src thay đổi. Bằng cách trang trí thuộc tính src bằng @property, chúng ta đã chọn thuộc tính này vào vòng đời cập nhật lit-element. Bất cứ khi nào giá trị của một trong các thuộc tính được trang trí này thay đổi, một loạt phương thức sẽ được gọi để có thể truy cập vào giá trị mới và cũ của các thuộc tính. Phương thức vòng đời mà chúng ta quan tâm được gọi là update. Phương thức update lấy một đối số PropertyValues, đối số này sẽ chứa thông tin về mọi thuộc tính vừa thay đổi. Đây là vị trí hoàn hảo để gọi _loadModel.

Thêm phương thức update:

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

Phần tử <brick-viewer> của chúng ta hiện có thể hiển thị một tệp gạch, được chỉ định bằng thuộc tính src.

Một phần tử brick-viewer hiển thị mô hình của một chiếc ô tô.

5. Hiển thị mô hình một phần

Bây giờ, hãy định cấu hình bước xây dựng hiện tại. Chúng ta muốn có thể chỉ định <brick-viewer step="5"></brick-viewer> và chúng ta sẽ thấy mô hình gạch trông như thế nào ở bước xây dựng thứ 5. Để thực hiện việc đó, hãy biến thuộc tính step thành một thuộc tính được quan sát bằng cách trang trí thuộc tính đó bằng @property.

Gắn thuộc tính step:

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

Bây giờ, chúng ta sẽ thêm một phương thức trợ giúp chỉ hiển thị các viên gạch cho đến bước xây dựng hiện tại. Chúng ta sẽ gọi trình trợ giúp trong phương thức cập nhật để trình trợ giúp này chạy mỗi khi thuộc tính step thay đổi.

Cập nhật phương thức update và thêm phương thức _updateBricksVisibility mới:

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

Được rồi, bây giờ hãy mở công cụ dành cho nhà phát triển của trình duyệt và kiểm tra phần tử <brick-viewer>. Thêm thuộc tính step vào phần tử đó, như sau:

Mã HTML của một phần tử brick-viewer, có thuộc tính step được đặt thành 10.

Hãy xem điều gì xảy ra với mô hình được kết xuất! Chúng ta có thể sử dụng thuộc tính step để kiểm soát mức độ hiển thị của mô hình. Đây là hình ảnh minh hoạ khi thuộc tính step được đặt thành "10":

Một mô hình bằng gạch chỉ có 10 bước xây dựng.

6. Điều hướng bộ gạch

mwc-icon-button

Người dùng cuối của <brick-viewer> cũng có thể điều hướng các bước xây dựng thông qua giao diện người dùng. Hãy thêm các nút để chuyển đến bước tiếp theo, bước trước và bước đầu tiên. Chúng ta sẽ sử dụng thành phần web nút của Material Design để giúp bạn dễ dàng thực hiện việc này. Vì @material/mwc-icon-button đã được nhập, nên chúng ta có thể thả vào <mwc-icon-button></mwc-icon-button>. Chúng ta có thể chỉ định biểu tượng mà chúng ta muốn sử dụng bằng thuộc tính biểu tượng, như sau: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Bạn có thể tìm thấy tất cả các biểu tượng có thể có tại đây: material.io/resources/icons.

Hãy thêm một số nút biểu tượng vào phương thức hiển thị:

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

Việc sử dụng Material Design trên trang của chúng ta rất dễ dàng, nhờ các thành phần web!

Liên kết sự kiện

Các nút này thực sự phải làm gì đó. Nút "reply" (trả lời) sẽ đặt lại bước xây dựng thành 1. Nút "navigate_before" (điều hướng_trước) sẽ giảm bước xây dựng và nút "navigate_next" (điều hướng_tiếp theo) sẽ tăng bước xây dựng. lit-element giúp bạn dễ dàng thêm chức năng này bằng các liên kết sự kiện. Trong chuỗi mẫu html, hãy sử dụng cú pháp @eventname=${eventHandler} làm thuộc tính phần tử. eventHandler sẽ chạy khi một sự kiện eventname được phát hiện trên phần tử đó! Ví dụ: hãy thêm trình xử lý sự kiện nhấp chuột vào 3 nút biểu tượng:

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

Hãy thử nhấp vào các nút ngay bây giờ. Bạn làm tốt lắm!

Kiểu

Các nút hoạt động, nhưng không đẹp. Tất cả đều tụ tập ở dưới cùng. Hãy tạo kiểu cho các nút này để phủ lên cảnh.

Để áp dụng kiểu cho các nút này, chúng ta sẽ quay lại thuộc tính static styles. Các kiểu này được đặt phạm vi, nghĩa là chúng sẽ chỉ áp dụng cho các phần tử trong thành phần web này. Đó là một trong những điều thú vị khi viết các thành phần web: bộ chọn có thể đơn giản hơn và CSS sẽ dễ đọc và viết hơn. Tạm biệt BEM!

Cập nhật các kiểu để chúng có dạng như sau:

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

Một phần tử trình xem gạch có các nút khởi động lại, lùi và tiến.

Nút đặt lại máy quay

Người dùng cuối của <brick-viewer> có thể xoay cảnh bằng các công cụ điều khiển chuột. Trong khi thêm các nút, hãy thêm một nút để đặt lại máy quay về vị trí mặc định. Một <mwc-icon-button> khác có liên kết sự kiện nhấp chuột sẽ hoàn thành công việc.

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

Duyệt nhanh hơn

Một số bộ gạch có nhiều bước. Người dùng có thể muốn bỏ qua một bước cụ thể. Việc thêm một thanh trượt có số bước có thể giúp điều hướng nhanh. Chúng ta sẽ sử dụng phần tử <mwc-slider> cho việc này.

mwc-slider

Phần tử thanh trượt cần một số dữ liệu quan trọng, chẳng hạn như giá trị thanh trượt tối thiểu và tối đa. Giá trị thanh trượt tối thiểu luôn có thể là "1". Giá trị thanh trượt tối đa phải là this._numConstructionSteps, nếu mô hình đã tải. Chúng ta có thể cho <mwc-slider> biết các giá trị này thông qua các thuộc tính của nó. Chúng ta cũng có thể sử dụng chỉ thị ifDefined lit-html để tránh đặt thuộc tính max nếu thuộc tính _numConstructionSteps chưa được xác định.

Thêm một <mwc-slider> giữa các nút "back" (quay lại) và "forward" (tiến tới):

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

Dữ liệu "lên"

Khi người dùng di chuyển thanh trượt, bước xây dựng hiện tại sẽ thay đổi và khả năng hiển thị của mô hình sẽ được cập nhật cho phù hợp. Phần tử thanh trượt sẽ phát ra một sự kiện đầu vào bất cứ khi nào thanh trượt được kéo. Thêm một liên kết sự kiện trên chính thanh trượt để nắm bắt sự kiện này và thay đổi bước xây dựng.

Thêm liên kết sự kiện:

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

Tuyệt vời! Chúng ta có thể sử dụng thanh trượt để thay đổi bước được hiển thị.

Dữ liệu "xuống"

Còn một điều nữa. Khi các nút "back" (quay lại) và "next" (tiếp theo) được dùng để thay đổi bước, thì cần cập nhật tay cầm thanh trượt. Liên kết thuộc tính giá trị của <mwc-slider> với this.step.

Thêm liên kết 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>
    `;
  }
}

Chúng ta sắp hoàn thành thanh trượt. Thêm một kiểu flex để làm cho thanh trượt hoạt động tốt với các công cụ điều khiển khác:

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

Ngoài ra, chúng ta cần gọi layout trên chính phần tử thanh trượt. Chúng ta sẽ thực hiện việc đó trong phương thức vòng đời firstUpdated, phương thức này được gọi một lần khi DOM được bố trí lần đầu tiên. Trình trang trí query có thể giúp chúng ta tham chiếu đến phần tử thanh trượt trong mẫu.

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

Dưới đây là tất cả các phần bổ sung thanh trượt được kết hợp với nhau (có thêm các thuộc tính pinmarkers trên thanh trượt để làm cho thanh trượt trông đẹp mắt):

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

Đây là sản phẩm cuối cùng!

Điều hướng mô hình gạch ô tô bằng phần tử brick-viewer

7. Kết luận

Chúng ta đã học được nhiều điều về cách sử dụng lit-element để xây dựng phần tử HTML của riêng mình. Chúng ta đã học cách:

  • Xác định phần tử tuỳ chỉnh
  • Khai báo API thuộc tính
  • Hiển thị khung hiển thị cho phần tử tuỳ chỉnh
  • Đóng gói kiểu
  • Sử dụng các sự kiện và thuộc tính để truyền dữ liệu

Nếu muốn tìm hiểu thêm về lit-element, bạn có thể đọc thêm trên trang web chính thức của lit-element.

Bạn có thể xem phần tử brick-viewer đã hoàn thành tại stackblitz.com/edit/brick-viewer-complete.

brick-viewer cũng được vận chuyển trên NPM và bạn có thể xem nguồn tại đây: Kho lưu trữ Github.