1. Giới thiệu
Stories là một thành phần giao diện người dùng phổ biến hiện nay. Các ứng dụng xã hội và tin tức đang tích hợp chúng vào nguồn cấp dữ liệu. Trong lớp học lập trình này, chúng ta sẽ xây dựng một thành phần câu chuyện bằng lit-element và TypeScript.
Đây là giao diện của thành phần câu chuyện sau khi hoàn tất:

Chúng ta có thể coi "tin bài" trên mạng xã hội hoặc tin tức là một tập hợp các thẻ được phát tuần tự, giống như một bản trình chiếu. Thực ra, câu chuyện là một bản trình chiếu theo đúng nghĩa đen. Thẻ thường có hình ảnh hoặc video phát tự động và có thể có thêm văn bản ở trên. Sau đây là những gì chúng ta sẽ xây dựng:
Danh sách tính năng
- Thẻ có hình nền là hình ảnh hoặc video.
- Vuốt sang trái hoặc phải để chuyển đổi giữa các phần của câu chuyện.
- Tự động phát video.
- Có thể thêm văn bản hoặc tuỳ chỉnh thẻ theo cách khác.
Về trải nghiệm của nhà phát triển đối với thành phần này, sẽ rất tốt nếu bạn có thể chỉ định thẻ câu chuyện bằng mã đánh dấu HTML thuần tuý, chẳng hạn như:
<story-viewer>
<story-card>
<img slot="media" src="some/image.jpg" />
<h1>Title</h1>
</story-card>
<story-card>
<video slot="media" src="some/video.mp4" loop playsinline></video>
<h1>Whatever</h1>
<p>I want!</p>
</story-card>
</story-viewer>
Vì vậy, hãy thêm cả tính năng đó vào danh sách.
Danh sách tính năng
- Chấp nhận một loạt thẻ trong mã đánh dấu HTML.
Bằng cách này, mọi người có thể sử dụng thành phần tin bài của chúng tôi chỉ bằng cách viết HTML. Đây là một lựa chọn tuyệt vời cho cả lập trình viên và người không phải là lập trình viên, đồng thời hoạt động ở mọi nơi mà HTML hoạt động: hệ thống quản lý nội dung, khung, v.v.
Điều kiện tiên quyết
- Một shell nơi bạn có thể chạy
gitvànpm - Trình chỉnh sửa văn bản
2. Thiết lập
Bắt đầu bằng cách sao chép kho lưu trữ này: story-viewer-starter
git clone git@github.com:PolymerLabs/story-viewer-starter.git
Môi trường đã được thiết lập bằng lit-element và TypeScript. Chỉ cần cài đặt các phần phụ thuộc:
npm i
Đối với người dùng VS Code, hãy cài đặt trình bổ trợ lit-plugin để sử dụng tính năng tự động hoàn thành, kiểm tra kiểu và tìm lỗi mã nguồn của các mẫu lit-html.
Khởi động môi trường phát triển bằng cách chạy:
npm run dev
Bạn đã sẵn sàng bắt đầu lập trình!
3. Thành phần <story-card>
Khi tạo các thành phần kết hợp, đôi khi bạn nên bắt đầu bằng các thành phần phụ đơn giản hơn rồi mới tạo các thành phần phức tạp. Vậy hãy bắt đầu bằng cách xây dựng <story-card>. Ứng dụng này phải có khả năng hiển thị video hoặc hình ảnh tràn lề. Người dùng có thể tuỳ chỉnh thêm, chẳng hạn như thêm văn bản lớp phủ.
Bước đầu tiên là xác định lớp của thành phần, lớp này sẽ mở rộng LitElement. Trình trang trí customElement sẽ giúp chúng ta đăng ký phần tử tuỳ chỉnh. Bây giờ là thời điểm thích hợp để đảm bảo bạn bật các decorator trong tsconfig bằng cờ experimentalDecorators (nếu đang dùng kho lưu trữ khởi động, thì cờ này đã được bật).
Đặt mã sau vào story-card.ts:
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('story-card')
export class StoryCard extends LitElement {
}
Giờ đây, <story-card> là một phần tử tuỳ chỉnh có thể sử dụng, nhưng chưa có nội dung nào để hiển thị. Để xác định cấu trúc nội bộ của phần tử, hãy xác định phương thức thực thể render. Đây là nơi chúng ta sẽ cung cấp mẫu cho phần tử, bằng cách sử dụng thẻ html của lit-html.
Nội dung nào nên có trong mẫu của thành phần này? Người dùng phải có thể cung cấp 2 thứ: một phần tử đa phương tiện và một lớp phủ. Vì vậy, chúng ta sẽ thêm một <slot> cho mỗi loại.
Khe cắm là cách chúng ta chỉ định các phần tử con của một phần tử tuỳ chỉnh sẽ được kết xuất. Để biết thêm thông tin, bạn có thể xem hướng dẫn chi tiết về cách sử dụng các khe cắm.
import { html } from 'lit';
export class StoryCard extends LitElement {
render() {
return html`
<div id="media">
<slot name="media"></slot>
</div>
<div id="content">
<slot></slot>
</div>
`;
}
}
Việc tách phần tử nội dung nghe nhìn thành một vị trí riêng sẽ giúp chúng tôi nhắm đến phần tử đó cho những việc như thêm kiểu tràn viền và tự động phát video. Đặt khe thứ hai (khe dành cho lớp phủ tuỳ chỉnh) vào bên trong một phần tử vùng chứa để sau này chúng ta có thể cung cấp một số khoảng đệm mặc định.
Giờ đây, bạn có thể sử dụng thành phần <story-card> như sau:
<story-card>
<img slot="media" src="some/image.jpg" />
<h1>My Title</h1>
<p>my description</p>
</story-card>
Nhưng trông rất tệ:

