Tóm tắt
Tìm hiểu cách chúng tôi sử dụng thư viện worker dịch vụ để tạo ứng dụng web Google I/O 2015 nhanh và ưu tiên chế độ ngoại tuyến.
Tổng quan
Ứng dụng web Google I/O 2015 năm nay là do nhóm Quan hệ nhà phát triển của Google viết, dựa trên thiết kế của các bạn của chúng tôi tại Instrument, những người đã viết thử nghiệm âm thanh/hình ảnh thú vị. Sứ mệnh của nhóm chúng tôi là đảm bảo rằng ứng dụng web I/O (mà tôi sẽ gọi tên mã là IOWA) cho thấy mọi tính năng mà web hiện đại có thể làm. Trải nghiệm ngoại tuyến đầy đủ nằm ở trên cùng trong danh sách các tính năng thiết yếu của chúng tôi.
Nếu đã đọc bất kỳ bài viết nào khác trên trang web này gần đây, chắc chắn bạn đã gặp trình chạy dịch vụ và sẽ không ngạc nhiên khi biết rằng tính năng hỗ trợ ngoại tuyến của IOWA phụ thuộc rất nhiều vào các trình chạy dịch vụ này. Do nhu cầu thực tế của IOWA, chúng tôi đã phát triển hai thư viện để xử lý hai trường hợp sử dụng ngoại tuyến khác nhau: sw-precache
để tự động hoá việc lưu trước tài nguyên tĩnh và sw-toolbox
để xử lý chiến lược lưu vào bộ nhớ đệm trong thời gian chạy và chiến lược dự phòng.
Các thư viện này bổ sung cho nhau một cách hiệu quả và cho phép chúng tôi triển khai một chiến lược hiệu suất trong đó "vỏ" nội dung tĩnh của IOWA luôn được phân phát trực tiếp từ bộ nhớ đệm và các tài nguyên động hoặc từ xa được phân phát từ mạng, với các phương án dự phòng cho phản hồi được lưu vào bộ nhớ đệm hoặc phản hồi tĩnh khi cần.
Lưu trước bằng sw-precache
Các tài nguyên tĩnh của IOWA (HTML, JavaScript, CSS và hình ảnh) cung cấp giao diện hạt nhân cho ứng dụng web. Có hai yêu cầu cụ thể quan trọng khi nghĩ đến việc lưu các tài nguyên này vào bộ nhớ đệm: chúng tôi muốn đảm bảo rằng hầu hết các tài nguyên tĩnh đều được lưu vào bộ nhớ đệm và được cập nhật.
sw-precache
được xây dựng dựa trên những yêu cầu đó.
Tích hợp trong thời gian xây dựng
sw-precache
với quy trình xây dựng dựa trên gulp
của IOWA, và chúng tôi dựa vào một loạt mẫu glob để đảm bảo rằng chúng tôi tạo danh sách đầy đủ tất cả tài nguyên tĩnh mà IOWA sử dụng.
staticFileGlobs: [
rootDir + '/bower_components/**/*.{html,js,css}',
rootDir + '/elements/**',
rootDir + '/fonts/**',
rootDir + '/images/**',
rootDir + '/scripts/**',
rootDir + '/styles/**/*.css',
rootDir + '/data-worker-scripts.js'
]
Các phương pháp thay thế, chẳng hạn như mã hoá cứng danh sách tên tệp vào một mảng và nhớ tăng số phiên bản bộ nhớ đệm mỗi khi có bất kỳ thay đổi nào đối với các tệp đó, dễ gặp lỗi hơn, đặc biệt là khi chúng tôi có nhiều thành viên trong nhóm kiểm tra mã. Không ai muốn làm hỏng tính năng hỗ trợ khi không có mạng bằng cách bỏ qua một tệp mới trong một mảng được duy trì theo cách thủ công! Việc tích hợp tại thời điểm tạo bản dựng có nghĩa là chúng ta có thể thực hiện các thay đổi đối với các tệp hiện có và thêm các tệp mới mà không phải lo lắng về những vấn đề đó.
Cập nhật tài nguyên lưu vào bộ nhớ đệm
sw-precache
tạo một tập lệnh trình chạy dịch vụ cơ sở chứa một hàm băm MD5 duy nhất cho mỗi tài nguyên được lưu vào bộ nhớ đệm trước. Mỗi khi một tài nguyên hiện có thay đổi hoặc một tài nguyên mới được thêm, tập lệnh worker dịch vụ sẽ được tạo lại. Thao tác này sẽ tự động kích hoạt quy trình cập nhật trình chạy dịch vụ, trong đó các tài nguyên mới được lưu vào bộ nhớ đệm và những tài nguyên đã lỗi thời sẽ bị xoá hoàn toàn.
Mọi tài nguyên hiện có có hàm băm MD5 giống hệt nhau sẽ được giữ nguyên. Điều đó có nghĩa là những người dùng đã truy cập vào trang web trước đó sẽ chỉ tải xuống một nhóm tài nguyên thay đổi tối thiểu, mang lại trải nghiệm hiệu quả hơn nhiều so với việc toàn bộ bộ nhớ đệm hết hạn toàn bộ.
Mỗi tệp khớp với một trong các mẫu hình cầu sẽ được tải xuống và lưu vào bộ nhớ đệm khi người dùng truy cập IOWA lần đầu tiên. Chúng tôi đã nỗ lực để đảm bảo rằng chỉ những tài nguyên quan trọng cần thiết để hiển thị trang mới được lưu vào bộ nhớ đệm trước. Nội dung phụ (như nội dung nghe nhìn dùng trong thử nghiệm âm thanh/hình ảnh hoặc ảnh hồ sơ của người nói trong phiên) đã được cố ý không lưu trước vào bộ nhớ đệm. Thay vào đó, chúng tôi đã dùng thư viện sw-toolbox
để xử lý các yêu cầu ngoại tuyến cho những tài nguyên đó.
sw-toolbox
, cho mọi nhu cầu linh động của chúng ta
Như đã đề cập, việc lưu trước mọi tài nguyên mà một trang web cần để hoạt động khi không có mạng là không khả thi. Một số tài nguyên quá lớn hoặc không được dùng thường xuyên để mang lại giá trị trong khi một số tài nguyên khác lại có tính linh động, chẳng hạn như phản hồi từ một API hoặc dịch vụ từ xa. Tuy nhiên, việc một yêu cầu không được lưu vào bộ nhớ đệm trước không có nghĩa là yêu cầu đó sẽ dẫn đến NetworkError
.
sw-toolbox
giúp chúng tôi triển khai trình xử lý yêu cầu một cách linh hoạt để xử lý việc lưu vào bộ nhớ đệm trong thời gian chạy đối với một số tài nguyên và tính năng dự phòng tuỳ chỉnh cho các tài nguyên khác. Chúng tôi cũng đã sử dụng tính năng này để cập nhật các tài nguyên đã lưu vào bộ nhớ đệm trước đó để phản hồi thông báo đẩy.
Dưới đây là một số ví dụ về trình xử lý yêu cầu tuỳ chỉnh mà chúng tôi đã xây dựng dựa trên sw-toolbox. Bạn có thể dễ dàng tích hợp chúng với tập lệnh của trình chạy dịch vụ cơ sở thông qua importScripts parameter
của sw-precache
. Tập lệnh này kéo các tệp JavaScript độc lập vào phạm vi của trình chạy dịch vụ.
Thử nghiệm âm thanh/hình ảnh
Đối với thử nghiệm âm thanh/hình ảnh, chúng tôi đã sử dụng chiến lược bộ nhớ đệm networkFirst
của sw-toolbox
. Trước tiên, tất cả các yêu cầu HTTP khớp với mẫu URL cho thử nghiệm sẽ được thực hiện trên mạng và nếu hệ thống trả về phản hồi thành công, thì phản hồi đó sẽ được lưu trữ bằng API bộ nhớ đệm.
Nếu một yêu cầu tiếp theo được thực hiện khi không có mạng, thì phản hồi đã lưu vào bộ nhớ đệm trước đó sẽ được sử dụng.
Vì bộ nhớ đệm được tự động cập nhật mỗi khi có phản hồi mạng thành công, nên chúng ta không cần phải tạo phiên bản tài nguyên hoặc hết hạn mục nhập.
toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);
Hình ảnh hồ sơ về người nói
Đối với hình ảnh hồ sơ của diễn giả, mục tiêu của chúng tôi là hiển thị phiên bản hình ảnh của một diễn giả nhất định đã lưu vào bộ nhớ đệm nếu có, nếu không thì sẽ quay lại mạng để truy xuất hình ảnh. Nếu yêu cầu mạng đó không thành công, thì phương án dự phòng cuối cùng là chúng tôi đã sử dụng một hình ảnh giữ chỗ chung đã được lưu trước vào bộ nhớ đệm (và do đó sẽ luôn có sẵn). Đây là chiến lược phổ biến để sử dụng khi xử lý các hình ảnh có thể được thay thế bằng phần giữ chỗ chung. Bạn có thể dễ dàng triển khai chiến lược này bằng cách tạo chuỗi trình xử lý cacheFirst
và cacheOnly
của sw-toolbox
.
var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';
function profileImageRequest(request) {
return toolbox.cacheFirst(request).catch(function() {
return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
});
}
toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
profileImageRequest,
{origin: /.*\.googleapis\.com/});
Cập nhật lịch biểu của người dùng
Một trong những tính năng chính của IOWA là cho phép người dùng đã đăng nhập tạo và duy trì lịch trình các phiên mà họ dự định tham dự. Như bạn mong đợi, các bản cập nhật phiên hoạt động được thực hiện thông qua các yêu cầu POST
HTTP đến máy chủ phụ trợ. Chúng tôi đã dành thời gian tìm ra cách tốt nhất để xử lý các yêu cầu sửa đổi trạng thái đó khi người dùng không có mạng. Chúng tôi đã tìm ra cách kết hợp các yêu cầu không thành công đã đưa vào hàng đợi trong IndexedDB, kết hợp với logic trong trang web chính đã kiểm tra IndexedDB cho các yêu cầu trong hàng đợi và thử lại bất kỳ yêu cầu nào tìm thấy.
var DB_NAME = 'shed-offline-session-updates';
function queueFailedSessionUpdateRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, request.method);
});
}
function handleSessionUpdateRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedSessionUpdateRequest(request);
});
}
toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
Vì các lượt thử lại được thực hiện trong bối cảnh của trang chính, nên chúng ta có thể chắc chắn rằng các lượt thử lại đó bao gồm một bộ thông tin xác thực người dùng mới. Sau khi thử lại thành công, chúng tôi hiển thị một thông báo để cho người dùng biết rằng các bản cập nhật trước đó đã được áp dụng.
simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
var replayPromises = [];
return db.forEach(function(url, method) {
var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
return db.delete(url).then(function() {
return true;
});
});
replayPromises.push(promise);
}).then(function() {
if (replayPromises.length) {
return Promise.all(replayPromises).then(function() {
IOWA.Elements.Toast.showMessage(
'My Schedule was updated with offline changes.');
});
}
});
}).catch(function() {
IOWA.Elements.Toast.showMessage(
'Offline changes could not be applied to My Schedule.');
});
Google Analytics ngoại tuyến
Tương tự như vậy, chúng tôi đã triển khai một trình xử lý để đưa mọi yêu cầu Google Analytics không thành công vào hàng đợi và cố gắng phát lại các yêu cầu đó sau, khi mạng có sẵn. Với phương pháp này, việc không có kết nối mạng không có nghĩa là bạn phải hy sinh thông tin chi tiết mà Google Analytics cung cấp. Chúng tôi đã thêm thông số qt
vào mỗi yêu cầu được xếp hàng đợi, đặt thành khoảng thời gian đã trôi qua kể từ lần đầu tiên gửi yêu cầu, để đảm bảo rằng thời gian phân bổ sự kiện thích hợp đã được gửi đến phần phụ trợ của Google Analytics. Google Analytics chính thức hỗ trợ các giá trị cho qt
chỉ tối đa 4 giờ, vì vậy, chúng tôi đã cố gắng hết sức để phát lại các yêu cầu đó sớm nhất có thể, mỗi khi trình chạy dịch vụ khởi động.
var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;
function replayQueuedAnalyticsRequests() {
simpleDB.open(DB_NAME).then(function(db) {
db.forEach(function(url, originalTimestamp) {
var timeDelta = Date.now() - originalTimestamp;
var replayUrl = url + '&qt=' + timeDelta;
fetch(replayUrl).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
db.delete(url);
}).catch(function(error) {
if (timeDelta > EXPIRATION_TIME_DELTA) {
db.delete(url);
}
});
});
});
}
function queueFailedAnalyticsRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, Date.now());
});
}
function handleAnalyticsCollectionRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedAnalyticsRequest(request);
});
}
toolbox.router.get('/collect',
handleAnalyticsCollectionRequest,
{origin: ORIGIN});
toolbox.router.get('/analytics.js',
toolbox.networkFirst,
{origin: ORIGIN});
replayQueuedAnalyticsRequests();
Trang đích thông báo đẩy
Worker không chỉ xử lý chức năng ngoại tuyến của IOWA mà còn hỗ trợ thông báo đẩy mà chúng tôi dùng để thông báo cho người dùng về nội dung cập nhật cho các phiên được đánh dấu. Trang đích liên kết với các thông báo đó hiển thị thông tin chi tiết về phiên đã cập nhật. Các trang đích đó vốn đã được lưu vào bộ nhớ đệm như một phần của trang web tổng thể, nên chúng hoạt động khi không có mạng, nhưng chúng tôi cần đảm bảo rằng thông tin chi tiết về phiên hoạt động trên trang đó đã được cập nhật, ngay cả khi được xem ngoại tuyến. Để làm điều đó, chúng tôi đã sửa đổi siêu dữ liệu phiên đã lưu vào bộ nhớ đệm trước đó bằng các nội dung cập nhật đã kích hoạt thông báo đẩy và lưu kết quả vào bộ nhớ đệm. Thông tin mới nhất này sẽ được sử dụng vào lần tiếp theo bạn mở trang chi tiết phiên, cho dù phiên đó diễn ra trên mạng hay ngoại tuyến.
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.match('api/v1/schedule').then(function(response) {
if (response) {
parseResponseJSON(response).then(function(schedule) {
sessions.forEach(function(session) {
schedule.sessions[session.id] = session;
});
cache.put('api/v1/schedule',
new Response(JSON.stringify(schedule)));
});
} else {
toolbox.cache('api/v1/schedule');
}
});
});
Các lỗi thường gặp và những điểm cần cân nhắc
Tất nhiên, không ai xử lý một dự án quy mô của IOWA mà không gặp phải một số gotcha. Sau đây là một số vấn đề mà chúng tôi gặp phải và cách giải quyết.
Nội dung cũ
Bất cứ khi nào bạn lên kế hoạch cho một chiến lược lưu vào bộ nhớ đệm, cho dù được triển khai thông qua worker dịch vụ hay với bộ nhớ đệm trình duyệt tiêu chuẩn, bạn đều phải đánh đổi giữa việc phân phối tài nguyên nhanh nhất có thể so với việc phân phối tài nguyên mới nhất. Thông qua sw-precache
, chúng tôi đã triển khai một chiến lược ưu tiên bộ nhớ đệm mạnh mẽ cho vỏ ứng dụng, nghĩa là worker dịch vụ sẽ không kiểm tra mạng để cập nhật trước khi trả về HTML, JavaScript và CSS trên trang.
May mắn thay, chúng tôi có thể tận dụng các sự kiện trong vòng đời của worker dịch vụ để phát hiện thời điểm có nội dung mới sau khi trang đã tải. Khi phát hiện một worker dịch vụ đã cập nhật, chúng ta sẽ hiển thị một thông báo ngắn cho người dùng để cho họ biết rằng họ nên tải lại trang để xem nội dung mới nhất.
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.onstatechange = function(e) {
if (e.target.state === 'redundant') {
var tapHandler = function() {
window.location.reload();
};
IOWA.Elements.Toast.showMessage(
'Tap here or refresh the page for the latest content.',
tapHandler);
}
};
}
Đảm bảo nội dung tĩnh là nội dung tĩnh!
sw-precache
sử dụng hàm băm MD5 của nội dung tệp cục bộ và chỉ tìm nạp các tài nguyên có hàm băm đã thay đổi. Điều này có nghĩa là các tài nguyên sẽ có sẵn trên trang gần như ngay lập tức, nhưng cũng có nghĩa là sau khi được lưu vào bộ nhớ đệm, tài nguyên sẽ vẫn được lưu vào bộ nhớ đệm cho đến khi được chỉ định một hàm băm mới trong tập lệnh worker dịch vụ đã cập nhật.
Chúng tôi gặp phải vấn đề với hành vi này trong sự kiện I/O do phần phụ trợ của chúng tôi cần cập nhật linh động mã video phát trực tiếp trên YouTube cho mỗi ngày của hội nghị. Vì tệp mẫu cơ bản ở dạng tĩnh và không thay đổi, nên quy trình cập nhật trình chạy dịch vụ của chúng tôi đã không được kích hoạt và mục đích của phản hồi động từ máy chủ khi cập nhật video trên YouTube trở thành phản hồi được lưu vào bộ nhớ đệm cho một số người dùng.
Bạn có thể tránh loại vấn đề này bằng cách đảm bảo ứng dụng web có cấu trúc sao cho shell luôn tĩnh và có thể được lưu trước vào bộ nhớ đệm một cách an toàn, trong khi mọi tài nguyên động sửa đổi shell đều được tải một cách độc lập.
Xoá bộ nhớ đệm các yêu cầu lưu trước
Khi đưa ra yêu cầu về tài nguyên để lưu trước vào bộ nhớ đệm, sw-precache
sẽ sử dụng các phản hồi đó vô thời hạn, miễn là hàm băm MD5 cho tệp chưa thay đổi. Điều này có nghĩa là điều đặc biệt quan trọng là phải đảm bảo rằng phản hồi cho yêu cầu lưu trước là phản hồi mới và không được trả về từ bộ nhớ đệm HTTP của trình duyệt. (Có, các yêu cầu fetch()
được thực hiện trong trình chạy dịch vụ có thể phản hồi bằng dữ liệu từ bộ nhớ đệm HTTP của trình duyệt.)
Để đảm bảo rằng các phản hồi mà chúng ta lưu vào bộ nhớ đệm trước là trực tiếp từ mạng chứ không phải bộ nhớ đệm HTTP của trình duyệt, sw-precache
sẽ tự động thêm một tham số truy vấn huỷ bộ nhớ đệm vào mỗi URL mà nó yêu cầu. Nếu bạn không sử dụng sw-precache
và đang sử dụng chiến lược phản hồi ưu tiên bộ nhớ đệm, hãy đảm bảo rằng bạn làm điều tương tự trong mã của riêng mình!
Một giải pháp rõ ràng hơn để loại bỏ bộ nhớ đệm là đặt chế độ bộ nhớ đệm của mỗi Request
dùng để lưu trước vào reload
. Điều này sẽ đảm bảo rằng phản hồi đến từ mạng. Tuy nhiên, tại thời điểm viết bài này, tuỳ chọn chế độ bộ nhớ đệm không được hỗ trợ trong Chrome.
Hỗ trợ đăng nhập và đăng xuất
IOWA cho phép người dùng đăng nhập bằng Tài khoản Google và cập nhật lịch sự kiện tuỳ chỉnh, nhưng điều đó cũng có nghĩa là người dùng có thể đăng xuất sau đó. Việc lưu dữ liệu phản hồi được cá nhân hoá vào bộ nhớ đệm rõ ràng là một chủ đề khó khăn và không phải lúc nào cũng có một phương pháp phù hợp.
Vì việc xem lịch cá nhân, ngay cả khi không có mạng, là yếu tố cốt lõi của trải nghiệm IOWA, nên chúng tôi quyết định sử dụng dữ liệu được lưu vào bộ nhớ đệm là phù hợp. Khi người dùng đăng xuất, chúng tôi đảm bảo xoá dữ liệu phiên được lưu vào bộ nhớ đệm trước đó.
self.addEventListener('message', function(event) {
if (event.data === 'clear-cached-user-data') {
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.keys().then(function(requests) {
return requests.filter(function(request) {
return request.url.indexOf('api/v1/user/') !== -1;
});
}).then(function(userDataRequests) {
userDataRequests.forEach(function(userDataRequest) {
cache.delete(userDataRequest);
});
});
});
}
});
Hãy cẩn thận với các tham số truy vấn bổ sung!
Khi kiểm tra phản hồi được lưu vào bộ nhớ đệm, worker dịch vụ sẽ sử dụng URL yêu cầu làm khoá. Theo mặc định, URL yêu cầu phải khớp chính xác với URL dùng để lưu trữ phản hồi được lưu vào bộ nhớ đệm, bao gồm cả mọi tham số truy vấn trong phần tìm kiếm của URL.
Điều này đã gây ra vấn đề cho chúng tôi trong quá trình phát triển, khi chúng tôi bắt đầu sử dụng tham số URL để theo dõi nguồn lưu lượng truy cập. Ví dụ: chúng tôi thêm
tham số utm_source=notification
vào các URL được mở khi nhấp vào một trong các thông báo
và sử dụng utm_source=web_app_manifest
trong start_url
cho tệp kê khai ứng dụng web.
Các URL trước đây khớp với phản hồi được lưu trong bộ nhớ đệm sẽ xuất hiện dưới dạng không khớp khi các thông số đó được thêm vào.
Vấn đề này được giải quyết một phần bằng tuỳ chọn ignoreSearch
mà bạn có thể sử dụng khi gọi Cache.match()
. Rất tiếc, Chrome chưa hỗ trợ ignoreSearch
, và ngay cả khi có hỗ trợ thì đó cũng là hành vi tất cả hoặc không. Điều chúng tôi cần là một cách để bỏ qua một số tham số truy vấn URL trong khi vẫn tính đến các tham số có ý nghĩa khác.
Cuối cùng, chúng tôi đã mở rộng sw-precache
để loại bỏ một số tham số truy vấn trước khi kiểm tra kết quả khớp trong bộ nhớ đệm và cho phép nhà phát triển tuỳ chỉnh những tham số nào sẽ bị bỏ qua thông qua tuỳ chọn ignoreUrlParametersMatching
.
Dưới đây là cách triển khai cơ bản:
function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
var url = new URL(originalUrl);
url.search = url.search.slice(1)
.split('&')
.map(function(kv) {
return kv.split('=');
})
.filter(function(kv) {
return ignoredRegexes.every(function(ignoredRegex) {
return !ignoredRegex.test(kv[0]);
});
})
.map(function(kv) {
return kv.join('=');
})
.join('&');
return url.toString();
}
Ý nghĩa của điều này đối với bạn
Việc tích hợp trình chạy dịch vụ trong Ứng dụng web Google I/O có thể là cách sử dụng thực tế phức tạp nhất đã được triển khai cho đến thời điểm này. Chúng tôi rất mong cộng đồng nhà phát triển web sử dụng các công cụ mà chúng tôi đã tạo sw-precache
và sw-toolbox
cũng như các kỹ thuật mà chúng tôi đang mô tả để hỗ trợ các ứng dụng web của riêng bạn.
Trình chạy dịch vụ là một tính năng nâng cao dần mà bạn có thể bắt đầu sử dụng ngay hôm nay. Khi được sử dụng trong một ứng dụng web có cấu trúc phù hợp, tốc độ và lợi ích khi không có mạng sẽ rất có ý nghĩa đối với người dùng.