Sử dụng requestIdleCallback

Nhiều trang web và ứng dụng có nhiều tập lệnh cần thực thi. JavaScript của bạn thường cần phải được chạy càng sớm càng tốt, nhưng đồng thời bạn cũng không muốn nó ảnh hưởng đến người dùng. Nếu bạn gửi dữ liệu phân tích khi người dùng đang cuộn trang hoặc bạn thêm các thành phần vào DOM trong khi họ tình cờ đang nhấn vào nút, ứng dụng web của bạn có thể không phản hồi, dẫn đến trải nghiệm người dùng kém.

Sử dụng requestIdleCallback để lên lịch cho công việc không thiết yếu.

Tin vui là hiện đã có API có thể giúp bạn: requestIdleCallback. Tương tự như việc sử dụng requestAnimationFrame, chúng tôi có thể lên lịch ảnh động đúng cách và tối đa hoá khả năng đạt được tốc độ 60 khung hình/giây, requestIdleCallback sẽ lên lịch hiển thị công việc khi có thời gian rảnh ở cuối khung hình hoặc khi người dùng không hoạt động. Điều này có nghĩa là bạn sẽ có cơ hội thực hiện công việc của mình mà không cản trở người dùng. Tính năng này đã có mặt trên Chrome 47 nên bạn có thể thử ngay hôm nay bằng cách sử dụng Chrome Canary! Vì đây là tính năng thử nghiệm và thông số kỹ thuật vẫn đang thay đổi, nên mọi thứ có thể thay đổi trong tương lai.

Tại sao tôi nên sử dụng requestIdleCallback?

Rất khó để lên lịch cho công việc không thiết yếu. Bạn không thể xác định chính xác thời gian kết xuất khung hình còn lại vì sau khi lệnh gọi lại requestAnimationFrame thực thi, sẽ có các phép tính về kiểu, bố cục, sơn và các thành phần nội bộ khác của trình duyệt cần chạy. Giải pháp quảng cáo tự động không thể tính đến bất kỳ yếu tố nào trong số đó. Để đảm bảo người dùng không tương tác theo một cách nào đó, bạn cũng cần đính kèm trình nghe vào mọi loại sự kiện tương tác (scroll, touch, click), ngay cả khi không cần chúng cho chức năng, chỉ để bạn có thể hoàn toàn chắc chắn rằng người dùng hiện không tương tác. Mặt khác, trình duyệt biết chính xác thời gian còn lại ở cuối khung hình và liệu người dùng có tương tác hay không. Vì vậy, thông qua requestIdleCallback, chúng ta nhận được một API cho phép tận dụng mọi thời gian rảnh theo cách hiệu quả nhất có thể.

Hãy xem xét kỹ hơn một chút và xem cách chúng ta có thể sử dụng nó.

Đang kiểm tra requestIdleCallback

Đây là ngày đầu của dịch vụ requestIdleCallback, vì vậy trước khi sử dụng, bạn nên kiểm tra để chắc chắn rằng dịch vụ này có thể sử dụng được:

if ('requestIdleCallback' in window) {
    // Use requestIdleCallback to schedule work.
} else {
    // Do what you’d do today.
}

Bạn cũng có thể điều chỉnh hành vi của thư viện này, yêu cầu quay lại setTimeout:

window.requestIdleCallback =
    window.requestIdleCallback ||
    function (cb) {
    var start = Date.now();
    return setTimeout(function () {
        cb({
        didTimeout: false,
        timeRemaining: function () {
            return Math.max(0, 50 - (Date.now() - start));
        }
        });
    }, 1);
    }

window.cancelIdleCallback =
    window.cancelIdleCallback ||
    function (id) {
    clearTimeout(id);
    }

Việc sử dụng setTimeout không hiệu quả vì ứng dụng này không biết về thời gian không hoạt động như requestIdleCallback, nhưng vì bạn sẽ gọi trực tiếp hàm của mình nếu requestIdleCallback không có sẵn, nên bạn vẫn có thể nhảy theo cách này. Với phần đệm, nếu requestIdleCallback có sẵn, các cuộc gọi của bạn sẽ tự động được chuyển hướng. Điều này thật tuyệt.

Hiện tại, hãy giả định rằng mã này tồn tại.

Sử dụng requestIdleCallback

Việc gọi requestIdleCallback rất giống với requestAnimationFrame ở chỗ hàm callback làm tham số đầu tiên:

requestIdleCallback(myNonEssentialWork);

Khi được gọi, myNonEssentialWork sẽ được cấp một đối tượng deadline chứa một hàm trả về một số cho biết thời gian còn lại cho công việc:

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0)
    doWorkIfNeeded();
}

Hàm timeRemaining có thể được gọi để nhận giá trị mới nhất. Khi timeRemaining() trả về 0, bạn có thể lên lịch cho một requestIdleCallback khác nếu vẫn còn việc khác cần làm:

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0 && tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