Thêm kiểu
Hãy thêm một chút phong cách. Với lit-element, chúng ta thực hiện việc đó bằng cách xác định một thuộc tính styles tĩnh và trả về một chuỗi mẫu được gắn thẻ bằng css. Bất kỳ CSS nào được viết ở đây chỉ áp dụng cho phần tử tuỳ chỉnh của chúng ta! CSS có DOM bóng thực sự hữu ích theo cách này.
Hãy tạo kiểu cho phần tử nội dung nghe nhìn có khe cắm để bao phủ <story-card>. Nhân tiện, chúng ta có thể cung cấp một số định dạng đẹp cho các phần tử trong vị trí thứ hai. Bằng cách đó, người dùng thành phần có thể thả một số <h1>, <p> hoặc bất kỳ thành phần nào khác và thấy một số thành phần đẹp theo mặc định.
import { css } from 'lit';
export class StoryCard extends LitElement {
static styles = css`
#media {
height: 100%;
}
#media ::slotted(*) {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Default styles for content */
#content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 48px;
font-family: sans-serif;
color: white;
font-size: 24px;
}
#content > slot::slotted(*) {
margin: 0;
}
`;
}

Giờ đây, chúng ta có các thẻ tin bài có nội dung nghe nhìn ở chế độ nền và có thể đặt bất cứ nội dung nào lên trên. Tuyệt vời! Chúng ta sẽ quay lại lớp StoryCard sau để triển khai tính năng tự động phát video.
4. Thành phần <story-viewer>
Phần tử <story-viewer> là phần tử mẹ của <story-card>. Thành phần này sẽ chịu trách nhiệm bố trí các thẻ theo chiều ngang và cho phép chúng ta vuốt giữa các thẻ. Chúng ta sẽ bắt đầu theo cách tương tự như đối với StoryCard. Chúng ta muốn thêm thẻ câu chuyện làm phần tử con của phần tử <story-viewer>, vì vậy, hãy thêm một vị trí cho các phần tử con đó.
Đặt mã sau vào story-viewer.ts:
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('story-viewer')
export class StoryViewer extends LitElement {
render() {
return html`<slot></slot>`;
}
}
Tiếp theo là bố cục ngang. Chúng ta có thể tiếp cận vấn đề này bằng cách đặt vị trí tuyệt đối cho tất cả các <story-card> được phân bổ và dịch chúng theo chỉ mục. Chúng ta có thể nhắm đến chính phần tử <story-viewer> bằng bộ chọn :host.
static styles = css`
:host {
display: block;
position: relative;
/* Default size */
width: 300px;
height: 800px;
}
::slotted(*) {
position: absolute;
width: 100%;
height: 100%;
}`;
Người dùng có thể kiểm soát kích thước của thẻ tin bằng cách ghi đè chiều cao và chiều rộng mặc định trên máy chủ lưu trữ từ bên ngoài. Chẳng hạn như:
story-viewer {
width: 400px;
max-width: 100%;
height: 80%;
}
Để theo dõi thẻ đang được xem, hãy thêm một biến thực thể index vào lớp StoryViewer. Việc trang trí bằng @property của LitElement sẽ khiến thành phần kết xuất lại bất cứ khi nào giá trị của thành phần thay đổi.
import { property } from 'lit/decorators.js';
export class StoryViewer extends LitElement {
@property({type: Number}) index: number = 0;
}
Mỗi thẻ cần được dịch theo chiều ngang vào vị trí. Hãy áp dụng các bản dịch này trong phương thức vòng đời update của lit-element. Phương thức cập nhật sẽ chạy bất cứ khi nào một thuộc tính được quan sát của thành phần này thay đổi. Thông thường, chúng ta sẽ truy vấn khe cắm và lặp lại slot.assignedElements(). Tuy nhiên, vì chúng ta chỉ có một khe cắm không tên, nên điều này cũng giống như việc sử dụng this.children. Hãy sử dụng this.children để thuận tiện.
import { PropertyValues } from 'lit';
export class StoryViewer extends LitElement {
update(changedProperties: PropertyValues) {
const width = this.clientWidth;
Array.from(this.children).forEach((el: Element, i) => {
const x = (i - this.index) * width;
(el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
});
super.update(changedProperties);
}
}
Các <story-card> của chúng tôi hiện đã được sắp xếp theo hàng. Nó vẫn hoạt động với các phần tử khác dưới dạng phần tử con, miễn là chúng ta chú ý tạo kiểu cho chúng một cách thích hợp:
<story-viewer>
<!-- A regular story-card child... -->
<story-card>
<video slot="media" src="some/video.mp4"></video>
<h1>This video</h1>
<p>is so cool.</p>
</story-card>
<!-- ...and other elements work too! -->
<img style="object-fit: cover" src="some/img.png" />
</story-viewer>
Chuyển đến build/index.html và xoá dấu nhận xét cho các phần tử còn lại của thẻ câu chuyện. Giờ hãy tạo một cách để chúng ta có thể chuyển đến các thành phần này!
5. Thanh tiến trình và điều hướng
Tiếp theo, chúng ta sẽ thêm một cách để di chuyển giữa các thẻ và một thanh tiến trình.
Hãy thêm một số hàm trợ giúp vào StoryViewer để điều hướng câu chuyện. Chúng sẽ đặt chỉ mục cho chúng ta trong khi kẹp chỉ mục đó vào một dải ô hợp lệ.
Trong story-viewer.ts, trong lớp StoryViewer, hãy thêm:
/** Advance to the next story card if possible **/
next() {
this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}
/** Go back to the previous story card if possible **/
previous() {
this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}
Để hiển thị chế độ điều hướng cho người dùng cuối, chúng ta sẽ thêm các nút "trước" và "tiếp theo" vào <story-viewer>. Khi người dùng nhấp vào một trong hai nút, chúng ta muốn gọi hàm trợ giúp next hoặc previous. lit-html giúp bạn dễ dàng thêm trình nghe sự kiện vào các phần tử; chúng ta có thể kết xuất các nút và thêm trình nghe lượt nhấp cùng một lúc.
Cập nhật phương thức render như sau:
export class StoryViewer extends LitElement {
render() {
return html`
<slot></slot>
<svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
<path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
</svg>
<svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
<path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
</svg>
`;
}
}
Hãy xem cách chúng ta có thể thêm trình nghe sự kiện nội tuyến vào các nút svg mới, ngay trong phương thức render. Điều này áp dụng cho mọi sự kiện. Bạn chỉ cần thêm một liên kết của @eventname=${handler} biểu mẫu vào một phần tử.
Thêm nội dung sau vào thuộc tính static styles để tạo kiểu cho các nút:
svg {
position: absolute;
top: calc(50% - 25px);
height: 50px;
cursor: pointer;
}
#next {
right: 0;
}
Đối với thanh tiến trình, chúng ta sẽ sử dụng lưới CSS để tạo kiểu cho các hộp nhỏ, mỗi hộp cho một thẻ tin. Chúng ta có thể sử dụng thuộc tính index để thêm các lớp có điều kiện vào các hộp nhằm cho biết liệu chúng đã được "xem" hay chưa. Chúng ta có thể dùng một biểu thức có điều kiện như i <= this.index : 'watched': '', nhưng mọi thứ có thể trở nên rườm rà nếu chúng ta thêm nhiều lớp hơn. May mắn thay, lit-html cung cấp một chỉ thị có tên là classMap để trợ giúp. Trước tiên, hãy nhập classMap:
import { classMap } from 'lit/directives/class-map';
Thêm mã đánh dấu sau vào cuối phương thức render:
<div id="progress">
${Array.from(this.children).map((_, i) => html`
<div
class=${classMap({watched: i <= this.index})}
@click=${() => this.index = i}
></div>`
)}
</div>
Chúng tôi cũng thêm một số trình xử lý lượt nhấp khác để người dùng có thể chuyển thẳng đến một thẻ tin bài cụ thể nếu muốn.
Sau đây là các kiểu mới cần thêm vào static styles:
::slotted(*) {
position: absolute;
width: 100%;
/* Changed this line! */
height: calc(100% - 20px);
}
#progress {
position: relative;
top: calc(100% - 20px);
height: 20px;
width: 50%;
margin: 0 auto;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
grid-gap: 10px;
align-content: center;
}
#progress > div {
background: grey;
height: 4px;
transition: background 0.3s linear;
cursor: pointer;
}
#progress > div.watched {
background: white;
}
Thanh tiến trình và thành phần điều hướng đã hoàn tất. Bây giờ, hãy thêm một chút điểm nhấn!
6. Vuốt
Để triển khai thao tác vuốt, chúng ta sẽ sử dụng thư viện kiểm soát cử chỉ Hammer.js. Hammer phát hiện các cử chỉ đặc biệt như cử chỉ di chuyển và gửi các sự kiện kèm theo thông tin liên quan (chẳng hạn như delta X) mà chúng ta có thể sử dụng.
Sau đây là cách chúng ta có thể dùng Hammer để phát hiện thao tác xoay và tự động cập nhật phần tử bất cứ khi nào sự kiện xoay xảy ra:
import { state } from 'lit/decorators.js';
import 'hammerjs';
export class StoryViewer extends LitElement {
// Data emitted by Hammer.js
@state() _panData: {isFinal?: boolean, deltaX?: number} = {};
constructor() {
super();
this.index = 0;
new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
}
}
Hàm khởi tạo của lớp LitElement là một nơi tuyệt vời khác để đính kèm trình nghe sự kiện trên chính phần tử lưu trữ. Hàm khởi tạo Hammer lấy một phần tử để phát hiện cử chỉ. Trong trường hợp này, đó là chính StoryViewer hoặc this. Sau đó, bằng cách sử dụng API của Hammer, chúng ta yêu cầu API này phát hiện cử chỉ "di chuyển" và đặt thông tin di chuyển vào một thuộc tính _panData mới.
Bằng cách trang trí thuộc tính _panData bằng @state, LitElement sẽ theo dõi các thay đổi đối với _panData và thực hiện một bản cập nhật, nhưng sẽ không có thuộc tính HTML nào được liên kết cho thuộc tính này.
Tiếp theo, hãy tăng cường logic update để sử dụng dữ liệu cử chỉ xoay:
// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
// deltaX is the distance of the current pan gesture.
// isFinal is whether the pan gesture is ending.
let { deltaX = 0, isFinal = false } = this._panData;
// When the pan gesture finishes, navigate.
if (!changedProperties.has('index') && isFinal) {
deltaX > 0 ? this.previous() : this.next();
}
// We don't want any deltaX when releasing a pan.
deltaX = isFinal ? 0 : deltaX;
const width = this.clientWidth;
Array.from(this.children).forEach((el: Element, i) => {
// Updated this line to utilize deltaX.
const x = (i - this.index) * width + deltaX;
(el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
});
// Don't forget to call super!
super.update(changedProperties);
}
Giờ đây, chúng ta có thể kéo các thẻ câu chuyện qua lại. Để mọi thứ diễn ra suôn sẻ, hãy quay lại static get styles và thêm transition: transform 0.35s ease-out; vào bộ chọn ::slotted(*):
::slotted(*) {
...
transition: transform 0.35s ease-out;
}
Giờ đây, chúng ta đã có thao tác vuốt mượt mà:

