Google 工程师为何要推荐 idle-until-urgent 策略?

  • 时间:2025-12-03 22:33 作者: 来源: 阅读:0
  • 扫一扫,手机访问
摘要:大家好,很高兴又见面了,我是"高级前端‬进阶‬",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。1.JavaScript 代码执行策略在为耗时的代码选择执行策略时,可以有以下几种方案。1.1 立即执行立即执行有一个缺点,即如果用户在代码执行时尝试与页面交互,浏览器必须等到代码执行完成后才能响应。此时如果页面看起来已经准备

大家好,很高兴又见面了,我是"高级前端‬进阶‬",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

Google 工程师为何要推荐 idle-until-urgent 策略?

1.JavaScript 代码执行策略

在为耗时的代码选择执行策略时,可以有以下几种方案。

1.1 立即执行

立即执行有一个缺点,即如果用户在代码执行时尝试与页面交互,浏览器必须等到代码执行完成后才能响应。此时如果页面看起来已经准备好响应用户输入,但实际上却不能,将严重影响用户体验。

同时,立即执行的代码越多,页面交互所需的时间就越长。

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());
    this.formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/Los_Angeles',
    });
    // 构造函数中立即执行
  }
  handleUserClick() {
    console.log(this.formatter.format(new Date()));
  }
}

1.2 惰性执行

惰性执行表明等到真正需要时再执行代码,但是该方案的问题很明显,即可能阻止用户输入。对于推迟从网络加载其他内容该方案是有意义的,但对于正在执行的大多数代码,例如:访问 localStorage、处理大型数据集等,开发者肯定希望其在用户交互之前发生。

class MyComponent {
    constructor() {
       addEventListener('click', () => this.handleUserClick());
    }
    handleUserClick() {
        // 在用户点击时惰性初始化 formatter
        if (!this.formatter) {
            this.formatter = new Intl.DateTimeFormat('en-US', {
                timeZone: 'America/Los_Angeles',
            });
        }
        console.log(this.formatter.format(new Date()));
    }
}

1.3 其他选择

  • 延迟执行 (Deferred evaluation):开发者可以使用 setTimeout 之类的方法调度代码在未来的任务中运行。
  • 空闲执行 (Idle evaluation):开发者可以使用类似 requestIdleCallback 的 API 来调度代码运行。

以上两种策略一般都 比立即求值或惰性求值更好,由于不太可能导致阻塞用户输入的单个长任务。这是由于,虽然浏览器无法中断任何单个任务来响应用户输入,但可以在调度的任务队列之间运行任务,并且大多数浏览器在任务由用户输入引起时都会这样做,即输入优先级。

换句话说,如果确保所有代码都在简短、不同的任务中运行(最好少于 50 毫秒),则代码执行将永远不会阻塞用户输入。

虽然浏览器可以在调度用户输入任务之前运行回调,但无法在调度 ` 微任务 ` 之前运行输入回调,而且由于 Promise 和 async 函数作为微任务运行,因此将同步代码转换为基于 Promise 的代码也会阻止用户输入。

结合延迟执行和空闲执行策略,代码重构后如下:

const main = () => {
  setTimeout(() => drawer.init(), 0);
  setTimeout(() => contentLoader.init(), 0);
  setTimeout(() => breakpoints.init(), 0);
  setTimeout(() => alerts.init(), 0);
  requestIdleCallback(() => analytics.init());
};
main();

以上代码通过 setTimeout 推迟了 UI 组件( contentLoader 和 drawer)的初始化,因此不太可能阻止用户输入,但问题是有可能在用户交互时尚未渲染好!

同时,虽然使用 requestIdleCallback() 延迟埋点上报,但在下一个空闲期之前任何交互都会丢失(未实例化好)。同时,如果在用户离开页面之前没有空闲期,这些回调可能根本不会运行。

2. 空闲直到紧急 (Idle-Until-Urgent) 的单任务策略

实则真正好的代码执行策略是: 代码默认被推迟到空闲时段,但一旦需要就会立即运行,即 Idle-Until-Urgent。该策略在最坏的情况下具有与惰性执行完全一样的性能,而在最好的情况下,根本不会阻止用户交互,由于执行发生在空闲时段。

import {IdleValue} from './path/to/IdleValue.mjs';

class MyComponent {
  constructor() {
    addEventListener('click', () => this.handleUserClick());
    //  不会在事件监听函数中实例化 formatter,由于会影响用户响应
    this.formatter = new IdleValue(() => {
      return new Intl.DateTimeFormat('en-US', {
        timeZone: 'America/Los_Angeles',
      });
    });
  }
  handleUserClick() {
    console.log(this.formatter.getValue().format(new Date()));
  }
}

下面是 IdleValue 类的具体实现:

export class IdleValue {
  constructor(init) {
    this._init = init;
    this._value;
    this._idleHandle = requestIdleCallback(() => {
      this._value = this._init();
    });
  }
  getValue() {
    if (this._value === undefined) {
      cancelIdleCallback(this._idleHandle);
      this._value = this._init();
    }
    return this._value;
  }
}

