阅读视图

发现新文章,点击刷新页面。

JavaScript事件循环深度解析:理解异步执行的本质

引言

你是否好奇过这段代码的执行顺序?为什么Promise总是比setTimeout先执行?让我们通过实际代码来揭开JavaScript事件循环的神秘面纱。

什么是Event Loop(事件循环)

**Event Loop(事件循环)**是JavaScript的执行机制,也是代码执行的开始。它是JavaScript引擎处理异步操作的核心机制,解决了单线程语言如何处理并发任务的问题。

Event Loop的本质

事件循环机制让JavaScript能够:

  • 非阻塞执行:处理耗时操作而不冻结用户界面
  • 任务调度:合理安排同步和异步任务的执行顺序
  • 性能优化:通过任务优先级实现高效的资源利用

Event Loop的工作原理

flowchart.png

关键特点

  • 循环执行:事件循环会不断重复这个过程
  • 阶段性处理:每个阶段处理特定类型的任务
  • 微任务优先:每个阶段结束后都会清空微任务队列

核心概念:单线程的智慧

JavaScript为什么是单线程?

// 一个 script 就是一个宏任务开始
// 同步任务
// js 调用栈
// cpu 计算

正如注释所说,JavaScript采用单线程设计有其深刻原因:

  • DOM操作安全:避免多线程同时修改DOM造成冲突
  • 简化编程模型:无需考虑线程同步和锁机制
  • 保证执行顺序:代码按预期顺序执行

单线程的执行策略

同一时刻只做一件事,但JavaScript通过巧妙的任务调度实现了高效执行:

同步任务优先级

  • 同步任务要尽快执行完
  • 渲染页面(重绘重排)
  • 响应用户的交互(优先)

耗时性任务的处理

  • setTimeout/setInterval - 定时器任务
  • fetch/ajax - 网络请求
  • eventListener - 事件监听

这些耗时任务被放入任务队列,避免阻塞主线程。

实战解析一:基础事件循环

让我们分析第一个核心示例:

// 同步任务
console.log('script start');

// 异步任务,宏任务 任务队列
setTimeout(()=>{
    console.log('setTimeout');
},0)

// .then 异步 微任务
// static 静态方法
Promise.resolve().then(()=>{
    console.log('promise');
})

// 同步任务
console.log('script end');

执行结果分析

输出顺序

script start
script end
promise
setTimeout

为什么是这个顺序?

  1. 同步代码优先script startscript end 立即执行
  2. 微任务抢跑Promise.then() 是微任务,优先级高于宏任务
  3. 宏任务排队setTimeout 是宏任务,最后执行

关键理解点

注释中提到的"异步任务 耗时性的任务 任务放到哪个地方?",答案是:

宏任务队列(script脚本就是宏任务):

  • setTimeout/setInterval - 定时器
  • fetch/ajax - 网络请求
  • eventListener - 事件监听
  • I/O操作

微任务队列(紧急的,优先的,同步任务执行完后的补充):

  • Promise.then() - Promise回调
  • MutationObserver - DOM变化监听
  • queueMicrotask - 手动微任务
  • process.nextTick() - Node.js专用

⚠️ 重要注意:Promise构造函数本身是同步执行的,只有Promise的.then().catch().finally()回调才是微任务!

实战解析二:多个Promise的执行

console.log('script start');

const promise1 = Promise.resolve('First Promise');
const promise2 = Promise.resolve('Second Promise');
const promise3 = new Promise(resolve => {
    resolve('Third Promise');
})

promise1.then(value => console.log(value));
promise2.then(value => console.log(value));
promise3.then(value => console.log(value));

setTimeout(()=>{
    console.log('下一把再相见');
},0)

console.log('同步end')

执行结果分析

输出顺序

script start
同步end
First Promise
Second Promise
Third Promise
下一把再相见

深度理解

  • 所有Promise.then都是微任务,会在当前宏任务结束后批量执行
  • 微任务队列会完全清空后才执行下一个宏任务
  • 这就是为什么所有Promise都在setTimeout之前执行

实战解析三:Promise构造函数的陷阱

var p4 = new Promise((resolve,reject)=>{
    // 宏任务
    // 先执行
    setTimeout(()=>{
        resolve(1000)// 将第一个 .then 的回调 放到微任务队列中
        console.log('哈哈哈')
    },0)
})

p4.then((res)=>{
    // 执行第一个.then 的回调 
    console.log('1')// 1
    console.log(res)// 1000
    console.log('2')// 2
}).then(()=>{
    console.log('第二次then')
}) 

执行结果分析

输出顺序

哈哈哈
1
1000
2
第二次then

关键理解

  1. Promise构造函数同步执行:setTimeout立即被调度
  2. resolve触发时机:只有当setTimeout执行时,resolve才被调用
  3. then链式执行:第一个then执行完后,立即执行第二个then

注释"将第一个 .then 的回调 放到微任务队列中"精准地说明了resolve的作用机制。

实战解析四:Node.js环境的特殊性

