router.js

Download
javascript 161 lines 4.2 KB
  1/**
  2 * Simple Hash-Based SPA Router
  3 *
  4 * Features:
  5 * - Route registration with path patterns
  6 * - Dynamic route parameters (e.g., /user/:id)
  7 * - Hash-based navigation (#/path)
  8 * - 404 handling
  9 * - Navigation guards (before/after hooks)
 10 * - History API support
 11 */
 12
 13class Router {
 14    constructor() {
 15        this.routes = {};
 16        this.currentRoute = null;
 17        this.notFoundHandler = null;
 18        this.beforeHooks = [];
 19        this.afterHooks = [];
 20
 21        // Listen for hash changes
 22        window.addEventListener('hashchange', () => this.handleRoute());
 23        window.addEventListener('load', () => this.handleRoute());
 24    }
 25
 26    /**
 27     * Register a route with its handler
 28     * @param {string} path - Route path (e.g., '/', '/user/:id')
 29     * @param {Function} handler - Function that returns HTML content
 30     */
 31    addRoute(path, handler) {
 32        this.routes[path] = {
 33            pattern: this.pathToRegex(path),
 34            handler: handler,
 35            path: path
 36        };
 37    }
 38
 39    /**
 40     * Convert path pattern to regex for matching
 41     * Supports dynamic parameters like :id, :name
 42     */
 43    pathToRegex(path) {
 44        // Escape special regex characters except :
 45        const pattern = path
 46            .replace(/\//g, '\\/')
 47            .replace(/:\w+/g, '([^/]+)');
 48        return new RegExp(`^${pattern}$`);
 49    }
 50
 51    /**
 52     * Extract parameters from URL path
 53     * @param {string} route - Route pattern with parameters
 54     * @param {string} path - Actual path from URL
 55     * @returns {Object} Parameters object
 56     */
 57    extractParams(route, path) {
 58        const params = {};
 59        const routeParts = route.split('/');
 60        const pathParts = path.split('/');
 61
 62        routeParts.forEach((part, i) => {
 63            if (part.startsWith(':')) {
 64                const paramName = part.slice(1);
 65                params[paramName] = decodeURIComponent(pathParts[i]);
 66            }
 67        });
 68
 69        return params;
 70    }
 71
 72    /**
 73     * Navigate to a new route programmatically
 74     */
 75    navigate(path) {
 76        window.location.hash = path;
 77    }
 78
 79    /**
 80     * Handle route changes
 81     */
 82    async handleRoute() {
 83        const path = window.location.hash.slice(1) || '/';
 84
 85        // Execute before hooks
 86        for (const hook of this.beforeHooks) {
 87            const result = await hook(path);
 88            if (result === false) return; // Cancel navigation
 89        }
 90
 91        // Find matching route
 92        let matchedRoute = null;
 93        let params = {};
 94
 95        for (const [routePath, route] of Object.entries(this.routes)) {
 96            if (route.pattern.test(path)) {
 97                matchedRoute = route;
 98                params = this.extractParams(routePath, path);
 99                break;
100            }
101        }
102
103        // Render content
104        const appElement = document.getElementById('app');
105
106        if (matchedRoute) {
107            this.currentRoute = { path, params };
108            appElement.innerHTML = await matchedRoute.handler(params);
109            this.updateActiveLinks(path);
110        } else if (this.notFoundHandler) {
111            appElement.innerHTML = await this.notFoundHandler();
112        } else {
113            appElement.innerHTML = '<h1>404 - Page Not Found</h1>';
114        }
115
116        // Add page transition animation
117        appElement.classList.remove('page-enter');
118        void appElement.offsetWidth; // Trigger reflow
119        appElement.classList.add('page-enter');
120
121        // Execute after hooks
122        for (const hook of this.afterHooks) {
123            await hook(path, params);
124        }
125    }
126
127    /**
128     * Update active state of navigation links
129     */
130    updateActiveLinks(currentPath) {
131        document.querySelectorAll('.nav-link').forEach(link => {
132            const href = link.getAttribute('href').slice(1); // Remove #
133            link.classList.toggle('active', href === currentPath);
134        });
135    }
136
137    /**
138     * Set 404 handler
139     */
140    setNotFound(handler) {
141        this.notFoundHandler = handler;
142    }
143
144    /**
145     * Add navigation guard (before route change)
146     */
147    beforeEach(hook) {
148        this.beforeHooks.push(hook);
149    }
150
151    /**
152     * Add hook after route change
153     */
154    afterEach(hook) {
155        this.afterHooks.push(hook);
156    }
157}
158
159// Export router instance
160export default new Router();