js的输入输出总结

210 阅读20分钟

深浅拷贝

const value = { number: 10 };
const multiply = (x = { ...value }) => {
  console.log((x.number *= 2))
}
multiply();
multiply();
multiply(value);
multiply(value);
// 20 20 20 40

第一次和第二次调用 multiply 函数时:

因为调用函数时没有传入参数,所以函数参数 x 会使用默认值。这个默认值是通过 {...value } 这种对象展开语法(浅拷贝)创建的新对象,每次调用都会重新创建这么一个独立的新对象,它的初始属性值来源于 value 对象,但后续对其操作不会影响到 value 本身,这两次调用函数内部操作的都是各自独立的新对象,走的就是默认值对应的逻辑。

第三次和第四次调用 multiply 函数时:

明确传入了 value 作为参数,此时函数内部的 x 就直接指向了传入的这个 value 对象了,函数里对 x 的操作就等同于直接对外部传入的 value 对象进行操作,不再是基于默认值去创建新对象然后操作了,所以这两次走的是传入外部 value 作为 x 参数的逻辑。

reduce函数

[1, 2, 3, 4].reduce((x, y) => console.log(x, y));
[1, 2, 3, 4].reduce((x, y) => console.log(x, y), 0);
[1, 2, 3, 4].reduce((x, y) => {
  console.log(x, y)
  return x + y
}, 0)

语法:

reduce(callbackFn)
reduce(callbackFn, initialValue)

回调函数(callbackFn)的参数

  • accumulator(累加器) :它是累计的结果,是上一次调用回调函数时返回的累积值或者是初始值(如果提供了的话)。在求和的例子中,它就像是一个不断累加的总和。
  • currentValue(当前值) :它是当前正在处理的数组元素。在每次调用回调函数时,currentValue 会从数组中按顺序取出一个元素。
  • currentIndex(可选的当前索引) :它表示当前元素在数组中的索引位置。这个参数是可选的,如果在回调函数中不需要知道元素的位置,就可以忽略它。

initialValue可选

  • 第一次调用回调时初始化 accumulator 的值。如果指定了 initialValue,则 callbackFn 从数组中的第一个值作为 currentValue 开始执行。如果没有指定 initialValue,则 accumulator 初始化为数组中的第一个值,并且 callbackFn 从数组中的第二个值作为 currentValue 开始执行。在这种情况下,如果数组为空(没有第一个值可以作为 accumulator 返回),则会抛出错误。

第一个reduce函数调用,没有初始值,有返回值,只是返回值是console.log函数的返回值,而console.log函数本身返回undefined。所以第一次x赋值为数组的第一个值为1,y赋值为数组的第二个值为2,第二次x为undefined,y为数组的第三个值为3,依次类推,最终输出为

1 2
undefined 3
undefined 4

第二个reduce函数调用,有初始值,只是返回值是console.log函数的返回值,而console.log函数本身返回undefined。所以第一次x赋值为0,y赋值为数组的第一个值为1,第二次x为undefined,y为数组的第二个值为2,依次类推,最终输出为

0 1
undefined 2
undefined 3
undefined 4

第三个reduce函数调用,有初始值,且有返回值,所以第一次x赋值为0,y赋值为数组的第一个值为1,有返回值x+y,所以第二次x为第一次调用回调函数返回的值,即第一次x+y的值为1,y为数组的第二个值为2,依次类推,最终输出为

0 1
1 2
3 3
6 4

注意:console.log 函数本身返回 undefined

解构赋值

(1)

const { firstName: myName } = { firstName: 'Lydia' };
console.log(myName); 
console.log(firstName);

对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。

这里firstName是属性,而myName是一个变量,它的值作为firstName的属性值,myName被赋值为了Lydia,第一次输出Lydia,但是第二次输出firstNamefirstName这个变量根本未定义,所以报错ReferenceError: firstName is not defined

(2)

const getList = ([x, ...y]) => [x, y]
const getUser = user => { name: user.name, age: user.age }
const list = [1, 2, 3, 4]
const user = { name: "Lydia", age: 21 }
console.log(getList(list)) 
console.log(getUser(user))

调用getList函数,根据解构赋值,输出[1, [2, 3, 4]]

