Vanilla JavaScript, Thư viện và Nhiệm vụ Kết xuất DOM có trạng thái

theanh

Administrator
Nhân viên
Trong bài viết quan trọng của mình “The Market For Lemons”, Alex Russell, một web crank nổi tiếng, đã nêu ra vô số thất bại của ngành công nghiệp của chúng ta, tập trung vào hậu quả thảm khốc đối với người dùng cuối. Sự phẫn nộ này hoàn toàn phù hợp theo điều lệ của phương tiện truyền thông của chúng ta.

Các khuôn khổ đóng vai trò rất lớn trong phương trình đó, tuy nhiên cũng có thể có những lý do chính đáng để các nhà phát triển front-end lựa chọn một khuôn khổ, hoặc thư viện: Việc cập nhật giao diện web một cách động có thể rất khó khăn theo những cách không rõ ràng. Hãy cùng tìm hiểu bằng cách bắt đầu từ đầu và quay trở lại các nguyên tắc đầu tiên.

Danh mục đánh dấu​

Mọi thứ trên web đều bắt đầu bằng đánh dấu, tức là HTML. Cấu trúc đánh dấu có thể được chia thành ba loại:
  1. Các phần tĩnh luôn giữ nguyên.
  2. Các phần biến được xác định một lần khi khởi tạo.
  3. Các phần biến được cập nhật động khi chạy.
Ví dụ: tiêu đề của bài viết có thể trông như thế này:
Mã:
 [HEADING=1]«Hello World»[/HEADING] «123» backlinks
Các phần biến được gói trong «guillemets» ở đây: "Hello World" là tiêu đề tương ứng, chỉ thay đổi giữa các bài viết. Tuy nhiên, bộ đếm liên kết ngược có thể được cập nhật liên tục thông qua tập lệnh phía máy khách; chúng tôi đã sẵn sàng để trở nên phổ biến trên blogosphere. Mọi thứ khác vẫn giống hệt nhau trong tất cả các bài viết của chúng tôi.

Bài viết bạn đang đọc sau đó tập trung vào danh mục thứ ba: Nội dung cần được cập nhật khi chạy.

Trình duyệt màu​

Hãy tưởng tượng chúng ta đang xây dựng một trình duyệt màu đơn giản: Một tiện ích nhỏ để khám phá một tập hợp màu được đặt tên được xác định trước, được trình bày dưới dạng danh sách ghép một mẫu màu với giá trị màu tương ứng. Người dùng phải có thể tìm kiếm tên màu và chuyển đổi giữa các mã màu thập lục phân và bộ ba màu Đỏ, Xanh lam và Xanh lục (RGB). Chúng ta có thể tạo ra một bộ xương trơ chỉ với một chút HTML và CSS:

