Now.js Framework Documentation
RouterManager
RouterManager
ภาพรวม
RouterManager คือระบบ client-side routing ใน Now.js Framework สำหรับ SPA (Single Page Application)
ใช้เมื่อ:
- ต้องการ SPA routing
- ต้องการ page navigation without reload
- ต้องการ route guards
- ต้องการ dynamic routes
ทำไมต้องใช้:
- ✅ Hash and History mode
- ✅ Route parameters
- ✅ Navigation guards
- ✅ Lazy loading
- ✅ Auto template loading
- ✅ Scroll restoration
การใช้งานพื้นฐาน
Initialization
await RouterManager.init({
mode: 'history', // or 'hash'
routes: [
{ path: '/', template: '/pages/home.html' },
{ path: '/about', template: '/pages/about.html' },
{ path: '/users/:id', template: '/pages/user.html' }
]
});HTML Links
<!-- ลิงก์ปกติจะทำงานอัตโนมัติ -->
<a href="/about">About</a>
<a href="/users/123">User Profile</a>
<!-- พร้อม query string ใน href -->
<a href="/search?q=hello&page=1">Search</a>
<!-- Navigation แบบ programmatic -->
<button onclick="RouterManager.navigate('/contact')">Contact</button>การส่ง Query Parameters ผ่านลิงก์
RouterManager รองรับ attribute data-params และ data-param-* บน <a> เพื่อส่ง query parameter ไปยังหน้าปลายทางโดยไม่ต้องเขียน JavaScript expression
data-params — ส่งต่อ params จาก URL ปัจจุบัน
อ่าน query parameter จาก URL ปัจจุบัน (window.location.search) แล้วส่งต่อไปยังปลายทาง
<!-- ส่งเฉพาะ "parent" จาก URL ปัจจุบัน -->
<!-- URL ปัจจุบัน: /menus?parent=0_MAINMENU -->
<!-- นำทางไป: /menu?parent=0_MAINMENU -->
<a href="/menu" data-params="parent">Add Menu</a>
<!-- ส่งหลาย params -->
<!-- URL ปัจจุบัน: /menus?parent=0_MAINMENU&lang=en -->
<!-- นำทางไป: /menu?parent=0_MAINMENU&lang=en -->
<a href="/menu" data-params="parent,lang">Add Menu</a>
<!-- ส่งทุก params จาก URL ปัจจุบัน -->
<a href="/menu" data-params="*">Add Menu</a>data-param-* — เพิ่ม params ค่าคงที่
ผนวก query parameter ค่าคงที่โดยไม่ขึ้นกับ URL ปัจจุบัน
<!-- นำทางไป: /menus?parent=0_MAINMENU -->
<a href="/menus" data-param-parent="0_MAINMENU">Main Menu</a>
<!-- หลาย params คงที่ -->
<a href="/menus" data-param-parent="0_MAINMENU" data-param-lang="en">Main Menu</a>ใช้ร่วมกัน
data-params (จาก URL) และ data-param-* (ค่าคงที่) ใช้ร่วมกันได้ โดยค่าคงที่จะมีความสำคัญกว่า
<!-- ส่ง "lang" จาก URL แต่กำหนด type=sub เสมอ -->
<a href="/menu" data-params="lang" data-param-type="sub">Add Sub-menu</a>การตั้งค่า
ตั้งผ่าน Now.init({router: {...}}) (ค่าที่ไม่ระบุใช้ค่าเริ่มต้นใน RouterManager.DEFAULT_CONFIG)
await Now.init({
router: {
enabled: true,
mode: 'history', // 'history' หรือ 'hash'
base: currentDir, // ไม่ระบุ = ตรวจหาเอง (autoDetectBase)
// layout ที่ทุกหน้าใช้ร่วมกัน — ดูหัวข้อ Layout ด้านล่าง
layout: 'layouts/app.html',
layoutOutlet: '[data-router-outlet]',
notFound: {
behavior: 'render', // 'render' | 'redirect' | 'preserve'
template: '404.html',
title: 'Page Not Found'
// layout: false // ไม่ระบุ = ใช้ router.layout
},
trailingSlash: {mode: 'remove'},
routes: {
'/': {template: 'index.html', title: 'Dashboard'},
'/users/:id': {template: 'user.html', title: 'User'},
'/login': {template: 'login.html', layout: false, requireGuest: true}
}
}
});เนื้อหาของหน้าจะถูกวางลง element หลัก Now.config.mainSelector (ค่าเริ่มต้น #main, [role="main"]) หรือลง outlet ของ layout ถ้า route นั้นมี layout
Layout
layout คือส่วนที่ทุกหน้าใช้ร่วมกัน เช่น sidebar, topbar, footer — หน้าแต่ละหน้าจะถูกวางลง outlet ([data-router-outlet]) ข้างใน layout และ layout จะไม่ถูกสร้างใหม่ตราบที่หน้าถัดไปใช้ layout เดียวกัน component และ api component ใน layout จึงไม่ถูก mount และไม่ดึงข้อมูลใหม่ทุกครั้งที่เปลี่ยนหน้า (เมนูไม่กระพริบ)
<!-- templates/layouts/app.html -->
<aside data-component="sidebar"></aside>
<div class="main-content">
<header data-component="topbar"></header>
<main class="content" data-router-outlet></main>
<footer class="footer">...</footer>
</div>template ของหน้าจึงมีแค่เนื้อหาของหน้านั้น ไม่ต้องใส่ sidebar/topbar ซ้ำทุกไฟล์
ค่า layout |
ผล |
|---|---|
| ไม่ระบุใน route | ใช้ router.layout |
'layouts/app.html' |
ชื่อ template — โหลดแบบเดียวกับ template ของ route |
'#layout-app' |
element ในหน้า เช่น <template id="layout-app"> ใน index.php (ไม่ต้องโหลดไฟล์เพิ่ม) |
false / null |
ไม่มี layout — หน้าเต็ม element หลัก (login, register) |
เมื่อไรที่ layout ถูกสร้างใหม่
- route ถัดไปใช้ layout คนละตัว เช่น
/login(layout: false) →/ที่ใช้ app layout หลัง login — sidebar ได้ข้อมูลผู้ใช้ที่เพิ่ง login เอง - มีอะไรเขียนทับ element หลักไปแล้ว (เช่น
AuthErrorHandlerแสดงหน้า error) — router ตรวจว่า outlet ยังอยู่ในหน้า (isConnected) ก่อนใช้ layout เดิมเสมอ
ข้อควรรู้
- layout ที่ไม่มี outlet ทำให้การเปลี่ยนหน้าล้มเหลว (
navigate()คืนfalseและ log error ที่บอกชื่อ selector ที่หายไป) โดยหน้าเดิมยังอยู่ครบ — router ตรวจก่อนทำลายของเดิม - ข้อมูลใน layout ที่เคย "สดใหม่" เพราะถูกสร้างใหม่ทุกหน้า จะไม่ถูกโหลดใหม่เองแล้ว เช่น ชื่อ/รูปผู้ใช้หลังแก้โปรไฟล์ — ให้ api component ใน layout ฟัง event ด้วย
data-refresh-eventแล้วยิง event นั้นหลังบันทึก - การแปลภาษาหลังเปลี่ยนหน้าแปลเฉพาะเนื้อหาที่เพิ่งวาง (
I18nManager.updateElements()) ไม่ยิงlocale:changed— event นี้เกิดเฉพาะตอนเปลี่ยนภาษาจริง RouterManager.getContentElement()คืน element ที่หน้าปัจจุบันถูกวางลงไป (outlet หรือ element หลัก)
Route Definition
{
// URL path
path: '/users/:id',
// Template file
template: '/pages/user.html',
// API endpoint
api: '/api/users/:id',
// Route name
name: 'user-profile',
// ลิงก์เมนูที่ active เมื่ออยู่หน้านี้ (หน้ารายละเอียดชี้ไปหน้ารายการ)
// API ของหน้าส่ง menu_path มาแทนได้ — ดู MenuManager.md#ให้-api-เลือกเมนู-menu_path
menuPath: '/users',
// layout ของหน้านี้ (ไม่ระบุ = router.layout, false = ไม่มี layout) — ดูหัวข้อ Layout
layout: 'layouts/app.html',
// Metadata
meta: {
requiresAuth: true,
title: 'User Profile'
},
// Before enter guard
beforeEnter: (to, from) => {
if (!isAuthenticated()) {
return '/login'; // Redirect
}
return true; // Allow
}
}API อ้างอิง
RouterManager.navigate(path, params?, options?)
นำทางไปยัง route
| พารามิเตอร์ | ชนิด | คำอธิบาย |
|---|---|---|
path |
string | path ปลายทาง ใส่ ?query และ #hash มาด้วยได้ และจะขึ้นต้นด้วย base ของ router ก็ได้ |
params |
object | query parameter รวมทับ query ใน path แล้วเขียนลงแถบที่อยู่ |
options.replace |
boolean | แทนที่รายการประวัติปัจจุบัน แทนการเพิ่มรายการใหม่ |
options.force |
boolean | render ใหม่แม้ URL ไม่เปลี่ยน |
options.preserveQuery |
boolean | คง query ปัจจุบันไว้เมื่อ path ไม่มี query |
options.scroll |
boolean | false = ไม่เลื่อนหน้า (ปกติเลื่อนขึ้นบนสุด) |
options.source |
string | ที่มาของการนำทาง ส่งต่อให้ route:before · ลิงก์ในเมนูส่ง menu หน้าที่เอกสารเปิดมาส่ง initial ค่าเริ่มต้นคือ link |
คืนค่า: Promise<boolean>
RouterManager.navigate('/users/123');
RouterManager.navigate('/search', {q: 'john'}); // → /search?q=john
RouterManager.navigate('/search?page=2', {q: 'john'}); // → /search?page=2&q=john
RouterManager.navigate('/login', {}, {replace: true});นำทางไปหน้าที่อยู่แล้ว ถ้า path ตรงกับหน้าปัจจุบัน และ query ที่จะได้ (query ใน path
รวมกับ params) เท่ากับ query ปัจจุบัน (ไม่สนลำดับ key) การนำทางจะถูกข้าม เปลี่ยนแค่ #hash
ถ้ามี ส่วน query อื่นจะ render route ใหม่ ดังนั้นสองแบบนี้คือ URL เดียวกันและทำงานเหมือนกัน:
// อยู่ที่ /verify
RouterManager.navigate('/verify', {id: 'A'}); // → /verify?id=A (render)
RouterManager.navigate('/verify?id=A'); // อยู่ที่ /verify?id=A แล้ว: ไม่ทำอะไร
RouterManager.navigate('/verify', {id: 'A'}, {force: true}); // render ใหม่ก่อนแก้ไข router เทียบเฉพาะ query ที่เขียนใน
pathทำให้navigate('/verify', {id: 'A'})
ขณะอยู่ที่/verifyจบไปโดยไม่ทำอะไร
RouterManager.getParams()
Get route parameters
Returns: Object
// URL: /users/123
const params = RouterManager.getParams();
// { id: '123' }RouterManager.getQuery()
Get query parameters
Returns: Object
// URL: /search?q=john&page=2
const query = RouterManager.getQuery();
// { q: 'john', page: '2' }RouterManager.beforeEach(guard)
Global navigation guard
| Parameter | Type | Description |
|---|---|---|
guard |
function | Guard function |
RouterManager.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login');
} else {
next();
}
});RouterManager.afterEach(hook)
After navigation hook
RouterManager.afterEach((to, from) => {
document.title = to.meta.title || 'My App';
trackPageView(to.path);
});เหตุการณ์
| Event | เมื่อเกิด | Detail |
|---|---|---|
router:initialized |
init() เสร็จ | ไม่มี |
route:before |
ก่อนเริ่มเปลี่ยน route | {path, params, source} — source ตาม options.source · ปุ่มย้อนกลับและไปข้างหน้าไม่ส่ง event นี้ |
route:changed |
เปลี่ยน route สำเร็จ | {path, params, query, hash, route, previous, isNotFoundPage} |
route:guard:rejected |
guard ปฏิเสธการเข้า route | {reason, originalPath, redirectTo, timestamp} |
route:notFound |
ไม่พบ route ที่ตรงกับ path | {path, params, redirectTo} |
content:rendered |
เนื้อหาใหม่ถูกวางลง DOM แล้ว | {path, main, container} — container คือส่วนที่ถูกแทนที่ (outlet ของ layout หรือ element หลัก) |
EventManager.on('route:changed', (event) => {
console.log('Navigated to:', event.data.path);
});SPA ในโฟลเดอร์ย่อย
เมื่อ admin panel ถูก serve จาก subdirectory (เช่น /nowjs-gcms/admin/) แทนที่จะเป็น root ของ domain จะเกิดปัญหา 2 อย่างกับ history-mode routing:
- เส้นทาง resource แบบ relative พัง เมื่อ browser อยู่ที่ URL ลึก เช่น
/admin/widgets/facebookแล้ว reload —js/main.jsจะ resolve เส้นทางจาก URL ปัจจุบัน ไม่ใช่จาก admin root - การตรวจจับ
currentDirด้วยwindow.location.pathnameจะคืนค่า directory ผิดเมื่อ refresh จาก deep route
วิธีแก้: PHP entry point + <base href> + currentDir จาก script src
admin/index.php — สร้าง <base href> แบบ dynamic โดยไม่ต้อง hardcode:
<?php
// ทำงานกับทุก install path ไม่ต้อง hardcode
// $_SERVER['SCRIPT_NAME'] ชี้ไปที่ PHP file ที่รันอยู่เสมอ
// ไม่ขึ้นกับ URL rewriting ดังนั้น dirname() จะให้ base ที่ถูกต้องเสมอ
$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/') . '/';
?>
<!DOCTYPE html>
<html>
<head>
<base href="<?= htmlspecialchars($base, ENT_QUOTES) ?>">
...admin/.htaccess — rewrite ทุก request ที่ไม่ใช่ไฟล์จริงไปที่ index.php:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]Root .htaccess — เพิ่ม rule ของ admin ก่อน public CMS catch-all ไม่งั้น CMS index.php จะดักจับ deep admin routes ก่อน:
# Admin SPA route ต้องไปถึง admin/index.php ไม่ใช่ public CMS
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]
# Public CMS catch-all (มาหลัง admin rule)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]admin/js/main.js — ตรวจจับ currentDir จาก attribute src ของ script ไม่ใช่จาก window.location.pathname:
// ❌ พังเมื่อ refresh จาก deep route (เช่น /admin/widgets/facebook)
const currentDir = window.location.pathname.substring(
0, window.location.pathname.lastIndexOf('/') + 1
);
// ✅ ถูกต้องเสมอ — อ่านจาก src attribute ของ script โดยตรง
const mainScriptEl = document.querySelector('script[src*="js/main.js"]');
let currentDir;
if (mainScriptEl) {
const scriptUrl = new URL(mainScriptEl.src);
currentDir = scriptUrl.pathname.replace(/\/js\/main\.js$/, '') + '/';
} else {
// Fallback สำหรับกรณีที่ไม่ใช่ SPA หรือโหลดโดยตรง
const currentPath = window.location.pathname;
currentDir = currentPath.substring(0, currentPath.lastIndexOf('/') + 1);
}จากนั้นส่ง currentDir เข้า Now.init:
await Now.init({
paths: {
templates: `${currentDir}templates`,
components: `${currentDir}components`,
translations: `${currentDir}../language`
},
router: {
base: currentDir,
...
}
});Dynamic Routes ที่ชี้ไปนอก Templates Directory
โดยปกติ TemplateManager จะ resolve เส้นทาง template ให้อยู่ภายใต้ paths.templates
Now.resolvePath() ตัด ../ ทั้งหมดออก เพื่อความปลอดภัย จึงไม่สามารถใช้ relative traversal หนีออกจาก templates directory ได้
รูปแบบ Widget — templates อยู่ใน sibling directory
ถ้า widget แต่ละตัวอยู่ใน directory ของตัวเองนอก admin/ (เช่น Widgets/facebook/index.html)
ให้สร้าง path เป็น http:// URL แบบเต็ม คำนวณครั้งเดียวหลังรู้ค่า currentDir
TemplateManager จะ bypass resolvePath โดยอัตโนมัติสำหรับ absolute URL:
// currentDir = '/nowjs-gcms/admin/'
// ตัด last path segment ("admin/") เพื่อได้ project root แล้วต่อ Widgets/
const widgetsDir =
window.location.origin +
currentDir.replace(/[^/]+\/$/, '') + // → '/nowjs-gcms/'
'Widgets/';
// widgetsDir = 'http://localhost/nowjs-gcms/Widgets/'ใช้ :module เป็น route parameter ใน template path:
routes: {
'/widgets/:module': {
template: `${widgetsDir}:module/index.html`,
// resolve เป็น เช่น http://localhost/nowjs-gcms/Widgets/facebook/index.html
title: '{LNG_Widget}',
menuPath: '/widgets',
requireAuth: true,
beforeEnter: requireAdmin
}
}โครงสร้าง directory:
nowjs-gcms/
├── admin/
│ ├── index.php ← SPA entry point พร้อม dynamic <base href>
│ ├── .htaccess ← rewrite ทุก route ไป index.php
│ ├── js/main.js
│ └── templates/ ← admin templates ปกติ
└── Widgets/
├── facebook/
│ └── index.html ← โหลดผ่าน widgetsDir
├── textlinks/
│ └── index.html
└── share/
└── index.htmlความปลอดภัย:
TemplateManager.validateRequest()บังคับ same-origin โดยค่าเริ่มต้น
allowedOriginsจะถูก seed ด้วยwindow.location.originตอน init อัตโนมัติ
ดังนั้น URL template ข้าม domain จะถูกปฏิเสธแม้จะใช้ prefixhttp://ก็ตาม
ข้อควรระวัง
⚠️ 1. History Mode ต้อง Server Config
# Apache (.htaccess)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule># Nginx
location / {
try_files $uri $uri/ /index.html;
}⚠️ 2. Guard ต้องเรียก next()
// ❌ ลืมเรียก next()
RouterManager.beforeEach((to, from, next) => {
console.log('Guard');
// Navigation stuck!
});
// ✅ Always call next()
RouterManager.beforeEach((to, from, next) => {
console.log('Guard');
next();
});⚠️ 3. Root .htaccess catch-all ดักจับ SPA subroute
ถ้า SPA อยู่ใน subdirectory และ root .htaccess มี catch-all rule
มันจะดักจับ request เช่น /admin/widgets/facebook ก่อนที่ admin/.htaccess จะทำงาน
ให้วาง admin rule ก่อน public catch-all เสมอ:
# ✅ Admin rule ก่อน
RewriteRule ^admin/(.*)$ admin/index.php [L,QSA]
# Public CMS catch-all ทีหลัง
RewriteRule ^(.*)$ index.php [L,QSA]⚠️ 4. ../ ใน template path ถูกตัดออก
Now.resolvePath() ลบ ../ ทั้งหมดออกเพื่อความปลอดภัย
ให้ใช้ absolute http:// URL (คำนวณจาก window.location.origin) แทน:
// ❌ ถูกตัดออกโดย resolvePath — resolve ไปเส้นทางผิด
template: `${currentDir}../Widgets/:module/index.html`
// ✅ http:// bypass resolvePath ได้ทั้งหมด
const widgetsDir = window.location.origin + currentDir.replace(/[^/]+\/$/, '') + 'Widgets/';
template: `${widgetsDir}:module/index.html`เอกสารที่เกี่ยวข้อง
- TemplateManager - Templates และการ resolve เส้นทาง
- AuthManager - Authentication