调用getList函数,这里需要注意,箭头函数使用了 {} ,箭头函数中,如果函数体部分使用花括号 {} 包裹,那么它被视为一个代码块,在代码块内的语句就需要遵循语句的规范,比如需要使用 return 关键字来返回值(如果有返回值的话)。

此时里面的语句应该遵循代码代码块的规范,很明显语句不符合规范,所以报错ReferenceError,如果这个箭头函数想返回一个对象,需要改写成如下这种形式

const getUser = user => {
  return {
    name: user.name,
    age: user.age
  }
}

Promise

(1)

const myPromise = () => Promise.resolve('I have resolved!');
function firstFunction() {
  myPromise().then(res => console.log(res));
  console.log('second');
}
async function secondFunction() {
  console.log(await myPromise());
  console.log('second')
};

firstFunction();
secondFunction();

输出:

second
I have resolved!
I have resolved!
second

(2)

// A.js
export async function initialize() {
  console.log('Initializing module A');
}
console.log('Module A loaded');


// B.js
import { initialize } from './A.js'
console.log('Module B loaded');
initialize().then(() => { console.log('Module A initialized from B') });


//index.js
import { initialize } from './A.js'
import './B.js'

console.log('Index module loaded');
initialize().then(() => { console.log('Module A initialized from index') })

执行index.js,执行结果:

(1)初始加载 index.js

  • 加载 index.js,同步执行,遇到 import 时按依赖顺序加载模块。

(2)加载 A.js

  • A.js 首次加载,以下内容依次执行:

    • 打印:Module A loaded
    • 定义 initialize 函数(但未执行)。
  • 同步任务执行完毕,A.js 加载完成。

(3) 加载 B.js

  • B.js 加载时,导入 A.js 中的 initialize(此时从缓存中取出,无需重新执行 A.js 的顶层代码)。

  • 打印:Module B loaded

  • 调用 initialize

    • initialize 返回一个 Promise,其 .then 方法将回调函数加入微任务队列
    • 打印:Initializing module A(这是同步任务)。

(4)返回到 index.js

  • B.js 加载完成后,返回到 index.js

    • 打印:Index module loaded

    • 再次调用 initialize

      • initialize 返回一个新的 Promise,其 .then 方法的回调函数再次加入微任务队列
      • 打印:Initializing module A(第二次调用,属于同步任务)。

(5)执行微任务队列:

  • 主线程同步任务执行完毕后,开始处理微任务:

    1. 执行第一个微任务(B.jsthen 回调):

      • 打印:Module A initialized from B
    2. 执行第二个微任务(index.jsthen 回调):

      • 打印:Module A initialized from index

注意知识点:

(1)async 函数的返回值是一个 Promise 对象

  • 返回值

    • async 函数的返回值是一个 Promise 对象。

    • 即使函数内部没有显式返回 Promise,它也会自动包装成 Promise

      • 如果 async 函数显式返回值,例如 return value,相当于返回 Promise.resolve(value)
      • 如果抛出异常,则返回 Promise.reject(error)
  • 内部 await 的作用

    • await 用于等待一个 Promise 的结果,当前 async 函数会暂停执行,直到该 Promise 完成。
    • 不含 awaitasync 函数相当于一个立即返回 Promise.resolve() 的函数。
  • then 的执行

    • 调用 async 函数后,可以链式调用 .then,处理 Promise 的结果。

(2)ES6 模块加载机制

1. 模块的加载

-   当你使用 `import` 导入一个模块时,无论你只导入了模块的某个部分(比如一个函数 `initialize`),整个模块的顶层代码都会被执行一次。

2. 模块的顶层代码

所谓顶层代码,是指模块中直接书写在 export 或函数之外的代码。

A.js 中,这段代码:

console.log('Module A loaded');

就是顶层代码。

3. 模块的执行与缓存

  • 第一次加载:当模块第一次被导入时,所有顶层代码都会执行,且模块的导出内容会被缓存。
  • 后续加载:如果该模块再次被导入,则直接使用缓存,不会再次执行顶层代码。

(3)

Promise.resolve().then(() => {
  console.log(0)
  return Promise.resolve(4)
}).then((res) => {
  console.log(res);
})

Promise.resolve().then(() => {
  console.log(1)
}).then(() => {
  console.log(2);
}).then(() => {
  console.log(3);
}).then(() => {
  console.log(5);
}).then(() => {
  console.log(6);
})

