開場白:一個變數的"無法無天"與它的"尋親之路"
話說在前端江湖的ES5時代,有個叫var的黑馬喽,這傢伙簡直無法無天!它想來就來,想走就走,完全不顧什麼塊級作用域的規矩。
// 你們看看這黑馬喽的德行
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 你猜輸出啥?3,3,3!
}, 100);
}
// 循環結束了,i還在外面晃蕩
console.log(i); // 3,瞧瞧,跑出來了吧!
但今天咱們不僅要扒一扒var的底褲,還要講講變數們是怎麼"尋親"的——這就是作用域和作用域鏈的故事。
要說清楚作用域,得先從JavaScript的編譯說起。別看JS是解釋型語言,它在執行前也要經歷一番"梳妝打扮"。
想像一下,編譯器就像個認真的語文老師,把代碼這個長句子拆成一個個有意義的詞語:
var a = 1 → var、a、=、1
注意:空格要不要拆開,得看它有沒有用。就像讀書時要不要停頓,得看語氣!
拆完詞之後,編譯器開始理清關係——誰聲明了誰,誰賦值給誰,最後生成一棵抽象語法樹(AST)。
這就像把一堆零散的家庭成員信息,整理成清晰的家譜。
最後,編譯器把家譜樹轉換成機器能懂的指令,準備執行。
關鍵點:JS的編譯發生在代碼執行前的一瞬間,快到你幾乎感覺不到!
var a = 1這麼簡單的一行代碼,背後居然上演著一場"三角戀":
// 當看到 var a = 1;
編譯器:管家,咱們這有叫a的變數嗎?
作用域:回大人,還沒有。
編譯器:那就在當前場合聲明一個a!
JS引擎:管家,我要找a這個人賦值!
作用域:大人請,a就在這兒。
JS引擎:好,把1賦給a!
這裡涉及到兩種查找方式:
LHS查詢:找容器(找新娘)
var a = 1; // 找到a這個容器裝1
RHS查詢:找源頭(找新娘的娘家)
console.log(a); // 找到a的值
foo(); // 找到foo函數本身