通过 IdleValue 类实现了以下两个目的:

  • 在 requestIdleCallback 调度之前,即空闲期之前获取 this.formatter,则立即执行方法并返回,此时具有和惰性求值完全一致的性能
  • 在 requestIdleCallback 调度之后获取 this.formatter 则不会阻止用户输入

对于计算成本高的单个属性值,没有理由不使用此策略,除了 Intl.DateTimeFormat 外还包括以下场景:

  • 处理大量值集合
  • 从 localStorage(或 cookie)获取值
  • 运行 getComputedStyle()、getBoundingClientRect() 或任何其他可能需要在主线程上重新计算样式或布局的 API

3. 空闲直到紧急 (Idle-Until-Urgent) 的多任务策略

上述技术对于可以使用单个函数计算其值的场景超级有效,但在某些情况下,逻辑不适合单个函数,或者即使从技术上讲可以,但仍希望将其分解为较小的函数从而不阻塞主线程。

在这种情况下,真正需要的是一个队列,开发者可以在其中调度多个任务以在浏览器空闲时运行,并在需要让出主线程时暂停任务的执行,例如:用户交互。

import {cIC, rIC} from './idle-callback-polyfills.mjs';
import {now} from './lib/now.mjs';
import {queueMicrotask} from './lib/queueMicrotask.mjs';
const DEFAULT_MIN_TASK_TIME = 0;
const isSafari_ = !!(typeof safari === 'object' && safari.pushNotification);
export class IdleQueue {
  constructor({
    ensureTasksRun = false,
    defaultMinTaskTime = DEFAULT_MIN_TASK_TIME,
  } = {}) {
    this.idleCallbackHandle_ = null;
    this.taskQueue_ = [];
    this.isProcessing_ = false;
    this.state_ = null;
    this.defaultMinTaskTime_ = defaultMinTaskTime;
    this.ensureTasksRun_ = ensureTasksRun;
    // 页面卸载 unload 时渲染可以不用执行
    // 但是保存用户状态和发送埋点等不可避免要执行
    this.runTasksImmediately = this.runTasksImmediately.bind(this);
    this.runTasks_ = this.runTasks_.bind(this);
    //  处理浏览器显示隐藏,保证事件必定会执行
    this.onVisibilityChange_ = this.onVisibilityChange_.bind(this);
    if (this.ensureTasksRun_) {
      addEventListener('visibilitychange', this.onVisibilityChange_, true);
      if (isSafari_) {
        addEventListener('beforeunload', this.runTasksImmediately, true);
      }
    }
  }
  pushTask(...args) {
    this.addTask_(Array.prototype.push, ...args);
  }
  unshiftTask(...args) {
    this.addTask_(Array.prototype.unshift, ...args);
  }
  runTasksImmediately() {
    this.runTasks_();
  }
  hasPendingTasks() {
    return this.taskQueue_.length > 0;
  }
  clearPendingTasks() {
    this.taskQueue_ = [];
    this.cancelScheduledRun_();
  }
  getState() {
    return this.state_;
  }
  destroy() {
    this.taskQueue_ = [];
    this.cancelScheduledRun_();

    if (this.ensureTasksRun_) {
      removeEventListener('visibilitychange', this.onVisibilityChange_, true);
      if (isSafari_) {
        removeEventListener(
            'beforeunload', this.runTasksImmediately, true);
      }
    }
  }
  addTask_(arrayMethod, task, {minTaskTime = this.defaultMinTaskTime_} = {}) {
    const state = {
      time: now(),
      visibilityState: document.visibilityState,
    };

    arrayMethod.call(this.taskQueue_, {state, task, minTaskTime});
    this.scheduleTasksToRun_();
  }
  scheduleTasksToRun_() {
    if (this.ensureTasksRun_ && document.visibilityState === 'hidden') {
      //  立即通过微任务调度
      queueMicrotask(this.runTasks_);
    } else {
      if (!this.idleCallbackHandle_) {
        this.idleCallbackHandle_ = rIC(this.runTasks_);
      }
    }
  }
  /**
   * 真正执行任务
   */
  runTasks_(deadline = undefined) {
    this.cancelScheduledRun_();
    if (!this.isProcessing_) {
      this.isProcessing_ = true;
      while (this.hasPendingTasks() &&
          !shouldYield(deadline, this.taskQueue_[0].minTaskTime)) {
        const {task, state} = this.taskQueue_.shift();
        // 拆分小任务并执行 Task 任务
        this.state_ = state;
        task(state);
        this.state_ = null;
      }
      this.isProcessing_ = false;
      if (this.hasPendingTasks()) {
        this.scheduleTasksToRun_();
      }
    }
  }
  cancelScheduledRun_() {
    cIC(this.idleCallbackHandle_);
    this.idleCallbackHandle_ = null;
  }
  //  页面隐藏是执行空闲任务的最佳时机
  onVisibilityChange_() {
    if (document.visibilityState === 'hidden') {
      this.runTasksImmediately();
    }
  }
}

