Now.js Framework Documentation

Now.js Framework Documentation

RouterManager

TH 09 Nov 2026 03:28

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' }
  ]
});
<!-- ลิงก์ปกติจะทำงานอัตโนมัติ -->
<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:

  1. เส้นทาง resource แบบ relative พัง เมื่อ browser อยู่ที่ URL ลึก เช่น /admin/widgets/facebook แล้ว reload — js/main.js จะ resolve เส้นทางจาก URL ปัจจุบัน ไม่ใช่จาก admin root
  2. การตรวจจับ 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 จะถูกปฏิเสธแม้จะใช้ prefix http:// ก็ตาม

ข้อควรระวัง

⚠️ 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`

เอกสารที่เกี่ยวข้อง