console.log('Start');
// node 微任务
// process 进程对象
process.nextTick(() => {
  console.log('Process Next Tick');
})
// 微任务
Promise.resolve().then(() => {
  console.log('Promise Resolved');
})
// 宏任务
setTimeout(() => {
  console.log('haha');
  Promise.resolve().then(() => {
    console.log('inner Promise')
  })
}, 0)
console.log('end');

执行结果分析

Node.js输出顺序

Start
end
Process Next Tick
Promise Resolved
haha
inner Promise

Node.js特殊规则

  • process.nextTick 优先级最高,甚至高于Promise.then
  • 这是Node.js独有的微任务,浏览器环境没有

实战解析五:DOM操作与微任务

// event loop 是JS 执行机制,也是代码执行的开始
// html 是第一个BFC 块级格式化上下文 
const target = document.createElement('div');
document.body.appendChild(target);
const observer = new MutationObserver(() => {
  console.log('微任务: MutationObserver');
})
// 监听target 节点的变化
observer.observe(target, {
  attributes: true,
  childList: true,
})

target.setAttribute('data-set', '123');
target.appendChild(document.createElement('span'));
target.setAttribute('style', 'background-color: green;');

执行结果分析

输出

微任务: MutationObserver

深度理解

  • MutationObserver会批量监听DOM变化
  • 多次DOM操作只触发一次回调
  • 在页面渲染前执行,性能优化的关键

注释"DOM 改变在页面渲染前 拿到DOM 有啥改变"完美解释了MutationObserver的价值:

  • 时机优势:在DOM更新后、页面渲染前执行
  • 性能优化:避免多次重绘重排
  • 批量处理:一次性获取所有DOM变化信息
  • 应用场景:组件库的响应式更新、性能监控

实战解析六:queueMicrotask的妙用

console.log('script start');
// 批量更新
// dom树 cssom layout 树 图层合并

queueMicrotask(()=>{
    // DOM 更新了,但不是渲染完了
    // 一个元素的高度 offsetHeight scrollTop
    // 立即重绘重排 耗性能
    console.log('微任务:queueMicrotask');
})

console.log('script end');

执行结果分析

输出顺序

script start
script end
微任务:queueMicrotask

性能优化关键

  • 在DOM更新后、页面渲染前执行
  • 避免"立即重绘重排 耗性能"的问题
  • 批量处理DOM操作的最佳时机

核心规律总结

基于所有代码示例的分析,事件循环遵循以下铁律:

执行优先级

  1. 同步代码 - 立即执行
  2. 微任务队列 - 批量清空
  3. 宏任务队列 - 逐个执行

微任务优先级(Node.js)

  1. process.nextTick - 最高优先级
  2. Promise.then - 标准微任务
  3. MutationObserver - DOM相关微任务
  4. queueMicrotask - 手动微任务

实际应用价值

性能优化

  • 使用微任务进行DOM批量更新
  • 避免不必要的重绘重排
  • 合理调度异步任务

代码调试

  • 理解异步代码的执行时机
  • 预测代码输出顺序
  • 解决时序相关的bug

结语

通过对这些实际代码的深度解析,我们不仅理解了事件循环的执行机制,更重要的是掌握了为什么会产生这样的执行结果。每一行注释都蕴含着深刻的理解,每一个输出顺序都有其必然的逻辑。

掌握事件循环,就是掌握了JavaScript异步编程的精髓。在实际开发中,这些知识将帮助你写出更高效、更可预测的代码。

JavaScript闭包实战:解析节流函数的精妙实现 🚀

👋 大家好,今天我们将深入解析一个实际项目中的节流函数实现,探讨闭包与高阶函数如何巧妙结合来提升前端性能。

🔍 为什么需要函数节流?

在前端开发中,我们经常遇到高频触发的事件,如滚动、输入、鼠标移动等。如果每次事件触发都执行复杂操作,会导致性能问题:

// 未优化的输入事件
inputElement.addEventListener('keyup', function(e) {
    // 每次按键都会触发请求,可能导致:
    // 1. 服务器压力过大
    // 2. 页面卡顿
    // 3. 不必要的计算资源浪费
    ajax(e.target.value);
});

💡 核心概念:节流(throttle)是指在一定时间内,无论事件触发多少次,函数只会执行一次。

🌟 高阶函数:函数式编程的精髓

在深入节流函数前,我们需要理解高阶函数的概念:

高阶函数是指接收函数作为参数和/或返回函数作为结果的函数。

节流函数是高阶函数的典型应用:它接收一个函数作为参数,并返回一个增强版的新函数。这种模式使我们能够在不修改原函数的情况下,为其添加节流功能。

🧩 深入解析:节流函数的实现

让我们逐行分析这个精巧的节流函数实现:

function throttle(fn, delay) {
    let last,       // 上次执行的时间
        deferTimer; // 延迟定时器ID
    
    return function(...args) {
        let that = this;  // 保存上下文
        let now = +new Date();  // 当前时间戳
        
        if (last && now < last + delay) {
            // 如果距离上次执行不足delay时间
            clearTimeout(deferTimer);
            deferTimer = setTimeout(function() {
                last = now;
                fn.apply(that, args);
            }, delay);
        } else {
            // 首次执行或者距离上次执行已超过delay
            last = now;
            fn.apply(that, args);
        }
    };
}