首先对return Promise.resolve(4)进行拆解

Promise.resolve().then(() => {
  console.log(0)
  return Promise.resolve(4)
})

会被转化为

Promise.resolve().then(() => {
  console.log(0)
  return 4
}).then().then().then((res) => {
  console.log(res)
})

分析执行顺序:

1. 初始化主线程任务

  • 两个 Promise.resolve() 的第一个 .then 被加入到微任务队列中,按照书写顺序:

    1. 第一个 Promise.resolveconsole.log(0)
    2. 第二个 Promise.resolveconsole.log(1)

2. 第一次微任务队列执行

按顺序执行:

第一个 .then(第一组 Promiseconsole.log(0)):

  • 打印:0
  • 返回 4,将其 .then 回调加入微任务队列。

第二个 .then(第二组 Promiseconsole.log(1)):

  • 打印:1

此时微任务队列新增任务:

  • 第一组 return 4.then 回调。
  • 第二组的下一个 .then 回调:console.log(2)

3. 第二次微任务队列执行

按顺序执行:

第一组 .then 回调then()):

第二组 .then 回调console.log(2)):

  • 打印:2

此时微任务队列新增任务:

  • 第一组 .then.then 回调。
  • 第二组的下一个 .then 回调:console.log(3)

4. 第三次微任务队列执行

按顺序执行:

第一组 .then 回调then()):

第二组 .then 回调console.log(3)):

  • 打印:3

此时微任务队列新增任务:

  • 第一组 .thenconsole.log(4) 回调。
  • 第二组的下一个 .then 回调:console.log(5)

5. 第四次微任务队列执行

按顺序执行: 第一组 .then 回调console.log(4)):

  • 打印:4

第二组 .then 回调console.log(5)):

  • 打印:5

此时微任务队列新增任务:

  • 第二组的下一个 .then 回调:console.log(6)

6. 第五次微任务队列执行

按顺序执行:

第二组 .then 回调console.log(6)):

  • 打印:6

最终结果:0 1 2 3 4 5 6

注意知识点:

(1)同一批次Promise 回调(.then())会按顺序加入微任务队列并执行。每个 Promise 中的 .then() 都会被加入队列,执行顺序遵循它们被注册的顺序。

跳过的 .then() :如果 .then() 没有回调函数,它会跳过但仍会将值传递给下一个 .then(),所以它们依然会加入微任务队列。

(2)当 then() 方法没有参数时,它的行为是跳过当前 .then() 并直接将其上一个 .then() 传递下去,返回一个新的 已解决的 Promise,并且该 Promise 会继续链式传递值。

具体来说,当 .then() 没有传入回调函数时:

  1. 它会自动返回一个 Promise.resolve(),并将前一个 .then() 的返回值传递给下一个 .then()
  2. 相当于该 .then() 什么都不做,只是将值原封不动地传递下去。

普通函数和箭头函数

(1)经典Foo函数问题1

var getName = () => { alert(4); }

function getName() { alert(5); }

getName()

分析:考察变量提升

即所有声明变量或声明函数都会被提升到当前函数的顶部。

console.log(x);//输出:function x(){}
var x=1;
function x(){}

这个会被转化为

var x
function x() { }
console.log(x);//输出:function x(){}
x = 1;

所以输出的是function x(){},x最终会被覆盖为1