Đảm bảo hàm của bạn được gọi

Bạn sẽ làm gì nếu công việc thực sự bận? Bạn có thể lo ngại rằng lệnh gọi lại của mình có thể không bao giờ được gọi. Mặc dù requestIdleCallback giống với requestAnimationFrame, nhưng cũng khác ở chỗ cần có tham số thứ hai không bắt buộc: một đối tượng tuỳ chọn có thuộc tính timeout (thời gian chờ). Nếu được đặt, thời gian chờ này sẽ cho trình duyệt khoảng thời gian tính bằng mili giây mà trình duyệt phải thực thi lệnh gọi lại:

// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });

Nếu lệnh gọi lại của bạn được thực thi do hết thời gian chờ, bạn sẽ nhận thấy hai điều:

  • timeRemaining() sẽ trả về 0.
  • Thuộc tính didTimeout của đối tượng deadline sẽ có giá trị là true.

Nếu thấy didTimeout là đúng, rất có thể bạn chỉ muốn chạy và hoàn thành công việc đó:

function myNonEssentialWork (deadline) {

    // Use any remaining time, or, if timed out, just run through the tasks.
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) &&
            tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

Do khả năng gián đoạn, thời gian chờ này có thể gây ra cho người dùng của bạn (công việc có thể khiến ứng dụng của bạn không phản hồi hoặc bị giật). Hãy thận trọng khi đặt tham số này. Nếu có thể, hãy để trình duyệt quyết định thời điểm thực hiện lệnh gọi lại.

Sử dụng requestIdleCallback để gửi dữ liệu phân tích

Hãy cùng tìm hiểu cách sử dụng requestIdleCallback để gửi dữ liệu phân tích. Trong trường hợp này, chúng ta có thể muốn theo dõi một sự kiện, chẳng hạn như nhấn vào một trình đơn điều hướng. Tuy nhiên, vì chúng thường tạo ảnh động trên màn hình, nên chúng ta sẽ muốn tránh gửi sự kiện này đến Google Analytics ngay lập tức. Chúng ta sẽ tạo một mảng sự kiện để gửi và yêu cầu gửi các sự kiện đó vào một thời điểm nào đó trong tương lai:

var eventsToSend = [];

function onNavOpenClick () {

    // Animate the menu.
    menu.classList.add('open');

    // Store the event for later.
    eventsToSend.push(
    {
        category: 'button',
        action: 'click',
        label: 'nav',
        value: 'open'
    });

    schedulePendingEvents();
}

Bây giờ, chúng ta cần sử dụng requestIdleCallback để xử lý mọi sự kiện đang chờ xử lý:

function schedulePendingEvents() {

    // Only schedule the rIC if one has not already been set.
    if (isRequestIdleCallbackScheduled)
    return;

    isRequestIdleCallbackScheduled = true;

    if ('requestIdleCallback' in window) {
    // Wait at most two seconds before processing events.
    requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
    } else {
    processPendingAnalyticsEvents();
    }
}

Ở đây, bạn có thể thấy tôi đã đặt thời gian chờ là 2 giây, nhưng giá trị này sẽ phụ thuộc vào ứng dụng của bạn. Đối với dữ liệu phân tích, bạn nên dùng thời gian chờ để đảm bảo dữ liệu được báo cáo trong một khung thời gian hợp lý thay vì chỉ tại một thời điểm trong tương lai.

Cuối cùng, chúng ta cần viết hàm mà requestIdleCallback sẽ thực thi.

function processPendingAnalyticsEvents (deadline) {

    // Reset the boolean so future rICs can be set.
    isRequestIdleCallbackScheduled = false;

    // If there is no deadline, just run as long as necessary.
    // This will be the case if requestIdleCallback doesn’t exist.
    if (typeof deadline === 'undefined')
    deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
    var evt = eventsToSend.pop();

    ga('send', 'event',
        evt.category,
        evt.action,
        evt.label,
        evt.value);
    }

    // Check if there are more events still to send.
    if (eventsToSend.length > 0)
    schedulePendingEvents();
}

Trong ví dụ này, tôi giả định rằng nếu requestIdleCallback không tồn tại thì dữ liệu phân tích sẽ được gửi ngay lập tức. Tuy nhiên, trong ứng dụng chính thức, bạn nên trì hoãn việc gửi bằng cách đặt thời gian chờ để đảm bảo việc gửi này không xung đột với bất kỳ hoạt động tương tác nào và gây ra hiện tượng giật.

Sử dụng requestIdleCallback để thay đổi DOM

Một tình huống khác mà requestIdleCallback có thể thực sự giúp cải thiện hiệu suất, đó là khi bạn phải thực hiện những thay đổi không thiết yếu đối với DOM, chẳng hạn như thêm các mục vào cuối danh sách tải từng phần. Hãy xem cách requestIdleCallback thực sự khớp với một khung hình thông thường.