代码解析:闭包与高阶函数的完美结合

  1. 高阶函数特性

    • 接收函数参数:throttle接收原始函数fn作为参数
    • 返回函数:返回一个包装后的新函数
    • 函数增强:为原函数添加了节流功能,而不改变其行为
  2. 闭包的关键作用

    • 状态保持:lastdeferTimer变量在返回的函数中持续存在
    • 私有变量:这些变量对外部不可见,避免了全局污染
    • 上下文保存:通过that = this捕获调用上下文
  3. 巧妙的执行逻辑

    • 条件判断:if(last && now < last + delay)检查是否在冷却期内
    • 定时器复用:每次触发都会清除之前的定时器,确保只有最后一次调用生效
    • 首次立即执行:首次调用或冷却期过后立即执行函数

与简单节流实现的对比

这个实现比简单的节流函数更加高级,它结合了"立即执行"和"延迟执行"两种模式:

  • 首次触发立即执行
  • 冷却期内的触发会被延迟到冷却期结束
  • 最后一次触发一定会执行,不会丢失

这种实现确保了良好的用户体验 - 既有即时响应,又不会过度执行。

🔧 this指向问题:闭包中的上下文处理

注意代码中的这一行:let that = this;

这行代码解决了JavaScript中常见的this指向问题。在setTimeout回调中,this默认指向全局对象(window),而不是触发事件的元素。通过闭包捕获当前上下文,我们确保了函数在正确的上下文中执行。

// 错误示例:没有保存上下文
function badThrottle(fn, delay) {
    let timer;
    return function(...args) {
        if (!timer) {
            timer = setTimeout(function() {
                // 这里的this指向window,而非调用对象
                fn.apply(this, args); // 错误的上下文
                timer = null;
            }, delay);
        }
    };
}

// 正确示例:4.html中的实现
deferTimer = setTimeout(function() {
    last = now;
    fn.apply(that, args); // 正确的上下文
}, delay);

📊 实际应用:输入事件优化

看看代码中如何应用节流函数优化输入事件:

// 原始函数
const ajax = function(content) {
    // 这里可能是复杂的AJAX请求
    console.log('ajax request', + content);
}

// 创建节流版本
let throttleAjax = throttle(ajax, 200);

// 应用到输入事件
inputC.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value);
});

这个实现优雅地解决了以下问题:

  1. 减少请求频率:无论用户输入多快,最多每200ms发送一次请求
  2. 保留即时反馈:首次输入立即响应,提供良好用户体验
  3. 确保最终结果:用户停止输入后的最后一次输入会被处理

🧪 深入思考:节流函数的工作原理

让我们通过一个时间轴来理解这个节流函数的工作原理:

时间轴: 0ms --- 100ms --- 200ms --- 300ms --- 400ms
事件触发:  ↑       ↑        ↑                 ↑
实际执行:  ↑                 ↑                 ↑
  1. 0ms:首次触发,立即执行(last为null,走else分支)
  2. 100ms:触发事件,但距离上次执行不足200ms,设置定时器
  3. 200ms:又触发事件,清除之前的定时器,重新设置定时器
  4. 300ms:没有事件触发,但之前设置的定时器执行
  5. 400ms:触发事件,已经过了200ms,立即执行

这种机制确保了:

  • 函数不会过于频繁执行(最少间隔200ms)
  • 不会丢失用户的最后一次操作
  • 首次操作能立即得到响应

🔍 闭包在节流函数中的核心价值

节流函数是闭包应用的经典案例,闭包在其中提供了三个关键能力:

  1. 状态记忆:记住上次执行时间和定时器ID
  2. 函数增强:在不修改原函数的情况下添加新功能
  3. 上下文保存:确保函数在正确的上下文中执行

如果没有闭包,我们将无法实现这种优雅的节流功能,可能需要使用全局变量或复杂的类设计。

💡 高阶函数的威力

高阶函数是函数式编程的核心概念,它使我们能够:

  1. 抽象行为模式:将"节流"这种行为模式抽象为可重用函数
  2. 分离关注点:业务逻辑(ajax请求)与控制逻辑(节流)分离
  3. 代码复用:一次编写节流函数,到处应用

在我们的例子中,throttle函数可以应用于任何需要节流的函数,而不仅仅是ajax请求。

📝 总结:闭包与高阶函数的完美结合

通过对节流函数的深入分析,我们看到了闭包和高阶函数如何协同工作,创造出优雅而强大的解决方案。这种实现:

  • 利用高阶函数实现了行为增强和关注点分离
  • 通过闭包保持状态和上下文
  • 巧妙平衡了性能用户体验

掌握这种模式,将帮助你写出更加优雅、高效的JavaScript代码,应对各种前端性能挑战。

🌈 实践建议:在处理高频事件时,优先考虑使用节流函数;理解闭包和高阶函数的结合,能够帮助你更好地实现各种函数增强模式。

❌