paint-brush
Bốn điều tôi đã làm khác biệt khi viết một khuôn khổ Frontendtừ tác giả@hacker-ss4mpor
Bài viết mới

Bốn điều tôi đã làm khác biệt khi viết một khuôn khổ Frontend

từ tác giả 17m2024/08/27
Read on Terminal Reader

dài quá đọc không nổi

Bốn ý tưởng mà có lẽ bạn chưa từng nghe đến, trong bối cảnh của các khuôn khổ giao diện người dùng: - Đối tượng theo nghĩa đen để tạo mẫu HTML. - Một kho lưu trữ toàn cục có thể định địa chỉ thông qua các đường dẫn. - Sự kiện và trình phản hồi để xử lý tất cả các đột biến. - Một thuật toán diff theo văn bản để cập nhật DOM.
featured image - Bốn điều tôi đã làm khác biệt khi viết một khuôn khổ Frontend
undefined HackerNoon profile picture
0-item
1-item

Quay trở lại năm 2013, tôi bắt đầu xây dựng một bộ công cụ tối giản để phát triển các ứng dụng web. Có lẽ điều tuyệt vời nhất xuất hiện trong quá trình đó là gotoB , một framework frontend JS thuần túy, phía máy khách được viết bằng 2k dòng mã.


Tôi có động lực viết bài viết này sau khi đọc rất nhiều bài viết thú vị của các tác giả của các nền tảng front-end rất thành công:


Điều khiến tôi hứng thú về những bài viết này là chúng nói về quá trình phát triển ý tưởng đằng sau những gì họ xây dựng; việc triển khai chỉ là một cách để biến chúng thành hiện thực, và những tính năng duy nhất được thảo luận là những tính năng thiết yếu nhất để thể hiện chính những ý tưởng đó.


Cho đến nay, khía cạnh thú vị nhất của gotoB là những ý tưởng phát triển sau khi đối mặt với những thách thức trong quá trình xây dựng nó. Đó là những gì tôi muốn đề cập ở đây.


Bởi vì tôi đã xây dựng khuôn khổ từ đầu và cố gắng đạt được cả tính tối giản và tính nhất quán nội bộ, tôi đã giải quyết bốn vấn đề theo cách mà tôi cho là khác với cách mà hầu hết các khuôn khổ khác giải quyết cùng một vấn đề.


Bốn ý tưởng này là những gì tôi muốn chia sẻ với bạn ngay bây giờ. Tôi làm điều này không phải để thuyết phục bạn sử dụng các công cụ của tôi (mặc dù bạn có thể sử dụng!), mà là hy vọng rằng bạn có thể quan tâm đến chính những ý tưởng đó.

Ý tưởng 1: đối tượng theo nghĩa đen để giải quyết mẫu

Bất kỳ ứng dụng web nào cũng cần tạo đánh dấu (HTML) ngay lập tức, dựa trên trạng thái của ứng dụng.


Điều này được giải thích tốt nhất bằng một ví dụ: trong một ứng dụng danh sách việc cần làm cực kỳ đơn giản, trạng thái có thể là một danh sách việc cần làm: ['Item 1', 'Item 2'] . Vì bạn đang viết một ứng dụng (khác với một trang tĩnh), nên danh sách việc cần làm phải có thể thay đổi.


Vì trạng thái thay đổi, HTML tạo nên giao diện người dùng của ứng dụng của bạn phải thay đổi theo trạng thái. Ví dụ, để hiển thị todos, bạn có thể sử dụng HTML sau:

 <ul> <li>Item 1</li> <li>Item 2</li> </ul>


Nếu trạng thái thay đổi và mục thứ ba được thêm vào, trạng thái của bạn sẽ trông như thế này: ['Item 1', 'Item 2', 'Item 3'] ; khi đó, HTML của bạn sẽ trông như thế này:

 <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>


