JavaScript và TypeScript khiến việc coi các tham chiếu đối tượng (object references) là thứ có thể vứt bỏ trở nên dễ dàng một cách nguy hiểm. Bạn tạo một đối tượng, truyền nó vào một hàm, lưu nó vào bộ nhớ đệm (cache), và sau đó thay thế biến đó bằng một thực thể (instance) mới. Ngôn ngữ không hề phàn nàn. Tham chiếu cũ vẫn tồn tại ở đâu đó khác trong chương trình của bạn, trỏ đến dữ liệu không còn tính cập nhật nữa. Đây không phải là một lỗi crash. Nó còn tệ hơn thế: một sự sai lệch âm thầm giữa hai phần trong mã nguồn của bạn mà cả hai đều nghĩ rằng mình đang nắm giữ sự thật.
Kỷ luật giúp ngăn chặn điều này được gọi là Hard Object References. Nó không phải là một thư viện hay một tính năng của trình biên dịch. Đó là một bản hợp đồng mà bạn áp dụng xuyên suốt mã nguồn của mình.
Vấn đề Alias Lỗi thời (Stale Alias)
Một alias lỗi thời xảy ra khi một module giữ tham chiếu đến một đối tượng trong khi một module khác thay thế đối tượng đó bằng một đối tượng mới. Tham chiếu đầu tiên vẫn là mã hợp lệ, nhưng nó không còn trỏ đến dữ liệu hiện tại nữa.
Hãy hình dung một bản ghi người dùng trong một ứng dụng web điển hình:
const user = {
name: "Alice",
address: {
city: "Seoul",
country: "KR"
}
};
Một thành phần vận chuyển (shipping component) ghi lại địa chỉ từ sớm:
const shippingAddress = user.address;
Sau đó, một bản cập nhật hồ sơ được gửi đến. Một reducer hoặc service handler quyết định thay thế toàn bộ đối tượng:
user.address = { city: "Tokyo", country: "JP" };
Tại thời điểm này, user.address trỏ đến Tokyo. Nhưng shippingAddress vẫn trỏ đến đối tượng cũ ở Seoul. Không có ngoại lệ (exception) nào được ném ra. TypeScript vẫn hoạt động bình thường vì các kiểu dữ liệu (types) vẫn khớp nhau. Giao diện người dùng (UI) có thể hiển thị thành phố đã cập nhật trên trang hồ sơ, trong khi nhãn vận chuyển lại âm thầm in địa chỉ cũ. Lỗi chỉ lộ ra khi người dùng phàn nàn rằng gói hàng của họ đã được gửi đến sai quốc gia.
Điều này xảy ra vì JavaScript tách biệt danh tính (identity) khỏi giá trị (value). Khi bạn thay thế một thuộc tính đối tượng bằng một object literal mới, bạn đã làm đứt chuỗi liên kết. Đối tượng cũ không bị hủy; nó chỉ đơn giản là bị mồ côi. Bất kỳ ai còn giữ nó đều đang làm việc với một "bóng ma".
Ý nghĩa của Hard Object References
Quy tắc rất đơn giản: hãy thay thế các giá trị nguyên thủy (primitive values), nhưng đừng bao giờ thay thế các tham chiếu đối tượng hoặc mảng. Khi có dữ liệu mới, hãy sao chép nó vào container hiện có thay vì tráo đổi container đó.
Điều này đòi hỏi ba thói quen cụ thể.
Thứ nhất, khai báo các đối tượng và mảng bằng const. Điều này loại bỏ sự cám dỗ của việc gán lại (rebind) biến cấp cao nhất cho một thực thể mới. Biến đó nên được giữ cố định trong suốt vòng đời của phạm vi (scope) đó.
Thứ hai, đừng bao giờ thay thế một thuộc tính đang giữ một đối tượng hoặc mảng bằng một cái mới vừa được tạo ra. Nếu bạn cần cập nhật địa chỉ, hãy thay đổi (mutate) các thuộc tính bên trong nó.
Thứ ba, nếu bạn cần xóa hoặc đặt lại trạng thái (state), hãy làm trống cấu trúc hiện có thay vì vứt bỏ nó để thay bằng một đối tượng hoặc mảng trống mới.
Xem lại ví dụ về địa chỉ, cách cập nhật đúng sẽ như sau:
user.address.city = "Tokyo";
user.address.country = "JP";
Nếu dữ liệu truyền vào là một phần hoặc mang tính động, hãy sử dụng Object.assign để ghi vào mục tiêu (target) hiện có:
Object.assign(user.address, incomingAddressData);
Biến shippingAddress, vốn trỏ đến cùng một đối tượng chính xác trong bộ nhớ, giờ đây sẽ thấy các trường mới ngay lập tức. Chỉ có một đối tượng chuẩn duy nhất đóng vai trò là nguồn sự thật (source of truth) đang hoạt động.
Nơi điều này quan trọng nhất
Kỷ luật này có vẻ là quá mức cần thiết đối với một đối tượng cấu hình phẳng. Nhưng nó trở nên thiết yếu khi trạng thái của bạn phát triển thành một đồ thị (graph), nơi nhiều hệ thống con giữ các con trỏ đến các nút (nodes) chồng lấp lên nhau.
Hãy xem xét một trình soạn thảo văn bản phong phú (rich text editor). Mô hình tài liệu là một cây các nút. Mô hình lựa chọn (selection model) giữ các tham chiếu đến các nút bắt đầu và kết thúc. Bộ đệm lịch sử (history buffer) giữ các tham chiếu đến các nút đã thay đổi trong thao tác cuối cùng. Lớp kết xuất (rendering layer) giữ các tham chiếu đến các nút mà nó đã đo lường để bố cục (layout). Nếu trình quản lý trạng thái thay thế một nút đoạn văn bằng một đối tượng mới vì văn bản của nó thay đổi, thì mọi hệ thống con đó hiện đang giữ một alias lỗi thời. Việc lựa chọn sẽ làm nổi bật sai vùng. Hệ thống lịch sử không thể hoàn tác (revert) chính xác. Trình kết xuất bị lỗi hoặc tệ hơn là hiển thị các con trỏ ảo (phantom cursors).
Rủi ro tương tự cũng xuất hiện trong hồ sơ người dùng với các cài đặt và quyền hạn lồng nhau được tham chiếu bởi UI, lớp kiểm soát truy cập (access-control layer) và quy trình tự động lưu (autosave routine). Nó xuất hiện trong các công cụ bố cục (layout engines) nơi các container cha lưu bộ nhớ đệm các phép đo của các nút con. Nó xuất hiện trong các trình chỉnh sửa trực quan và công cụ canvas nơi một bộ điều khiển runtime theo dõi các thực thể đang hoạt động bằng tham chiếu. Trong tất cả các lĩnh vực này, các thành phần nắm lấy một "tay cầm" (handle) của một đối tượng và kỳ vọng rằng tay cầm đó vẫn là một chế độ xem trực tiếp của sự thật.
Hard Object References coi đối tượng như một địa chỉ ổn định. Đồ đạc bên trong có thể thay đổi, nhưng cánh cửa vẫn nằm ở cùng một vị trí. Bất kỳ ai giữ địa chỉ đó đều có thể bước vào và thấy được bố cục hiện tại.
Tính phản ứng (Reactivity) thay vì Thay thế
Nếu bạn đã từng làm việc với Redux hoặc các thư viện quản lý trạng thái bất biến (immutable state) tương tự, mô hình này có vẻ sẽ nghe rất ngược đời. Trong các hệ thống đó, sự thay đổi được báo hiệu bằng cách tạo ra một đối tượng mới. Sự thay đổi tham chiếu chính là tín hiệu. Các component so sánh prevProps.data === nextProps.data để biết liệu có cần render lại hay không.
Hard Object References yêu cầu bạn phải đảo ngược giả định đó. Vì tham chiếu được giữ cố định, nên việc so sánh bằng tham chiếu sẽ không cho bạn biết liệu dữ liệu có thay đổi hay không. Bạn cần một cách khác để phát tín hiệu cập nhật.
Trong thực tế, điều này có nghĩa là phải dựa vào các hệ thống phản ứng (reactivity systems), các trình quan sát (observers) tường minh, hoặc các cờ bẩn (dirty flags). Việc thay đổi (mutate) user.address.city có thể kích hoạt một setter để thông báo cho các subscriber. Một đối tượng có thể phát ra một sự kiện thay đổi thông qua một event bus. Một vòng lặp trò chơi (game loop) hoặc công cụ canvas có thể thiết lập một cờ bẩn (dirty flag) toàn cục và quét lại đồ thị vào cuối mỗi khung hình. Tham chiếu là cố định, vì vậy bạn phải làm cho luồng dữ liệu trở nên hiển thị thông qua các cơ chế khác.
Sự chuyển dịch về kiến trúc này là lý do tại sao cách tiếp cận này phù hợp nhất với trạng thái frontend phức tạp, trạng thái cục bộ của component lớn, các trình chỉnh sửa trực quan, công cụ canvas và các bộ điều khiển runtime. Các hệ thống này vốn đã dựa vào các cập nhật chi tiết (granular updates), thay đổi trực tiếp (direct mutations) hoặc các API mệnh lệnh (imperative APIs). Việc ép buộc tính bất biến (immutability) lên trên thường tạo ra áp lực cấp phát (allocation pressure) và sự biến động tham chiếu (reference churn) quá mức mà không mang lại sự rõ ràng tương xứng. Khi mỗi khung hình đều quan trọng, việc cấp phát một đồ thị đối tượng mới chỉ để di chuyển một thanh trượt là một sự lãng phí. Việc giữ tham chiếu cố định và thay đổi nội dung bên trong sẽ phù hợp với cơ chế thực tế của vấn đề.
Để Áp Dụng Thành Công
Một trong những lợi ích thường bị bỏ qua của quy tắc này là
