Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

JavaScript 模組作用域是什麼?為什麼沒有 import 就讀不到別的檔案的變數

最後更新:2026年8月31日JavaScript

你有沒有遇過這個狀況:在 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);    // ReferenceError

fileA.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 不是那樣運作的。

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

JavaScript

目錄

  • 作用域決定一個變數可以在哪些地方被使用
  • JavaScript 的語法裡沒有「檔案」這個概念
  • 檔案還不是邊界的年代:兩個檔案共用同一個全域作用域
  • 模組作用域:寫在最外層的變數不再是全域的
  • 誰決定一個檔案算不算模組
  • export 指名哪些東西可以被帶出模組
  • 為什麼 sayHi 讀得到 username,fileB.js 卻讀不到
  • 為什麼有些文章說「檔案被偷偷包了一層函式」
  • 這件事什麼時候會咬到你
  • 回到你最初的問題