7. Tự động phát
Tính năng cuối cùng mà chúng tôi sẽ thêm là video tự động phát. Khi thẻ tin bài xuất hiện, chúng ta muốn video nền phát (nếu có). Khi thẻ câu chuyện không còn được lấy tiêu điểm, chúng ta nên tạm dừng video của thẻ đó.
Chúng ta sẽ triển khai việc này bằng cách gửi các sự kiện tuỳ chỉnh "entered" (đã nhập) và "exited" (đã thoát) trên các thành phần con thích hợp bất cứ khi nào chỉ mục thay đổi. Trong StoryCard, chúng ta sẽ nhận được những sự kiện đó và phát hoặc tạm dừng mọi video hiện có. Tại sao bạn chọn gửi các sự kiện trên các thành phần con thay vì gọi các phương thức thực thể "entered" và "exited" được xác định trên StoryCard? Với các phương thức, người dùng thành phần sẽ không có lựa chọn nào khác ngoài việc viết một phần tử tuỳ chỉnh nếu họ muốn viết thẻ câu chuyện của riêng mình bằng các ảnh động tuỳ chỉnh. Với các sự kiện, họ chỉ cần đính kèm một trình nghe sự kiện!
Hãy tái cấu trúc thuộc tính index của StoryViewer để sử dụng một setter, cung cấp một đường dẫn mã thuận tiện để gửi các sự kiện:
class StoryViewer extends LitElement {
@state() private _index: number = 0
get index() {
return this._index
}
set index(value: number) {
this.children[this._index].dispatchEvent(new CustomEvent('exited'));
this.children[value].dispatchEvent(new CustomEvent('entered'));
this._index = value;
}
}
Để hoàn tất tính năng tự động phát, chúng ta sẽ thêm trình nghe sự kiện cho "entered" và "exited" trong hàm khởi tạo StoryCard để phát và tạm dừng video.
Hãy nhớ rằng người dùng thành phần có thể cung cấp hoặc không cung cấp cho <story-card> một phần tử video trong khe cắm nội dung nghe nhìn. Thậm chí, có thể họ sẽ không cung cấp phần tử nào trong vị trí nội dung nghe nhìn. Chúng ta phải cẩn thận để không gọi play trên img hoặc trên giá trị rỗng.
Quay lại story-card.ts, hãy thêm đoạn mã sau:
import { query } from 'lit/decorators.js';
class StoryCard extends LitElement {
constructor() {
super();
this.addEventListener("entered", () => {
if (this._slottedMedia) {
this._slottedMedia.currentTime = 0;
this._slottedMedia.play();
}
});
this.addEventListener("exited", () => {
if (this._slottedMedia) {
this._slottedMedia.pause();
}
});
}
/**
* The element in the "media" slot, ONLY if it is an
* HTMLMediaElement, such as <video>.
*/
private get _slottedMedia(): HTMLMediaElement|null {
const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
return el instanceof HTMLMediaElement ? el : null;
}
/**
* @query(selector) is shorthand for
* this.renderRoot.querySelector(selector)
*/
@query("slot[name=media]")
private _mediaSlot!: HTMLSlotElement;
}
Đã kết thúc quá trình tự động phát. ✅
8. Tip the Scales
Giờ đây, khi đã có tất cả các tính năng cần thiết, hãy thêm một tính năng nữa: hiệu ứng thu phóng mượt mà. Hãy quay lại phương thức update của StoryViewer một lần nữa. Một số phép toán được thực hiện để lấy giá trị trong hằng số scale. Giá trị này sẽ bằng 1.0 đối với thành phần con đang hoạt động và bằng minScale đối với các thành phần khác, đồng thời nội suy giữa hai giá trị này.
Thay đổi vòng lặp trong phương thức update trong story-viewer.ts thành:
update(changedProperties: PropertyValues) {
// ...
const minScale = 0.8;
Array.from(this.children).forEach((el: Element, i) => {
const x = (i - this.index) * width + deltaX;
// Piecewise scale(deltaX), looks like: __/\__
const u = deltaX / width + (i - this.index);
const v = -Math.abs(u * (1 - minScale)) + 1;
const scale = Math.max(v, minScale);
// Include the scale transform
(el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
});
// ...
}
Tất cả chỉ có thế! Trong bài đăng này, chúng ta đã tìm hiểu rất nhiều, bao gồm một số tính năng của LitElement và lit-html, các phần tử khe HTML và chế độ điều khiển bằng cử chỉ.
Để xem phiên bản hoàn chỉnh của thành phần này, hãy truy cập vào: https://github.com/PolymerLabs/story-viewer.