1. 从基础语法到核心机制:Day 6该往哪里使劲
坚持到第六天,说实话已经淘汰掉一批人了。前五天我们通常把 HTML 结构、CSS 样式、JS 基础语法过了一遍,能写点静态页面、能做点简单的交互,比如点击按钮弹个提示框、切换个 Tab 页签、做个图片轮播。但这时候很多人会卡在一个很尴尬的阶段——代码能跑,但一遇到稍微复杂点的逻辑就懵,不知道为什么 var 和 let 行为不一样,不知道为什么回调函数里的 this 不是自己想象的那个对象,更搞不清楚异步任务到底是怎么排队的。
这个阶段我称之为"前端新手的分水岭":说白了就是开始从"能写"走向"懂为什么这么写"。Day 6 这个节点,我建议把精力全部砸在 JavaScript 的核心机制上,而不是急着去学 Vue、React 或者各种构建工具。原因很简单——框架天天变,但 JS 引擎里那套"作用域、闭包、原型链、事件循环"的机制,十年了基本没变过。你有没有想过,为什么面试题翻来覆去就是那几道:防抖节流怎么实现?[1,2,3].map(parseInt) 输出什么?Promise 和 setTimeout 谁先执行?这些问题的底层,全部指向今天我们要聊的这几个核心概念。
所以今天这篇文章,不教你写什么炫酷的页面,也不贴大段大段的框架代码。我们就老老实实把 JavaScript 最硬核的几块骨头啃一遍:作用域与提升、闭包、this 指向、原型链、事件循环。每块我都会结合实际的代码案例和常见的面试场景来讲,保证你能看懂、能复现、能用在实战里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 作用域与提升机制:var、let、const 的底层差异
2.1 执行上下文到底是什么
很多初学者学到作用域的时候,第一反应是"作用域不就是变量的有效范围吗",这句话对,但不够。真正理解作用域,得先理解一个概念——执行上下文(Execution Context)。你可以把执行上下文想象成一个"代码运行时的环境快照",JavaScript 引擎在执行一段代码之前,会先创建一个上下文,然后把变量声明、函数声明、this 的值、作用域链等信息都挂到这个上下文上。
JavaScript 有三种执行上下文:全局执行上下文、函数执行上下文、eval 执行上下文。我们平时写的代码,大部分时间就在全局上下文和函数上下文之间切换。每次调用一个函数,引擎都会给这个函数创建一个全新的执行上下文,并把它压入调用栈(Call Stack)。函数执行完,上下文弹出,相关的变量如果没有被外部引用,就会被垃圾回收。
这就解释了一个新手特别容易困惑的问题:为什么局部变量在函数外部访问不到?因为那个变量只存在于函数执行上下文的变量环境里,外部上下文根本看不到它。
2.2 变量提升的真相与暂时性死区
看一段代码:
javascript复制console.log(a); // undefined
var a = 10;
很多新手第一次看到这个输出 undefined 而不是报错,都会觉得莫名其妙。这个现象的底层机制就是变量提升(Hoisting)。var 声明的变量,在编译阶段会被"提升"到当前作用域的顶部,但只是声明被提升,赋值操作还留在原地。所以上面的代码实际等价于:
javascript复制var a; // 声明被提升,默认值为 undefined
console.log(a); // undefined
a = 10; // 赋值还在原地
但如果你把 var 换成 let:
javascript复制console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 10;
let 和 const 也会被提升,但在赋值之前访问它们会直接报错。这个"声明了但还没初始化"的区域,就叫暂时性死区(Temporal Dead Zone, TDZ)。为什么 JS 要搞一个 TDZ 出来?根本原因是 let 和 const 的设计目标就是弥补 var 的缺陷——var 的提升行为极其容易造成隐式 bug,比如忘记初始化就使用变量,结果拿到一个 undefined,排查半天才发现是声明位置的问题。
从实战角度我给你的建议是:全面使用 const,只在变量确实需要重新赋值时才用 let,永远不要用 var。这不仅仅是风格偏好,而是能实实在在减少一类很难排查的 bug。我自己在 code review 的时候,看到有人还在用 var 都会问一句为什么,基本没有人能给出非用不可的理由。
2.3 块级作用域为什么是革命性的
在 ES6 之前,JavaScript 只有全局作用域和函数作用域,没有块级作用域。这意味着 if 块、for 循环块里面的 var 变量,在外面照样能访问到。
javascript复制for (var i = 0; i < 5; i++) {
// 循环体
}
console.log(i); // 5,var 没有块级作用域,i 泄漏到全局
这个特性在早期的前端代码里造成过无数经典 bug,最著名的就是循环里绑定事件的闭包问题。let 和 const 引入之后,块级作用域才真正落地:
javascript复制for (let j = 0; j < 5; j++) {
// 循环体
}
console.log(j); // ReferenceError: j is not defined
注意 let 在 for 循环里还有一个特殊待遇——每次迭代都会创建一个新的绑定。也就是说循环体里的 j,在每次迭代中都是独立的一个变量。这个特性在解决"循环绑定事件拿到错误索引"这类问题时,简直是杀手锏。后面讲闭包的时候我还会回到这个案例。
3. 闭包:不只是面试题,更是前端架构的基石
3.1 闭包产生的条件和本质
闭包(Closure)是 JavaScript 中被讨论最多、也最容易被误解的概念之一。很多初学者觉得闭包很玄乎,甚至有人把闭包等同于"函数里返回函数"。其实不对。
闭包的本质是:当一个内部函数引用外部函数的变量时,即使外部函数已经执行完毕,这个内部函数依然保留着对外部函数作用域的引用能力。换句话说,闭包是"函数 + 函数定义时的词法作用域"的组合体。
看这个最经典的例子:
javascript复制function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
按理说 createCounter 执行完了,它内部的 count 变量应该被垃圾回收了才对。但因为返回的函数还在引用 count,所以 count 被保留在内存中,每次调用 counter() 都能修改和读取它。这就是闭包——内部函数"记住"了外部函数作用域中的变量。
从实现原理上讲,JavaScript 引擎会给每个函数对象保存一个 [[Environment]] 内部属性,指向函数定义时的词法环境。当函数被调用时,引擎会基于这个 [[Environment]] 创建新的作用域链。这样,内部函数即使跑到天涯海角,也能通过作用域链找到定义时的变量。
3.2 闭包的三大实战场景
闭包在真实项目里的应用非常广泛,我挑三个最常见的场景说。
场景一:数据私有化。 想模拟私有变量,没有比闭包更合适的方案。比如一个全局唯一的配置对象,不想被外部随意篡改:
javascript复制function createConfig() {
let config = {
theme: 'dark',
lang: 'zh-CN'
};
return {
get(key) {
return config[key];
},
set(key, value) {
if (key === 'theme' || key === 'lang') {
config[key] = value;
}
}
};
}
const appConfig = createConfig();
appConfig.set('theme', 'light');
console.log(appConfig.get('theme')); // 'light'
console.log(appConfig.config); // undefined,外部无法直接访问 config
这种模式在组件库、SDK 的封装中到处可见,核心目的就是控制访问权限,避免外部代码直接改内部状态导致不可预期的行为。
场景二:函数柯里化和偏函数。 闭包让函数可以"记住"预设的参数,从而实现延迟计算。比如一个加价函数:
javascript复制function addPrice(rate) {
return function(price) {
return price * (1 + rate);
};
}
const addVAT = addPrice(0.13); // 记住税率 13%
const addServiceFee = addPrice(0.05); // 记住服务费 5%
console.log(addVAT(100)); // 113
console.log(addServiceFee(100)); // 105
上面代码里的 addVAT 和 addServiceFee 分别记住了不同的税率,这就是闭包在实际业务中很典型的一种用法。
场景三:防抖与节流。 前端性能优化里最基础也最常用的两个函数,底层都是闭包:
javascript复制function debounce(fn, delay = 300) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 使用
const handleResize = debounce(function() {
console.log('窗口大小变了,做点重计算');
}, 500);
window.addEventListener('resize', handleResize);
这里的 timer 变量之所以能跨多次调用保存状态,完全靠闭包对 debounce 函数作用域的引用。如果没有闭包,每次调用 handleResize 都会重新创建一个 timer,防抖逻辑就完全失效了。
3.3 闭包的内存管理:什么时候需要警惕
闭包虽好,但有一个绕不开的代价——内存占用。因为闭包会阻止外部函数的变量被垃圾回收,所以如果你创建了大量闭包,或者闭包引用了很大的对象,就可能导致内存泄漏。
实战中我见过一个真实案例:某个后台管理系统,在表格组件里给每一行都绑定了事件处理函数,这些函数通过闭包引用了整行数据对象。用户切换页面时,表格组件虽然被销毁了,但因为事件绑定没有被正确解绑,闭包引用链断不掉,导致切了几十个页面之后浏览器内存暴涨,页面明显变卡。
规避方法总结成三条经验:
- 不需要的时候,把闭包函数赋值为
null,让它失去引用,方便回收。 - 尽量避免在循环体内创建长生命周期的闭包,尤其是那些引用了大数据对象的。
- 使用事件监听时,记得在组件销毁时调用
removeEventListener。
4. this 指向:告别瞎猜,彻底搞懂四套绑定规则
4.1 默认绑定与隐式绑定
this 的指向问题,可以说是前端面试里出现频率最高的考点,也是新手最容易翻车的地方。本质上,this 指向什么,取决于函数调用时的调用方式,而不是函数定义的位置。
默认绑定:当函数以普通函数形式独立调用时(不带任何修饰),非严格模式下 this 指向全局对象(浏览器里是 window),严格模式下指向 undefined。
javascript复制function showThis() {
console.log(this);
}
showThis(); // window(非严格模式)
function showThisStrict() {
'use strict';
console.log(this);
}
showThisStrict(); // undefined
隐式绑定:当函数作为某个对象的方法调用时,this 指向那个对象。
javascript复制const user = {
name: '小明',
greet() {
console.log(`你好,我是${this.name}`);
}
};
user.greet(); // 你好,我是小明
但隐式绑定有个经典陷阱——把方法赋值给变量后再调用:
javascript复制const greet = user.greet;
greet(); // 你好,我是undefined
为什么?因为 greet() 是普通函数调用,this 又变回默认绑定,指向 window 了。window.name 不存在,所以是 undefined。这个场景在回调函数里特别容易踩,比如把对象方法直接传给 setTimeout 或者事件监听器,就会丢掉 this。
4.2 显式绑定:call、apply、bind 三兄弟
显式绑定就是通过 call、apply、bind 人为指定函数执行时的 this。
call(thisArg, arg1, arg2, ...):立即执行,参数逐个传入。apply(thisArg, [arg1, arg2, ...]):立即执行,参数以数组传入。bind(thisArg, arg1, arg2, ...):不立即执行,返回一个绑定好this的新函数。
javascript复制const user = { name: '小红' };
function greet(greeting, punctuation) {
console.log(`${greeting},${this.name}${punctuation}`);
}
greet.call(user, '你好', '!'); // 你好,小红!
greet.apply(user, ['你好', '!']); // 你好,小红!
const boundGreet = greet.bind(user, '你好');
boundGreet('。'); // 你好,小红。
实战中 bind 的出场率远高于 call 和 apply,尤其在把方法传给事件回调时:
javascript复制const user = { name: '小黑' };
function showName() {
console.log(this.name);
}
document.addEventListener('click', showName.bind(user));
// 点击页面任意位置输出 "小黑"
apply 则有个经典妙用——找数组最大最小值:
javascript复制const arr = [3, 7, 1, 9, 4];
console.log(Math.max.apply(null, arr)); // 9
// ES6 以后更推荐
console.log(Math.max(...arr)); // 9
4.3 new 绑定与箭头函数的特殊规则
当函数用 new 关键字调用时,this 指向新创建的对象实例:
javascript复制function Person(name) {
this.name = name;
}
const p = new Person('小刚');
console.log(p.name); // 小刚
new 的执行过程可以用四句话概括:创建一个新的空对象;把新对象的原型指向构造函数的 prototype;让构造函数里的 this 指向这个新对象并执行;如果构造函数没有显式返回对象,就返回这个新对象。
四条规则里,new 绑定的优先级最高,显式绑定次之,隐式绑定再次,默认绑定最低。
箭头函数则完全不同——它没有自己的 this,它的 this 是在定义时从外层作用域捕获的,而且一旦确定就不能再被 call、apply、bind 改变。这个特性让箭头函数在回调场景里特别省心:
javascript复制const user = {
name: '小白',
delayGreet() {
setTimeout(() => {
console.log(`你好,我是${this.name}`); // 箭头函数捕获了 delayGreet 的 this
}, 100);
}
};
user.delayGreet(); // 你好,我是小白
如果这里用普通函数,this 在 setTimeout 回调里就指向 window 了,输出会是"你好,我是undefined"。所以我的经验是:在需要保持外层 this 时优先用箭头函数,在需要动态 this 时(比如事件回调里需要拿到当前元素)就用普通函数。
5. 原型与原型链:理解对象继承的底层逻辑
5.1 prototype、proto 与 constructor 的三方关系
原型链是 JavaScript 实现继承的核心机制,也是很多初学者觉得最抽象的一块。我先用一个比喻切入:原型链相当于"继承谱系"。每个对象内部都有一个隐藏属性 [[Prototype]],指向它的"父类"。访问对象属性时,如果对象本身没有,引擎就会顺着这条链往下找,直到找到或到达链的尽头。
在 JavaScript 里,函数比较特殊,它天生有一个 prototype 属性,这个属性指向一个对象,叫"原型对象"。用 new 创建实例时,实例的 __proto__ 就指向构造函数的 prototype。
javascript复制function Animal(name) {
this.name = name;
}
Animal.prototype.say = function() {
console.log(`${this.name}在叫`);
};
const dog = new Animal('旺财');
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.constructor === Animal); // true
console.log(dog instanceof Animal); // true
要记住三句话:
构造函数.prototype是原型对象,挂载共享方法。实例.__proto__指向构造函数.prototype。原型对象.constructor指回构造函数,形成闭环。
5.2 原型链查找的完整链路
当你访问 dog.say() 时,引擎做了这样几件事:
- 检查
dog自己身上有没有say,没有。 - 检查
dog.__proto__(即Animal.prototype)上有没有say,有,调用。 - 如果
Animal.prototype上也没有,继续找Animal.prototype.__proto__(即Object.prototype)。 - 如果
Object.prototype还没有,再找Object.prototype.__proto__,此时为null,查找结束,返回undefined。
这就是原型链的完整链路。所有对象最终都会指向 Object.prototype,而 Object.prototype 的 __proto__ 是 null,这条链就此终结。
5.3 继承方式的演进与 ES6 class 的语法糖本质
理解了原型链,再来看看 JavaScript 里继承的几种写法演进。
原型链继承:让子类的 prototype 指向父类的实例。
javascript复制function Dog(name) {
Animal.call(this, name); // 借用构造函数继承属性
}
Dog.prototype = Object.create(Animal.prototype); // 继承方法
Dog.prototype.constructor = Dog; // 修正 constructor 指向
Dog.prototype.bark = function() {
console.log('汪汪!');
};
这种组合写法的核心是:用 call 借用父构造函数继承实例属性,用 Object.create 继承原型方法,修正 constructor 指回子类。
到了 ES6,class 语法把这个过程封装成了更直观的写法:
javascript复制class Animal {
constructor(name) {
this.name = name;
}
say() {
console.log(`${this.name}在叫`);
}
}
class Dog extends Animal {
constructor(name) {
super(name); // 调用父类构造函数
}
bark() {
console.log('汪汪!');
}
}
但要记住,class 只是语法糖,底层依然是原型链那一套东西。super() 的实质就是 Animal.call(this),extends 的实质就是刚才那串原型链操作。
我见过不少新手学完 class 就觉得原型链没用了,这是不对的。实际开发中,很多第三方库的源码、框架的插件机制都用到了原型操作。比如给一个类的所有实例统一加方法,可以直接操作原型:
javascript复制Array.prototype.last = function() {
return this[this.length - 1];
};
const arr = [1, 2, 3];
console.log(arr.last()); // 3
不过这种修改内置原型的行为要非常谨慎,团队协作的项目里尤其不建议,容易造成不可预知的冲突。
6. 事件循环与异步编程:单线程下的并发魔法
6.1 为什么 JavaScript 是单线程的
JavaScript 从诞生起就是单线程语言,这是由它最初的使用场景决定的——操作 DOM。如果 JS 是多线程的,两个线程同时对同一个 DOM 节点做修改,浏览器该听谁的?为了避免这种竞争条件,干脆规定 JS 主线程只有一个。
但单线程不意味着"只能一个一个慢慢来"。JavaScript 通过事件循环(Event Loop)机制,实现了非阻塞的异步执行。程序不会傻等网络请求或定时器完成,而是先把异步任务丢给浏览器其他线程去处理,等主线程空闲了再回头处理结果。
6.2 调用栈、任务队列和微任务队列
要理解事件循环,得先认识三个关键角色。
调用栈(Call Stack):主线程正在执行的任务的"执行记录栈",LIFO 结构。函数调用时压栈,函数返回时弹栈。
宏任务队列(Macro Task Queue):存放 setTimeout、setInterval、I/O 回调、UI 渲染、事件回调等任务。
微任务队列(Micro Task Queue):存放 Promise.then/catch/finally 回调、MutationObserver、queueMicrotask 等任务。
事件循环的完整执行逻辑是:
- 执行调用栈上的所有同步代码。
- 同步代码执行完,调用栈清空后,检查微任务队列,把里面的任务全部执行完。
- 从宏任务队列里取一个宏任务执行。
- 宏任务执行完,再次清空微任务队列。
- 重复步骤 3 和 4。
注意一个关键点:每执行一个宏任务,都要清空一次微任务队列。微任务可以"插队",宏任务则老老实实排队。
6.3 经典输出顺序题:彻底掌握 async/await 与 Promise
来来来,直接上硬菜。这道题几乎是前端面试的"必考题":
javascript复制console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
console.log('script end');
输出结果是:
code复制script start
script end
promise1
promise2
setTimeout
执行流程拆解一下:
- 同步代码依次执行,输出
script start,把setTimeout回调放入宏任务队列,把promise1的任务放入微任务队列。 - 继续输出
script end。 - 调用栈清空,开始清空微任务队列,输出
promise1,此时promise1的.then返回了一个新的 Promise,把promise2的任务又放进微任务队列。 - 继续执行微任务,输出
promise2。 - 微任务队列空了,回到宏任务队列,取出
setTimeout回调,输出setTimeout。
再加一点难度,把 async/await 加进来:
javascript复制async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
Promise.resolve().then(() => {
console.log('promise1');
});
console.log('script end');
输出是:
code复制script start
async1 start
async2
script end
promise1
async1 end
setTimeout
这里最容易搞错的点是 async1 end 的位置。很多人以为 await 后面会立即执行 async1 end,其实 await 相当于把后面的代码包装成了一个微任务。所以 async1 end 要等到同步代码执行完、微任务队列里其他任务也处理完之后才输出。
我的记忆口诀是:await 的下一行,不是立刻执行,而是"排队等号",等同步代码和先来的微任务都处理完,排到了才执行。
6.4 实战中的异步优化:不要阻塞主线程
理解事件循环不只是为了应付面试题。在实际项目里,最常见的性能问题就是长任务阻塞主线程。比如你写了一个循环 100 万次的同步计算,界面会直接卡住,用户点什么都没反应。
解决方案是把大任务拆成小块,让主线程"喘口气"。一个经典的做法是用 setTimeout 把任务切片:
javascript复制const tasks = []; // 假设有大量任务
let index = 0;
function processNextBatch() {
const batchSize = 100;
const end = Math.min(index + batchSize, tasks.length);
for (let i = index; i < end; i++) {
// 处理 tasks[i]
}
index = end;
if (index < tasks.length) {
setTimeout(processNextBatch, 0); // 让出主线程,处理完一帧再继续
}
}
processNextBatch();
更现代的做法是使用 Web Worker 把计算任务丢到子线程里。热词里提到的"前端使用 worker 上传大文件",就是把文件分片、计算哈希这类 CPU 密集型任务放到 Worker 线程,避免 UI 卡顿。这个思路的原理,跟事件循环直接相关——Worker 跑在独立线程,不占用主线程的调用栈和事件循环。
7. 核心机制自测的五道经典题与我的心得
学完以上内容,光看不练等于白学。我挑了五道非常典型的题目,你可以先自己写答案,再看解析,这个自检过程比单纯看十遍书都有效。
第 1 题:
javascript复制var name = '全局';
const obj = {
name: '对象',
fn: function() {
console.log(this.name);
}
};
setTimeout(obj.fn, 100);
解析:setTimeout 的回调是普通函数调用,this 指向全局,所以输出 '全局'。如果想让输出 '对象',需要写 setTimeout(obj.fn.bind(obj), 100)。
第 2 题:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
解析:var 没有块级作用域,三个定时器回调共享同一个 i,循环结束时 i 已经是 3,所以输出三个 3。把 var 换成 let,每次迭代创建独立绑定,输出 0, 1, 2。
第 3 题:
javascript复制const Person = {
name: '张三',
getName: function() {
return this.name;
}
};
const getName = Person.getName;
console.log(getName());
解析:普通函数调用,this 指向全局,全局没有 name,输出 undefined。
第 4 题:
javascript复制function Foo() {
this.name = 'foo';
}
Foo.prototype.say = function() {
console.log(this.name);
};
const f = new Foo();
f.say();
console.log(f.__proto__.say === Foo.prototype.say);
解析:f.say() 输出 'foo',f.__proto__ === Foo.prototype,所以第二个输出 true。
第 5 题:
javascript复制console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
Promise.resolve().then(() => {
console.log(3);
setTimeout(() => console.log(4), 0);
});
console.log(5);
解析:输出 1, 5, 3, 2, 4。同步先输出 1 和 5;微任务输出 3,同时把 4 的定时器放入宏任务队列;此时宏任务队列里已有 2 的定时器,按先进先出执行 2,再执行 4。
做完这几道题,你应该能感觉到,这些题考来考去,本质上就是在考作用域、this 绑定、闭包、事件循环这几个机制的组合应用。把这几个机制的底层原理吃透了,万变不离其宗。
我在带新人的时候经常说,前端学习曲线不是一条平滑的直线,而是一个一个的"陡坡"。过了 Day 6 这个陡坡,你会发现后面学框架时,很多之前看不懂的概念——比如 Vue 里的 nextTick 为什么是微任务、React 里的 useCallback 为什么能稳定引用、状态管理库为什么能实现跨组件共享——突然全都串起来了。因为这些框架底层的设计,最终都绕不开我们今天聊的这套 JavaScript 核心机制。建议你学完这些内容后,不要急着往下赶进度,花两天时间把每个机制对应的代码都亲手写一遍、跑一遍、改一改再跑一遍,这个"亲手折腾"的过程,才是真正把知识变成能力的关键一步。
