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:
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
.
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:
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"
:
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;
}
`;
}
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 pin
và markers
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!
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.