在ES5時代,var這傢伙真是目中無人:
{
var rogue = "我是黑馬喽,我想去哪就去哪";
}
console.log(rogue); // 照樣能訪問!
console.log(naughty); // undefined,而不是報錯!
var naughty = "我提升了";
這貨相當於:
var naughty; // 聲明提升到頂部
console.log(naughty); // undefined
naughty = "我提升了"; // 賦值留在原地
ES6時代,如來佛祖(TC39委員會)看不下去了,派出了let和const兩位大神:
{
let disciplined = "我在塊裡面很老實";
const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不來咯
console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?沒門!";
真相:
let/const其實也會提升,但是被關進了"暫時性死區"這個五指山裡,在聲明前誰都別想訪問!
function bar() {
console.log(myName); // 黑馬喽:這裡該輸出啥?
}
function foo() {
var myName = "白嗎喽";
bar();
console.log("1:", myName); // 這個我懂,輸出"白嗎喽"
}
var myName = "黑嗎喽";
foo(); // 輸出:"黑嗎喽","白嗎喽"
黑馬喽挠著頭想:"不對啊!bar()在foo()裡面調用,不是應該找到foo()裡的myName = "白嗎喽"嗎?怎麼會是黑嗎喽呢?"
原來,在編譯階段,每個函數就已經確定了自己的"娘家"(詞法作用域):
// 編譯階段發生的事情:
// 1. bar函數出生,它的outer指向全局作用域(它聲明在全局)
// 2. foo函數出生,它的outer也指向全局作用域(它聲明在全局)
// 3. 變數myName聲明提升:var myName = "黑嗎喽"
// 執行階段:
var myName = "黑嗎喽"; // 全局myName賦值為"黑嗎喽"
foo(); // 調用foo函數
黑馬喽的錯誤理解:
bar() → foo() → 全局
實際的作用域查找(根據outer指針):
bar() → 全局
如圖

詞法作用域(JavaScript):看出生地
var hero = "全局英雄";
function createWarrior() {
var hero = "部落勇士";
function fight() {
console.log(hero); // 永遠輸出"部落勇士"
}
return fight;
}
const warrior = createWarrior();
warrior(); // "部落勇士" - 記得出生時的環境
動態作用域:看調用地(JavaScript不是這樣!)
// 假設JavaScript是動態作用域(實際上不是!)
var hero = "戰場英雄";
const warrior = createWarrior();
warrior(); // 如果是動態作用域,會輸出"戰場英雄"
var grandma = "奶奶的糖果";
function mom() {
var momCookie = "媽媽的餅乾";
function me() {
var myCandy = "我的棒棒糖";
console.log(myCandy); // 自己口袋找
console.log(momCookie); // outer指向mom
console.log(grandma); // outer的outer指向全局
}
me();
}
mom();
// 全局作用域
var city = "北京";
function buildDistrict() {
var district = "朝陽區";
function buildStreet() {
var street = "三里屯";
console.log(street); // 自己的
console.log(district); // outer指向buildDistrict
console.log(city); // outer的outer指向全局
}
return buildStreet;
}
// 編譯階段就確定的關係:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域
如圖
function createCounter() {
let count = 0; // 私有變數,外部無法直接訪問
return {
increment: function() {
count++; // 閉包:outer指向createCounter作用域
return count;
},
getValue: function() {
return count;
}
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 報錯!count是私有的
黑馬喽的坑
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 3, 3, 3 - 所有函數共享同一個i
}, 100);
}
作用域鏈的救贖
// 方法1:使用let創建塊級作用域
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2 - 每個i都有自己的作用域
}, 100);
}
// 方法2:IIFE創建新作用域
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 0, 1, 2 - j在IIFE作用域中
}, 100);
})(i);
}
function foo() {
var myName = "yang";
function bar() { // 現在bar的outer指向foo了!
console.log("2:", myName); // 找到foo的myName
}
bar();
console.log("1:", myName);
}
var myName = "yang1";
foo(); // 輸出:2: yang, 1: yang
function modernScope() {
var functionScoped = "函數作用域";
let blockScoped = "塊級作用域";
if (true) {
let innerLet = "內部的let";
var innerVar = "內部的var"; // 依然提升到函數頂部!
console.log(blockScoped); // ✅ 可以訪問外層的let
console.log(functionScoped); // ✅ 可以訪問外層的var
}
console.log(innerVar); // ✅ 可以訪問
// console.log(innerLet); // ❌ 報錯!let是塊級作用域
}
// 全局作用域
const GLOBAL = "地球";
function country() {
// 函數作用域
let nationalLaw = "國家法律";
{
// 塊級作用域1
let provincialLaw = "省法規";
if (true) {
// 塊級作用域2
let cityRule = "市規定";
console.log(cityRule); // ✅ 本市有效
console.log(provincialLaw); // ✅ 本省有效
console.log(nationalLaw); // ✅ 全國有效
console.log(GLOBAL); // ✅ 全球有效
}
// console.log(cityRule); // ❌ 跨市無效
}
}
var globalVar = "我在最外層";
function level3() {
// 這個查找要經過:自己 → level2 → level1 → 全局
console.log(globalVar);
}
function level2() {
level3();
}
function level1() {
level2();
}
function optimized() {
const localCopy = globalVar; // 局部緩存,減少查找深度
function inner() {
console.log(localCopy); // 直接訪問,快速!
}
inner();
}
經過這番學習,黑馬喽終於明白了作用域的真諦:
var無視塊級作用域,到處擾擾let/const引入塊級作用域和暫時性死區// 好的作用域設計就像好的家風
function createFamily() {
// 外層:家族秘密,內部共享
const familySecret = "傳家寶";
function teachChild() {
// 中層:教育方法
const education = "嚴格教育";
return function child() {
// 內層:個人成長
const talent = "天賦異禀";
console.log(`我有${talent},接受${education},知道${familySecret}`);
};
}
return teachChild();
}
const familyMember = createFamily();
familyMember(); // 即使獨立生活,依然記得家族傳承
黑馬喽感慨地總結道:
"原來JavaScript的作用域就像血緣關係:
- 作用域是家規(在哪裡能活動)
- 作用域鏈是族譜(怎麼找到祖先)
- outer指針是出生證明(一輩子不變)
- 詞法作用域是家族傳承(看出生地,不是看現住地)"
從此,黑馬喽明白了:想要在前端江湖混得好,就要遵守作用域的家規,理解作用域鏈的族譜,尊重outer指針的出生證明!
🐒 黑馬喽寄語:記住,函數的作用域是它的"娘家",編譯時定親,一輩子不變!理解了這套規則,你就能馴服任何JavaScript代碼!