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> để hiển thị các mô hình viên gạch!

phần tử lit

Để giúp 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ở nhẹ, thêm một số cú pháp đơn giản vào tiêu chuẩn thành phần web. Điều này sẽ giúp chúng ta dễ dàng thiết lập và chạy thành phần tuỳ chỉnh.

Bắt đầu

Chúng ta sẽ lập trình 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

Định nghĩa lớp

Để xác định một phần tử tuỳ chỉnh, hãy tạo 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 Gạch-viewer.ts, hãy đặt:

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

Bây giờ, 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ó nội dung nào hiển thị. Hãy khắc phục vấn đề đó.

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

Để triển khai thành phần hiển thị, hãy xác định một phương thức có tên là kết xuất. Phương thức này sẽ trả về một giá trị cố định của mẫu được gắn thẻ bằng hàm html. Đặt bất kỳ HTML nào bạn muốn vào giá trị cố định của mẫu được gắn thẻ. Kết quả 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 thuộc tính

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

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

Vì đang tạo 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 thuộc tính nguồn, giống như thẻ <img> hoặc <video>. Với phần tử lit, bạn có thể trang trí thuộc tính lớp bằng @property một cách dễ dàng. Tuỳ chọn type cho phép bạn chỉ định cách phần tử lit 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ó 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 từ trong lớp BrickViewer nhờ phần tử 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 giá trị đó trong giá trị cố định của mẫu của phương thức kết xuất. Nội suy các giá trị vào giá trị cố định của mẫu bằng cú pháp ${value}.

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

Bây giờ, chúng ta sẽ thấy giá trị của thuộc tính src trong phần tử <brick-viewer> của cửa sổ. Hãy thử cách 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 tiếp tục, hãy dùng thử...

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

4. Đặt Scene bằng Three.js

Ánh sáng, máy ảnh, kết xuất!

Phần tử tùy chỉnh của chúng ta sẽ sử dụng đảm bảo 3.js để hiển thị các mô hình khối hình 3D. Có một số việc mà chúng ta chỉ muốn làm 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 Three.js, camera và ánh sáng. Chúng ta sẽ thêm các đối tượng 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: camera, cảnh, thành phần điều khiển và trình kết xuất đồ hoạ.

Thêm chế độ 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 đã kết xuất. Bạn có thể truy cập vào thuộc tính này thông qua thuộc tính domElement. Chúng ta có thể nội suy giá trị này vào giá trị cố định của mẫu kết xuất bằng cú pháp ${value}.

Xoá thông báo src mà chúng ta có trong mẫu rồi 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}
    `;
  }
}

Để phần tử dom của trình kết xuất hiển thị toàn bộ, 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 kiểu trong một thuộc tính tĩnh có tên là styles, được đặt thành giá trị cố định 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;
    }
  `;
}

Hiện tại, <brick-viewer> đang hiển thị cảnh ba.js được kết xuất:

Phần tử trình xem khối hiển thị một cảnh đã kết xuất nhưng trống.

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

Trình tải gạch

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

Tệp LDraw có thể tách một mô hình Khối 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à chế độ hiển thị của từng viên gạch thông qua API LDrawLoader.

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 hàm 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 phần tử lit. Bất cứ khi nào một trong các giá trị của 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 các 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ó tên 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ề bất kỳ thuộc tính nào vừa thay đổi. Đây là địa điểm hoàn hảo để gọi cho _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);
  }
}

Giờ đây, phần tử <brick-viewer> của chúng ta 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ử khung nhìn bằng gạch cho thấy mô hình một chiếc xe ô tô.

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

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

Trang trí 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ị những khối hình lên đế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);
  }
}

Bây giờ, hãy mở devtools của trình duyệt và kiểm tra phần tử <brick-viewer>. Thêm thuộc tính step vào thuộc tính đó, như sau:

Mã HTML của một phần tử trình xem viên gạch, với thuộc tính bước được đặt thành 10.

Hãy xem điều gì sẽ xảy ra với mô hình đã 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. Dưới đây là giao diện khi thuộc tính step được đặt thành "10":

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

6. Điều hướng đặt gạch

mwc-icon-button

Người dùng cuối của <brick-viewer> cũng phải 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 sang 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 cho nút của Material Design để dễ dàng thực hiện việc này. Vì @material/mwc-icon-button đã được nhập nên chúng ta đã sẵn sàng thêm <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 với 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ể sử dụng 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 kết xuất:

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 thậ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 phải thực sự làm được việc gì đó. Nút "trả lời" nên đặt lại bước thiết lập về 1. Nút "Navigate_before" sẽ giảm bước xây dựng và nút "Navigate_next" sẽ tăng dần. Phần tử lit-element giúp bạn dễ dàng thêm chức năng này thông qua các liên kết sự kiện. Trong giá trị cố định của mẫu html, hãy sử dụng cú pháp @eventname=${eventHandler} làm thuộc tính phần tử. Giờ đây, eventHandler sẽ chạy khi phát hiện sự kiện eventname trên phần tử đó! Ví dụ: hãy thêm trình xử lý sự kiện nhấp 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. Tốt!

Kiểu

Các nút hoạt động được nhưng trông không đẹp mắt. Tất cả đều nằm ở dưới cùng. Hãy tạo kiểu cho chúng để phủ trên cảnh.

Để áp dụng kiểu cho các nút này, chúng ta quay lại thuộc tính static styles. Các kiểu này có giới hạn, nghĩa là 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 niềm vui khi viết 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 kiểu để chúng trông giố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 thành phần trình xem khối có các nút khởi động lại, lùi và tiến.

Nút đặt lại camera

Người dùng cuối trên <brick-viewer> của chúng tôi có thể xoay cảnh bằng các nút điều khiển bằng chuột. Khi thêm các nút, hãy thêm một nút để đặt lại camera 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ộ đồ chơi xếp hình có rất nhiều bước. Người dùng có thể muốn chuyển tới một bước cụ thể. Thêm 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

Thành phần 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ị nhỏ nhất của thanh trượt luôn có thể là "1". Giá trị tối đa của thanh trượt 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 directive ifDefined lit-html để tránh đặt thuộc tính max nếu chưa xác định thuộc tính _numConstructionSteps.

Thêm <mwc-slider> giữa các nút "quay lại" và "chuyển tiếp":

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 "tăng"

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à chế độ hiển thị của mô hình phải được cập nhật tương ứng. Phần tử thanh trượt sẽ phát 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 tạo.

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ể dùng thanh trượt để thay đổi bước sẽ hiển thị.

Dữ liệu "bị giảm"

Còn một điều nữa. Khi sử dụng các nút "quay lại" và "tiếp theo" để thay đổi bước, bạn cần cập nhật tay điều khiển thanh trượt. Liên kết thuộc tính value 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 tất phần thanh trượt. Thêm kiểu linh hoạt để chơi mượt mà với các chế độ đ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 sau 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 của 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 để trông thật bắt 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 hoàn thiện!

Điều hướng mô hình khối hình xe hơi bằng phần tử trình xem viên gạch

7. Kết luận

Chúng ta đã tìm hiểu rất nhiều về cách sử dụng lit-element để tạo phần tử HTML của riêng mình. Chúng ta đã tìm hiểu cách:

  • Xác định phần tử tuỳ chỉnh
  • Khai báo API thuộc tính
  • Kết xuất khung hiển thị cho một phần tử tuỳ chỉnh
  • Đóng gói kiểu
  • Sử dụng 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 tại trang web chính thức của thành phần này.

Bạn có thể xem phần tử trình xem viên gạch đã hoàn tất tại stackblitz.com/edit/brick-viewer-complete.

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