系列文章
- 為什麼我們選擇不用 React/Vue?談 Vanilla JS 的適用場景 ← 目前閱讀
- 視窗系統(上):讓網頁變成桌面 - 基礎拖曳功能
- 視窗系統(中):縮放、最大化與多視窗管理
- 視窗系統(下):Window Snap 與 Taskbar 整合
- CSS 設計系統:一行程式碼切換全站主題
這篇文章要解決什麼問題?
老闆:「React 工程師離職了,專案怎麼辦?」
人資:「市場上 React 人才薪水開很高,而且還要適應我們的專案…」
前端工程師:「其實這個內部系統用原生 JS 就夠了。會 JavaScript 的人都能接手,不用綁定特定框架。」
老闆:「這樣人才選擇更多,風險更低?」
前端工程師:「對,而且少一層框架抽象,除錯更直覺,不用追著框架版本升級跑。」
「公司要開發一個內部系統,該用 React 還是 Vue?」——這個問題我被問過無數次。但很少有人問:「我們真的需要框架嗎?」
在開發 ChingTech OS 時,我們選擇了純 JavaScript(Vanilla JS),帶來這些好處:
| 面向 | 使用框架 | 使用 Vanilla JS |
|---|---|---|
| 學習成本 | 新人需學習框架語法 | 只需懂 JS 基礎 |
| 維護週期 | 框架升級可能破壞程式碼 | 瀏覽器 API 極少破壞性更新 |
| 除錯難度 | 需理解框架內部機制 | 直接看瀏覽器錯誤訊息 |
| 專案壽命 | 框架可能被淘汰 | 原生 JS 永遠可用 |
| 打包複雜度 | 需要 Webpack/Vite 等工具 | 可直接用 <script> 引入 |
技術概念
框架解決什麼問題?
框架(React、Vue、Angular)主要解決以下問題:
- 狀態管理:資料變動時自動更新畫面
- 元件化:程式碼複用與組織
- 路由:單頁應用的頁面切換
- 生態系:現成的 UI 元件庫
什麼時候不需要框架?
當你的專案符合以下條件,可以考慮不用框架:
- 內部系統:不需要 SEO,不需要複雜的首屏優化
- 使用者固定:企業內部員工,不是公開網站
- 功能獨立:各功能模組相對獨立,不需要複雜的狀態共享
- 團隊規模小:2-5 人開發,溝通成本低
- 長期維護:預計使用 5 年以上,不想被框架版本綁架
IIFE 模組模式
IIFE(Immediately Invoked Function Expression,立即執行函式表達式)是一種用原生 JS 實現模組化的模式。
白話解釋:把程式碼包在一個函式裡,函式執行後回傳你想公開的 API,其他變數都被隱藏起來。
就像一個黑盒子:
┌─────────────────────────┐
│ IIFE 模組 │
│ │
輸入 ──►│ 私有變數(外面看不到) │──► 公開 API
│ 私有函式(外面看不到) │
│ │
└─────────────────────────┘
跟著做:Step by Step
第一步:建立基礎 HTML 結構
建立 index.html:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IIFE 模組範例</title>
<style>
body {
font-family: sans-serif;
padding: 20px;
background: #1a1a1a;
color: #f0f0f0;
}
.counter {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0;
}
button {
padding: 8px 16px;
background: #0891b2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0ea5c9;
}
.count {
font-size: 24px;
min-width: 60px;
text-align: center;
}
</style>
</head>
<body>
<h1>IIFE 模組範例</h1>
<div id="app"></div>
<!-- 載入模組 -->
<script src="counter.js"></script>
<script src="main.js"></script>
</body>
</html>
第二步:建立第一個 IIFE 模組
建立 counter.js:
/**
* Counter 模組
* 展示 IIFE 模式的基本結構
*/
const Counter = (function() {
'use strict';
// ====== 私有變數(外部無法存取)======
let count = 0;
let containerElement = null;
let countDisplay = null;
// ====== 私有函式(外部無法存取)======
/**
* 更新畫面上的數字顯示
*/
function updateDisplay() {
if (countDisplay) {
countDisplay.textContent = count;
}
}
/**
* 建立 UI 元素
* @returns {HTMLElement} 容器元素
*/
function createUI() {
const container = document.createElement('div');
container.className = 'counter';
// 減少按鈕
const decreaseBtn = document.createElement('button');
decreaseBtn.textContent = '-';
decreaseBtn.addEventListener('click', decrease);
// 數字顯示
countDisplay = document.createElement('span');
countDisplay.className = 'count';
countDisplay.textContent = count;
// 增加按鈕
const increaseBtn = document.createElement('button');
increaseBtn.textContent = '+';
increaseBtn.addEventListener('click', increase);
// 組裝
container.appendChild(decreaseBtn);
container.appendChild(countDisplay);
container.appendChild(increaseBtn);
return container;
}
// ====== 公開函式(透過 return 暴露)======
/**
* 初始化模組
* @param {string|HTMLElement} target - 目標容器
*/
function init(target) {
// 支援傳入選擇器字串或 DOM 元素
containerElement = typeof target === 'string'
? document.querySelector(target)
: target;
if (!containerElement) {
console.error('Counter: 找不到目標容器');
return;
}
// 建立並插入 UI
const ui = createUI();
containerElement.appendChild(ui);
}
/**
* 增加計數
*/
function increase() {
count++;
updateDisplay();
}
/**
* 減少計數
*/
function decrease() {
count--;
updateDisplay();
}
/**
* 取得目前計數值
* @returns {number} 目前計數
*/
function getCount() {
return count;
}
/**
* 設定計數值
* @param {number} value - 新的計數值
*/
function setCount(value) {
count = value;
updateDisplay();
}
// ====== 公開 API ======
return {
init,
increase,
decrease,
getCount,
setCount
};
})();
第三步:使用模組
建立 main.js:
/**
* 主程式入口
*/
document.addEventListener('DOMContentLoaded', function() {
// 初始化 Counter 模組
Counter.init('#app');
// 示範:外部可以呼叫公開 API
console.log('目前計數:', Counter.getCount()); // 0
// 示範:外部無法存取私有變數
console.log('嘗試存取私有變數 count:', typeof count); // undefined
});
第四步:用瀏覽器開啟測試
直接用瀏覽器開啟 index.html,你會看到:
- 一個可以運作的計數器
- Console 顯示
目前計數: 0 - Console 顯示
嘗試存取私有變數 count: undefined(證明私有變數被隱藏)
進階技巧與踩坑紀錄
技巧一:模組間通訊
當模組需要互相溝通時,可以透過公開 API 或事件系統:
// 方法一:直接呼叫其他模組的公開 API
const ModuleA = (function() {
function doSomething() {
// 呼叫 ModuleB 的公開方法
ModuleB.handleData({ source: 'ModuleA' });
}
return { doSomething };
})();
// 方法二:使用自訂事件(更鬆耦合)
const ModuleB = (function() {
function init() {
// 監聽自訂事件
document.addEventListener('app:dataReady', handleDataReady);
}
function handleDataReady(event) {
console.log('收到資料:', event.detail);
}
return { init };
})();
// 發送事件
document.dispatchEvent(new CustomEvent('app:dataReady', {
detail: { message: 'Hello' }
}));
技巧二:確保載入順序
模組間有依賴關係時,載入順序很重要:
<!-- 基礎工具模組先載入 -->
<script src="js/utils.js"></script>
<!-- 核心模組 -->
<script src="js/api-client.js"></script>
<script src="js/notification.js"></script>
<!-- 依賴上述模組的應用程式模組 -->
<script src="js/counter.js"></script>
<!-- 最後載入主程式 -->
<script src="js/main.js"></script>
技巧三:避免全域命名污染
使用命名空間來組織多個模組:
// 建立命名空間
window.MyApp = window.MyApp || {};
// 模組掛在命名空間下
MyApp.Counter = (function() {
// ...
return { init, increase, decrease };
})();
MyApp.Theme = (function() {
// ...
return { toggle, getCurrent };
})();
// 使用時
MyApp.Counter.init('#app');
MyApp.Theme.toggle();
踩坑紀錄
坑 1:忘記 use strict
// 沒有 use strict,可能意外建立全域變數
const BadModule = (function() {
function doSomething() {
data = 'oops'; // 沒有 var/let/const,變成全域變數!
}
return { doSomething };
})();
// 正確做法
const GoodModule = (function() {
'use strict'; // 加上這行,上述錯誤會直接報錯
function doSomething() {
let data = 'safe'; // 必須宣告
}
return { doSomething };
})();
坑 2:this 指向問題
const Module = (function() {
'use strict';
function handleClick() {
console.log(this); // 在 strict mode 下是 undefined,不是 window!
}
// 解決方法:不依賴 this,直接用閉包存取變數
let state = {};
function handleClick() {
console.log(state); // 透過閉包存取,穩定可靠
}
return { handleClick };
})();
坑 3:非同步初始化的時機
const AsyncModule = (function() {
'use strict';
let isReady = false;
async function init() {
const data = await fetch('/api/config').then(r => r.json());
// 處理資料...
isReady = true;
}
function doSomething() {
if (!isReady) {
console.warn('模組尚未初始化完成');
return;
}
// 實際邏輯...
}
return { init, doSomething };
})();
// 使用時要注意等待初始化完成
async function main() {
await AsyncModule.init();
AsyncModule.doSomething(); // 現在可以安全呼叫
}
小結
重點整理
- IIFE 模式讓你用原生 JS 實現模組化,不需要打包工具
- 私有變數在函式作用域內,外部無法存取
- 公開 API 透過
return物件暴露 - 載入順序很重要,被依賴的模組要先載入
什麼時候選擇 Vanilla JS?
✅ 適合:內部系統、長期維護專案、小團隊、功能相對獨立的應用
❌ 不適合:複雜狀態管理、需要 SSR/SSG、大量表單驗證、團隊已熟悉特定框架
下一篇預告
下一篇我們將實作視窗系統的拖曳功能,讓網頁變成可以拖曳視窗的桌面環境。你會學到:
- DOM 事件處理的三部曲(mousedown → mousemove → mouseup)
- 如何計算拖曳偏移量
- 防止視窗拖出畫面的技巧
完整程式碼
完整的範例程式碼可以在這裡取得:
counter.js(完整版)
/**
* Counter 模組 - 完整範例
* 展示 IIFE 模式的最佳實踐
*/
const Counter = (function() {
'use strict';
// 私有變數
let count = 0;
let containerElement = null;
let countDisplay = null;
let options = {
min: -Infinity,
max: Infinity,
step: 1,
onChange: null
};
// 私有函式
function updateDisplay() {
if (countDisplay) {
countDisplay.textContent = count;
}
// 觸發 onChange callback
if (typeof options.onChange === 'function') {
options.onChange(count);
}
}
function clamp(value) {
return Math.max(options.min, Math.min(options.max, value));
}
function createUI() {
const container = document.createElement('div');
container.className = 'counter';
const decreaseBtn = document.createElement('button');
decreaseBtn.textContent = '-';
decreaseBtn.addEventListener('click', decrease);
countDisplay = document.createElement('span');
countDisplay.className = 'count';
countDisplay.textContent = count;
const increaseBtn = document.createElement('button');
increaseBtn.textContent = '+';
increaseBtn.addEventListener('click', increase);
container.appendChild(decreaseBtn);
container.appendChild(countDisplay);
container.appendChild(increaseBtn);
return container;
}
// 公開函式
function init(target, userOptions = {}) {
containerElement = typeof target === 'string'
? document.querySelector(target)
: target;
if (!containerElement) {
console.error('Counter: 找不到目標容器');
return false;
}
// 合併選項
options = { ...options, ...userOptions };
// 初始值處理
if (typeof userOptions.initialValue === 'number') {
count = clamp(userOptions.initialValue);
}
const ui = createUI();
containerElement.appendChild(ui);
return true;
}
function increase() {
count = clamp(count + options.step);
updateDisplay();
}
function decrease() {
count = clamp(count - options.step);
updateDisplay();
}
function getCount() {
return count;
}
function setCount(value) {
count = clamp(value);
updateDisplay();
}
function destroy() {
if (containerElement) {
containerElement.innerHTML = '';
}
count = 0;
containerElement = null;
countDisplay = null;
}
// 公開 API
return {
init,
increase,
decrease,
getCount,
setCount,
destroy
};
})();
使用範例
document.addEventListener('DOMContentLoaded', function() {
// 基本使用
Counter.init('#app');
// 進階使用:帶選項
Counter.init('#app', {
initialValue: 10,
min: 0,
max: 100,
step: 5,
onChange: function(newValue) {
console.log('計數變更為:', newValue);
}
});
});