所以上面的getName()的调用结果为弹出4,因为getName最终被覆盖为() => { alert(4);

(2)经典Foo函数问题2

function Foo() {
    getName = function () { alert (1); };
    return this;
}
Foo.getName = function () { alert (2);};
Foo.prototype.getName = function () { alert (3);};
var getName = function () { alert (4);};
function getName() { alert (5);}

Foo.getName();
getName();
Foo().getName();
getName();
new Foo.getName();
new Foo().getName();
new new Foo().getName();

具体详细回答见www.cnblogs.com/xxcanghai/p…

结果:2 4 1 1 2 3 3

第三个弹出2,为啥?

  • 首先执行Foo(),在Foo函数内部,有getName = function () { console.log(1); };这行代码,这里的getName没有使用var等关键字声明,它实际上是在全局作用域下隐式地创建了一个全局变量(或者说是修改了已经存在的getName变量,如果有的话),并将其赋值为一个新的函数,这个函数执行时会输出 1
  • 然后Foo函数返回了this,因为是全局调用,所以this指向全局对象(在浏览器环境中就是window对象)。由于前面的操作已经把全局的getName修改为新的函数了,所以当通过返回的this(也就是全局对象)去调用getName方法时,实际上调用的就是刚刚被重新赋值的那个会输出 1的函数,因此这里会输出 1

隐式全局变量

当在函数内部,直接给一个没有使用 varletconst 等关键字声明的变量赋值时,JavaScript 会把这个变量当作全局变量来处理(如果这个变量名在全局作用域中不存在,就会创建它;如果已经存在,就会修改它的值)但是只有在调用函数,执行到函数内部那段代码时,才会创建隐式的全局变量,这也就是为啥第二问是输出的4

第五个弹出2,为啥?

这里涉及到运算符优先级的问题,new 操作符和点号(.)操作符同时出现时,点号(.)的优先级更高,所以代码实际执行顺序相当于 new (Foo.getName)();,也就是先获取 Foo 函数对象上的 getName 静态方法,然后使用 new 操作符去调用这个方法构造一个新对象。而 Foo.getName 对应的方法执行时会弹出 2,所以这里弹出 2

第七个弹出3,为啥?

new new Foo().getName(); 的执行顺序就是先通过 new Foo() 创建一个实例对象,然后在实例对象的原型链上找到 getName 方法,最后再用外层的 new 操作符基于这个 getName 方法来创建一个新对象,而 getName 方法执行时弹出 3,所以最终整体的执行结果就是弹出 3 。可以理解为执行顺序是new (new Foo().getName)()

new操作符的行为

-   当使用`new`关键字来调用一个函数(在这个例子中是`Car`函数)时,它会经历以下几个步骤:

    -   创建一个新的空对象。
    -   将这个新对象的`__proto__`属性(在 ES6 的规范中是`[[Prototype]]`内部属性)设置为构造函数(这里是`Car`)的`prototype`属性。
    -   将这个新对象作为`this`上下文传递给构造函数,并执行构造函数。
    -   如果构造函数没有显式地返回一个非原始值(对象、函数、数组等),那么`new`表达式的值就是这个新创建的对象。

这两种写法是等价的

function Car() {
  console.log(1);
}
var car1 = new Car; // 虽然没有在函数名后面加括号`()`,但是 JavaScript 会自动在内部为你调用这个函数,就好像你写了`var car1 = new Car();`一样。
var car2 = new Car();

React中的setState

import React, { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()
    this.state = {
      val: 0
    }
  }

  componentDidMount() {
    this.setState({ val: this.state.val + 1 })
    console.log('第一次', this.state.val)
    // 第 1 次 log
    this.setState({ val: this.state.val + 1 })
    console.log('第二次', this.state.val)
    // 第 2 次 log
    this.setState({ val: this.state.val + 1 })
    console.log('第三次', this.state.val)
    // 第 3 次 log
  }

  render() {
    return (
      <div>{ this.state.val }</div>
    )
  }
}

关键点分析:

  1. componentDidMount 是一个生命周期方法,它会在组件挂载后立即执行,因此所有的 setState 调用将在组件加载完毕后触发。

  2. setState 的异步性

    • setState 是异步的,每次调用 setState 并不会立即更新 this.state,而是将状态更新请求排入队列,等到事件处理函数执行完毕之后,React 才会批量更新状态。
    • componentDidMount 方法中,每次调用 setState 后,console.log 打印的 this.state.val 依然是更新前的值,因为 React 还没有执行状态更新。
  3. this.state.val 是闭包中的值

    • componentDidMount 执行过程中,每次 setState 调用后,console.log 打印的是当前事件循环中的值,而不是更新后的值。
    • 由于 setState 是异步的,所以每次调用 setState 后,this.state.val 依然是更新前的值,直到 React 批量更新并重新渲染组件。

执行顺序:

  1. 第一次 setState 调用
  • this.state.val 初始值是 0

  • 调用 this.setState({ val: this.state.val + 1 }) 后,React 将这个更新请求排入更新队列,但不会立即更新 this.state.val

  • 此时 console.log('第一次', this.state.val) 打印的还是 0,因为更新还未完成。

  1. 第二次 setState 调用
  • 同样,调用第二次 setState 时,this.state.val 依然是 0(React 尚未完成第一次的状态更新)。

  • console.log('第二次', this.state.val) 打印的仍然是 0

  1. 第三次 setState 调用
  • 同样地,第三次调用 setState 时,this.state.val 依然是 0(React 尚未完成前两次的状态更新)。
  • console.log('第三次', this.state.val) 打印的仍然是 0