Một khung hình thông thường.

Có thể trình duyệt sẽ quá bận nên không thể chạy bất kỳ lệnh gọi lại nào trong một khung nhất định, vì vậy, bạn không nên kỳ vọng sẽ có bất kỳ thời gian rảnh nào ở cuối khung để thực hiện thêm tác vụ. Điều đó khiến nó khác với setImmediate, vốn chạy trong mỗi khung hình.

Nếu lệnh gọi lại được kích hoạt ở cuối khung, thì lệnh gọi lại đó sẽ được lên lịch để thực hiện sau khi khung hiện tại đã được cam kết, có nghĩa là các thay đổi về kiểu sẽ được áp dụng và quan trọng hơn là tính toán bố cục. Nếu chúng ta thực hiện thay đổi DOM bên trong lệnh gọi lại ở trạng thái rảnh, các phép tính bố cục đó sẽ không hợp lệ. Nếu có bất kỳ loại bố cục đọc nào trong khung tiếp theo, chẳng hạn như getBoundingClientRect, clientWidth, v.v., trình duyệt sẽ phải thực hiện Bố cục đồng bộ bắt buộc. Đây là nút thắt cổ chai tiềm ẩn về hiệu suất.

Một lý do khác khiến chúng tôi không thể kích hoạt thay đổi DOM trong lệnh gọi lại không hoạt động là do tác động thời gian của việc thay đổi DOM là không thể dự đoán, và do đó chúng tôi có thể dễ dàng vượt quá thời hạn mà trình duyệt đã cung cấp.

Phương pháp hay nhất là chỉ thực hiện thay đổi DOM bên trong lệnh gọi lại requestAnimationFrame, vì trình duyệt sẽ lên lịch cho loại công việc đó. Điều đó có nghĩa là mã của chúng ta sẽ cần sử dụng một mảnh tài liệu. Sau đó, mảnh này có thể được thêm vào lệnh gọi lại requestAnimationFrame tiếp theo. Nếu đang sử dụng thư viện VDOM, bạn sẽ sử dụng requestIdleCallback để thực hiện thay đổi, nhưng bạn sẽ áp dụng các bản vá DOM trong lệnh gọi lại requestAnimationFrame tiếp theo, chứ không phải lệnh gọi lại ở trạng thái rảnh.

Do đó, hãy cùng xem xét mã:

function processPendingElements (deadline) {

    // If there is no deadline, just run as long as necessary.
    if (typeof deadline === 'undefined')
    deadline = { timeRemaining: function () { return Number.MAX_VALUE } };

    if (!documentFragment)
    documentFragment = document.createDocumentFragment();

    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {

    // Create the element.
    var elToAdd = elementsToAdd.pop();
    var el = document.createElement(elToAdd.tag);
    el.textContent = elToAdd.content;

    // Add it to the fragment.
    documentFragment.appendChild(el);

    // Don't append to the document immediately, wait for the next
    // requestAnimationFrame callback.
    scheduleVisualUpdateIfNeeded();
    }

    // Check if there are more events still to send.
    if (elementsToAdd.length > 0)
    scheduleElementCreation();
}

Ở đây, tôi tạo phần tử và sử dụng thuộc tính textContent để điền phần tử đó, nhưng có khả năng bạn sẽ cần nhiều công sức hơn để tạo phần tử! Sau khi tạo phần tử scheduleVisualUpdateIfNeeded được gọi, thao tác này sẽ thiết lập một lệnh gọi lại requestAnimationFrame duy nhất. Lệnh gọi lại này sẽ nối mảnh tài liệu vào phần nội dung:

function scheduleVisualUpdateIfNeeded() {

    if (isVisualUpdateScheduled)
    return;

    isVisualUpdateScheduled = true;

    requestAnimationFrame(appendDocumentFragment);
}

function appendDocumentFragment() {
    // Append the fragment and reset.
    document.body.appendChild(documentFragment);
    documentFragment = null;
}

Mọi thứ đều ổn, giờ đây, chúng ta sẽ thấy bớt hiện tượng giật khi thêm các mục vào DOM. Tuyệt vời!

