javaScript : 變數、作用域、函數、分號與註釋

 


如果你剛開始寫 JS,或是寫了一陣子總覺得哪裡怪怪的,這篇是把觀念、手感、工具一次補齊的快速路線。從命名與保留字,到變數宣告與作用域,再到函數寫法與分號選擇,我會用可複製的範例帶你走過一遍。

中間穿插「雷點清單」:像是 ASI 造成的 return 陷阱、await 沒加錯誤處理、innerHTML 帶來的 XSS 風險。讀完不會變魔法師,但能少走冤枉路,寫得更乾淨也更安心。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


有哪些字不能用?——保留字與「不該用」的識別字

ECMAScript 保留字(關鍵字)

以下字不能拿來當變數/函數名(不同階段與嚴格模式有細微差異,以下列常見且實務需避用者):

break,   case,   catch,   class,   const,   continue, 

debugger,   default,   delete,   do,   else,    enum, 

export,    extends,    false,    finally,    for,    function,

if,    implements,   import,    in,     instanceof,    interface, 

let,    new,    null,    package,    private,    protected,

public,     return,    super,    switch,    static,    this,    throw, 

true,   try,   typeof,   var,   void,    while,   with,   yield,   await


備註 : 

1.    await 在 async/module 情境下視為保留。

2.    of 是「情境保留字」(如 for...of),一般不要用來當識別字。

3.    arguments、eval 雖非關鍵字,但強烈建議不要拿來命名,尤其在嚴格模式下有多種限制與副作用。

4.    物件屬性可以使用保留字(以字串鍵名),如:obj["default"] = 1,但識別字(變數名)不行。


命名規則與限制

識別字可使用 Unicode,但不建議用容易混淆的字元(例如全形空白、相似字母),避免可讀性問題。

不可用數字開頭(1name ❌),可用底線/錢字號(_name, $el)但需要節制。

建議:

        變數/函數採 camelCase(totalPrice)

        類別/建構式用 PascalCase(UserService)

        常數(非重新指定、跨檔案共用)可 UPPER_SNAKE_CASE(MAX_RETRY)

        布林前綴 is/has/can/should:isReady、hasToken


變數宣告與作用域:var / let / const

1.    三種宣告的本質差異

var:

        函數作用域、會變數提升(hoisting)、可重複宣告,容易出 bug,除非維護舊碼,不建議新專案使用。

let:

        區塊作用域、不可重複宣告、存在 暫時性死區(TDZ),可重新賦值。

const:

        區塊作用域、不可重複宣告、不可重新指定(注意:物件屬性仍可變),預設首選。

// TDZ 範例:在宣告前讀取會拋錯
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;

// var 的提升陷阱
console.log(b); // undefined(非錯誤)
var b = 2;

// const 不可重新指定
const config = { retries: 3 };
config.retries = 5;      // ✅ 允許改屬性
// config = {}           // ❌ 不可重新指定


2.    作用域觀念(Block / Function / Module)

        區塊作用域:

                { ... } 內用 let/const 只在該區塊有效。

        函數作用域:

                 var 與函數內部變數只在函數內可見。

        模組作用域:

                 ES Modules(.mjs 或 type: "module")檔案本身是一個作用域,天然嚴格模式、頂層的 this 為 undefined。


{
  const x = 1;
}
console.log(typeof x); // 'undefined'


3.    何時用 let、何時用 const

預設用 const;

只有在「需要重新指定」的情境改用 let(例如計數器、累加器);

基本上不再使用 var。


函數宣告:宣告式、表達式、箭頭函數

1.    三種寫法

// 1) 函數宣告(有提升)——適合工具類純函數
function sum(a, b) {
  return a + b;
}

// 2) 函數表達式(無提升)
const multiply = function (a, b) {
  return a * b;
};

// 3) 箭頭函數(詞法 this、無 prototype)
const divide = (a, b) => a / b;


2.    什麼時候選哪個?

宣告式:純函數工具,需提前使用、或希望在檔案上半部快速索引。

箭頭函數:回呼(callback)、內聯小函數、需要詞法綁定 this 的場景(例如在 class 方法內用私有欄位搭配箭頭函數作事件處理)。

表達式:需要閉包、或想讓函數只在某區塊存在。


3.    this 與箭頭函數

箭頭函數的 this 來自外層詞法作用域;不適合當建構函數。

Class 成員方法若要當事件處理器,常見寫法:

class Counter {
  #count = 0;
  constructor(btn) {
    this.btn = btn;
    this.handleClick = () => {
      this.#count++;
      this.render();
    };
    this.btn.addEventListener('click', this.handleClick);
  }
  render() { /* ... */ }
}


需要用分號 ; 嗎?

建議:統一使用分號。瀏覽器有自動插入分號(ASI),但某些邊界會出問題,例如行首是 [、(、/、+、- 或以 return 換行時:

// 風險案例:
function f() {
  return
  {
    ok: true
  }
}
// 實際上回傳的是 undefined,而不是物件


    從下圖可觀察,會有這種問題發生

    test1函數傳回undefined  ,  test2函數傳回物件


此案例比較直白的寫法如下



若團隊偏好「無分號」,請務必用 ESLint + Prettier 固定化,並遵循特定換行規則,避免 ASI 陷阱。整體來說,加分號最省心。


註釋:怎麼寫才加分

單行與多行

// 單行:解釋「為什麼」或標註 TODO/FIXME
/* 多行:
   描述設計考量或複雜邏輯的脈絡 */


 JSDoc(搭配型別提示更穩)

/**
 * 計算折扣後金額
 * @param {number} price 原價(>=0)
 * @param {number} rate 折扣率(0~1)
 * @returns {number} 折扣後金額
 */
function discounted(price, rate) {
  if (price < 0 || rate < 0 || rate > 1) throw new RangeError('invalid input');
  return Math.round(price * (1 - rate));
}


優點:

讓編輯器提供更準確的自動完成與跳轉

不用引入 TypeScript 也能獲得不少型別安全(可作為過渡)


實作範例:一步一步做出「表單驗證+清單篩選」的小模組

場景:你要做一個簡單的商品清單頁,具備兩件事

        a.    依關鍵字篩選商品

        b.    表單送出前檢查資料(例:金額為正、關鍵字長度限制)

1.    規劃(先定資料結構)

// 單一商品
/**
 * @typedef {Object} Item
 * @property {string} id
 * @property {string} name
 * @property {number} price
 */

// 篩選條件
/**
 * @typedef {Object} Filter
 * @property {string} keyword
 * @property {number} [maxPrice]
 */


2.    utilities.js(純函數、可測)

/**
 * @param {string} s
 * @returns {string}
 */
export function normalizeKeyword(s) {
  return (s ?? '').trim().toLowerCase();
}

/**
 * @param {Item[]} items
 * @param {Filter} f
 * @returns {Item[]}
 */
export function filterItems(items, f) {
  const kw = normalizeKeyword(f.keyword);
  return items.filter(it => {
    const nameHit = it.name.toLowerCase().includes(kw);
    const priceHit = typeof f.maxPrice === 'number' ? it.price <= f.maxPrice : true;
    return nameHit && priceHit;
  });
}

/**
 * 檢查表單資料是否合法
 * @param {{ keyword: string, maxPrice?: number }} form
 * @returns {{ ok: true } | { ok: false, message: string }}
 */
export function validateForm(form) {
  const kw = normalizeKeyword(form.keyword);
  if (kw.length === 0) return { ok: false, message: '請輸入關鍵字' };
  if (kw.length > 30) return { ok: false, message: '關鍵字過長(上限 30)' };
  if (form.maxPrice != null && (isNaN(form.maxPrice) || form.maxPrice <= 0)) {
    return { ok: false, message: '金額需為正數' };
  }
  return { ok: true };
}


3.    index.js(組裝、事件處理、與 DOM 互動)

import { filterItems, validateForm } from './utilities.js';

const items = [
  { id: 'p01', name: 'Macchiato Coffee', price: 120 },
  { id: 'p02', name: 'Matcha Latte', price: 150 },
  { id: 'p03', name: 'Cocoa', price: 100 },
];

const formEl = document.querySelector('#search-form');
const listEl = document.querySelector('#item-list');
const msgEl = document.querySelector('#message');

function render(list) {
  listEl.innerHTML = list.map(it => `<li>${it.name} - $${it.price}</li>`).join('');
}

formEl.addEventListener('submit', (e) => {
  e.preventDefault();
  const formData = new FormData(formEl);
  const keyword = formData.get('keyword');
  const maxPriceRaw = formData.get('maxPrice');
  const maxPrice = maxPriceRaw ? Number(maxPriceRaw) : undefined;

  const result = validateForm({ keyword, maxPrice });
  if (!result.ok) {
    msgEl.textContent = result.message;
    return;
  }
  msgEl.textContent = '';
  const filtered = filterItems(items, { keyword, maxPrice });
  render(filtered);
});

// 初次渲染
render(items);


4.    HTML(簡版)

<form id="search-form">
  <input name="keyword" placeholder="輸入關鍵字">
  <input name="maxPrice" type="number" min="1" placeholder="最高價格">
  <button type="submit">搜尋</button>
</form>
<p id="message" style="color:crimson;"></p>
<ul id="item-list"></ul>


5.    常見錯誤(對應此範例)

忘了 e.preventDefault() 導致頁面重新載入。

將 FormData.get() 的結果直接當數字用(字串需轉型)。

innerHTML 拼接未處理使用者輸入(如需安全,請用 textContent 或模板轉義)。

normalizeKeyword 沒處理 null/undefined。

事件綁定在錯誤的元素上(form vs button)。


更多語言眉角與實務建議

1.    嚴格模式

ES Module 自帶嚴格模式;非模組環境建議在檔案頂部加 "use strict"; 以避免隱式全域變數等問題。

2.    比較運算子:一律用 === / !==

避免 == 的隱式轉型陷阱(0 == false 為真、'' == 0 為真);

若需容忍 null 與 undefined 共同判斷,可用 value == null(僅此例外)。

if (input == null) { /* 同時攔截 null 與 undefined */ }


3.    真值/偽值(Truthy/Falsy)

偽值:false, 0, -0, 0n, "", null, undefined, NaN。

檢查字串是否有內容,應用 .trim() 後再判斷。


4.    浮點數與金額

避免直接用浮點數計價,改存整數的最小貨幣單位(例如分)。或用 Intl.NumberFormat 與 BigInt/decimal(新提案)策略搭配。

const priceCents = 1999;              // $19.99
const totalCents = priceCents * 3;
const display = (totalCents / 100).toFixed(2);


5.     this 綁定與回呼

DOM 事件處理器裡使用 class 成員時,確保 this 指向正確(箭頭函數或 bind)。

不要把需要 this 的方法直接當回呼丟出(容易丟失綁定)。


6.     非同步:async/await 的錯誤處理

每個 await 旁都應有錯誤處理策略:try/catch、或封裝成回傳 { ok, data|error } 的函數。

多個獨立請求併發,用 Promise.all;彼此相依則串接。

async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return { ok: true, data: await res.json() };
  } catch (err) {
    return { ok: false, error: err };
  }
}