解释为什么 div 中显示 1

React 批量更新并重新渲染

  • React 将所有 setState 合并成一次更新,并且会在下一轮渲染时应用状态更新。
  • 但是,React 只有在所有 setState 调用完成后才会重新渲染组件。因此,组件会在状态更新完成后重新渲染,<div> 中显示的 val1,而不是 3,这是因为在 React 内部处理 setState 时,会将多个更新合并成一次更新,并根据最新的合并状态值来渲染组件。

如何避免

import React, { Component } from 'react';

export default class App extends Component {
  constructor() {
    super();
    this.state = {
      val: 0
    };
  }

  onClick = () => {
    this.setState(prevState => ({ val: prevState.val + 1 }));
    console.log('第一次', this.state.val); // 打印的是当前状态值

    this.setState(prevState => ({ val: prevState.val + 1 }));
    console.log('第二次', this.state.val); // 打印的是当前状态值

    this.setState(prevState => ({ val: prevState.val + 1 }));
    console.log('第三次', this.state.val); // 打印的是当前状态值
  }
  
  render() {
    return (
      <div onClick={this.onClick}>{this.state.val}</div>
    );
  }
}

使用函数式 setState

函数式 setState 允许你基于前一个状态值来计算新的状态。React 会自动传递当前的状态值(即 prevState),你可以在更新中直接使用这个值,而不是依赖于外部的 this.state 值。

  • prevState => ({ val: prevState.val + 1 }) :每次调用 setState 时,都会传入一个回调函数,该回调函数接收 prevState(前一个状态)作为参数,使用它来计算下一个状态。这样就可以确保每次更新都基于最新的状态,而不是基于当前 this.state.val

  • 这样,即使 setState 是异步的,你每次都能确保基于最新的状态更新,而不会受到闭包的影响。

为什么这种方法有效?

  1. 异步状态更新:因为 setState 是异步的,React 会将多次更新合并成一个批量更新,而你使用函数式更新时,React 会在每次 setState 调用时都根据最新的 prevState 来计算新的状态值。
  2. 避免闭包问题:函数式更新确保了每次 setState 都是基于上一个更新后的最新状态,而不是基于事件处理函数开始时的状态值,避免了闭包导致的状态值不一致的问题。

setState同步执行

class App extends React.Component {
 constructor() {
   super()
   this.state = {
     val: 0
   }
 }

 onClick = () => {
   setTimeout(() => {
     this.setState({ val: this.state.val + 1 })
     console.log('第一次', this.state.val)
     this.setState({ val: this.state.val + 1 })
     console.log('第二次', this.state.val)
     this.setState({ val: this.state.val + 1 })
     console.log('第三次', this.state.val)
   }, 0)
 }

 render() {

   const { val } = this.state

   console.log('组件渲染了,count的值为' + val)

   return <div onClick={this.onClick}>{val}</div>
 }
}

export default App

image.png

当第一次执行了onClick函数的时候,组件就会重新渲染,这意味着在setTimeout中的setState函数仍然还是同步执行的,但是在执行完setState函数之后,当前作用域中的count并没有发生改变,所以在当前作用域下获取count的值仍然为旧的状态,但是在新渲染的组件作用域中就可以访问到最新的状态,count打印出来是1;

对象作为键

let a = {}
let b = { key: 'b' }
let c = { key: 'c' }
a[b] = 123
a[c] = 456
console.log(a[b]);

在JavaScript中,对象属性的键本质上是字符串或Symbol类型。当使用对象作为属性名时,会通过toString()方法隐式转换为字符串"[object Object]"。因此,代码中a[b]a[c]实际上访问的是同一个键"[object Object]",导致后者覆盖前者:

let a = {};
let b = { key: 'b' };
let c = { key: 'c' };

// 以下两行等价于:
// a["[object Object]"] = 123;
// a["[object Object]"] = 456;
a[b] = 123;
a[c] = 456;

console.log(a[b]); // 输出456(因为键相同,后赋值覆盖前值)

关键点解析