Vấn đề tạo HTML dựa trên trạng thái của ứng dụng thường được giải quyết bằng ngôn ngữ mẫu , ngôn ngữ này chèn các cấu trúc ngôn ngữ lập trình (biến, điều kiện và vòng lặp) vào HTML giả và được mở rộng thành HTML thực tế.


Ví dụ, đây là hai cách có thể thực hiện việc này trong các công cụ tạo mẫu khác nhau:

 // Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>


Tôi chưa bao giờ thích những cú pháp này, những cú pháp đưa logic vào HTML. Nhận ra rằng việc tạo mẫu đòi hỏi phải lập trình, và muốn tránh việc có một cú pháp riêng cho nó, tôi quyết định thay vào đó đưa HTML vào js, sử dụng các đối tượng theo nghĩa đen . Vì vậy, tôi có thể chỉ cần mô hình hóa HTML của mình dưới dạng các đối tượng theo nghĩa đen:

 ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]


Nếu sau đó tôi muốn sử dụng phép lặp để tạo danh sách, tôi có thể chỉ cần viết:

 ['ul', items.map ((item) => ['li', item])]


Và sau đó sử dụng một hàm để chuyển đổi đối tượng này thành HTML. Theo cách này, tất cả các mẫu có thể được thực hiện trong JS, mà không cần bất kỳ ngôn ngữ mẫu hoặc biên dịch nào. Tôi sử dụng tên liths để mô tả các mảng này biểu diễn HTML.


Theo hiểu biết của tôi, không có khuôn khổ JS nào khác tiếp cận việc tạo mẫu theo cách này. Tôi đã tìm hiểu và tìm thấy JSONML , sử dụng cấu trúc gần như giống nhau để biểu diễn HTML trong các đối tượng JSON (gần giống với các đối tượng JS theo nghĩa đen), nhưng không tìm thấy khuôn khổ nào được xây dựng xung quanh nó.


MithrilHyperapp khá giống với phương pháp tôi đã sử dụng, nhưng chúng vẫn sử dụng lệnh gọi hàm cho từng phần tử.

 // Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])


Phương pháp sử dụng đối tượng theo nghĩa đen hoạt động tốt với HTML, vì vậy tôi đã mở rộng nó sang CSS và hiện cũng tạo ra tất cả CSS của mình thông qua đối tượng theo nghĩa đen.


Nếu vì lý do nào đó bạn đang ở trong môi trường không thể biên dịch JSX hoặc sử dụng ngôn ngữ mẫu và không muốn nối chuỗi, bạn có thể sử dụng phương pháp này thay thế.


Tôi không chắc cách tiếp cận của Mithril/Hyperapp có tốt hơn cách tiếp cận của tôi không; tôi thấy rằng khi viết các đối tượng dài biểu diễn lith, đôi khi tôi quên dấu phẩy ở đâu đó và đôi khi có thể khó tìm. Ngoài ra, không có gì phàn nàn thực sự. Và tôi thích thực tế là biểu diễn cho HTML là 1) dữ liệu và 2) trong JS. Biểu diễn này thực sự có thể hoạt động như một DOM ảo, như chúng ta sẽ thấy khi chúng ta đến với Ý tưởng số 4.


Chi tiết bổ sung: nếu bạn muốn tạo HTML từ các đối tượng theo nghĩa đen, bạn chỉ cần giải quyết hai vấn đề sau:

  1. Thực thể hóa chuỗi (ví dụ: thoát khỏi các ký tự đặc biệt).
  2. Biết thẻ nào nên đóng và thẻ nào không nên đóng.

Ý tưởng 2: một kho lưu trữ toàn cầu có thể định địa chỉ thông qua các đường dẫn để lưu trữ tất cả trạng thái ứng dụng

Tôi chưa bao giờ thích các thành phần. Việc cấu trúc một ứng dụng xung quanh các thành phần đòi hỏi phải đặt dữ liệu thuộc về thành phần đó bên trong chính thành phần đó. Điều này khiến việc chia sẻ dữ liệu đó với các phần khác của ứng dụng trở nên khó khăn hoặc thậm chí là không thể.


Trong mọi dự án tôi làm, tôi thấy rằng tôi luôn cần một số phần của trạng thái ứng dụng được chia sẻ giữa các thành phần khá xa nhau. Một ví dụ điển hình là tên người dùng: bạn có thể cần điều này trong phần tài khoản và cả trong tiêu đề. Vậy tên người dùng thuộc về đâu?


Do đó, tôi đã quyết định ngay từ đầu là tạo một đối tượng dữ liệu đơn giản ( {} ) và nhồi nhét tất cả trạng thái của tôi vào đó. Tôi gọi nó là store . Store giữ trạng thái cho tất cả các phần của ứng dụng và do đó có thể được sử dụng bởi bất kỳ thành phần nào.


Cách tiếp cận này có phần kỳ lạ vào giai đoạn 2013-2015, nhưng kể từ đó đã trở nên phổ biến và thậm chí thống trị.


Điều tôi nghĩ vẫn còn khá mới lạ là tôi sử dụng đường dẫn để truy cập bất kỳ giá trị nào bên trong cửa hàng. Ví dụ, nếu cửa hàng là:

 { user: { firstName: 'foo' lastName: 'bar' } }


Tôi có thể sử dụng đường dẫn để truy cập (ví dụ) lastName , bằng cách viết B.get ('user', 'lastName') . Như bạn có thể thấy, ['user', 'lastName']đường dẫn đến 'bar' . B.get là một hàm truy cập vào store và trả về một phần cụ thể của store, được chỉ ra bởi đường dẫn bạn truyền cho hàm.


Ngược lại với cách trên, cách tiêu chuẩn để truy cập các thuộc tính phản ứng là tham chiếu chúng thông qua một biến JS. Ví dụ:

 // Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');


Tuy nhiên, điều này yêu cầu bạn phải giữ tham chiếu đến firstNamelastName (hoặc userStore ) ở bất kỳ nơi nào bạn cần giá trị đó. Cách tiếp cận mà tôi sử dụng chỉ yêu cầu bạn có quyền truy cập vào store (toàn cục và có sẵn ở mọi nơi) và cho phép bạn có quyền truy cập chi tiết vào store mà không cần xác định biến JS cho chúng.


Immutable.js và Firebase Realtime Database thực hiện một số việc gần giống với những gì tôi đã làm, mặc dù chúng hoạt động trên các đối tượng riêng biệt. Nhưng bạn có thể sử dụng chúng để lưu trữ mọi thứ ở một nơi duy nhất có thể được giải quyết chi tiết.

 // Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });


Có dữ liệu của tôi trong một kho lưu trữ có thể truy cập toàn cục có thể được truy cập chi tiết thông qua các đường dẫn là một mẫu mà tôi thấy cực kỳ hữu ích. Bất cứ khi nào tôi viết const [count, setCount] = ... hoặc một cái gì đó tương tự, nó cảm thấy thừa thãi. Tôi biết tôi có thể chỉ cần thực hiện B.get ('count') bất cứ khi nào tôi cần truy cập vào đó, mà không cần phải khai báo và truyền xung quanh count hoặc setCount .

Ý tưởng 3: mọi thay đổi đều được thể hiện qua các sự kiện

Nếu Ý tưởng số 2 (một kho lưu trữ toàn cầu có thể truy cập thông qua các đường dẫn) giải phóng dữ liệu khỏi các thành phần, Ý tưởng số 3 là cách tôi giải phóng mã khỏi các thành phần. Đối với tôi, đây là ý tưởng thú vị nhất trong bài viết này. Đây rồi!


Trạng thái của chúng ta là dữ liệu mà theo định nghĩa là có thể thay đổi (đối với những người sử dụng tính bất biến, lập luận vẫn đúng: bạn vẫn muốn phiên bản mới nhất của trạng thái thay đổi, ngay cả khi bạn giữ ảnh chụp nhanh các phiên bản cũ hơn của trạng thái). Làm thế nào để chúng ta thay đổi trạng thái?


Tôi quyết định sử dụng sự kiện. Tôi đã có đường dẫn đến cửa hàng, vì vậy một sự kiện có thể chỉ là sự kết hợp của một động từ (như set , add hoặc rem ) và một đường dẫn. Vì vậy, nếu tôi muốn cập nhật user.firstName , tôi có thể viết như sau:

 B.call ('set', ['user', 'firstName'], 'Foo')


Điều này chắc chắn dài dòng hơn là viết:

 user.firstName = 'Foo';


Nhưng nó cho phép tôi viết mã phản hồi với thay đổi trong user.firstName . Và đây là ý tưởng quan trọng: trong UI, có những phần khác nhau phụ thuộc vào những phần khác nhau của trạng thái. Ví dụ, bạn có thể có những phụ thuộc này:

  • Tiêu đề: phụ thuộc vào usercurrentView
  • Phần tài khoản: tùy thuộc vào user
  • Danh sách việc cần làm: tùy thuộc vào items


Câu hỏi lớn mà tôi phải đối mặt là: làm thế nào để cập nhật tiêu đề và phần tài khoản khi user thay đổi, nhưng không phải khi items thay đổi? Và làm thế nào để quản lý các phụ thuộc này mà không cần phải thực hiện các lệnh gọi cụ thể như updateHeader hoặc updateAccountSection ? Các loại lệnh gọi cụ thể này đại diện cho "lập trình jQuery" ở mức khó bảo trì nhất.


Với tôi, ý tưởng có vẻ tốt hơn là làm điều gì đó như thế này:

 B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });


Vì vậy, nếu một set kiện được gọi cho user , hệ thống sự kiện sẽ thông báo cho tất cả các chế độ xem quan tâm đến thay đổi đó (phần tiêu đề & tài khoản), trong khi để các chế độ xem khác (danh sách việc cần làm) không bị xáo trộn. B.respond là hàm tôi sử dụng để đăng ký người phản hồi (thường được gọi là "người nghe sự kiện" hoặc "phản ứng"). Lưu ý rằng người phản hồi là toàn cục và không bị ràng buộc với bất kỳ thành phần nào; tuy nhiên, họ chỉ lắng nghe các sự kiện set trên một số đường dẫn nhất định.


Bây giờ, sự kiện change được gọi như thế nào ngay từ đầu? Đây là cách tôi đã làm:

 B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });


Tôi sẽ đơn giản hóa một chút, nhưng về cơ bản thì đó là cách hoạt động của gotoB.


Điều khiến hệ thống sự kiện mạnh hơn các lệnh gọi hàm đơn thuần là lệnh gọi sự kiện có thể thực thi 0, 1 hoặc nhiều đoạn mã, trong khi lệnh gọi hàm luôn gọi chính xác một hàm . Trong ví dụ trên, nếu bạn gọi B.call ('set', ['user', 'firstName'], 'Foo'); , hai đoạn mã được thực thi: đoạn mã thay đổi tiêu đề và đoạn mã thay đổi chế độ xem tài khoản. Lưu ý rằng lệnh gọi cập nhật firstName không "quan tâm" đến việc ai đang lắng nghe lệnh này. Lệnh chỉ thực hiện nhiệm vụ của mình và để người trả lời nhận các thay đổi.


Sự kiện mạnh mẽ đến mức, theo kinh nghiệm của tôi, chúng có thể thay thế các giá trị tính toán cũng như các phản ứng. Nói cách khác, chúng có thể được sử dụng để thể hiện bất kỳ thay đổi nào cần xảy ra trong một ứng dụng.


Giá trị tính toán có thể được thể hiện bằng trình phản hồi sự kiện. Ví dụ, nếu bạn muốn tính toán fullName và không muốn sử dụng nó trong cửa hàng, bạn có thể thực hiện như sau:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });


Tương tự như vậy, phản ứng có thể được thể hiện bằng người trả lời. Hãy xem xét điều này:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });


Nếu bạn bỏ qua một phút chuỗi ký tự gây khó chịu để tạo ra HTML, những gì bạn thấy ở trên là một trình phản hồi đang thực hiện "tác dụng phụ" (trong trường hợp này là cập nhật DOM).


(Lưu ý: định nghĩa đúng về tác dụng phụ trong bối cảnh của ứng dụng web là gì? Với tôi, nó có thể tóm gọn lại thành ba điều: 1) cập nhật trạng thái của ứng dụng; 2) thay đổi DOM; 3) gửi lệnh gọi AJAX).


Tôi thấy rằng thực sự không cần một vòng đời riêng biệt để cập nhật DOM. Trong gotoB, có một số hàm phản hồi cập nhật DOM với sự trợ giúp của một số hàm trợ giúp. Vì vậy, khi user thay đổi, bất kỳ hàm phản hồi nào (hay chính xác hơn là hàm view , vì đó là tên tôi đặt cho các hàm phản hồi được giao nhiệm vụ cập nhật một phần của DOM) phụ thuộc vào nó sẽ thực thi, tạo ra một hiệu ứng phụ kết thúc bằng việc cập nhật DOM.


Tôi đã làm cho hệ thống sự kiện có thể dự đoán được bằng cách chạy các hàm phản hồi theo cùng một thứ tự và từng hàm một. Các hàm phản hồi không đồng bộ vẫn có thể chạy như đồng bộ và các hàm phản hồi "sau" chúng sẽ chờ chúng.


Các mẫu phức tạp hơn, trong đó bạn cần cập nhật trạng thái mà không cập nhật DOM (thường là vì mục đích hiệu suất) có thể được thêm bằng cách thêm các động từ câm , như mset , sửa đổi store nhưng không kích hoạt bất kỳ trình phản hồi nào. Ngoài ra, nếu bạn cần thực hiện điều gì đó trên DOM sau khi quá trình vẽ lại diễn ra, bạn chỉ cần đảm bảo rằng trình phản hồi đó có mức độ ưu tiên thấp và chạy sau tất cả các trình phản hồi khác:

 B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });


Cách tiếp cận ở trên, có một hệ thống sự kiện sử dụng động từ và đường dẫn và một tập hợp các trình phản hồi toàn cục được khớp (thực thi) bởi các lệnh gọi sự kiện nhất định, có một lợi thế khác: mọi lệnh gọi sự kiện có thể được đặt trong một danh sách. Sau đó, bạn có thể phân tích danh sách này khi gỡ lỗi ứng dụng của mình và theo dõi các thay đổi đối với trạng thái.


Trong bối cảnh của giao diện người dùng, sau đây là những gì sự kiện và trình phản hồi cho phép:

  • Để cập nhật một số phần của cửa hàng với rất ít mã (chỉ cần chi tiết hơn một chút so với việc chỉ gán biến).
  • Để các phần của DOM tự động cập nhật khi có sự thay đổi trên các phần của cửa hàng mà phần DOM đó phụ thuộc vào.
  • Không để bất kỳ phần nào của DOM tự động cập nhật khi không cần thiết.
  • Có thể tính toán các giá trị và phản ứng không liên quan đến việc cập nhật DOM, được thể hiện dưới dạng phản hồi.


Đây là những gì họ cho phép (theo kinh nghiệm của tôi) không cần:

  • Phương pháp vòng đời hoặc móc nối.
  • Các biến quan sát được.
  • Sự bất biến.
  • Ghi nhớ.


Tất cả thực sự chỉ là các lệnh gọi sự kiện và trình phản hồi, một số trình phản hồi chỉ quan tâm đến chế độ xem và một số khác quan tâm đến các hoạt động khác. Tất cả các thành phần bên trong của khuôn khổ chỉ sử dụng không gian người dùng .


Nếu bạn tò mò về cách thức hoạt động của gotoB, bạn có thể xem phần giải thích chi tiết này.

Ý tưởng 4: thuật toán diff văn bản để cập nhật DOM

Liên kết dữ liệu hai chiều giờ nghe có vẻ khá lỗi thời. Nhưng nếu bạn quay ngược thời gian về năm 2013 và giải quyết vấn đề vẽ lại DOM khi trạng thái thay đổi từ những nguyên tắc đầu tiên, thì điều gì nghe có vẻ hợp lý hơn?

  • Nếu HTML thay đổi, hãy cập nhật trạng thái của bạn trong JS. Nếu trạng thái trong JS thay đổi, hãy cập nhật HTML.
  • Mỗi lần trạng thái trong JS thay đổi, hãy cập nhật HTML. Nếu HTML thay đổi, hãy cập nhật trạng thái trong JS và sau đó cập nhật lại HTML để khớp với trạng thái trong JS.


Thật vậy, tùy chọn 2, là luồng dữ liệu một chiều từ trạng thái đến DOM, nghe có vẻ phức tạp hơn và kém hiệu quả hơn.


Bây giờ chúng ta hãy làm cho điều này trở nên cụ thể hơn: trong trường hợp <input> hoặc <textarea> tương tác được tập trung, bạn cần tạo lại các phần của DOM với mỗi lần nhấn phím của người dùng! Nếu bạn đang sử dụng luồng dữ liệu một chiều, mọi thay đổi trong input sẽ kích hoạt một thay đổi trong trạng thái, sau đó vẽ lại <input> để khớp chính xác với những gì nó cần.


Điều này đặt ra một tiêu chuẩn rất cao cho các bản cập nhật DOM: chúng phải nhanh chóng và không cản trở tương tác của người dùng với các thành phần tương tác. Đây không phải là một vấn đề dễ giải quyết.


Bây giờ, tại sao dữ liệu một chiều từ trạng thái đến DOM (JS đến HTML) lại thắng? Bởi vì dễ lý giải hơn. Nếu trạng thái thay đổi, thì không quan trọng sự thay đổi này đến từ đâu (có thể là lệnh gọi lại AJAX mang dữ liệu từ máy chủ, có thể là tương tác của người dùng, có thể là bộ đếm thời gian). Trạng thái thay đổi (hay đúng hơn là bị đột biến ) theo cùng một cách. Và những thay đổi từ trạng thái luôn chảy vào DOM.


Vậy, làm thế nào để thực hiện cập nhật DOM theo cách hiệu quả mà không cản trở tương tác của người dùng? Điều này thường được tóm gọn là thực hiện số lượng cập nhật DOM tối thiểu để hoàn thành công việc. Điều này thường được gọi là "diffing", vì bạn đang lập danh sách các điểm khác biệt mà bạn cần để lấy một cấu trúc cũ (DOM hiện có) và chuyển đổi nó thành một cấu trúc mới (DOM mới sau khi trạng thái được cập nhật).


Khi tôi bắt đầu làm việc với vấn đề này vào khoảng năm 2016, tôi đã gian lận bằng cách xem react đang làm gì. Họ đã cho tôi cái nhìn sâu sắc quan trọng rằng không có thuật toán hiệu suất tuyến tính, tổng quát nào để so sánh hai cây (DOM là một cây). Nhưng, cố chấp nếu có, tôi vẫn muốn có một thuật toán mục đích chung để thực hiện so sánh. Điều tôi đặc biệt không thích ở React (hoặc hầu hết mọi khuôn khổ khác) là sự khăng khăng rằng bạn cần sử dụng khóa cho các phần tử liền kề:

 function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }


Với tôi, chỉ thị key là không cần thiết, vì nó không liên quan gì đến DOM; nó chỉ là một gợi ý cho khuôn khổ.


