你有沒有遇過這個狀況:在 a.js 裡宣告了一個變數,換到 b.js 就完全讀不到,一定要先寫 import 才行。
這種「跨檔案就讀不到」的行為,到底叫什麼?
是作用域嗎?
可是上網查作用域,查到的都是全域、函式、區塊這三種,沒有一篇特別強調檔案。
那你遇到的現象到底屬於哪一種?
這篇文章要處理的就是這個矛盾感。
先講結論:這個概念叫做模組作用域(Module Scope)。
它確實是作用域的一種,只是基礎教學通常只列三種,把它漏掉了。
而你查不到它的真正原因是——把變數隔開的那件事,其實發生在「模組」上,不是發生在「檔案」上。
這兩個詞差在哪裡,就是整篇文章的關鍵。
作用域決定一個變數可以在哪些地方被使用
作用域(Scope)是一個變數或函式可以被使用的範圍。
超出這個範圍去用它,程式不會回你 undefined,而是直接噴 ReferenceError——因為那個名字在這裡根本不存在。
基礎教學會告訴你 JavaScript 有三種作用域,用一段程式碼就能全部看完:
const globalVar = "任何地方都能用我";
function outer() {
const functionVar = "只有 outer() 裡面能用我";
if (true) {
const blockVar = "只有這對大括號裡面能用我";
}
console.log(blockVar); // ReferenceError
}
console.log(functionVar); // ReferenceError這段程式碼裡三個變數的差別,只在於它們被寫在程式碼的第幾層。
globalVar 寫在最外層,沒有被任何括號包住,這是全域作用域(Global Scope)。
functionVar 寫在 function outer() 後面那對大括號裡,這是函式作用域(Function Scope)。
所以最後一行在函式外面用它,會失敗。
blockVar 又更深一層,被 if 的大括號包住,這是區塊作用域(Block Scope)。
所以連 outer() 自己在那對 {} 外面用它都會失敗。
這三種作用域有一個共同點,而這個共同點就是你困惑的來源:它們的邊界,全都能從程式碼本身看出來。
全域的邊界是「沒有被任何東西包住」,函式和區塊的邊界則是那對 {}。
三種都是程式碼寫出來的語法位置,你光看程式碼就知道一個變數屬於哪一種。
JavaScript 的語法裡沒有「檔案」這個概念
回頭看前面那三種作用域,它們的邊界都是你指得出來的東西。
if (true) { ... } 的邊界就是那對 {},function outer() { ... } 的邊界就是它的那對 {},全域的邊界就是「什麼都沒包住」。
你可以把手指放在程式碼上,說「就是這裡」。
檔案的邊界指不出來。
你沒辦法在程式碼裡寫下一個符號代表「這個檔案到此為止」,因為 JavaScript 的語法根本沒有為檔案準備過任何寫法。
檔名、副檔名、資料夾路徑,這些都是作業系統在管的事。
而作用域的規則,是用語法寫成的。
規則說「函式的大括號圍出一個作用域」,是因為大括號是語法的一部分。
檔案不在語法裡,所以那三條規則自然就管不到它。
你的懷疑完全正確:在最原始的作用域規則裡,檔案真的不是一道邊界。
而這件事不只是理論上成立。
在 JavaScript 還沒有辦法把檔案隔開的年代,這個特性是真的會咬人的。
檔案還不是邊界的年代:兩個檔案共用同一個全域作用域
在只有傳統 <script> 標籤的年代,你分成幾個檔案,對瀏覽器來說沒有任何差別。
瀏覽器做的事情很單純:按照 <script> 出現的順序,把每個檔案的內容依序丟進同一個全域作用域執行。
它不會替每個檔案準備一塊獨立的空間。
既然語法上沒有檔案這回事,瀏覽器也就沒理由替檔案做隔離。
這會有兩個可以預期的後果:後面的檔案讀得到前面的檔案宣告的東西,而且後面的檔案還可以把前面的蓋掉。
下面這個例子兩件事都會發生。先看 HTML 怎麼依序載入這兩個檔案:
<script src="fileA.js"></script>
<script src="fileB.js"></script>fileA.js 宣告了一個變數和一個函式,兩個都寫在最外層,所以它們都活在全域作用域裡:
var username = "Alex";
function sayHi() {
console.log("Hi!");
}fileB.js 沒有寫任何一行 import,但這兩樣東西它都拿得到:
console.log(username); // "Alex"
sayHi(); // "Hi!"
var username = "Bob"; // fileA 的 username 就這樣被蓋掉了前兩行印得出東西,正是因為兩個檔案根本就在同一個全域作用域裡,彼此當然看得到。
最後一行則是這個年代最典型的災難:fileB.js 宣告了一個同名變數,fileA.js 的 username 直接被覆蓋,而且不會有任何警告。
專案越大、引入的第三方套件越多,這種名字撞車的意外就越難查。
模組作用域:寫在最外層的變數不再是全域的
ES6(ECMAScript 2015,2015 年發布的 JavaScript 標準版本)為了解決上面那個問題,加進了「模組」(Module)這個東西。
這套模組系統的正式名稱是 ES 模組(ES Modules),也就是你寫 import 和 export 時用的那一套。
「模組」你可以先簡單理解成:一份被特別標示過的程式碼。
至於是誰標示的、怎麼標示,下一小節就會講。
新規則是這樣:寫在模組最外層的變數,只在這份模組裡有效,不會跑到全域去。
拿前面那個例子來對照就很清楚。
同樣是 var username = "Alex" 寫在最外層,在傳統 <script> 底下它是全域變數,別的檔案讀得到。
但在模組裡,它就只活在這份程式碼裡,別的地方都碰不到。
換句話說,每個模組都替自己圍起了一道牆,牆裡面的變數出不去。
這道牆就叫模組作用域(Module Scope)。
這不是哪個工具外掛上去的行為,而是明文寫在 ECMAScript 規範裡的。
規範替模組準備了一種專屬的作用域紀錄(Module Environment Record),跟全域、函式、區塊那幾種並列。
換句話說,模組作用域就是第四種作用域。
你查資料時看到的那三種,只是漏掉了它。
拿剛才那兩個檔案來對照,你會看得更清楚。
程式碼一個字都不用改,只換載入方式:
❌ 當成傳統 <script> 載入:
<script src="fileA.js"></script>
<script src="fileB.js"></script>fileA.js 的 username 是全域變數,fileB.js 直接讀得到,也可以把它覆蓋掉。
兩個檔案之間沒有任何隔離。
✅ 當成模組載入:
<script type="module" src="fileA.js"></script>
<script type="module" src="fileB.js"></script>一模一樣的兩個檔案,fileB.js 裡的 console.log(username) 會直接噴 ReferenceError: username is not defined。
差別只有 type="module" 這幾個字。
加上它之後,fileA.js 的最外層不再是全域作用域,而是它自己的模組作用域。
這道牆擋住的東西比你想的還多。
在模組裡,連 var 都不會再變成全域變數:
// 在一個模組裡
var username = "Alex";
console.log(window.username); // undefined傳統 <script> 底下,最外層的 var 會自動變成 window 的一個屬性,這是很多老專案跨檔案共用資料的手法。
變成模組之後這條路就斷了。
誰決定一個檔案算不算模組
這裡是整件事最容易糊掉的接縫,也是你查資料時那股矛盾感真正的來源。
前面那句「模組是一個作用域」,是語言規範說的。
規範就是定義 JavaScript 這個語言該怎麼運作的那份文件。
不管你在哪裡執行程式碼,這條規則都成立。
但「這個檔案是一個模組」,規範沒有說,而且它也說不出口。
原因你在前面已經看過了:規範不知道什麼是檔案。
它的語法裡根本沒有檔案這個概念,當然不可能規定「一個檔案就是一個模組」。
這句話只能由知道檔案存在的人來說,也就是執行環境——實際負責跑你程式碼的那個東西。
不同的執行環境,用不同方式判斷一個檔案該不該被當成模組:
- 瀏覽器:看
<script>標籤有沒有type="module"。有就當模組載入,沒有就照傳統方式丟進全域作用域。 - Node.js:看副檔名是不是
.mjs,或是專案的package.json裡有沒有寫"type": "module"。兩個都沒有,Node.js 會當成 CommonJS 處理,那是另一套模組系統,後面會講到。 - Vite、webpack 這類打包工具:預設就把你的檔案全部當模組。所以你平常寫前端專案時幾乎不用管這件事,寫
import就是能動。
換句話說,「一個檔案 = 一個模組」是執行環境幫你設定的預設值,不是 JavaScript 本身的規定。
你也可以讓它不成立。
同一個 .js 檔案,用 <script> 載入時是普通程式碼,加上 type="module" 就變成模組,裡面的內容一個字都不用改。
把兩條規則接起來,就是你實際看到的現象:
環境決定「一個檔案 = 一個模組」,規範決定「一個模組 = 一個作用域」。
所以「跨檔案就讀不到」是這兩條規則相乘的結果,不是一條規則。
檔案從頭到尾都不是作用域的邊界,是模組才是;只是在今天的環境裡,一個檔案剛好就等於一個模組,看起來才像是檔案在擋。
這也解釋了為什麼基礎教學不把檔案列進作用域:對語言本身來說,那件事真的不歸它管。
export 指名哪些東西可以被帶出模組
前面說過,模組會把自己最外層的變數關在裡面,不讓它們跑到別的地方去。
正因為預設是關著的,export 和 import 才有事情可做——一個負責放行,一個負責接收。
// fileA.js
const username = "Alex";
const password = "secret";
export function sayHi() {
console.log(`Hi, ${username}!`);
}// fileB.js
import { sayHi } from './fileA.js';
sayHi(); // "Hi, Alex!"
console.log(username); // ReferenceErrorfileA.js 裡有三樣東西:username、password,還有 sayHi。
它們都寫在同一個模組作用域裡,所以彼此用得到。
而三樣裡面,只有 sayHi 前面加了 export。
這行 export 的意思是:只有 sayHi 可以被別的模組拿走,username 和 password 留在原地。
這也是為什麼你常聽到「模組裡的東西預設是私有的」——不寫 export,就出不去。
接著看 fileB.js。
它用 import 把 sayHi 拿了進來,所以 sayHi() 呼叫得動。
但呼叫的結果值得注意:印出來的是 Hi, Alex!。
fileB.js 從頭到尾沒有 import 過 username,Alex 這個值卻出現在輸出裡。
為什麼 sayHi 讀得到 username,fileB.js 卻讀不到
更奇怪的是下一行:fileB.js 自己寫 console.log(username),得到的是 ReferenceError。
同一個檔案,sayHi() 拿得到 username 的值,fileB.js 自己卻讀不到。
原因是 username 從頭到尾都沒有離開過 fileA.js。
sayHi 這個函式是寫在 fileA.js 裡的。
而 JavaScript 判斷函式裡的變數名字指向誰,看的是這個函式寫在哪,不是看它被呼叫在哪。
所以不管 sayHi 被搬到哪個檔案、被誰呼叫,它裡面那個 username 永遠指向 fileA.js 的那一個。
至於 fileB.js 自己寫的那行 console.log(username),它是寫在 fileB.js 裡的,只能在 fileB.js 的模組作用域裡找 username,找不到就報錯。
換句話說,import 搬過來的只有 sayHi 這個函式本身。
fileA.js 裡的其他變數,並沒有跟著一起搬家。
為什麼有些文章說「檔案被偷偷包了一層函式」
查資料的時候,你很可能會撞到另一種說法:檔案之所以能隔離,是因為執行環境偷偷在你的程式碼外層包了一層看不見的函式,所以本質上還是函式作用域。
這個說法不是憑空捏造的,但它講的是另一套模組系統:CommonJS。
先說 CommonJS 是什麼。
ES 模組是 2015 年才進到 JavaScript 語言裡的,但 Node.js 早在 2009 年就出現了。
當時的 JavaScript 完全沒有內建的模組機制,可是後端程式不可能全部擠在一個檔案裡,所以 Node.js 自己弄了一套來用,這套就是 CommonJS。
它的語法跟 import / export 長得完全不一樣:
// math.js
function add(a, b) {
return a + b;
}
module.exports = { add };// index.js
const { add } = require('./math.js');
console.log(add(1, 2)); // 3這裡有個關鍵:CommonJS 不是 JavaScript 語言的一部分。
ECMAScript 規範裡沒有 require,也沒有 module.exports,這兩個東西是 Node.js 自己實作出來的。
雖然現在新專案大多改用 ES 模組了,但 CommonJS 到今天還是隨處可見——大量既有的 Node.js 專案、還有 npm 上很多套件,都仍然是這種寫法,所以你遲早會遇到。
回到那個「包一層函式」的說法。
Node.js 執行一個 CommonJS 檔案時,確實會把整份檔案的內容塞進一個函式裡再執行,大致長這樣:
(function (exports, require, module, __filename, __dirname) {
// 你寫在 math.js 裡的內容,整份被放進這裡
function add(a, b) {
return a + b;
}
module.exports = { add };
});在這套做法底下,隔離是靠函式作用域達成的——你的變數被關住,是因為它們真的在一個函式裡面。
這也是 CommonJS 唯一能走的路。
它不是規範的一部分,沒辦法叫語言本身生出一種新的作用域,只能拿語言已經有的東西(函式作用域)來湊出隔離效果。
這也順便解開一件你可能沒想過的事。
前面那個例子裡,math.js 直接用了 module.exports,index.js 直接用了 require。
但這兩個名字是哪來的?你沒有宣告過它們,也沒有 import 過。
答案就在上面那段包裝裡:require 和 module 是那個外層函式的參數。
Node.js 呼叫這個函式的時候,順手把它們一起傳了進去。
旁邊的 __filename 和 __dirname 也是一樣的道理,它們分別是這個檔案的完整路徑、以及它所在的資料夾。
但 ES 模組不是這樣運作的。
ES 模組的隔離是規範直接規定的,沒有任何一層隱形函式。
兩者的差別從一個地方就看得出來:CommonJS 檔案最外層的 this 指向 module.exports,ES 模組最外層的 this 是 undefined。
如果只是包一層普通函式,不會生出這種差異。
所以你會查到兩種互相矛盾的解釋,是因為它們在描述兩套不同的東西。
你現在寫的 import / export 屬於 ES 模組,適用的是「模組本身就是一個作用域」這個說法。
這件事什麼時候會咬到你
最常見的一則錯誤訊息長這樣:
SyntaxError: Cannot use import statement outside a module它的意思不是你的 import 語法寫錯了,而是執行環境沒有把這個檔案當成模組,所以讀到 import 這個關鍵字的時候不知道那是什麼。
解法不在程式碼裡,在設定裡:瀏覽器補上 type="module",Node.js 補上 "type": "module" 或改用 .mjs。
知道「誰決定一個檔案算不算模組」之後,這則錯誤就變得很好處理。
另一個會咬人的情況,是把舊程式碼搬進模組。
很多老專案是靠全域變數跨檔案共用資料的:
// config.js
var config = { apiUrl: "https://example.com" };// app.js
console.log(window.config.apiUrl); // "https://example.com"這樣能動,是因為 config.js 是用傳統 <script> 載入的。
前面提過,這種情況下最外層的 var 會自動掛到 window 上,所以 app.js 才找得到 window.config。
現在假設你要把這個專案現代化,把載入方式換成模組:
<script type="module" src="config.js"></script>
<script type="module" src="app.js"></script>兩個 .js 檔案一行都沒改,但 app.js 會直接噴錯:
TypeError: Cannot read properties of undefined (reading 'apiUrl')因為 config 現在只活在 config.js 自己的模組作用域裡,window.config 是 undefined。
這種地方要改成 export 和 import 才會通。
回到你最初的問題
你查作用域查不到檔案,不是因為那些教學寫得不好,而是因為在 JavaScript 的語法裡,檔案本來就不是一道邊界。
真正把變數關起來的是模組。
而「一個檔案就是一個模組」這件事,是執行環境替你決定的——瀏覽器看 type="module",Node.js 看副檔名和 package.json,打包工具則是預設全部當模組。
規範只負責另一半:一個模組就是一個作用域。
兩條規則接起來,才變成你每天遇到的「跨檔案就讀不到」。
知道邊界是畫在模組上而不是檔案上,很多原本零散的現象就串起來了。
用傳統 <script> 載入的檔案不是模組,所以它們共用全域作用域、會互相覆蓋。
模組裡的 var 不再掛到 window 上,所以老專案搬進模組會壞掉。
Cannot use import statement outside a module 講的也不是你的語法錯了,而是這個檔案還沒被當成模組。
至於 export 和 import,它們處理的是這道邊界上的進出。
export 指名哪些東西可以被帶走,import 負責去拿。
沒有被指名的就留在原本的模組裡——而且就算你 import 了一個函式,它讀的仍然是自己被寫下來的那個模組裡的變數。
最後,如果你之後又查到「檔案其實是被偷偷包了一層函式」這種說法,不用再被它繞進去。
那講的是 CommonJS,是 Node.js 在 ES 模組出現之前自己實作的系統。
它不在規範裡,只能借用函式作用域來湊出隔離效果。
你現在寫的 import 和 export 不是那樣運作的。