Bài 25.4: Caching với Apollo Client

Bài 25.4: Caching với Apollo Client
Chào mừng trở lại với chuỗi bài về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một trong những tính năng mạnh mẽ nhất và cũng là trọng tâm của Apollo Client: Caching. Hiểu và tận dụng caching là bí quyết để xây dựng các ứng dụng GraphQL nhanh, mượt mà và hiệu quả.
Tại sao Caching lại Quan trọng?
Trong thế giới ứng dụng web hiện đại, dữ liệu là linh hồn. Việc tìm nạp dữ liệu từ server thường là tác vụ tốn kém nhất về mặt thời gian và tài nguyên. Mỗi lần người dùng tương tác, chuyển trang, hoặc cập nhật dữ liệu, việc gửi yêu cầu lên server và chờ phản hồi có thể tạo ra độ trễ đáng kể.
Caching ra đời để giải quyết vấn đề này. Về cơ bản, caching là kỹ thuật lưu trữ bản sao của dữ liệu mà chúng ta đã tìm nạp trước đó vào bộ nhớ cục bộ (ở đây là bộ nhớ trình duyệt của người dùng). Khi cần dữ liệu đó lần nữa, thay vì gửi yêu cầu mới lên server, ứng dụng sẽ kiểm tra trong bộ nhớ cache trước. Nếu dữ liệu có sẵn và còn hợp lệ, nó sẽ được trả về ngay lập tức, giảm thiểu đáng kể thời gian chờ đợi và tải trọng lên server.
Với GraphQL và Apollo Client, caching không chỉ đơn thuần là lưu trữ kết quả của một query. Apollo Client cung cấp một hệ thống cache tinh vi được gọi là Normalized Cache, mang lại khả năng quản lý dữ liệu tuyệt vời và tự động cập nhật UI.
Apollo Client's Normalized Cache
Trái tim của Apollo Client chính là in-memory cache, mặc định là InMemoryCache
. Điểm đặc biệt của cache này là nó không chỉ lưu trữ kết quả của các query dưới dạng JSON thô. Thay vào đó, nó chuẩn hóa (normalize) dữ liệu.
Chuẩn hóa (Normalization) là quá trình phá vỡ các đối tượng dữ liệu thành các "thực thể" (entities) riêng lẻ và lưu trữ chúng trong cache dưới dạng một cấu trúc phẳng, giống như các bảng trong cơ sở dữ liệu. Mỗi thực thể thường được xác định bằng một khóa duy nhất, thường là sự kết hợp của __typename
(kiểu dữ liệu trong schema GraphQL) và một trường ID duy nhất (ví dụ: id
hoặc _id
).
Ví dụ, nếu bạn fetch một danh sách các bài viết, mỗi bài viết sẽ được lưu trữ như một thực thể riêng biệt trong cache, thay vì chỉ là một mảng lồng nhau bên trong kết quả query.
query GetPosts { posts { id title author { id name } } }
Kết quả trả về có thể trông như thế này:
{
"data": {
"posts": [
{
"id": "post-1",
"title": "Bài viết đầu tiên",
"author": {
"id": "author-a",
"name": "Alice"
},
"__typename": "Post"
},
{
"id": "post-2",
"title": "Bài viết thứ hai",
"author": {
"id": "author-b",
"name": "Bob"
},
"__typename": "Post"
},
// ...
]
}
}
Khi Apollo lưu trữ vào cache, nó sẽ chuẩn hóa:
{
"ROOT_QUERY": {
"posts": [
{"__ref": "Post:post-1"},
{"__ref": "Post:post-2"}
// ...
]
},
"Post:post-1": {
"id": "post-1",
"title": "Bài viết đầu tiên",
"author": {"__ref": "Author:author-a"},
"__typename": "Post"
},
"Post:post-2": {
"id": "post-2",
"title": "Bài viết thứ hai",
"author": {"__ref": "Author:author-b"},
"__typename": "Post"
},
"Author:author-a": {
"id": "author-a",
"name": "Alice",
"__typename": "Author"
},
"Author:author-b": {
"id": "author-b",
"name": "Bob",
"__typename": "Author"
}
// ...
}
Giải thích: Thay vì lặp lại thông tin tác giả cho mỗi bài viết, Apollo tạo ra các thực thể riêng cho Author:author-a
và Author:author-b
. Các bài viết chỉ lưu trữ tham chiếu (__ref
) đến các thực thể tác giả này. Điều này đảm bảo mỗi thực thể dữ liệu chỉ tồn tại một lần trong cache.
Lợi ích của Normalized Cache:
- Nhất quán (Consistency): Khi dữ liệu của một thực thể (ví dụ: tên tác giả) thay đổi ở một nơi (ví dụ: thông qua mutation), tất cả các query khác đang hiển thị thực thể đó (ví dụ: danh sách bài viết, trang chi tiết tác giả) sẽ tự động được cập nhật mà không cần fetch lại dữ liệu từ server. Đây là sức mạnh "phép thuật" của Apollo Client!
- Hiệu quả: Tránh lưu trữ dữ liệu trùng lặp.
- Tốc độ: Truy cập dữ liệu nhanh chóng bằng khóa định danh.
Nếu kiểu dữ liệu của bạn không có trường ID mặc định (id
hoặc _id
), bạn cần cấu hình cho Apollo biết trường nào sẽ dùng làm khóa định danh thông qua typePolicies
:
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
MyTypeWithoutId: {
keyFields: ['customIdField'] // Dùng trường 'customIdField' làm ID
},
AnotherType: {
keyFields: ['key1', 'key2'] // Hoặc dùng nhiều trường kết hợp
}
}
});
Đọc Dữ liệu từ Cache (fetchPolicy
)
Khi bạn sử dụng useQuery
hoặc client.query
, Apollo Client sẽ kiểm tra cache trước khi gửi yêu cầu mạng. Hành vi này được kiểm soát bởi tùy chọn fetchPolicy
. Đây là một trong những tùy chọn quan trọng nhất bạn cần hiểu khi làm việc với cache.
Các fetchPolicy
phổ biến bao gồm:
cache-first
(Mặc định): Kiểm tra cache trước. Nếu dữ liệu đầy đủ và hợp lệ có sẵn trong cache, trả về ngay lập tức. Nếu không, gửi yêu cầu mạng và lưu kết quả vào cache. Đây là tùy chọn tốt cho hầu hết các trường hợp để ưu tiên tốc độ.cache-and-network
: Trả về dữ liệu từ cache ngay lập tức (nếu có), sau đó vẫn gửi yêu cầu mạng để lấy dữ liệu mới nhất. Kết quả từ mạng sẽ cập nhật cache và UI. Tùy chọn này mang lại trải nghiệm người dùng nhanh (thấy dữ liệu cũ ngay) nhưng vẫn đảm bảo dữ liệu luôn được cập nhật.network-only
: Bỏ qua cache hoàn toàn. Luôn gửi yêu cầu mạng. Kết quả trả về sẽ được lưu vào cache. Sử dụng khi bạn chắc chắn cần dữ liệu mới nhất từ server và không muốn hiển thị dữ liệu cũ dù chỉ một khoảnh khắc (ví dụ: trang quản trị hiển thị dữ liệu nhạy cảm).cache-only
: Chỉ đọc từ cache. Không bao giờ gửi yêu cầu mạng. Nếu dữ liệu không có trong cache, query sẽ trả về lỗi. Hữu ích cho việc truy cập dữ liệu đã được load bởi query khác hoặc quản lý local state trong cache.no-cache
: Bỏ qua cache khi đọc và không lưu kết quả từ mạng vào cache. Ít khi dùng.
Ví dụ sử dụng fetchPolicy
trong useQuery
:
import { useQuery, gql } from '@apollo/client'; const GET_USERS = gql` query GetUsers { users { id name email } } `; function UserList() { const { loading, error, data } = useQuery(GET_USERS, { // Thử các fetchPolicy khác nhau ở đây fetchPolicy: 'cache-and-network', // Ví dụ: hiển thị dữ liệu cũ ngay, sau đó fetch mới // fetchPolicy: 'network-only', // Ví dụ: luôn fetch dữ liệu mới nhất }); if (loading && !data) return <p>Đang tải...</p>; if (error) return <p>Lỗi: {error.message}</p>; return ( <ul> {data.users.map(user => ( <li key={user.id}>{user.name} ({user.email})</li> ))} </ul> ); }
Giải thích: Bằng cách thay đổi giá trị fetchPolicy
, bạn thay đổi cách useQuery
tương tác với cache. cache-and-network
mang lại trải nghiệm người dùng tốt nhất trong nhiều trường hợp bằng cách hiển thị dữ liệu nhanh chóng từ cache trong khi vẫn đảm bảo dữ liệu được làm mới từ server.
Ghi Dữ liệu vào Cache (Mutations & update
)
Khi bạn thực hiện một mutation, dữ liệu trả về từ server sẽ tự động được Apollo Client xử lý và lưu vào cache. Nhờ cơ chế chuẩn hóa, nếu mutation trả về dữ liệu của một thực thể đã có trong cache (dựa trên __typename:id
), Apollo sẽ tự động cập nhật thực thể đó trong cache. Điều này khiến mọi component đang sử dụng dữ liệu của thực thể đó tự động re-render mà không cần làm gì thêm.
Ví dụ về cập nhật tự động:
Giả sử bạn có query lấy thông tin người dùng:
query GetUser($userId: ID!) { user(id: $userId) { id name email } }
Và mutation cập nhật tên người dùng:
mutation UpdateUserName($userId: ID!, $newName: String!) { updateUser(id: $userId, name: $newName) { id name # Quan trọng: Mutation trả về các trường bạn muốn cập nhật } }
Khi mutation updateUser
thành công và trả về { id: "...", name: "Tên mới" }
, Apollo sẽ tìm trong cache thực thể User:userId
và cập nhật trường name
của nó. Bất kỳ component nào đang hiển thị tên người dùng này (ví dụ: từ query GetUser
) sẽ tự động cập nhật.
Tuy nhiên, cập nhật tự động không phải lúc nào cũng đủ. Đặc biệt là khi mutation thực hiện các thao tác như thêm một mục mới vào danh sách hoặc xóa một mục. Trong trường hợp này, kết quả mutation không tự động cho Apollo biết làm thế nào để thay đổi danh sách trong cache.
Đây là lúc bạn cần sử dụng tùy chọn update
trong mutation. Hàm update
cho phép bạn truy cập trực tiếp vào cache sau khi mutation hoàn thành và thực hiện các thay đổi cần thiết một cách thủ công.
Ví dụ sử dụng hàm update
để thêm một bài viết mới:
import { useMutation, gql } from '@apollo/client'; const ADD_POST = gql` mutation AddPost($title: String!, $content: String!) { addPost(title: $title, content: $content) { id title content __typename # Luôn fetch __typename khi thêm mới } } `; const GET_POSTS = gql` query GetPosts { posts { id title __typename # Luôn fetch __typename trong queries cần update } } `; function NewPostForm() { const [addPost] = useMutation(ADD_POST, { // Hàm update được gọi sau khi mutation thành công update(cache, { data: { addPost } }) { // 1. Đọc dữ liệu hiện tại của query danh sách bài viết từ cache const existingPosts = cache.readQuery({ query: GET_POSTS }); // 2. Kiểm tra xem dữ liệu có tồn tại không if (existingPosts && existingPosts.posts) { // 3. Tạo mảng mới với bài viết vừa thêm const newPostList = [...existingPosts.posts, addPost]; // 4. Ghi mảng mới này trở lại cache cache.writeQuery({ query: GET_POSTS, data: { posts: newPostList } }); } }, // Hoặc đơn giản hơn (nhưng kém hiệu quả hơn): refetchQueries: [{ query: GET_POSTS }] }); // ... form handling ... const handleSubmit = async (values) => { try { await addPost({ variables: values }); // Thành công, cache đã được update bởi hàm update } catch (error) { console.error("Lỗi khi thêm bài viết:", error); } }; // ... render form ... }
Giải thích:
update
nhận vào đối tượngcache
(để tương tác với cache) vàdata
(kết quả từ mutation).- Chúng ta sử dụng
cache.readQuery
để đọc trạng thái hiện tại của queryGET_POSTS
từ cache. - Chúng ta tạo một mảng
newPostList
bằng cách thêm bài viết mới (addPost
từ kết quả mutation) vào danh sách cũ. - Chúng ta dùng
cache.writeQuery
để ghi đè danh sách bài viết cũ trong cache bằngnewPostList
mới. - Apollo Client thấy dữ liệu của query
GET_POSTS
thay đổi trong cache, nó sẽ tự động thông báo cho mọi component đang sử dụng query đó để re-render, hiển thị bài viết mới ngay lập tức mà không cần chờ fetch lại toàn bộ danh sách từ server.
Phương pháp sử dụng update
để thao tác trực tiếp với cache là cách hiệu quả nhất để xử lý các cập nhật cache phức tạp, đặc biệt là với các thao tác trên danh sách.
Một cách đơn giản hơn (nhưng thường kém hiệu quả hơn về performance và trải nghiệm người dùng) là sử dụng refetchQueries
:
const [addPost] = useMutation(ADD_POST, { refetchQueries: [ { query: GET_POSTS }, // Sau khi thêm bài viết, tự động chạy lại query GET_POSTS ], });
Giải thích: refetchQueries
chỉ đơn giản là yêu cầu Apollo chạy lại một hoặc nhiều query đã chỉ định sau khi mutation thành công. Điều này đảm bảo dữ liệu được cập nhật, nhưng nó tốn kém hơn vì phải gửi yêu cầu mạng mới và chờ phản hồi, thay vì cập nhật cache tại chỗ.
Tương tác trực tiếp với Cache (client.readQuery
, client.writeQuery
)
Ngoài việc được sử dụng trong hàm update
của mutation, bạn cũng có thể tương tác trực tiếp với cache từ bất kỳ đâu trong ứng dụng bằng cách truy cập vào đối tượng client
của Apollo (thường có được thông qua hook useApolloClient
hoặc được truyền vào context).
client.readQuery({ query, variables })
: Đọc dữ liệu từ cache cho một query cụ thể. Tương tự nhưcache.readQuery
nhưng dùng ngoài hook mutation. Nếu dữ liệu không có, nó trả vềnull
.client.writeQuery({ query, variables, data })
: Ghi dữ liệu vào cache cho một query cụ thể. Tương tự nhưcache.writeQuery
.
Ví dụ sử dụng useApolloClient
để đọc/ghi cache:
import { useApolloClient, gql } from '@apollo/client'; import { useState } from 'react'; const GET_LOCAL_MESSAGE = gql` query GetLocalMessage { localMessage @client # @client chỉ định đây là dữ liệu cục bộ, không fetch từ GraphQL server } `; function LocalStateDemo() { const client = useApolloClient(); const [message, setMessage] = useState(''); // Đọc giá trị hiện tại từ cache khi component mount (hoặc bất cứ khi nào cần) const readMessageFromCache = () => { try { const { localMessage } = client.readQuery({ query: GET_LOCAL_MESSAGE }); alert(`Tin nhắn trong cache: ${localMessage}`); } catch (e) { // Query chưa có trong cache, readQuery sẽ throw alert('Tin nhắn chưa có trong cache.'); } }; // Ghi giá trị mới vào cache const writeMessageToCache = () => { client.writeQuery({ query: GET_LOCAL_MESSAGE, data: { localMessage: message }, }); alert('Đã ghi tin nhắn vào cache.'); }; return ( <div> <h2>Quản lý Local State với Cache</h2> <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Nhập tin nhắn..." /> <button onClick={writeMessageToCache}>Lưu vào Cache</button> <button onClick={readMessageFromCache}>Đọc từ Cache</button> {/* Bạn cũng có thể dùng useQuery để theo dõi dữ liệu @client này và tự động cập nhật UI */} </div> ); }
Giải thích: Mặc dù ví dụ này đơn giản, nó minh họa cách bạn có thể sử dụng readQuery
và writeQuery
để thao tác với dữ liệu trong cache một cách độc lập. Kỹ thuật này đặc biệt hữu ích cho việc quản lý "local state" - dữ liệu chỉ tồn tại ở client và không cần đồng bộ với GraphQL server. Apollo Client cho phép bạn lưu trữ cả dữ liệu remote và local state trong cùng một cache, đơn giản hóa đáng kể việc quản lý state phức tạp.
Comments