Câu hỏi thường gặp

  • Có lớp phủ polyfill không? Tiếc là không, nhưng có một shim nếu bạn muốn chuyển hướng rõ ràng đến setTimeout. Lý do API này tồn tại là vì nó tạo ra một lỗ hổng rất thực sự trong nền tảng web. Rất khó để suy luận thiếu hoạt động, nhưng không có API JavaScript nào để xác định lượng thời gian rảnh ở cuối khung, vì vậy tốt nhất bạn phải đoán. Bạn có thể dùng các API như setTimeout, setInterval hoặc setImmediate để lên lịch công việc, nhưng các API này không được định thời gian để tránh tương tác của người dùng theo cách requestIdleCallback.
  • Điều gì sẽ xảy ra nếu tôi vượt quá thời hạn? Nếu timeRemaining() trả về 0, nhưng bạn chọn chạy lâu hơn, thì bạn có thể làm như vậy mà không sợ trình duyệt tạm dừng công việc của mình. Tuy nhiên, trình duyệt sẽ cho bạn thời hạn để thử nhằm đảm bảo trải nghiệm suôn sẻ cho người dùng. Vì vậy, trừ phi có lý do chính đáng, bạn nên luôn tuân thủ thời hạn này.
  • Có giá trị tối đa mà timeRemaining() sẽ trả về không? Có, hiện tại là 50 mili giây. Khi cố gắng duy trì một ứng dụng thích ứng, tất cả phản hồi cho tương tác của người dùng đều phải dưới 100 mili giây. Trong hầu hết các trường hợp, người dùng phải tương tác trong khoảng thời gian 50 mili giây để cho phép lệnh gọi lại ở trạng thái rảnh hoàn tất và để trình duyệt phản hồi các hoạt động tương tác của người dùng. Bạn có thể nhận được nhiều lệnh gọi lại ở trạng thái rảnh được lên lịch liên tiếp (nếu trình duyệt xác định có đủ thời gian để chạy các lệnh gọi lại đó).
  • Có loại công việc nào tôi không nên làm trong requestIdleCallback không? Lý tưởng nhất là công việc bạn thực hiện nên chia thành từng phần nhỏ (những công việc vi mô) có những đặc điểm tương đối dễ dự đoán. Ví dụ: việc thay đổi DOM cụ thể sẽ có thời gian thực thi khó dự đoán, vì sẽ kích hoạt việc tính toán kiểu, bố cục, vẽ và kết hợp. Do đó, bạn chỉ nên thực hiện các thay đổi DOM trong lệnh gọi lại requestAnimationFrame như đề xuất ở trên. Một điều khác cần cảnh giác là giải quyết (hoặc từ chối) Lời hứa, vì các lệnh gọi lại sẽ thực thi ngay sau khi lệnh gọi lại ở trạng thái rảnh kết thúc, ngay cả khi không còn thời gian nữa.
  • Tôi luôn nhận được requestIdleCallback ở cuối khung hình chứ? Không, không phải lúc nào cũng vậy. Trình duyệt sẽ lên lịch lệnh gọi lại bất cứ khi nào có thời gian rảnh ở cuối một khung hình hoặc trong những khoảng thời gian người dùng không hoạt động. Bạn không nên dự kiến lệnh gọi lại sẽ được gọi trên mỗi khung hình. Nếu cần lệnh gọi lại trong một khung thời gian nhất định, bạn nên tận dụng thời gian chờ.
  • Tôi có thể có nhiều lệnh gọi lại requestIdleCallback không? Có, nhiều như bạn có thể có nhiều lệnh gọi lại requestAnimationFrame. Tuy nhiên, bạn cần nhớ rằng nếu lệnh gọi lại đầu tiên sử dụng hết thời gian còn lại trong lệnh gọi lại, thì bạn sẽ không còn thời gian cho bất kỳ lệnh gọi lại nào khác. Các lệnh gọi lại khác sau đó sẽ phải đợi cho đến khi trình duyệt ở trạng thái rảnh tiếp theo thì mới có thể chạy. Tuỳ thuộc vào công việc bạn đang cố gắng hoàn thành, tốt hơn là bạn nên có một lệnh gọi lại ở trạng thái rảnh và phân chia công việc trong đó. Ngoài ra, bạn có thể sử dụng thời gian chờ để đảm bảo rằng không có lệnh gọi lại nào bị thiếu thời gian.
  • Điều gì sẽ xảy ra nếu tôi đặt một lệnh gọi lại mới ở trạng thái rảnh bên trong một lệnh gọi lại khác? Lệnh gọi lại mới ở trạng thái rảnh sẽ được lên lịch chạy sớm nhất có thể, bắt đầu từ khung tiếp theo (thay vì khung hiện tại).

Tạm vắng!

requestIdleCallback là một cách tuyệt vời để đảm bảo bạn có thể chạy mã mà không làm ảnh hưởng đến người dùng. API này dễ sử dụng và rất linh hoạt. Tuy nhiên, tính năng này vẫn chỉ mới bắt đầu và thông số kỹ thuật chưa được giải quyết hoàn chỉnh, nên chúng tôi rất mong nhận được ý kiến phản hồi của bạn.

Hãy dùng thử tính năng này trong Chrome Canary, khám phá phiên bản này cho các dự án của bạn và cho chúng tôi biết cách thức hoạt động của bạn!