
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 bè của chúng tôi tại Instrument. Họ đã viết thử nghiệm âm thanh/hình ảnh thú vị. Nhiệm vụ của nhóm chúng tôi là đảm bảo ứng dụng web I/O (mà tôi sẽ gọi bằng tên mã là IOWA) giới thiệu mọi thứ mà web hiện đại có thể làm được. Trải nghiệm hoàn toàn ưu tiên chế độ ngoại tuyến đứng đầu danh sách các tính năng bắt buộc phải có.
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 các phản hồi tĩnh hoặc được lưu vào bộ nhớ đệm 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 tại thời điểm tạo bản 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 bỏ qua 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 điều đó.
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à các tài nguyên đã lỗi thời sẽ bị xoá.
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 đó 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 glob sẽ được tải xuống và lưu vào bộ nhớ đệm trong lần đầu tiên người dùng truy cập vào IOWA. 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ụ, chẳng hạn như nội dung nghe nhìn được dùng trong thử nghiệm âm thanh/hình ảnh hoặc hình ảnh hồ sơ của người nói trong các phiên, đã được cố ý không lưu vào bộ nhớ đệm trước. Thay vào đó, chúng tôi đã sử dụng thư viện sw-toolbox
để xử lý các yêu cầu ngoại tuyến cho các 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 ít được sử dụng nên không đáng để lưu vào bộ nhớ đệm, còn một số tài nguyên khác thì 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
cho phép chúng ta linh hoạt triển khai trình xử lý yêu cầu để xử lý việc lưu vào bộ nhớ đệm thời gian chạy cho một số tài nguyên và 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 các tệp này với tập lệnh worker dịch vụ cơ sở thông qua importScripts parameter
của sw-precache
. Thao tác này sẽ kéo các tệp JavaScript độc lập vào phạm vi của worker 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ơ của diễn giả
Đố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 trước đó 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, chúng tôi sẽ sử dụng hình ảnh phần giữ chỗ chung được lưu vào bộ nhớ đệm trước (và do đó sẽ luôn có sẵn) làm phương án dự phòng cuối cùng. Đâ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/});

Nội dung cập nhật về Lịch 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ợ và 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 đã đưa ra một tổ hợp kết hợp các yêu cầu không thành công trong IndexedDB, cùng với logic trong trang web chính để kiểm tra IndexedDB cho các yêu cầu đã xếp hàng 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ần 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ần 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 tham số qt
vào mỗi yêu cầu trong hàng đợi, đặt thành khoảng thời gian đã trôi qua kể từ lần đầu tiên yêu cầu được thực hiện, để đả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 worker 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 đó đã được lưu vào bộ nhớ đệm như một phần của trang web tổng thể, vì vậy, chúng đã hoạt động khi không có mạng. Tuy nhiên, chúng tôi cần đảm bảo rằng thông tin chi tiết về phiên trên trang đó được cập nhật, ngay cả khi xem khi không có mạng. Để 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');
}
});
});
Những điều cần lưu ý và cân nhắc
Tất nhiên, không ai làm việc trên một dự án có quy mô như IOWA mà không gặp phải một số vấn đề. 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à 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 là tĩnh và không thay đổi, nên luồng cập nhật trình chạy dịch vụ của chúng tôi không được kích hoạt. Điều đáng ra phải là phản hồi động từ máy chủ với việc cập nhật video trên YouTube lại 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ủa mình được cấu trúc sao cho vỏ luôn tĩnh và có thể được lưu vào bộ nhớ đệm trước một cách an toàn, trong khi mọi tài nguyên động sửa đổi vỏ đều được tải độc lập.
Xoá bộ nhớ đệm các yêu cầu lưu trước
Khi sw-precache
đưa ra yêu cầu về tài nguyên để lưu vào bộ nhớ đệm trước, lớp này sẽ sử dụng các phản hồi đó vô thời hạn miễn là lớp này cho rằng hàm băm MD5 cho tệp không 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 đã 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 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 vào 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. Chúng ta cần một cách để bỏ qua một số tham số truy vấn URL trong khi vẫn xem xét những 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 chính sách này đối với bạn
Tính năng tích hợp worker dịch vụ trong Ứng dụng web Google I/O có thể là trường hợp sử dụng phức tạp nhất và thực tế 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.