对象转字符串b.toString()c.toString()的结果均为"[object Object]",导致属性名冲突

属性覆盖机制:后设置的属性值会覆盖同键的旧值

运算符转换

console.log(+'1' + '2' + '1');
console.log('A' - 'B' + '1');

根据JavaScript的类型转换规则,两个表达式的输出结果如下:

console.log(+'1' + '2' + '1'); // 输出: 121
console.log('A' - 'B' + '1'); // 输出: NaN1

第一个表达式:+'1' + '2' + '1'

  1. 一元加操作符(+)​:第一个操作数是+'1',这里的加号是一元操作符,会将字符串'1'转换为数值1。因此,表达式变为 1 + '2' + '1'
  2. 字符串拼接:在JavaScript中,当加法操作中有一个操作数是字符串时,整个表达式会执行字符串拼接。因此,1 + '2' 会变成字符串 '12',接着再加上 '1',结果为 '121'。

所以,第一个表达式的输出应该是 '121'

第二个表达式:'A' - 'B' + '1'

  1. 减法操作符(-)​:第一个操作数是'A',第二个是'B'。减法操作符会尝试将操作数转换为数值。由于'A'和'B'无法转换为有效的数值,结果为 NaN
  2. 加法操作:接下来,NaN + '1'。此时,加法操作符的一边是 NaN,另一边是字符串 '1'。根据规则,如果有一个操作数是字符串,整个表达式会执行字符串拼接。因此,NaN 会被转换为字符串 'NaN',结果为 'NaN1'。

所以,第二个表达式的输出应该是 'NaN1'

定时器setInterval偏差

const startTime = new Date().getTime(); // 1000 
let count = 0;
const interval = setInterval(function () {
  count++
  console.log(new Date().getTime() - (startTime + count * 1000) + 'ms')
  if (count == 10) {
    clearInterval(interval);
  }
}, 1000)

输出结果:

image.png

image.png

可以看到,每次打印的输出结果都是随机的

具体原因及分析:

一、单线程与任务队列的阻塞

JavaScript 是单线程的,所有同步任务和异步回调(包括 setInterval)都在同一线程中排队执行。当主线程被其他任务(如 UI 渲染、复杂计算、网络请求等)占用时,setInterval 的回调会被推迟到主线程空闲时执行。例如:

  • 长任务阻塞:如果某次回调函数内部有耗时操作(如循环或 DOM 操作),后续回调会积压在任务队列中,导致实际执行时间远落后于预期
  • 异步任务延迟:即使回调本身不耗时,主线程其他任务的执行也会占用时间,导致 setInterval 的回调无法按时触发。

二、setInterval 的固有缺陷

setInterval 并非“精准计时器”,而是周期性将回调推入任务队列。其设计机制导致以下问题:

  1. 任务堆积
    如果回调执行时间超过设定的间隔(如 1000ms),下一次回调会被直接加入队列,导致两次回调之间的间隔小于预期,甚至出现连续执行的情况。

  2. 误差累积
    每次回调的实际执行时间与理想时间的差值会逐渐积累。例如: 每次回调的实际执行时间与理想时间的差值会逐渐积累。例如:

    javascript
    // 假设某次回调因主线程阻塞延迟了 200ms
    // 下一次回调的触发时间仍按 1000ms 计算,而非根据实际执行时间调整
    

三、浏览器优化与限制

  1. 最小延迟限制
    现代浏览器对 setInterval 的最小延迟有隐性限制(通常为 4ms)。若设置时间小于 4ms(如 setInterval(fn, 0)),实际延迟会被强制调整为 4ms。

  2. 后台标签页降频
    如果页面处于非活动状态(如切换到其他标签页),浏览器会降低定时器执行频率(最低至 1 秒一次),以节省资源。

  3. 系统调度影响
    操作系统和硬件的性能差异(如 CPU 负载、电池节能模式)也会导致定时器触发时间不稳定。

四、代码示例中的具体表现

代码中,console.log 输出的时间差是 ​当前时间与预期时间(startTime + count * 1000)的偏差。由于上述原因,每次回调的实际触发时间会与理想时间存在不可控的偏移,具体表现为:

  • 首次回调可能接近准确(如延迟 10ms)。
  • 后续回调的延迟逐渐增大(如 50ms → 200ms → 500ms),甚至因主线程阻塞出现跳跃式增长