文章目錄
- 一、為什么需要作用域?
- 二、什么是 JS 作用域?
- 2.1 什么是詞法作用域和動態作用域?
- 1. 詞法作用域(Lexical Scpoe)
- 2. 動態作用域
- 2.2 JS 的作用域
- 2.3 JS 作用域的分類
- 1. 全局作用域
- 2. 模塊作用域
- 3. 函數作用域
- 4. 塊級作用域 (ES6 引入)
- 5. 對比
- 2.4 JS 的作用域和詞法作用域的關系
- 2.5 作用域鏈
- 1. 理解
- 2. 它是如何形成的呢?
- 3. 為什么需要作用域鏈?
- 4. 總結
- 三、閉包
- 1. 什么是閉包?
- 2. 通過例子理解
- 3. 總結
一、為什么需要作用域?
想象一下,如果在一個大型 JavaScript 項目中,所有變量都是全局變量會怎樣?我覺得會有下面的問題:
- 不同文件/模塊里可能不小心用到同名變量,導致沖突、覆蓋;
- 沒有邊界,調試困難;
- 開發者需要記憶所有變量名字,增加心智負擔。
所以,我的理解廣泛來說就是限定變量的可見范圍,避免命名沖突,實現數據隔離和封裝,讓代碼更清晰、模塊。
如果從設計者角度去思考的話,我覺得比較重要的原因是:
- JS(和大部分編程語言)需要通過作用域,讓名字(標識符)和存儲位置(內存)建立映射關系。
- 這樣在編譯或執行時,解釋器/引擎能高效地定位、分配和管理內存。
- 同時保證封裝性和可維護性,讓復雜程序拆分成更小、互不干擾的模塊。
所以,我認為作用域讓變量只在需要的地方可見,防止沖突,讓代碼更清晰、可靠。 還能幫助編譯器/解釋器確定變量生命周期和存儲位置的結構化機制。
二、什么是 JS 作用域?
在談 JS 作用域之前,我們先來討論一下詞法作用域和動態作用域。
2.1 什么是詞法作用域和動態作用域?
1. 詞法作用域(Lexical Scpoe)
「Lexical」指的是詞法(lexical analysis):在編譯器前端,對源代碼進行分詞、語法分析的階段。因此詞法作用域也叫靜態作用域(Static Scope):變量作用域在代碼書寫時就確定,不會被運行時的調用關系改變。
大多數現代語言(包括:JavaScript、C、C++、Java、Python、Go、Rust、TypeScript…)都是詞法作用域語言。
關鍵點:
- 編譯時就能確定:哪個名字屬于哪個作用域。
- 執行時沿著「寫在哪里」形成的作用域鏈找變量。
舉例:
let a = 1;function foo() {console.log(a);
}function bar() {let a = 2;foo();
}bar(); // 輸出 1
foo 定義時在全局,外層有 a = 1。執行時無論 foo 從哪里被調用(bar 調用也好),作用域鏈都不會變:foo 只會在定義處向外找 → 找到全局的 a。
2. 動態作用域
在早期或特殊的語言設計里(如 Lisp 某些方言、Perl 的 local、bash 腳本等),變量的作用域不是在編譯時確定,而是在運行時,根據函數是從哪里被調用決定。所以叫「動態」:變量查找時不是根據寫在哪里,而是看當前的調用棧。
關鍵點:
- 調用棧決定作用域。
- 執行時如果在當前函數沒找到變量,就在調用者(而非定義時的外層作用域)中找。
還是上述的那個例子: ?? JS 實際不支持動態作用域,但為了說明原理:
let a = 1;function foo() {console.log(a);
}function bar() {let a = 2;foo();
}bar(); // 輸出 2
執行到 bar() 時,bar 調用 foo,foo 查找 a 會先去調用者 bar 的作用域 → 找到 a=2 → 輸出 2。
了解完之后我們再看 JS 的作用域。
2.2 JS 的作用域
首先 JS 的作用域是詞法作用域的一部分,再看 MDN 中對于 JS 作用域的解釋。
根據 MDN 解釋:
- 作用域是指當前的執行上下文,在其中的值和表達式可以被訪問。
通俗點說:作用域決定了程序的哪些部分可以 “看到” 和 “使用” 某個變量。
舉個例子:
let x = 10
function test() {let y = 20console.log(x) // 可以訪問console.log(y) // 可以訪問
}
test()
console.log(x) // 可以訪問
console.log(y) // 報錯:y is not define
y 只在函數內部可見,外部看不到。
2.3 JS 作用域的分類
JavaScript 中常見的四種作用域:
類型 | 簡介 | 示例 |
---|---|---|
全局作用域 | 腳本模式運行所有代碼的默認作用域 | var a = 1 |
模塊作用域 | 模塊模式中運行代碼的作用域 | export const c = 4 |
函數作用域 | 由函數創建的作用域 | function foo() { let x = 2 } |
塊級作用域 | 用一對花括號(一個代碼塊)創建出來的作用域 | { let b = 3 } |
下面我們通過幾個例子,更清楚感受一下各個作用域的實際效果。
1. 全局作用域
在腳本(或 HTML 的 <script>
)里直接聲明的變量,就屬于全局作用域,全局可見。
// 全局作用域
const globalVar = 'I am global'
function sayHello() {console.log(globalVar) // 可以訪問全局變量
}sayHello(); // 輸出:I am global
console.log(globalVar); // 輸出:I am global
globalVar 定義在最外層(文件最外層或 script 最外層),可以在整個文件或頁面中訪問到。
2. 模塊作用域
當你用 export / import 或 .mjs 模塊時,每個模塊文件默認是私有作用域,文件內部聲明的變量只有本文件能訪問。
// file: utils.js
const secret = 'hidden'
export const publicData = 'exported data'// file: main.js
import { publicData } from './utils.js'
console.log(publicData) // 輸出:exported data
console.log(secret) // 報錯:secret is not defined
secret 沒有導出,只能在 utils.js 內使用,屬于模塊作用域。publicData 被 export 導出后才能在其他模塊里訪問。
3. 函數作用域
函數內部用var、let、const 定義的變量,只能在這個函數體內部訪問。
function greet() {let name = 'HopeBearer'console.log('Hello, ' + name)
}greet() // 輸出:Hello, HopeBearer
console.log(name) // 報錯:name is not defined
name 只在 greet 函數里可見。函數作用域是最經典的作用域形式。
4. 塊級作用域 (ES6 引入)
在 if、for、while、{} 塊 內用 let 和 const 聲明的變量,只在這個塊內有效。
if(true) {const message = 'inside block'console.log(message) // 輸出:inside block
}
console.log(message) // 報錯:message is not defined
塊級作用域讓你在小范圍內聲明變量,避免污染外層作用域。
注意:var 聲明的變量沒有塊級作用域,只受函數作用域控制。
5. 對比
類型 | 關鍵詞 | 可訪問范圍 |
---|---|---|
全局作用域 | 無特殊關鍵詞 | 全文件 / 頁面 |
模塊作用域 | import/export | 模塊文件內部 |
函數作用域 | var let const | 函數體內部 |
塊級作用域 | let const | 花括號內 |
2.4 JS 的作用域和詞法作用域的關系
上面我們聊到 JS 的作用域是詞法作用域的一部分,其實指的是: JS的作用域都是詞法作用域體系的一部分。 簡單來說,誰寫在誰里面 -> 形成作用域鏈,執行時,JS 按照 詞法結構(寫在哪里)順序查找變量,不會因為函數是從哪里被調用而改變作用域鏈。
2.5 作用域鏈
1. 理解
作用域鏈(Scope Chain)是 JavaScript 在運行時用來查找變量的一套機制和數據結構。
- 本質上是一個鏈式結構,由當前執行上下文的變量對象(Variable Environment / Lexical Environment),以及外層(父級)的變量對象,一直串到全局作用域的變量對象。
- 通過這條鏈,JS 引擎在需要解析變量名時,按順序向外查找直到找到變量,或者到最外層(全局作用域)還沒找到就報錯。
2. 它是如何形成的呢?
作用域鏈不是寫死的,而是根據代碼的詞法結構在函數定義時確定的。具體來說,就是:
當你在寫一個函數時,這個函數「捕獲」了它定義處的外層作用域(也就是詞法作用域)。函數執行時,JS 引擎會根據這個結構把當前作用域對象放到鏈的最前面(頂端),外層作用域依次排在后面。
舉個例子:
let a = 10
function outer() {let b = 20function inner() {let c = 30console.log(a, b, c)}inner()
}
outer()
執行 inner 時的作用域鏈:
[inner 的作用域(包含 c),outer 的作用域(包含 b),全局作用域(包含 a)
]
當 console.log(a, b, c) 執行:
- 先在 inner 的作用域里找 a,找不到;
- 再去 outer 的作用域找,找不到;
- 最后到全局作用域找到 a=10;
- 同理,b 在 outer 找到,c 在 inner 找到。
3. 為什么需要作用域鏈?
JS 必須在運行時知道的,一個變量到底屬于那個作用域。
有了作用域鏈:就能:
- 保證變量隔離(內外變量不會沖突)
- 支持閉包(內部函數能訪問外層變量)
- 高效的查找變量(只需從當前開始,逐層向外找)
4. 總結
作用域鏈是 JavaScript 在執行時用來按詞法結構順序查找變量的一條鏈,它讓內部作用域可以訪問到外層作用域的變量,而不是反過來。
三、閉包
都說到這了,我們順便了解一下閉包。
1. 什么是閉包?
MDN 解釋:
- 閉包是由捆綁起來(封閉的)的函數和函數周圍狀態(詞法環境)的引用組合而成。換言之,閉包讓函數能訪問它的外部作用域。在 JavaScript 中,閉包會隨著函數的創建而同時創建。
我覺得簡單來說就是一個函數“記住了”它被創建時的外層作用域,即使這個函數在外層作用域已經結束后依然可以訪問這些變量。
2. 通過例子理解
舉個例子:
function outer() {let count = 0return function inner() {count++console.log(count)}
}const fn = outer()fn() // 1
fn() // 2
執行流程:
- 調用
outer()
:- 創建一個新的作用域 S1。
- 在 S1 的符號表中記錄:
counter -> 內存地址A(初始值0)
。
- 在
outer
內部定義了inner
函數:inner
的作用域中捕獲了外層作用域 S1。- 也就是
inner
會記住:當時counter
在 S1 的符號表里,對應內存地址A。
outer()
返回inner
函數:- 外層函數
outer
執行結束,理論上作用域 S1 應該銷毀。但是,返回的inner
函數還引用著 S1 (通過作用域鏈),所以垃圾回收器不會銷毀 S1,也不會釋放內存地址 A。
- 外層函數
- 后續調用
fn
:fn
相當于調用inner
inner
會在 S1 中找到counter
,進行自增。- 所以每次輸出:1、2、3…
為什么變量不被回收?
我們先看一下 JS 的垃圾回收(GC)的可達性(Reachability):從根出發,只要能沿著引用鏈訪問到的對象,就叫做可達(reachable),不可達(unreachable)對象就認為“沒用了”,可以被垃圾回收。
根(Root)一般是:
- 全局對象(比如
window / global
) - 當前調用棧中的局部變量(活動記錄)
- 活動的閉包函數引用的變量
在這個例子中, inner
函數還引用著 外層作用域 S1,S1中的變量 counter
就還"活著"。所以可以出現1,2,3…這樣的情況,一旦 fn 被銷毀(比如賦值為 null),S1 就不再被引用,這是垃圾回收期就可以釋放 S1 對應的內存,包括 counter
。
3. 總結
閉包讓外層作用域中的變量保持活躍, 原因是內部函數把外層作用域放進了自己的作用域鏈里,所以變量依然可訪問,不會被垃圾回收器回收。