7.    JSON 與資料邊界

JSON.parse 需 try/catch;

API 回傳可能是空字串、204 No Content,別直接 parse。


8.    日期與時區

使用 Date 時注意時區轉換;顯示建議 Intl.DateTimeFormat 或引入專門庫(若可)。

避免自己手刻時差計算。


9.    物件與陣列的不可變更新

使用展開運算子(spread)或 Array.prototype.map/filter,避免直接改原陣列(除非刻意)。

const next = { ...state, count: state.count + 1 };
const newList = list.map(x => x.id === id ? { ...x, done: true } : x);


10.    模組與匯出

預設用 ES Modules:export / import。

一個檔案最好僅有一個預設匯出(default),其餘用具名匯出,利於 IDE 跳轉與 Tree Shaking。


詳細操作步驟(把上述原則落地)

以下以「做一個可重用的數值工具庫」為例:包含安全加法、四捨五入到指定位數、平均值。

步驟 1:建立專案結構

/js-utils
  /src
    number.js
    average.js
    index.js
  /test
    number.test.js
    average.test.js


步驟 2:定義需求與邊界

safeAdd(a, b):若任一不是有限數,拋錯。

roundTo(num, digits):digits 為整數且介於 0~10。

average(nums):空陣列拋錯;若含非數字,拋錯。


步驟 3:實作(src/number.js)

/**
 * @param {unknown} x
 * @returns {boolean}
 */
function isFiniteNumber(x) {
  return typeof x === 'number' && Number.isFinite(x);
}

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export function safeAdd(a, b) {
  if (!isFiniteNumber(a) || !isFiniteNumber(b)) {
    throw new TypeError('safeAdd expects finite numbers');
  }
  return a + b;
}

/**
 * @param {number} num
 * @param {number} digits
 */
export function roundTo(num, digits = 0) {
  if (!Number.isInteger(digits) || digits < 0 || digits > 10) {
    throw new RangeError('digits must be 0..10');
  }
  if (!isFiniteNumber(num)) throw new TypeError('num must be finite');
  const p = 10 ** digits;
  return Math.round(num * p) / p;
}


步驟 4:實作(src/average.js)

