หน้าเว็บว่างเปล่า ไม่มี error สักบรรทัด เพราะตั้งชื่อฟังก์ชันว่า top
บั๊กที่แพงที่สุดคือบั๊กที่ไม่ทิ้งร่องรอยไว้ให้เลย ไม่มี error ในล็อกฝั่งเซิร์ฟเวอร์ ไม่มี 500 ไม่มีอะไรผิดใน static check หน้าเว็บแค่ว่างเปล่า และคำตอบอยู่ในที่ที่เราไม่ได้เปิดดู คือคอนโซลของเบราว์เซอร์จริง
สรุปใน 1 นาที
- อาการ/ปัญหา
- deploy แล้วหน้าว่าง ไม่มี error ใน log และ node --check ผ่าน เพราะฟังก์ชันชื่อ top ชนกับ window.top
- ใครควรอ่าน
- คนเขียน JavaScript ในหน้าเว็บที่ใช้ฟังก์ชันระดับ global
- ทางแก้แบบสั้น
- เลี่ยงชื่อฟังก์ชันที่ชนกับ property ของ window และจัดลำดับโหลดสคริปต์ให้ถูก พร้อมด่านตรวจเปิดหน้าจริงด้วย Playwright ทุกโปรเจกต์
พอดีผมแก้หน้าเว็บหน้าหนึ่งเสร็จแล้ว deploy ขึ้นไป เปิดดูปรากฏว่าเนื้อหากลางหน้าหายเกลี้ยง เหลือแต่หัวกับท้าย ล็อกฝั่งเซิร์ฟเวอร์เงียบสนิท status คือ 200 ทุก request view source ก็เห็น HTML ครบทุกบรรทัด แต่บนจอไม่มีอะไร ผมนั่งไล่ cache ไล่ opcache ไล่ permission อยู่เกือบชั่วโมงก่อนจะเปิดคอนโซลของเบราว์เซอร์แล้วเจอคำตอบในสามวินาที ต้นเหตุคือผมตั้งชื่อฟังก์ชันว่า top
ชื่อไฟล์ ชื่อตัวแปรข้อมูล และชื่อหน้าในบทความนี้ผมเปลี่ยนใหม่หมด แต่อาการ ข้อความ error และวิธีตรวจคือของจริง
อาการ: ทุกด่านบอกว่าผ่าน แต่หน้าว่าง
โครงหน้าคือเลย์เอาต์กลาง แล้วเนื้อหาส่วนกลางเรนเดอร์ด้วย JavaScript จากตัวแปรข้อมูลก้อนหนึ่งที่โหลดมาจากไฟล์แยก พอเข้าหน้าจริงได้กรอบว่าง
<!-- layout: ข้างในเป็นสคริปต์ที่เรนเดอร์การ์ดทั้งหมดของหน้า -->
<div id="list"></div>
<script>
// ฟังก์ชันช่วยเรียงลำดับ ตั้งชื่อสั้น ๆ ให้อ่านง่าย
function top(items, n) { // <-- บรรทัดนี้ฆ่าทั้งไฟล์
return items.slice().sort((a, b) => b.score - a.score).slice(0, n);
}
function render() {
const box = document.getElementById('list');
box.innerHTML = top(CATALOG.items, 12).map(cardHTML).join('');
}
document.addEventListener('DOMContentLoaded', render);
</script>
สิ่งที่ผมตรวจไปก่อนหน้านั้นแล้วทั้งหมดคือ
# 1) ไวยากรณ์ผ่าน
$ node --check public/js/page.js
# (ไม่มี output แปลว่าผ่าน)
# 2) ไฟล์ขึ้นไปจริง และเนื้อหาใหม่
$ curl -s "https://example.com/js/page.js?v=$RANDOM" | grep -c 'function top'
1
# 3) หน้าเว็บตอบ 200 และมี div ปลายทางอยู่จริง
$ curl -s "https://example.com/list?v=$RANDOM" | grep -c 'id="list"'
1
สามด่านนี้ผ่านหมด และนี่คือเหตุผลที่ผมไปหลงทางอยู่เกือบชั่วโมง เพราะความรู้สึกว่า "ตรวจแล้วนี่ครับ" มันเลยพาไปโทษแคชกับ deploy แทน
node --check ตรวจแค่ว่าไวยากรณ์ถูก และมันรันอยู่บน Node ที่ไม่มี window ข้อผิดพลาดทั้งชุดที่เกิดจากการชนกับ global ของเบราว์เซอร์เลยมองไม่เห็นด้วยเครื่องมือฝั่ง Node เด็ดขาด ตัวตรวจต้องตรงกับลักษณะความพังเสมอ ถ้าอาการเกิดในเบราว์เซอร์ ตัวตรวจก็ต้องเป็นเบราว์เซอร์
ตัวจริง: ชื่อฟังก์ชันชนกับ property ของ window
พอเปิดคอนโซลในเบราว์เซอร์จริง ข้อความอยู่ตรงนั้นตั้งแต่แรก
# คอนโซลของเบราว์เซอร์ — ข้อความต่างกันไปตาม engine แต่ใจความเดียวกัน
Uncaught SyntaxError: cannot declare global binding 'top': property must be configurable
at page.js:1
window.top เป็น property ที่เบราว์เซอร์นิยามไว้ให้เองตั้งแต่ต้น และนิยามไว้แบบที่แก้ไม่ได้ ลบไม่ได้ พอสคริปต์ของเราประกาศ function top() ที่ระดับบนสุด มันคือการขอสร้าง binding ชื่อ top บน global object ซึ่งชนกับของเดิมที่ทับไม่ได้ เอนจินเลยโยน error ทิ้งตั้งแต่ขั้นตอนเตรียมสโคป
จุดที่โหดคือมันไม่ได้พังเฉพาะฟังก์ชันนั้น ขั้นตอนการเตรียม binding ของทั้งสคริปต์เกิดขึ้น ก่อน ที่โค้ดบรรทัดแรกจะได้รัน พอขั้นตอนนี้ล้ม โค้ดทั้งบล็อกเลยไม่ได้ทำงานเลยแม้แต่บรรทัดเดียว ฟังก์ชัน render ก็ไม่ถูกนิยาม ตัวดักอีเวนต์ก็ไม่ถูกติดตั้ง ผลคือกล่องว่าง และเพราะมันเป็น error ของสคริปต์ล้วน ๆ ฝั่งเซิร์ฟเวอร์เลยไม่มีอะไรให้บันทึก
รายชื่อที่ต้องเลี่ยง
ชื่อที่เจอปัญหาบ่อยเพราะเป็นคำที่คนชอบเอามาตั้งเป็นชื่อฟังก์ชันหรือตัวแปรพอดี
// ชื่อที่ห้ามใช้ประกาศที่ระดับบนสุดของสคริปต์ เพราะ window มีอยู่แล้ว
const RESERVED = [
'top', 'name', 'length', 'status', 'open', 'close', 'closed',
'parent', 'self', 'frames', 'event', 'location', 'history', 'origin',
'focus', 'blur', 'print', 'stop', 'scroll', 'external', 'menubar',
];
// ความรุนแรงไม่เท่ากัน:
// top / parent / self / closed / length -> ประกาศไม่ได้เลย ทั้งบล็อกตาย
// name / status / origin -> ประกาศทับได้ แต่กลายเป็นการเขียนค่าใส่ window
// แล้ว window.name จะบังคับให้เป็นสตริง = บั๊กเงียบ
// event -> ทับได้ แต่ไปพังโค้ดตัวอื่นที่อ่าน window.event
เคสที่ยากกว่าคือ name กับ status เพราะมันไม่ระเบิด var status = 0 จะกลายเป็น window.status = 0 แล้วถูกแปลงเป็นสตริง "0" ทันที พอเอาไปเทียบแบบเข้มงวดก็ไม่ตรง คนอ่านโค้ดเป็นเดือนก็ยังไม่เห็นว่าผิดตรงไหน ส่วนตัวผมเลยตั้งกฎง่ายกว่านั้นคือ ไม่ประกาศอะไรที่ระดับบนสุดเลย ถ้าเลี่ยงได้
// ทางแก้ที่ตรงที่สุด: ครอบทั้งไฟล์ไว้ในสโคปของตัวเอง ชื่ออะไรก็ไม่ชนแล้ว
(function () {
'use strict';
function top(items, n) { // อยู่ในสโคปของ IIFE ไม่แตะ window
return items.slice().sort((a, b) => b.score - a.score).slice(0, n);
}
function render() {
const box = document.getElementById('list');
if (!box || !window.CATALOG) return; // กันไว้ก่อน จะได้ไม่พังเงียบ
box.innerHTML = top(window.CATALOG.items, 12).map(cardHTML).join('');
}
document.addEventListener('DOMContentLoaded', render);
window.renderList = render; // เปิดเฉพาะตัวที่คนอื่นต้องเรียกจริง
})();
ประกาศที่ระดับบนสุด
- ทุกชื่อไปนั่งบน window
- ชนของเบราว์เซอร์ = ทั้งบล็อกตาย
- ชนกับสคริปต์ตัวอื่นเงียบ ๆ ได้
- เครื่องมือฝั่ง Node มองไม่เห็น
ครอบด้วยสโคปของตัวเอง
- ชื่อภายในเป็นของไฟล์นั้นคนเดียว
- ตั้งชื่อว่าอะไรก็ได้ ไม่ต้องท่องรายชื่อ
- เปิดออก window เฉพาะที่จำเป็น
- ย้ายไปเป็น module ทีหลังง่าย
ถ้าโปรเจกต์ย้ายไปใช้ ES module ได้ ปัญหานี้หายไปเองเลยครับ เพราะ <script type="module"> มีสโคปของตัวเองอยู่แล้ว การประกาศข้างในไม่ไปสร้าง binding บน global แต่โปรเจกต์เก่าที่ยังเป็นสคริปต์ธรรมดาหลายไฟล์ ทางที่เจ็บน้อยที่สุดคือครอบ IIFE ทีละไฟล์
ตัวตรวจแบบ static ที่พอช่วยได้
ถึงจะบอกว่า static check ไม่พอ แต่มันยังมีประโยชน์ในฐานะด่านแรกที่เร็วมาก ผมเขียนตัวไล่หาการประกาศที่ระดับบนสุดแล้วเทียบกับรายชื่อต้องห้ามไว้ตัวหนึ่ง
# tools/scan_globals.py — ด่านแรก เร็ว แต่ไม่ใช่ด่านสุดท้าย
import re, sys, pathlib
RESERVED = {'top', 'name', 'length', 'status', 'open', 'close', 'closed', 'parent',
'self', 'frames', 'event', 'location', 'history', 'origin', 'focus',
'blur', 'print', 'stop', 'scroll', 'external', 'menubar'}
DECL = re.compile(r'^(?:function|var|let|const|class)\s+([A-Za-z_$][\w$]*)')
bad = 0
for p in pathlib.Path('public/js').rglob('*.js'):
for i, line in enumerate(p.read_text(encoding='utf-8').splitlines(), 1):
if line[:1] in ' \t': # เยื้องอยู่ = ไม่ใช่ระดับบนสุด ข้ามไป
continue
m = DECL.match(line)
if m and m.group(1) in RESERVED:
print(f'[ERR] {p}:{i} ประกาศ "{m.group(1)}" ที่ระดับบนสุด ชนกับ global ของ window')
bad += 1
print(f'[{"FAIL" if bad else "OK"}] พบปัญหา {bad} จุด')
sys.exit(1 if bad else 0)
ตัวนี้จับได้เฉพาะเคสที่เขียนตรง ๆ ไม่เยื้อง ซึ่งก็คือเคสส่วนใหญ่ที่เจอจริง แต่มันไม่รู้จักสคริปต์ที่ฝังอยู่ในไฟล์เทมเพลต และไม่รู้จักโค้ดที่สร้างขึ้นตอนรัน เพราะงั้นอย่าไปวางใจมันเป็นด่านเดียว
กับดักที่สอง: สคริปต์วางผิดที่ ข้อมูลยังไม่มา
หลังแก้ชื่อฟังก์ชันแล้วหน้าก็กลับมา แต่ผ่านไปอีกหน้าหนึ่งผมเจออาการว่างเปล่าซ้ำอีก คราวนี้ไม่มี error ในคอนโซลด้วยซ้ำ ต้นเหตุคนละเรื่องกันเลยคือ ลำดับ
เลย์เอาต์กลางโหลดไฟล์ข้อมูลไว้ท้ายสุดของหน้า ส่วนสคริปต์ที่เรนเดอร์ผมดันเขียนแทรกไว้ในเนื้อหาตรงกลาง ตัวที่เรนเดอร์เลยรันก่อนที่ข้อมูลจะมาถึง
<!-- ผิด: สคริปต์กลางหน้า รันก่อนไฟล์ข้อมูลท้ายหน้า -->
<section>
<div id="list"></div>
<script>
// CATALOG ยังไม่มีตัวตนตอนบรรทัดนี้ทำงาน
const items = (window.CATALOG && window.CATALOG.items) || [];
document.getElementById('list').innerHTML = items.map(cardHTML).join('');
// ได้ [] แล้วเรนเดอร์ว่าง — ไม่มี error เพราะเราดักไว้ด้วย || [] เรียบร้อย
</script>
</section>
<!-- ท้ายเลย์เอาต์ -->
<script src="/js/catalog.js"></script>
<script src="/js/site.js"></script>
ตัว || [] ที่ใส่ไว้ด้วยความหวังดีนี่แหละที่ทำให้มันเงียบ ถ้าไม่ใส่มันจะโยน error ให้เห็นตั้งแต่ครั้งแรก ผมเลยเปลี่ยนวิธีทั้งหมดเป็นย้ายสคริปต์ไปท้ายเลย์เอาต์ และให้มันส่งเสียงถ้าข้อมูลหาย
<!-- ถูก: ทุกสคริปต์ที่ใช้ข้อมูล ไปอยู่ท้ายเลย์เอาต์หลังไฟล์ข้อมูลเสมอ -->
<script src="/js/catalog.js"></script>
<script src="/js/site.js"></script>
<script>
(function () {
if (!window.CATALOG) { // ห้ามกลืนเงียบ
console.error('[list] CATALOG ยังไม่ถูกโหลด — สคริปต์นี้วางผิดที่');
return;
}
window.renderList();
})();
</script>
รูปแบบ const x = window.DATA || [] ดูปลอดภัยดี แต่มันเปลี่ยน "พังแล้วรู้ทันที" ให้กลายเป็น "ว่างเปล่าแบบไม่มีสาเหตุ" ถ้าค่านั้นเป็นสิ่งที่ต้องมี ให้ตรวจแล้วโยน error หรืออย่างน้อย console.error ออกมา ค่า fallback มีไว้สำหรับของที่ขาดได้จริง ไม่ใช่สำหรับกลบความผิดพลาดของเราเอง
ด่านมาตรฐาน: เปิดด้วยเบราว์เซอร์จริง แล้วอ่าน pageerror
หลังโดนสองรอบผมเลิกเดา แล้วทำไฟล์ตรวจตัวหนึ่งวางไว้ในทุกโปรเจกต์ที่มีหน้าเว็บ หน้าที่ของมันมีอย่างเดียวคือเปิดหน้าจริงด้วยเบราว์เซอร์จริง เก็บ pageerror กับ error ในคอนโซล แล้วยืนยันว่าสิ่งที่ควรเรนเดอร์นั้นเรนเดอร์จริง
// tools/page-check.cjs — ด่านสุดท้ายก่อนบอกว่าเสร็จ
const { chromium } = require('playwright');
const TARGETS = [
{ url: 'https://example.com/', expect: '#hero', min: 1 },
{ url: 'https://example.com/list', expect: '#list .card', min: 12 },
];
(async () => {
const b = await chromium.launch();
let bad = 0;
for (const t of TARGETS) {
const p = await b.newPage();
const errs = [];
p.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
p.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); });
await p.goto(t.url + '?v=' + Date.now(), { waitUntil: 'load', timeout: 35000 });
await p.waitForTimeout(1200);
const n = await p.evaluate(s => document.querySelectorAll(s).length, t.expect);
const ok = n >= t.min && errs.length === 0;
console.log(`[${ok ? 'OK' : 'FAIL'}] ${t.url} — ${t.expect} = ${n} (ต้องการ ${t.min})`);
errs.forEach(e => console.log(' ', e));
if (!ok) bad++;
await p.close();
}
await b.close();
console.log(bad ? `\n[FAIL] มีปัญหา ${bad} หน้า` : '\n[OK] ผ่านทุกหน้า');
process.exit(bad ? 1 : 0);
})();
รันแล้วผลตอนที่ยังไม่แก้หน้าตาแบบนี้ ซึ่งชี้เป้าให้เสร็จสรรพในบรรทัดเดียว
$ node tools/page-check.cjs
[OK] https://example.com/ — #hero = 1 (ต้องการ 1)
[FAIL] https://example.com/list — #list .card = 0 (ต้องการ 12)
PAGEERROR: cannot declare global binding 'top': property must be configurable
[FAIL] มีปัญหา 1 หน้า
ตอนแรกผมเขียนให้มันเช็กแค่ว่า document.body.innerText ยาวกว่าศูนย์ ซึ่งผ่านตลอดเพราะหัวกับท้ายเว็บยังอยู่ ตัวตรวจต้องนับ selector ที่เจาะจง ของสิ่งที่หน้านั้นมีหน้าที่แสดง และต้องระบุจำนวนขั้นต่ำด้วย ไม่งั้นหน้าที่เรนเดอร์การ์ดได้ใบเดียวจากที่ควรมีสิบสองใบก็ยังผ่าน
ผูกเข้ากระบวนการ ไม่ให้พึ่งความจำ
ตัวตรวจที่ต้องนึกได้เองถึงจะรัน คือตัวตรวจที่ไม่มีอยู่จริงครับ ผมผูกมันไว้สองจุด
# .git/hooks/pre-push — ด่านเร็ว ตรวจไฟล์ก่อนดันขึ้น
changed=$(git diff --name-only origin/main...HEAD)
echo "$changed" | grep -qE '\.(js|php|blade\.php|html)$' || exit 0
python tools/scan_globals.py || { echo "[BLOCK] ชื่อชนกับ global ของ window"; exit 1; }
# ด่านช้า ตรวจหน้าจริงหลัง deploy — ผูกไว้ท้ายสคริปต์ deploy
# node tools/page-check.cjs || echo "[WARN] หน้าจริงยังไม่ผ่าน ให้ย้อน deploy"
ลำดับที่ผมใช้จริงทุกครั้งก่อนจะบอกใครว่างานเสร็จ
- รัน
scan_globals.pyเป็นด่านแรก ใช้เวลาไม่ถึงวินาที จับเคสตั้งชื่อชนได้เกือบหมด - ครอบสคริปต์ทุกไฟล์ด้วย IIFE หรือย้ายไปเป็น module แล้วเปิดออก
windowเฉพาะตัวที่คนอื่นเรียก - ย้ายสคริปต์ที่ใช้ตัวแปรข้อมูลไปท้ายเลย์เอาต์ หลังไฟล์ข้อมูลเสมอ ห้ามแทรกกลางเนื้อหา
- ถอด fallback แบบ
|| []ออกจากค่าที่ต้องมี เปลี่ยนเป็นตรวจแล้วส่งเสียง - deploy แล้วรัน
page-check.cjsกับ URL จริง ไม่ใช่ไฟล์ในเครื่อง ต้องได้ OK ทุกหน้า - ถ้าเจอ FAIL ให้อ่านบรรทัด PAGEERROR ก่อนอย่างอื่นทั้งหมด อย่าเพิ่งไปไล่แคชหรือ permission
ตัวเลขของงานนี้
บทเรียนที่ผมเอาไปใช้ต่อคือเวลาเจอหน้าว่าง อย่าเพิ่งไปแตะ server เลยครับ หน้าว่างโดยที่ HTML มาครบและ status เป็น 200 แปลว่าโค้ดฝั่งเซิร์ฟเวอร์ทำงานจบไปแล้วเรียบร้อย ปัญหาอยู่หลังจากนั้นทั้งหมด และที่เดียวที่บันทึกเหตุการณ์ช่วงนั้นไว้คือคอนโซลของเบราว์เซอร์ ซึ่งเป็นที่ที่เราไม่ค่อยเปิดดูเพราะมันไม่ได้อยู่ในกระบวนการ deploy ของใครเลย ใครจะเอาสคริปต์ตรวจสองตัวนี้ไปปรับ selector กับรายชื่อต้องห้ามให้เข้ากับโปรเจกต์ตัวเองก็ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย