
Hai lựa chọn thiết kế React mà nhà phát triển không thích nhưng không thể tránh
Two React Design Choices Developers Don’t Like—But Can’t Avoid
Các nhà phát triển chưa bao giờ ngại ngùng về việc không thích một số API React nhất định. Họ cảm thấy lúng túng, hạn chế hoặc đơn giản là phản trực giác. Nhưng thực tế là hai lựa chọn thiết kế bị phàn nàn nhiều nhất trong React...
Các nhà phát triển chưa bao giờ ngại ngùng khi không thích một số API React nhất định. Họ cảm thấy lúng túng, hạn chế hoặc đơn giản là phản trực giác. Nhưng thực tế là hai lựa chọn thiết kế bị phàn nàn nhiều nhất trong React không hề tùy tiện - chúng là những dấu hiệu ban đầu của những hạn chế sâu sắc hơn mà mọi mô hình giao diện người dùng cuối cùng đều gặp phải.
Như nhiều bạn đã biết, tôi đã làm việc trên Solid 2.0 trong vài năm qua. Đó là một cuộc hành trình. Tôi đã sử dụng Tín hiệu được hơn một thập kỷ và tôi nghĩ mình đã hiểu toàn bộ không gian thiết kế. Nhưng càng đi sâu, tôi càng thấy mình ở lãnh địa không ngờ tới.
Và đâu đó trên đường đi, tôi nhận ra điều gì đó khó chịu. React đã đúng về những quyết định thiết kế mà mọi người hoàn toàn không thể chấp nhận được. Không phải mô hình của React - Tôi không ở đây để bảo vệ điều đó. Nhưng React đã xác định chính xác hai bất biến mà phần còn lại của hệ sinh thái, bao gồm cả Solid 1.x, đã bỏ qua.
Tôi đang nói về các cam kết trạng thái trì hoãn:
const [trạng thái, setState] = useState(1);
// sau
setState(2 );
state === 1; //chưa cam kết
Và mảng phụ thuộc trên Effects:
useEffect(() => bảng điều khiển.log (tiểu bang), [tiểu bang]);
Đây là hai vấn đề mà Tín hiệu lẽ ra phải “sửa chữa”. Và theo một nghĩa nào đó, họ đã làm được. Nhưng không phải theo cách mọi người nghĩ. Hôm nay, chúng ta sẽ xem tại sao đó không phải là toàn bộ câu chuyện.
Sống trong một thế giới không đồng bộ
Mọi thứ chúng tôi thực hiện trên web đều được xây dựng dựa trên tính không đồng bộ. Toàn bộ nền tảng được xác định bởi máy khách và máy chủ được phân tách bằng ranh giới mạng. Truyền trực tuyến, tìm nạp dữ liệu, cập nhật phân tán, đột biến giao dịch, giao diện người dùng lạc quan — tất cả đều bắt nguồn từ sự thật đơn giản đó.
Async đẩy chúng ta ra khỏi vùng an toàn bắt buộc của mình. Mã bắt buộc là viết: “đặt cái này, sau đó đọc lại”. Async nói về các lần đọc: "giá trị này có sẵn, cũ hay vẫn đang hoạt động?" Đó là câu hỏi mà mọi giao diện người dùng phải trả lời trước khi hiển thị bất cứ thứ gì: tôi có thể hiển thị cái này không, hay tôi sẽ phơi bày thứ gì đó không nhất quán?
Đối với hầu hết các khung, async trông giống như trạng thái phù du chuyển vào và ra khỏi thế giới khai báo đồng bộ. Có vẻ như không thể đoán trước được vì chúng tôi chỉ nhìn thấy những khoảnh khắc mà tính không đồng bộ giao nhau với quá trình tính toán của chúng tôi. Nhưng async không phải là hỗn loạn — đó chỉ là thời gian. Và nếu muốn lý giải về nó, chúng ta cần có ngôn ngữ để thể hiện nó một cách trực tiếp.
Nó bắt đầu với cách chúng ta thể hiện trạng thái. Nếu một giá trị chưa có sẵn thì không có phần giữ chỗ nào có thể thay thế một cách an toàn. Việc trả về null, không xác định hoặc một trình bao bọc sẽ phá vỡ tính xác định. Dù sao đi nữa, việc tiếp tục tạo ra một kết quả không bao giờ tương ứng với bất kỳ thời điểm thực tế nào. Cách duy nhất để giữ sự nhất quán là dừng lại.
Nó cũng cần phải tôn trọng mô hình khai báo. Điều làm cho các hệ thống phản ứng (bao gồm cả React) trở nên hấp dẫn là khả năng thể hiện giao diện người dùng dưới dạng trạng thái tại một thời điểm nhất định. Tất cả sự rõ ràng về kiến trúc và đảm bảo thực hiện đều bắt nguồn từ điều này. Mục tiêu là tính xác định: các đầu vào giống nhau sẽ tạo ra các đầu ra giống nhau, thời gian không làm thay đổi hình dạng và giao diện người dùng luôn nhất quán.
Khi tính năng không đồng bộ rò rỉ vào không gian người dùng — thông qua các nhánh có điều kiện hoặc hình dạng giá trị thay thế — chúng tôi buộc người dùng phải quản lý tính nhất quán theo cách thủ công và mô hình khai báo sẽ bị hỏng.
// Tính toán phái sinh buộc phải phân nhánh ở trạng thái không đồng bộ
const firstInitial = người dùng.đang tải ? "" : người dùng.tên[0];
Khả năng chi trả giao diện người dùng cho tính năng không đồng bộ—chỉ báo tải, khung, dự phòng—không phải là vấn đề. Đó là những mối quan tâm trình bày. Vấn đề là khi async trở thành một phần của giá trị chảy qua biểu đồ trạng thái. Nó buộc mọi người tiêu dùng phải phân nhánh. Giao diện người dùng có thể hiển thị bất cứ thứ gì nó muốn, nhưng biểu đồ chỉ được phép nhìn thấy các giá trị thực.
1. Async phải được tách biệt khỏi các cam kết
" style="độ rộng tối đa: 100%;">
Không giống như các hệ thống phản ứng khác, sự kết hợp chặt chẽ giữa trạng thái và kết xuất của React đã buộc nó phải sớm đối mặt với vấn đề này. Khi mọi thay đổi trạng thái đều kích hoạt kết xuất lại, bạn không thể che giấu sự không nhất quán đằng sau quá trình dẫn xuất đồng bộ. Tín hiệu tránh điều này vì mọi thứ luôn cập nhật vào thời điểm bạn đọc—không hiển thị lại, không phối hợp, không lãng phí công việc.
Nhưng những đặc điểm đó chỉ che giấu một sự thật cơ bản. Bạn không thể để async hoạt động xen kẽ với các cam kết đồng bộ. Nếu một phép tính vẫn đang chờ trên async thì mọi thao tác ghi mà nó thực hiện đều mang tính suy đoán. Bạn không thể hiển thị giao diện người dùng dựa trên trạng thái mà bạn chưa có, bởi vì nếu họ tương tác với nó, họ mong muốn tương tác với những gì họ nhìn thấy—chứ không phải trạng thái trung gian nào đó mà khung đang nắm giữ.
Hãy cân nhắc:
cho phép đếm = 0;
cho phép doubleCount = đếm * 2;
chức năng tăng() {
đếm++;
bảng điều khiển.log(`${count * 2 = ${doubleCount`);
<nút onClick={tăng>{số lượng * 2 = {doubleCount></div>
Tôi đã sử dụng ví dụ này nhiều lần trước đây nhưng nó nắm bắt được bản chất của vấn đề. Xem:
Trong JavaScript đơn giản, count và doubleCount khác nhau. Tín hiệu khắc phục điều này bằng cách cập nhật doubleCount khi đọc. Nhưng điều đó vẫn để lại câu hỏi. Khi nào bản cập nhật này đến DOM? Nếu bạn xóa ngay lập tức (như Solid 1.x), các bản cập nhật liên tiếp có thể tốn kém. Nếu không, bạn không thừa nhận rằng một số lượng lịch trình vốn có của hệ thống.
React là hệ thống duy nhất không cập nhật count ngay lập tức và mọi người ghét nó. Nhưng động lực là đúng đắn. React muốn các trình xử lý sự kiện thấy trạng thái nhất quán và không có cách nào cập nhật các giá trị dẫn xuất cho đến khi thành phần chạy lại.
Bây giờ hãy tưởng tượng trình xử lý là:
chức năng onClick(sự kiện) {
setBooks([]);
// giá trị dẫn xuất
if (độ dài sách) { sách[sáchĐộ dài - 1]
Nếu sách cập nhật nhưng booksLength không cập nhật thì bạn đang đọc quá giới hạn.
Tín hiệu giữ cho trạng thái và trạng thái dẫn xuất được đồng bộ hóa hoàn hảo và điều đó mang lại cho nhà phát triển cảm giác an toàn cao độ. Bạn viết mã một lần và nó sẽ hoạt động. Nhưng sự tự tin đó sẽ trở thành trách nhiệm pháp lý khi giá trị dẫn xuất chuyển sang trạng thái không đồng bộ vì không có gì đảm bảo rằng giá trị đó sẽ tiếp tục được đồng bộ hóa.
Quay lại count và doubleCount, nhưng tạo doubleCount không đồng bộ. Nếu bạn muốn giao diện người dùng luôn nhất quán — tiếp tục hiển thị 1 * 2 = 2 cho đến khi doubleCount không đồng bộ được giải quyết — thì bạn cũng phải trì hoãn việc cập nhật count. Nếu không bạn sẽ rơi vào một tình huống kỳ lạ. Giao diện người dùng vẫn hiển thị 1 * 2 = 2, nhưng bảng điều khiển đã ghi 2 * 2 = 2 vì dữ liệu cơ bản đã chuyển sang count = 2.
Khi bạn thấy sự không khớp đó — giao diện người dùng đang chờ tính nhất quán trong khi dữ liệu đã được nâng cao — thì kết luận là điều không thể tránh khỏi. Thế giới đồng bộ khiến bạn cảm thấy an toàn vì mọi thứ được cập nhật cùng nhau, nhưng sự an toàn đó chỉ là ảo tưởng được xây dựng trên giả định rằng tất cả các giá trị dẫn xuất đều có sẵn ngay lập tức. Thời điểm một trong số chúng trở nên không đồng bộ, giả định đó sẽ sụp đổ. Nếu bạn muốn giao diện người dùng duy trì nhất quán, bạn phải trì hoãn cam kết. Và một khi bạn trì hoãn cam kết trong giao diện người dùng, bạn cũng phải trì hoãn nó trong dữ liệu, nếu không cả hai sẽ khác nhau theo những cách vi phạm chính những đảm bảo mà bạn đã dựa vào. Async không chỉ tăng thêm độ trễ; nó buộc một mô hình thực thi khác.
2. Sự phụ thuộc của các Hiệu ứng phải được biết tại Thời điểm Tính toán
" style="độ rộng tối đa: 100%;">
Mô hình kết xuất lại của React đã buộc nó phải đối mặt với một sự thật khác rất lâu trước bất kỳ ai khác. Các dẫn xuất và tác dụng phụ tuân theo các quy tắc khác nhau.
Khi các thành phần chạy lại sau mỗi lần thay đổi, việc tính toán lại mọi thứ mỗi lần sẽ rất lãng phí. Vì vậy, khi Hook được giới thiệu, các mảng phụ thuộc đã đi kèm với chúng — một hình thức ghi nhớ đơn giản nhưng hiệu quả.
So với Tín hiệu, nơi các phần phụ thuộc được phát hiện một cách linh hoạt và chỉ chạy lại các tính toán cần thiết, điều này có vẻ hạn chế. Nhưng nó đã có một hậu quả quan trọng. React biết tất cả các phần phụ thuộc của cây trước khi chạy bất kỳ hiệu ứng kết xuất hoặc tác dụng phụ nào.
Chi tiết đó trở nên quan trọng ngay khi tính năng async xuất hiện trong ảnh. Nếu quá trình kết xuất có thể bị gián đoạn bất kỳ lúc nào — bị tạm dừng, phát lại hoặc bị hủy bỏ — thì chưa có tác dụng phụ nào có thể xảy ra. Một tác dụng phụ xảy ra trước tất cả các phần phụ thuộc là những rủi ro đã biết xảy ra với trạng thái một phần hoặc trạng thái đầu cơ. Kiến trúc của React đã bộc lộ điều này ngay lập tức. Quá trình kết xuất không được đảm bảo hoàn thành nên không thể gắn hiệu ứng với quá trình kết xuất.
Các tín hiệu, với độ chính xác phẫu thuật của chúng, đã tránh được vấn đề này trong nhiều năm. Việc lan truyền thay đổi diễn ra đồng bộ và tách biệt, do đó các dẫn xuất và tác dụng phụ dường như chạy theo một luồng duy nhất, có thể dự đoán được. Nhưng khả năng dự đoán đó sẽ biến mất khi tính năng không đồng bộ xuất hiện trên biểu đồ.
Bởi vì nếu async chỉ được phát hiện trong các tác dụng phụ thì đã quá muộn. Và nếu tính năng async có thể bị gián đoạn — chẳng hạn như đưa ra một lời hứa và thực hiện lại độ phân giải — việc thực thi sẽ trở nên hoàn toàn không thể đoán trước được.
Hãy cân nhắc:
const a = asyncSignal(fetchA());
const b = asyncSignal(fetchB());
const c = asyncSignal(fetchC());
hiệu ứng(() => {
bảng điều khiển.log(a());
bảng điều khiển.log(b());
bảng điều khiển.log(c ());
});
Nhật ký hiệu ứng là gì? Nó chạy bao nhiêu lần? Trong một thế giới hoàn toàn đồng bộ, những câu hỏi này hầu như không quan trọng - các dẫn xuất ổn định và các hiệu ứng chạy một lần cho mỗi lần xác nhận. Nhưng với async, chúng trở nên không thể trả lời được. Mỗi nguồn không đồng bộ có thể giải quyết vào một thời điểm khác nhau. Mỗi độ phân giải có thể kích hoạt lại hiệu ứng. Và nếu bất kỳ lệnh nào trong số đó tạm dừng hoặc thử lại, toàn bộ lệnh thực thi sẽ trở thành không xác định.
Và đó chỉ là lần tải ban đầu. Nếu các nguồn không đồng bộ này có thể cập nhật độc lập theo thời gian thì khả năng không thể đoán trước sẽ tăng lên. Bạn không thể suy luận về tác dụng phụ nếu bạn không thể suy luận về thời điểm hiệu ứng chạy hoặc giá trị mà nó nhìn thấy.
Giải pháp rất đơn giản và không thể tránh khỏi. Hiệu ứng chỉ được chạy sau khi tất cả các nguồn không đồng bộ mà chúng phụ thuộc đã được giải quyết. Và để làm được điều đó, bạn phải biết tất cả các phần phụ thuộc trước khi thực hiện bất kỳ hiệu ứng nào. Bạn phải tách riêng việc thu thập các phần phụ thuộc khỏi việc thực hiện hiệu ứng.
Điều này có ý nghĩa gì đối với các giải pháp dựa trên tín hiệu
Tại thời điểm này, kiến trúc buộc phải lựa chọn. Đối đầu trực tiếp với async hoặc tiếp tục giả vờ đảm bảo tính đồng bộ được giữ vững trong một thế giới không đồng bộ. Async là có thật. Nó sẽ xuất hiện ở đâu đó trong biểu đồ. Và một khi điều đó xảy ra, những đảm bảo mà bạn dựa vào trong trường hợp đồng bộ sẽ không còn hiệu lực trừ khi hệ thống xác nhận điều đó.
Trình biên dịch có thể giải quyết vấn đề này không?
Không. Trình biên dịch không thể khắc phục vấn đề ngữ nghĩa bằng cách sắp xếp lại cú pháp. Những cam kết sớm không phải là giới hạn cơ học - chúng là giới hạn về tính chính xác. Khi tính năng async đi vào biểu đồ, hệ thống phải biết khi nào một giá trị là thực và khi nào là giá trị suy đoán. Không có lượng phân tích tĩnh nào có thể thay đổi được điều đó.
Trình biên dịch có thể trích xuất các phần phụ thuộc từ một hàm hiệu ứng không? Nói một cách nông cạn thì đúng vậy - trình biên dịch của React thực hiện chính xác điều đó. Nhưng việc trích xuất dựa trên trình biên dịch chỉ nhìn thấy những gì trong phạm vi. Nó không thể nhìn thấy toàn bộ biểu đồ. Nếu nguồn của bạn là các hàm gọi tín hiệu chứ không phải chính tín hiệu thì trình biên dịch không có cách nào để biết liệu các hàm đó là thuần túy hay chúng ẩn các tác dụng phụ.
Đây chính xác là lý do tại sao Svelte 5 chuyển sang Runes (Tín hiệu). Việc thu thập phần phụ thuộc trong thời gian biên dịch đã đạt đến giới hạn cố định. Nó không thể theo dõi các nguồn không hiển thị về mặt cú pháp.
cho phép đếm = 0;
hàm getDoubleCount() {
trở lại đếm * 2;
// không bao giờ cập nhật vì số lượng không
// hiển thị trong phạm vi này
$ : nhân đôi = getDoubleCount();
Sau khi chạm đến những giới hạn này, bạn phải tự hỏi liệu mức độ phức tạp được thêm vào, các quy tắc ẩn và mức độ bao phủ không đầy đủ có xứng đáng hay không. Suy luận của trình biên dịch có thể giải quyết vấn đề nhưng không thể giải quyết được. Async là hiện tượng thời gian chạy. Các đảm bảo phải được thực thi trong thời gian chạy.
Điều này có nghĩa là chúng ta sẽ phải bắt chước phản ứng?
Không hề. Đây không phải là sao chép React. Nó thừa nhận sự thật cơ bản tương tự mà React đã gặp phải đầu tiên. Lực lượng Async cam kết cô lập. Phân chia hiệu ứng lực Async. Vue đã có sự phân chia này về người theo dõi (hiệu ứng) trong nhiều năm. Đây không phải là chủ nghĩa React. Chúng là bất biến đối với bất kỳ hệ thống nào muốn duy trì tính nhất quán khi có tính năng không đồng bộ.
Và điều quan trọng là việc áp dụng những bất biến này không làm mất đi những lợi thế của Tín hiệu:
- các bản cập nhật vẫn được xử lý chi tiết
- các thành phần không bao giờ kết xuất lại
- các phần phụ thuộc vẫn có tính năng động và có thể khám phá sâu sắc
- chỉ các hiệu ứng mới yêu cầu tách biệt — các tính toán thuần túy thì không
- biểu đồ phản ứng vẫn chính xác, tối thiểu và đồng bộ
Trên thực tế, việc nắm bắt những bất biến này làm nổi bật sức mạnh của mô hình. Nó kết hợp sức mạnh biểu cảm của Tín hiệu với nguyên tắc chính xác của lập trình chức năng. Nó thừa nhận thực tế thay vì chống lại nó. Và nó mang lại cho async tính xác định và độ rõ ràng giống như Tín hiệu đã mang lại cho tính toán đồng bộ.
Kết luận
" style="độ rộng tối đa: 100%;">
Solid luôn vượt qua các ranh giới của kiến trúc giao diện người dùng, không phải bằng cách theo đuổi tính mới mà bằng cách khám phá các quy tắc cơ bản giúp giao diện người dùng có thể dự đoán, nhất quán và nhanh chóng. React gặp phải những quy tắc này đầu tiên vì kiến trúc của nó buộc nó phải làm như vậy. Nó không chọn những ràng buộc này - nó đã gặp phải chúng. Gọi chúng là “quyết định thiết kế” gần như phóng đại về cơ quan có liên quan. Chúng là những khám phá.
Việc lựa chọn chấp nhận những bất biến đó từ vị trí sức mạnh là một điều hoàn toàn khác. Chúng tôi không áp dụng những ràng buộc này vì chúng tôi bị giới hạn - chúng tôi áp dụng chúng vì chúng đúng. Lực lượng Async cam kết cô lập. Phân chia hiệu ứng lực Async. Async buộc phải có ảnh chụp nhanh nhất quán. Đây không phải là chủ nghĩa Phản ứng; chúng là đặc tính vật lý của giao diện người dùng.
Việc chấp nhận điều này không phải là bắt chước. Đó là sự trưởng thành. Đó là chọn con đường tất yếu với tầm nhìn rộng mở và xây dựng một hệ thống xử lý async không phải như một trường hợp phức tạp mà là một phần hạng nhất của kiến trúc. Đây là bước tiếp theo để giúp Solid không chỉ nhanh mà còn đúng về cơ bản.
Sự rõ ràng không đơn giản hóa thế giới nhưng nó giúp định hướng rõ ràng.
Tác giả: Ryan Carniato