Xem Bút [Trình duyệt màu (trống) [phân nhánh]](https://codepen.io/smashingmag/pen/RwdmbGd) của FND.
Xem Bút Trình duyệt màu (trống) [forked] bởi FND.

Kết xuất phía máy khách​

Chúng tôi miễn cưỡng quyết định sử dụng kết xuất phía máy khách cho phiên bản tương tác. Đối với mục đích của chúng tôi ở đây, không quan trọng liệu tiện ích này có cấu thành một ứng dụng hoàn chỉnh hay chỉ là một hòn đảo độc lập được nhúng trong một tài liệu HTML tĩnh hoặc do máy chủ tạo ra hay không.

Với sở thích của chúng tôi đối với JavaScript nguyên bản (so sánh các nguyên tắc đầu tiên và tất cả), chúng tôi bắt đầu với các API DOM tích hợp của trình duyệt:
Mã:
function renderPalette(colors) { let items = []; for(let color of colors) { let item = document.createElement("li"); items.push(item); let value = color.hex; makeElement("input", { parent: item, type: "color", value }); makeElement("span", { parent: item, text: color.name }); makeElement("code", { parent: item, text: value }); } let list = document.createElement("ul"); list.append(...items); return list;}
Lưu ý:
Phần trên dựa vào một hàm tiện ích nhỏ để tạo phần tử ngắn gọn hơn:
Mã:
function makeElement(tag, { parent, children, text, ...attribs }) { let el = document.createElement(tag); if(text) { el.textContent = text; } for(let [name, value] of Object.entries(attribs)) { el.setAttribute(name, value); } if(children) { el.append(...children); } parent?.appendChild(el); return el;}
Bạn cũng có thể nhận thấy sự không nhất quán về phong cách: Trong vòng lặp items, các phần tử mới được tạo sẽ tự gắn vào vùng chứa của chúng. Sau đó, chúng ta đảo ngược trách nhiệm, vì vùng chứa list sẽ nhập các phần tử con thay thế.
Voilà: renderPalette tạo danh sách màu của chúng ta. Hãy thêm một biểu mẫu để tương tác:
Mã:
function renderControls() { return makeElement("form", { method: "dialog", children: [ createField("search", "Search"), createField("checkbox", "RGB") ] });}
Hàm tiện ích createField đóng gói các cấu trúc DOM cần thiết cho các trường nhập liệu; đây là một thành phần đánh dấu có thể tái sử dụng nhỏ:
Mã:
function createField(type, caption) { let children = [ makeElement("span", { text: caption }), makeElement("input", { type }) ]; return makeElement("label", { children: type === "checkbox" ? children.reverse() : children });}
Bây giờ, chúng ta chỉ cần kết hợp các phần đó. Hãy gói chúng trong một phần tử tùy chỉnh:
Mã:
import { COLORS } from "./colors.js"; // một mảng các đối tượng `{ name, hex, rgb }`customElements.define("color-browser", class ColorBrowser extends HTMLElement { colors = [...COLORS]; // bản sao cục bộ connectedCallback() { this.append( renderControls(), renderPalette(this.colors) ); }});
Từ nay trở đi, một phần tử ở bất kỳ đâu trong HTML của chúng ta sẽ tạo ra toàn bộ giao diện người dùng ngay tại đó. (Tôi thích nghĩ về nó như một macro mở rộng tại chỗ.) Việc triển khai này phần nào mang tính khai báo1, với các cấu trúc DOM được tạo ra bằng cách kết hợp nhiều trình tạo đánh dấu đơn giản, các thành phần được phân định rõ ràng, nếu bạn muốn.

1 Giải thích hữu ích nhất về sự khác biệt giữa lập trình khai báo và lập trình mệnh lệnh mà tôi từng gặp tập trung vào người đọc. Thật không may, tôi không nhớ rõ nguồn cụ thể đó, vì vậy tôi sẽ diễn đạt lại ở đây: Mã khai báo mô tả cái gì trong khi mã mệnh lệnh mô tả cách thức. Một hậu quả là mã bắt buộc đòi hỏi nỗ lực nhận thức để tuần tự thực hiện từng bước qua các hướng dẫn của mã và xây dựng một mô hình tinh thần về kết quả tương ứng.

Tương tác​

Lúc này, chúng ta chỉ đang tạo lại bộ khung trơ của mình; vẫn chưa có tương tác thực sự nào. Trình xử lý sự kiện để giải cứu:
Mã:
class ColorBrowser extends HTMLElement { colors = [...COLORS]; query = null; rgb = false; connectedCallback() { this.append(renderControls(), renderPalette(this.colors)); this.addEventListener("input", this); this.addEventListener("change", this); } handleEvent(ev) { let el = ev.target; switch(ev.type) { case "change": if(el.type === "checkbox") { this.rgb = el.checked; } break; case "input": if(el.type === "search") { this.query = el.value.toLowerCase(); } break; } }}
Lưu ý:
handleEvent có nghĩa là chúng ta không cần phải lo lắng về ràng buộc hàm. Nó cũng đi kèm với nhiều lợi thế khác nhau. Có các mẫu khác.
Bất cứ khi nào một trường thay đổi, chúng tôi sẽ cập nhật biến thể hiện tương ứng (đôi khi được gọi là ràng buộc dữ liệu một chiều). Than ôi, việc thay đổi trạng thái nội bộ này2 cho đến nay vẫn chưa được phản ánh ở bất kỳ đâu trong UI.

2 Trong bảng điều khiển dành cho nhà phát triển của trình duyệt, hãy kiểm tra document.querySelector("color-browser").query sau khi nhập một thuật ngữ tìm kiếm.

Lưu ý rằng trình xử lý sự kiện này được liên kết chặt chẽ với các thành phần bên trong renderControls vì nó mong đợi một hộp kiểm và trường tìm kiếm tương ứng. Do đó, bất kỳ thay đổi tương ứng nào đối với renderControls — có thể là chuyển sang các nút radio để biểu diễn màu — giờ đây cần tính đến đoạn mã khác này: hành động từ xa! Mở rộng hợp đồng của thành phần này để bao gồm tên trường có thể làm giảm bớt những lo ngại đó.

Bây giờ chúng ta phải đối mặt với sự lựa chọn giữa:
  1. Truy cập vào DOM đã tạo trước đó để sửa đổi hoặc
  2. Tạo lại DOM trong khi kết hợp trạng thái mới.

Kết xuất lại​

Vì chúng ta đã định nghĩa thành phần đánh dấu của mình ở một nơi, hãy bắt đầu với tùy chọn thứ hai. Chúng tôi chỉ cần chạy lại trình tạo đánh dấu của mình, cung cấp cho chúng trạng thái hiện tại.
Mã:
class ColorBrowser mở rộng HTMLElement { // [bỏ qua chi tiết trước] connectedCallback() { this.#render(); this.addEventListener("input", this); this.addEventListener("change", this); } handleEvent(ev) { // [bỏ qua chi tiết trước] this.#render(); } #render() { this.replaceChildren(); this.append(renderControls(), renderPalette(this.colors)); }}
Chúng tôi đã chuyển toàn bộ logic kết xuất vào một phương thức chuyên dụng3, phương thức này không chỉ được gọi một lần khi khởi động mà còn bất cứ khi nào trạng thái thay đổi.

3 Bạn có thể muốn tránh các thuộc tính riêng tư, đặc biệt là nếu những thuộc tính khác có thể được xây dựng dựa trên triển khai của bạn.

Tiếp theo, chúng ta có thể biến colors thành một phương thức truy xuất để chỉ trả về các mục khớp với trạng thái tương ứng, tức là truy vấn tìm kiếm của người dùng:
Mã:
class ColorBrowser extends HTMLElement { query = null; rgb = false; // [bỏ qua các chi tiết trước đó] get colors() { let { query } = this; if(!query) { return [...COLORS]; } return COLORS.filter(color => color.name.toLowerCase().includes(query)); }}
Lưu ý:
Tôi thiên về mẫu bouncer.
Việc chuyển đổi biểu diễn màu sắc được để lại như một bài tập cho người đọc. Bạn có thể truyền this.rgb vào renderPalette rồi điền bằng color.hex hoặc color.rgb, có thể sử dụng tiện ích này:
Mã:
function formatRGB(value) { return value.split(",").map(num => num.toString().padStart(3, " ")).join(", ");}
Điều này hiện tạo ra hành vi thú vị (thực sự là khó chịu):

Xem Bút [Trình duyệt màu (lỗi) [phân nhánh]](https://codepen.io/smashingmag/pen/YzgbKab) của FND.
Xem Bút Trình duyệt màu (lỗi) [phân nhánh] của FND.
Có vẻ như không thể nhập truy vấn vì trường nhập mất tiêu điểm sau khi có thay đổi, khiến trường nhập trở nên trống rỗng. Tuy nhiên, nhập một ký tự không phổ biến (ví dụ: "v") sẽ cho thấy rõ ràng rằng có điều gì đó đang xảy ra: Danh sách màu thực sự thay đổi.

Lý do là cách tiếp cận tự làm (DIY) hiện tại của chúng ta khá thô sơ: #render xóa và tạo lại toàn bộ DOM với mỗi thay đổi. Việc loại bỏ các nút DOM hiện có cũng sẽ đặt lại trạng thái tương ứng, bao gồm giá trị, tiêu điểm và vị trí cuộn của trường biểu mẫu. Điều đó không tốt!

Kết xuất gia tăng​

Giao diện người dùng theo hướng dữ liệu của phần trước có vẻ là một ý tưởng hay: Cấu trúc đánh dấu được xác định một lần và được kết xuất lại theo ý muốn, dựa trên mô hình dữ liệu thể hiện rõ ràng trạng thái hiện tại. Tuy nhiên, trạng thái rõ ràng của thành phần của chúng ta rõ ràng là không đủ; chúng ta cần điều hòa nó với trạng thái ngầm định của trình duyệt trong khi kết xuất lại.

Chắc chắn, chúng ta có thể thử làm cho trạng thái ngầm định đó rõ ràng và kết hợp nó vào mô hình dữ liệu của chúng ta, như bao gồm các thuộc tính giá trị hoặc đã kiểm tra của trường. Nhưng điều đó vẫn còn nhiều thứ chưa được tính đến, bao gồm quản lý tiêu điểm, vị trí cuộn và vô số chi tiết mà chúng ta thậm chí có thể chưa nghĩ đến (thường là các tính năng trợ năng). Chẳng bao lâu nữa, chúng ta sẽ thực sự tạo lại trình duyệt!

Thay vào đó, chúng ta có thể thử xác định những phần nào của UI cần cập nhật và giữ nguyên phần còn lại của DOM. Thật không may, điều đó không hề tầm thường, đó là nơi các thư viện như React xuất hiện cách đây hơn một thập kỷ: Trên bề mặt, chúng cung cấp một cách khai báo hơn để xác định cấu trúc DOM4 (đồng thời cũng khuyến khích thành phần hóa, thiết lập một nguồn sự thật duy nhất cho từng mẫu UI riêng lẻ). Ở bên trong, các thư viện như vậy đã giới thiệu các cơ chế5 để cung cấp các bản cập nhật DOM gia tăng, chi tiết thay vì tạo lại các cây DOM từ đầu — vừa để tránh xung đột trạng thái vừa để cải thiện hiệu suất6.

4 Trong bối cảnh này, về cơ bản điều đó có nghĩa là viết thứ gì đó trông giống HTML, mà, tùy thuộc vào hệ thống niềm tin của bạn, là điều cần thiết hoặc đáng ghê tởm. Tình trạng tạo mẫu HTML khi đó khá tệ và vẫn còn kém trong một số môi trường.
5 “Hãy cùng tìm hiểu cách các khuôn khổ JavaScript hiện đại hoạt động bằng cách xây dựng một khuôn khổ” của Nolan Lawson cung cấp nhiều thông tin chi tiết có giá trị về chủ đề đó. Để biết thêm chi tiết, tài liệu dành cho nhà phát triển của lit-html rất đáng để nghiên cứu.
6 Từ đó, chúng tôi biết rằng một số cơ chế đó thực sự rất tốn kém.

Điểm mấu chốt: Nếu chúng ta muốn đóng gói các định nghĩa đánh dấu và sau đó lấy UI của mình từ một mô hình dữ liệu biến, chúng ta phải dựa vào thư viện của bên thứ ba để đối chiếu.

Actus Imperatus​

Mặt khác cuối phổ, chúng ta có thể lựa chọn các sửa đổi phẫu thuật. Nếu chúng ta biết mục tiêu là gì, mã ứng dụng của chúng ta có thể tiếp cận DOM và chỉ sửa đổi những phần cần cập nhật.

Tuy nhiên, đáng tiếc là cách tiếp cận đó thường dẫn đến sự kết hợp chặt chẽ một cách thảm khốc, với logic có liên quan được phân tán khắp ứng dụng trong khi các thói quen được nhắm mục tiêu chắc chắn sẽ vi phạm tính đóng gói của các thành phần. Mọi thứ trở nên phức tạp hơn nữa khi chúng ta xem xét các hoán vị UI ngày càng phức tạp (nghĩ đến các trường hợp ngoại lệ, báo cáo lỗi, v.v.). Đó chính là những vấn đề mà các thư viện đã đề cập ở trên hy vọng sẽ loại bỏ được.

Trong trường hợp trình duyệt màu của chúng ta, điều đó có nghĩa là tìm và ẩn các mục nhập màu không khớp với truy vấn, chưa kể đến việc thay thế danh sách bằng một thông báo thay thế nếu không còn mục nhập nào khớp. Chúng ta cũng phải hoán đổi các biểu diễn màu tại chỗ. Bạn có thể tưởng tượng ra đoạn mã kết quả sẽ phá vỡ mọi sự phân tách mối quan tâm, làm rối tung các thành phần ban đầu chỉ thuộc về renderPalette.
Mã:
class ColorBrowser extends HTMLElement { // [bỏ qua chi tiết trước] handleEvent(ev) { // [bỏ qua chi tiết trước] for(let item of this.#list.children) { item.hidden = !item.textContent.toLowerCase().includes(this.query); } if(this.#list.children.filter(el => !el.hidden).length === 0) { // inject substitute message } } #render() { // [bỏ qua chi tiết trước] this.#list = renderPalette(this.colors); }}
Như một người khôn ngoan đã từng nói: Quá nhiều kiến thức!

Mọi thứ trở nên nguy hiểm hơn với các trường biểu mẫu: Chúng ta không chỉ phải cập nhật trạng thái cụ thể của một trường mà còn cần biết nơi để đưa thông báo lỗi vào. Trong khi việc tiếp cận renderPalette đã đủ tệ, ở đây chúng ta sẽ phải xuyên thủng nhiều lớp: createField là một tiện ích chung được renderControls sử dụng, sau đó được ColorBrowser cấp cao nhất của chúng ta gọi.

Nếu mọi thứ trở nên phức tạp ngay cả trong ví dụ tối thiểu này, hãy tưởng tượng có một ứng dụng phức tạp hơn với nhiều lớp và gián tiếp hơn nữa. Việc theo dõi tất cả các kết nối đó gần như là không thể. Những hệ thống như vậy thường trở thành một quả cầu bùn lớn, nơi không ai dám thay đổi bất cứ điều gì vì sợ vô tình làm hỏng mọi thứ.

Kết luận​

Có vẻ như có một sự thiếu sót rõ ràng trong các API trình duyệt được chuẩn hóa. Sở thích của chúng ta đối với các giải pháp JavaScript thuần túy không phụ thuộc bị cản trở bởi nhu cầu cập nhật không phá hủy các cấu trúc DOM hiện có. Điều đó giả định rằng chúng ta coi trọng một cách tiếp cận khai báo với tính đóng gói không thể xâm phạm, hay còn gọi là "Kỹ thuật phần mềm hiện đại: Những phần tốt".

Theo tình hình hiện tại, ý kiến cá nhân của tôi là một thư viện nhỏ như lit-html hoặc Preact thường được bảo hành, đặc biệt là khi sử dụng với khả năng thay thế trong tâm trí: Một API được chuẩn hóa vẫn có thể xảy ra! Dù bằng cách nào, các thư viện đầy đủ có dấu chân nhẹ và thường không gây nhiều trở ngại cho người dùng cuối, đặc biệt là khi kết hợp với cải tiến dần dần.

Tuy nhiên, tôi không muốn để bạn phải chờ đợi, vì vậy tôi đã đánh lừa triển khai JavaScript gốc của chúng tôi để chủ yếu thực hiện những gì chúng tôi mong đợi:

Xem Bút [Trình duyệt màu [phân nhánh]](https://codepen.io/smashingmag/pen/vYPwBro) của FND.
Xem Bút Trình duyệt màu [phân nhánh] của FND.
 
Back
Bên trên