多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

前端进阶硬核原理:闭包、事件循环与this绑定底层机制详解

前端进阶硬核原理:闭包、事件循环与this绑定底层机制详解 前几天有个同事跑来问我一个问题为什么同一个函数换个地方调用this就变了为什么明明已经写了很多业务代码遇到闭包相关的内存泄漏还是手足无措我反手甩给他一句话进阶技巧从来不是靠背 API 背出来的而是靠理解底层原理推出来的。这篇内容不是写给刚学会console.log的初学者看的而是写给那些已经能写出能跑的代码、但遇到复杂问题就开始发怵的开发者。我会从闭包、原型链、事件循环、this绑定、性能优化这几个前端绕不开的硬核话题入手把每个技巧背后的运行机制拆开揉碎再附上我实际踩坑和排查问题的完整记录。你读完不一定能成为架构师但至少面对这些概念时不会再靠猜。1. 闭包先搞懂它再谈进阶闭包这个术语几乎每个前端面试题里都会出现但真正能把它讲清楚的人不多。我们先不看定义直接从 JavaScript 引擎的执行机制说起。1.1 闭包到底是怎么产生的闭包的产生和函数的词法作用域有关。JavaScript 在函数定义时就确定了它的作用域链而不是在调用时。当一个函数内部定义了另一个函数并且内部函数引用了外部函数的变量外部函数执行完毕后内部函数依然持有对那个变量的引用。这个“持有引用”的组合体就是闭包。用一个代码来看最直观function outer() { let count 0; function inner() { count; console.log(count); } return inner; } const fn outer(); fn(); // 1 fn(); // 2 fn(); // 3按直觉说outer()执行完局部变量count应该被垃圾回收了。但为什么fn()每次都能访问到最新的count原因就在于inner的函数对象里保存着一个[[Environment]]内部槽位它指向outer的整个词法环境。outer虽然执行完了但它的词法环境并没有被销毁因为inner还在引用它。你可以把这个机制理解成“借书”outer是个图书馆count是其中一本书inner是借书人。图书馆下班了函数执行结束但借书人手上还拿着钥匙词法环境引用所以他随时可以进去看书甚至还能在书上做笔记修改变量值。我在项目中真正用到闭包的场景主要集中在模块化封装和防抖节流上。比如实现一个计数器或者做一个只能被内部修改的私有状态用闭包比用全局变量优雅得多不会污染全局作用域。1.2 闭包在真实项目中的三个经典用途闭包最有价值的三个落地场景我逐个说下实际写法。私有变量封装。ES2022 虽然有了#private语法但很多老项目还在用闭包做状态隐藏function createCounter(start 0) { let value start; return { increment() { value; return value; }, decrement() { value--; return value; }, getValue() { return value; } }; }value完全与外部隔离只能通过暴露的接口操作。这在封装插件、组件内部状态时非常实用。函数记忆化。利用闭包保存计算结果避免重复执行耗时的计算function memoize(fn) { const cache new Map(); return function(...args) { const key JSON.stringify(args); if (cache.has(key)) { return cache.get(key); } const result fn.apply(this, args); cache.set(key, result); return result; }; } const fib memoize(function(n) { if (n 1) return n; return fib(n - 1) fib(n - 2); });这个版本的斐波那契数列计算第 40 项几乎秒出而不加记忆化的话浏览器直接卡死。闭包在这里的作用就是让cache在递归调用中持续存活。一次性初始化。很多包比如连接池、单例配置只需要执行一次初始化后续全部复用同一个结果function createDBConnection() { let connection; return function() { if (!connection) { // 这里写真正的初始化逻辑 connection { id: Date.now() }; } return connection; }; }1.3 闭包的内存使用误区闭包不是银弹它最大的坑就是内存泄漏。这里的泄漏不是指闭包本身产生内存垃圾而是闭包意外地被长期持有导致它的词法环境一直无法释放。我遇到过一个真实案例在 React 组件里用useEffect注册了一个滚动监听函数函数内部引用了组件状态。由于监听函数一直在全局注册组件卸载后监听没有移除每次滚动都会触发一个持有旧状态的闭包最终导致页面越来越卡。排查思路很简单在 Chrome 的 Memory 面板里抓取 JS Heap 快照搜索那个监听函数的函数名就能看到它是否被全局对象引用。解决方式是在useEffect的清理函数里移除监听useEffect(() { const handleScroll () { console.log(scrolling...); }; window.addEventListener(scroll, handleScroll); return () { window.removeEventListener(scroll, handleScroll); }; }, []);注意用addEventListener注册的函数移除时必须是同一个函数引用。如果直接传匿名函数你将永远无法移除它。还有一个小技巧在闭包里引用大量数据时用完记得手动置为null给垃圾回收一个明确的信号。2. 原型链与继承别再死记硬背很多开发者对原型链的理解停留在“prototype是函数上的属性”这个层面遇到Object.create和class混用时就晕了。其实原型链的底层原理藏在 JavaScript 对象的一个内部槽位[[Prototype]]里。2.1 从构造函数到原型对象每个函数都有一个prototype属性指向一个对象。当你用new调用函数时JavaScript 引擎会做四件事创建一个新的对象把这个对象的[[Prototype]]指向构造函数的prototype对象将函数内部的this绑定到这个新对象上执行函数体并返回这个对象如果函数没有显式返回对象。function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, Im ${this.name}); }; const p new Person(Kai); p.sayHello(); // Hello, Im Kai这里的p本身并没有sayHello这个属性但访问p.sayHello时引擎会沿着原型链查找先查p自身找不到就去p.__proto__即Person.prototype再找不到就去Object.prototype直到null。我用一个生活化的类比来解释你家里有一个工具箱构造函数里面有各种工具方法。当你生成了一个新角色实例时角色手上没有工具但你给角色配了一条“去工具箱拿工具”的通道原型链。角色本身不需要背所有方法用时去拿就行。这省了每个实例都复制一遍方法的巨大内存开销。2.2 手写继承的三种方案与取舍虽然现在写继承都用class extends但理解手写继承能让你真正明白class是怎么被“翻译”出来的。第一种是原型链继承function Animal(name) { this.name name; } Animal.prototype.eat function() { console.log(${this.name} is eating); }; function Dog(name) { this.name name; } Dog.prototype new Animal(); Dog.prototype.constructor Dog;问题很明显多个Dog实例共享同一个Animal实例如果Animal有引用类型的属性比如数组就会互相污染。第二种是构造函数继承function Dog(name) { Animal.call(this, name); }解决了属性共享问题但Dog拿不到Animal.prototype上的方法。第三种是组合继承也是实际项目里最稳的方案function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog;用Object.create隔离了Animal.prototype不会多执行一次Animal函数也不会出现原型链交叉污染。2.3 class 语法糖的底层真相class的本质还是函数加原型操作但有一处关键区别class内部的方法不可枚举不像手写时代那样可以直接通过for...in遍历。而且class不能被当作普通函数调用必须用newclass Person { constructor(name) { this.name name; } greet() { console.log(Im ${this.name}); } } // TypeError: Class constructor Person cannot be invoked without new Person(Kai);你可以在 DevTools 里把 class 转译成 ES5 看一下会发现它本质就是构造函数加Object.defineProperty把方法标记为non-enumerable。理解这一点你就能明白为什么有些老代码里会有Object.defineProperty手工定义方法的写法。3. 事件循环异步顺序的底层真相JavaScript 是单线程的但为什么它能处理网络请求、定时器、UI 渲染而互不卡死答案就在事件循环Event Loop这套调度机制里。我之前也一直以为宏任务微任务只是面试题直到线上出了一个定时器被无限延后的 bug才知道这套机制是真是假直接影响线上稳定性。3.1 宏任务、微任务与顺序推导事件循环的核心可以简化成三步规则每轮循环先执行一个宏任务宏任务执行完清空整个微任务队列微任务队列清空后进行渲染渲染时机由浏览器决定然后进入下一轮宏任务。常见的宏任务有setTimeout、setInterval、I/O、UI 渲染微任务有Promise.then、queueMicrotask、MutationObserver。为什么微任务必须先跑完因为微任务的优先级是“尽快让异步结果生效”它被设计成不经过渲染、立刻在当前宏任务结束后执行。而渲染工作是独立阶段如果每执行一个微任务就渲染一次性能会炸掉所以微任务队列是批量执行。3.2 一道经典面试题的完整推导看这段代码把你认为的输出顺序写出来console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve().then(() { console.log(promise1); }).then(() { console.log(promise2); }); queueMicrotask(() { console.log(microtask); }); console.log(script end);实际输出是script start script end promise1 microtask promise2 setTimeout推导过程第一个宏任务就是整段 script先输出script start和script end遇到setTimeout回调塞进宏任务队列遇到Promise.resolve().then回调塞进微任务队列queueMicrotask也进微任务队列宏任务执行完毕清空微任务队列按先进先出输出promise1、microtask清空过程中promise2又被加入队列末尾于是紧接着执行微任务清空后进入下一轮宏任务输出setTimeout。这里有一条容易忽略的规则微任务执行顺序是先进先出但微任务执行过程中新产生的微任务会继续排在当前队列末尾不会被跳过。3.3 异步 Bug 排查实录我在一个项目里碰到过“定时器不按时触发”的问题。背景是用setTimeout实现重试逻辑预期是 3 秒后重试但实际经常 10 秒甚至更久才执行。排查后发现原因藏在微任务里每次请求失败后代码会写一条错误日志而日志系统内部是把日志放到一个队列里批量化上报的。由于 Promise 链上有大量微任务一直在产生日志上报本身就是Promise.then链浏览器每轮循环要花大量时间清空微任务setTimeout的宏任务就被不断挤到后面。解决思路是把重试逻辑也用queueMicrotask不对重试本身是耗时操作不能用微任务阻塞渲染。正确做法是给重试逻辑加上独立的调度不要跟高频日志任务挤在同一个事件循环里。这类问题能暴露出来的前提是你对宏任务微任务的执行顺序有清晰认知否则只能靠瞎改碰运气。4. this 绑定四个规则和一个例外this是 JavaScript 里最容易被误解的机制。很多人死记硬背“谁调用指向谁”但到代码里还是会错。我从底层角度拆一下它的四套规则外加一个完全不同的例外。4.1 四种调用场景默认绑定。函数直接调用非严格模式下this指向全局对象严格模式下是undefinedfunction showThis() { use strict; console.log(this); // undefined } showThis();隐式绑定。函数作为某个对象的方法被调用时this指向该对象const obj { name: obj, greet() { console.log(this.name); } }; obj.greet(); // obj显式绑定。call、apply、bind让你自己指定thisfunction greet() { console.log(this.name); } const a { name: A }; greet.call(a); // Anew 绑定。用new调用函数时this指向新创建的对象优先级最高。如果多种规则冲突优先级从高到低是new绑定 显式绑定 隐式绑定 默认绑定。举个例子obj.greet.call(otherObj)最终this是otherObj因为显式绑定高于隐式绑定。4.2 箭头函数为什么改不了 this箭头函数没有自己的this它的this是在定义时从作用域链上继承过来的而且不会变。你给它用call、apply、bind都没用它直接忽略const obj { name: outer, arrow: () { console.log(this.name); } }; obj.arrow.call({ name: changed }); // 输出 undefined严格模式或全局对象上的 name箭头函数真正确定的时刻是定义的那一瞬间而不是调用时。所以想要一个动态的this用普通函数想要一个固定不变的this用箭头函数。在 React 类组件里事件处理器一开始写成普通函数导致this丢失后来统一改成箭头函数属性问题才根除这是很多老前端都经历过的坑。4.3 实战中的三种处理思路第一是提前绑定。在构造函数里bindclass MyComponent { constructor() { this.handleClick this.handleClick.bind(this); } handleClick() { console.log(this); } }第二是直接使用箭头函数属性class MyComponent { handleClick () { console.log(this); } }第三是显式传参。在调用处用call保证上下文正确function execute(fn, context) { fn.call(context); }这三招分开用都行但不建议混用因为bind过的函数再次被bind会以第一次绑定的this为准容易造成“为什么我重新 bind 不生效”的困惑。5. 性能优化从底层原理反推实践做性能优化不能只靠工具跑分你至少要理解浏览器渲染的底层流程不然很容易做“假优化”。我常用的思路是先把渲染管线搞懂再去看代码里哪些操作可能触发整条管线最后针对性地改。5.1 渲染管线与重排重绘浏览器渲染一帧主要经历五个阶段JavaScript、样式计算、布局Layout、绘制Paint、合成Composite。其中布局就是计算元素几何位置的过程绘制是填充像素的过程。重排Reflow是指修改了元素的几何属性宽高、边距、定位导致浏览器从头计算布局。重绘Repaint是指只改了颜色、背景等不影响布局的属性跳过了布局阶段但还要重新绘制。合成则是把不同图层在 GPU 上合并最诱发一点。实操中我总结出一条口诀凡是改变几何属性的必引起重排凡是只改视觉属性的最多引起重绘提升为独立图层可以绕过重排重绘。比如用transform: translate()做动画比用lefttop快很多因为transform操作发生在合成阶段不触发重排重绘。这是把 GPU 合成利用起来的首选做法。看个对比写法// 差触发重排 box.style.left ${x}px; // 好进入合成层 box.style.transform translateX(${x}px);5.2 事件委托的底层逻辑事件委托能提升性能但如果你不明白事件冒泡的机制用起来容易出错。所有事件都先从触发目标一路向上传给祖先节点这个传播路径称为冒泡。事件委托就是在祖先节点上统一监听再通过event.target判断具体是谁触发的。底层逻辑很简单你不用在每个子元素上都注册监听函数而是只注册一个。对于大量动态生成的列表项这个优化可以省掉大量内存和时间的占用document.querySelector(.list).addEventListener(click, (event) { const target event.target.closest(.item); if (!target) return; // 处理具体逻辑 });closest方法会从target向上查找匹配选择器的祖先节点省去手动写循环判断的麻烦。5.3 防抖节流的实现细节防抖debounce和节流throttle都是利用闭包保存定时器状态来实现的。区别在于防抖是把多次触发合并成最后一次执行适用于搜索框输入节流是固定频率内只执行一次适用于滚动加载。防抖典型写法function debounce(fn, wait 300) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); timer null; }, wait); }; }节流典型写法function throttle(fn, interval 300) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }这两段代码都利用了闭包保存timer和last。理解闭包原理的好处在这里就体现了你不会纠结“闭包变量什么时候释放”这种问题因为你知道定时器还在就跑不掉。注意防抖的this必须透传否则在类组件里函数内部使用this时会拿到错误上下文。上面的写法用fn.apply(this, args)就是为了修正这个问题。6. 常见问题与排查技巧实录这一节我把平时答疑时被问得最多的几个问题整理出来每一条都对应一个我实际踩过的坑。6.1 内存泄漏定位有段时间我们的页面切页越切越卡用 Performance 面板看到内存占用曲线一直往上走从 40MB 涨到 800MB。定位步骤我整理成了固定流程打开 Chrome DevTools 的 Memory 面板抓三组 Heap Snapshot操作前、操作中、操作后用快照对比功能过滤Detached节点这是判断 DOM 是否泄漏的标准展开 Detached 节点查看它们被哪个闭包引用定位到具体组件修复后重新对比快照确认 Detached 节点消失。实际修复的问题通常是事件监听没移除、定时器没清除、全局变量缓存数据无限增长三个类型。6.2 异步顺序错乱修复异步顺序错乱最常见的是“竞态条件”。比如搜索输入框用户先输入“a”再输入“ab”但慢请求导致响应顺序颠倒最终页面显示的是“a”的结果而不是“ab”的。修复方式是用一个递增序号判断响应是否过期let searchId 0; async function handleInput(value) { const currentId searchId; const results await searchApi(value); if (currentId searchId) { renderResults(results); } }每次请求都拿当前的searchId比对只有最新一次请求才允许渲染。这个技巧在前端防抖场景里格外好用比单纯防抖更安全。6.3 面试进阶题速查末尾附上我总结的进阶题速查表这些题背后的原理文章里都覆盖了题目考察点回答要点闭包是什么有什么危害作用域链、垃圾回收内部函数引用外部变量注意内存泄漏原型链继承的几种方式原型对象、Object.create组合继承是标准答案Promise 和 setTimeout 谁先执行事件循环微任务先于宏任务箭头函数里的 this 指向什么词法作用域定义时确定非调用时确定transform 动画为什么快渲染管线合成阶段无关布局防抖和节流区别闭包、事件机制合并执行 vs 固定频率我个人在实际排查中最大的体会是遇到 JavaScript 里的诡异问题不要急着搜答案先画一遍作用域链或事件循环的推演图。很多问题的答案不是记住的而是推出来的。把底层机制理顺之后进阶技巧自然就长在你身上了后续项目里再遇到框架、工具层面的新东西你也会有更清晰的判断力。
返回列表