import { safeAdd } from './number.js';

/**
 * @param {number[]} nums
 * @returns {number}
 */
export function average(nums) {
  if (!Array.isArray(nums) || nums.length === 0) {
    throw new TypeError('nums must be non-empty array');
  }
  let sum = 0;
  for (const n of nums) {
    if (typeof n !== 'number' || !Number.isFinite(n)) {
      throw new TypeError('all elements must be finite numbers');
    }
    sum = safeAdd(sum, n);
  }
  return sum / nums.length;
}


步驟 5:集中匯出(src/index.js)

export * from './number.js';
export * from './average.js';


步驟 6:簡單使用(示例)

import { roundTo, average } from './src/index.js';

console.log(roundTo(1.005, 2)); // 1.01(正確處理小數)
console.log(average([1, 2, 3, 4])); // 2.5


步驟 7:加上基本測試要點(概念示例)

safeAdd(1, NaN) 應拋 TypeError

roundTo(1.2345, 2) 應等於 1.23

average([]) 應拋錯;average([1, Infinity]) 應拋錯


步驟 8:工具化與一致性

安裝並配置 ESLint + Prettier(規則如:必加分號、單引號、無未用變數、eqeqeq)。

在 Git pre-commit 加上 lint-staged:提交前自動格式化、靜態檢查。


常見錯誤與雷點

1.    把 var 當 let 用

        風險:提升與函數作用域導致值被覆蓋、for 迴圈閉包錯亂。

        作法:新碼一律 const / let,for 迴圈用 let i = 0。

2.   忘了處理 undefined/null

        風險:TypeError: Cannot read properties of undefined。

        作法:可用可選鏈 ?.、空值合併 ??,但別濫用;邏輯邊界要先定義清楚。

3.    濫用全域狀態

        風險:測試困難、難以追蹤副作用。

        作法:封裝在模組或 class,明確的輸入/輸出。

4.    this 綁定錯誤

        風險:方法裡的 this 是 undefined 或指到 window。

        作法:箭頭函數/bind;或在 constructor 進行綁定。

5.    await 沒有錯誤處理

        風險:一個錯誤讓整串流程中斷。

        作法:try/catch 或包裝成 { ok, data|error }。

6.    不必要的深拷貝

        風險:效能殺手。

        作法:先想資料流,再決定用淺拷貝(spread)或結構分享(immutable patterns)。

7.    JSON 處理掉坑

        風險:非 JSON 字串導致 JSON.parse 拋錯。

        作法:先判斷內容、或 try/catch 包住;後端回應設計要一致。

8.    浮點數誤差

        風險:金額計算不準。

        作法:存最小單位(整數),顯示才除以 10^n;或集中用工具函數。

9.    正則過度貪婪

        風險:意外吃掉過多內容。

        作法:學會使用懶惰量詞 *?、具名群組、與邊界錨點。

10.    DOM XSS 風險

        風險:把使用者輸入直接塞進 innerHTML。

        作法:用 textContent 或可信模板引擎;必要時做嚴格轉義。

11.    魔法數字/字串

        風險:看不懂,也難改。

        作法:提升為常數或列舉(enum-like object)。

12.    過度抽象

        風險:程式變繞口令。

        作法:先求直白,確定重複再抽象;抽象後要有測試保護。


範例片段大全

1.    防呆型資料讀取

/**
 * @template T
 * @param {unknown} x
 * @param {(x: unknown) => x is T} guard
 * @param {T} fallback
 * @returns {T}
 */
export function getOr(x, guard, fallback) {
  return guard(x) ? x : fallback;
}

export const isString = (x) => typeof x === 'string';
const name = getOr(data.name, isString, 'Guest');


2.    小而美的錯誤類型

export class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.name = 'HttpError';
    this.status = status;
  }
}


3.    併發請求與超時

export function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout')), ms)),
  ]);
}

const [user, posts] = await Promise.all([
  withTimeout(fetchJSON('/user'), 5000),
  withTimeout(fetchJSON('/posts'), 5000),
]);


結語:讓程式替你省腦,而不是偷你的腦

寫 JavaScript 的路上,最花時間的不是語法,而是「選擇」:用什麼宣告、如何命名、哪裡要拆函數、要不要分號、怎麼註釋。你不必每次都從零想,只要把一套穩定原則變成肌肉記憶。久了,你的專案會更安靜——少跳錯、多通過、重構也不心虛。


延伸閱讀推薦:



張貼留言 (0)
較新的 較舊