Sau đó tôi nghĩ đến việc thử một thuật toán diff theo văn bản trên các phiên bản đã làm phẳng của một cây. Sẽ thế nào nếu tôi làm phẳng cả hai cây (phần DOM cũ mà tôi có và phần DOM mới mà tôi muốn thay thế) và tính toán diff trên đó (một tập hợp tối thiểu các chỉnh sửa), để tôi có thể đi từ phần cũ đến phần mới trong số bước ít hơn?


Vì vậy, tôi đã sử dụng thuật toán Myers , thuật toán mà bạn sử dụng mỗi khi chạy git diff và áp dụng nó vào các cây đã làm phẳng của tôi. Hãy minh họa bằng một ví dụ:

 var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];


Như bạn thấy, tôi không làm việc với DOM mà với cách biểu diễn đối tượng theo nghĩa đen mà chúng ta đã thấy ở Ý tưởng 1. Bây giờ, bạn sẽ nhận thấy rằng chúng ta cần thêm <li> mới vào cuối danh sách.


Những cái cây bị dẹt trông như thế này:

 var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];


O là viết tắt của "open tag", L là viết tắt của "literal" (trong trường hợp này là một số văn bản) và C là viết tắt của "close tag". Lưu ý rằng mỗi cây bây giờ là một danh sách các chuỗi và không còn bất kỳ mảng lồng nhau nào nữa. Đây là những gì tôi muốn nói đến khi nói đến việc làm phẳng.


Khi tôi chạy lệnh diff trên từng phần tử này (xử lý từng mục trong mảng như thể chúng là một đơn vị), tôi nhận được:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];


Như bạn có thể suy ra, chúng tôi giữ lại hầu hết danh sách và thêm <li> vào cuối danh sách. Đó là các mục add mà bạn thấy.


Nếu bây giờ chúng ta thay đổi văn bản của <li> thứ ba từ Item 3 thành Item 4 và chạy lệnh diff trên đó, chúng ta sẽ thu được:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];


Tôi không biết cách tiếp cận này kém hiệu quả về mặt toán học như thế nào, nhưng trên thực tế, nó hoạt động khá tốt. Nó chỉ hoạt động kém khi phân biệt các cây lớn có nhiều điểm khác biệt giữa chúng; khi điều đó thỉnh thoảng xảy ra, tôi dùng đến thời gian chờ 200ms để ngắt quá trình phân biệt và chỉ cần thay thế hoàn toàn phần DOM gây lỗi. Nếu tôi không sử dụng thời gian chờ, toàn bộ ứng dụng sẽ bị dừng trong một thời gian cho đến khi phân biệt hoàn tất.


Một lợi thế may mắn khi sử dụng Myers diff là nó ưu tiên xóa hơn chèn: điều này có nghĩa là nếu có một lựa chọn hiệu quả như nhau giữa việc xóa một mục và thêm một mục, thuật toán sẽ xóa một mục trước. Trên thực tế, điều này cho phép tôi lấy tất cả các phần tử DOM đã loại bỏ và có thể tái chế chúng nếu tôi cần chúng sau này trong diff. Trong ví dụ cuối cùng, <li> cuối cùng được tái chế bằng cách thay đổi nội dung của nó từ Item 3 thành Item 4 Bằng cách tái chế các phần tử (thay vì tạo các phần tử DOM mới), chúng tôi cải thiện hiệu suất đến mức người dùng không nhận ra rằng DOM đang liên tục được vẽ lại.


Nếu bạn đang thắc mắc việc triển khai cơ chế làm phẳng & diffing này phức tạp như thế nào, áp dụng các thay đổi cho DOM, tôi đã thực hiện được trong 500 dòng javascript ES5 và thậm chí chạy được trong Internet Explorer 6. Nhưng phải thừa nhận rằng, đây có lẽ là đoạn mã khó nhất mà tôi từng viết. Sự bướng bỉnh phải trả giá.

Phần kết luận

Đó là bốn ý tưởng tôi muốn trình bày! Chúng không hoàn toàn mới nhưng tôi hy vọng chúng sẽ vừa mới lạ vừa thú vị với một số người. Cảm ơn vì đã đọc!