const shouldYield = (deadline, minTaskTime) => {
  if (deadline && deadline.timeRemaining() <= minTaskTime) {
    return true;
  }
  return false;
};

使用起来也超级方便:

const queue = new IdleQueue();
queue.pushTask(() => {
  // 空闲时要运行的任务
});
queue.pushTask(() => {
 // 依赖于上面任务执行的任务
});

IdleQueue 也提供了一种方法用于在需要时立即执行。同样,最后一点也超级重大:不仅仅是由于有时需要尽快计算,而且代码常常会与同步的第三方 API 集成,因此如果想要兼容则也需要能够同步运行任务。

在理想的世界中,所有 JavaScript API 都是非阻塞、异步的,并且由可以随意返回主线程的小块代码组成。 但在现实世界中,由于遗留的代码库或与无法控制的第三方库集成,因此可能一般别无选择,只能同步。

而 Idle-Until-Urgent 模式的一大优势在于:其可以轻松应用于大多数程序,而无需大规模重写架构。

4. Idle-Until-Urgent 的典型用例

4.1 持久化应用程序状态

例如 Redux 应用程序将状态存储在内存中,但也需要将其存储在持久存储(如 localStorage)中,以便下次用户访问页面时可以重新加载。

大多数将状态存储在 localStorage 中的 Redux 应用程序使用防抖技术:

let debounceTimeout;
store.subscribe(() => {
  clearTimeout(debounceTimeout);
  // Schedule the save with a 1000ms timeout (debounce),
  // so frequent changes aren't saved unnecessarily.
  debounceTimeout = setTimeout(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  }, 1000);
});

虽然使用防抖技术有必定的效果,但并不完美,缺点在于无法保证当防抖函数运行时不会阻塞主线程,最终影响用户操作。因此,最好是将 localStorage 写入调度放在空闲时间。同时,借助于 ensureTasksRun 配置,最终保证用户离开页面也能正常写入。

const queue = new IdleQueue({ensureTasksRun: true});
store.subscribe(() => {
  queue.clearPendingTasks();
  queue.pushTask(() => {
    const jsonData = JSON.stringify(store.getState());
    localStorage.setItem('redux-data', jsonData);
  });
});

4.2 Analytics 埋点数据发送

另一个超级适合使用 idle-until-urgent 的用例是发送埋点,下面的示例使用 IdleQueue 类来安排发送分析数据,以确保即使用户在下一个空闲期之前关闭选项卡或离开,也会发送分析数据。

const queue = new IdleQueue({ensureTasksRun: true});

const signupBtn = document.getElementById('signup');
signupBtn.addEventListener('click', () => {
  queue.pushTask(() => {
    ga('send', 'event', {
      eventCategory: 'Signup Button',
      eventAction: 'click',
    });
  });
});

除了确保紧急之外,将任务添加到 IdleQueue 还可以确保不会阻塞响应用户点击所需的任何其他代码。

实际上,一般最好让所有 Analytics 代码(包括初始化代码)放在空闲状态执行。对于 analytics.js 等库而言,其 API 实际上已经是一个队列,因此只需将这些命令添加到 IdleQueue 实例中即可。

const queue = new IdleQueue({ensureTasksRun: true});
queue.pushTask(() => ga('create', 'UA-XXXXX-Y', 'auto'));
queue.pushTask(() => ga('send', 'pageview'));
// 添加到任务队列

参考资料

https://philipwalton.com/articles/idle-until-urgent/

https://github.com/GoogleChromeLabs/idlize

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat

  • 全部评论(0)
最新发布的资讯信息
【系统环境|】创建一个本地分支(2025-12-03 22:43)
【系统环境|】git 如何删除本地和远程分支?(2025-12-03 22:42)
【系统环境|】2019|阿里11面+EMC+网易+美团面经(2025-12-03 22:42)
【系统环境|】32位单片机定时器入门介绍(2025-12-03 22:42)
【系统环境|】从 10 月 19 日起,GitLab 将对所有免费用户强制实施存储限制(2025-12-03 22:42)
【系统环境|】价值驱动的产品交付-OKR、协作与持续优化实践(2025-12-03 22:42)
【系统环境|】IDEA 强行回滚已提交到Master上的代码(2025-12-03 22:42)
【系统环境|】GitLab 15.1发布,Python notebook图形渲染和SLSA 2级构建工件证明(2025-12-03 22:41)
【系统环境|】AI 代码审查 (Code Review) 清单 v1.0(2025-12-03 22:41)
【系统环境|】构建高效流水线:CI/CD工具如何提升软件交付速度(2025-12-03 22:41)
手机二维码手机访问领取大礼包
返回顶部