1 /** 2 * @fileOverview simple router to get path parameter and query parameter from hash(old ie) or url(html5) 3 * @author yiminghe@gmail.com 4 */ 5 KISSY.add('mvc/router', function (S, Event, Base) { 6 var queryReg = /\?(.*)/, 7 each = S.each, 8 // take a breath to avoid duplicate hashchange 9 BREATH_INTERVAL = 100, 10 grammar = /(:([\w\d]+))|(\\\*([\w\d]+))/g, 11 // all registered route instance 12 allRoutes = [], 13 win = S.Env.host, 14 history = win.history , 15 supportNativeHistory = !!(history && history['pushState']), 16 ROUTER_MAP = "__routerMap"; 17 18 function findFirstCaptureGroupIndex(regStr) { 19 var r, i; 20 for (i = 0; i < regStr.length; i++) { 21 r = regStr.charAt(i); 22 // skip escaped reg meta char 23 if (r == "\\") { 24 i++; 25 } else if (r == "(") { 26 return i; 27 } 28 } 29 throw new Error("impossible to not to get capture group in kissy mvc route"); 30 } 31 32 function getHash() { 33 // 不能 location.hash 34 // http://xx.com/#yy?z=1 35 // ie6 => location.hash = #yy 36 // 其他浏览器 => location.hash = #yy?z=1 37 return location.href.replace(/^[^#]*#?!?(.*)$/, '$1'); 38 } 39 40 /** 41 * get url fragment and dispatch 42 */ 43 function getFragment() { 44 if (Router.nativeHistory && supportNativeHistory) { 45 return location.pathname.substr(Router.urlRoot.length) + location.search; 46 } else { 47 return getHash(); 48 } 49 } 50 51 /** 52 * slash ------------- start 53 */ 54 55 /** 56 * whether string end with slash 57 * @param str 58 */ 59 function endWithSlash(str) { 60 return S.endsWith(str, "/"); 61 } 62 63 function startWithSlash(str) { 64 return S.startsWith(str, "/"); 65 } 66 67 function removeEndSlash(str) { 68 if (endWithSlash(str)) { 69 str = str.substring(0, str.length - 1); 70 } 71 return str; 72 } 73 74 function removeStartSlash(str) { 75 if (startWithSlash(str)) { 76 str = str.substring(1); 77 } 78 return str; 79 } 80 81 function addEndSlash(str) { 82 return removeEndSlash(str) + "/"; 83 } 84 85 function addStartSlash(str) { 86 return "/" + removeStartSlash(str); 87 } 88 89 function equalsIgnoreSlash(str1, str2) { 90 str1 = removeEndSlash(str1); 91 str2 = removeEndSlash(str2); 92 return str1 == str2; 93 } 94 95 /** 96 * slash ------------------ end 97 */ 98 99 /** 100 * get full path from fragment for html history 101 * @param fragment 102 */ 103 function getFullPath(fragment) { 104 return location.protocol + "//" + location.host + 105 removeEndSlash(Router.urlRoot) + addStartSlash(fragment) 106 } 107 108 /** 109 * get query object from query string 110 * @param path 111 */ 112 function getQuery(path) { 113 var m, 114 ret = {}; 115 if (m = path.match(queryReg)) { 116 return S.unparam(m[1]); 117 } 118 return ret; 119 } 120 121 /** 122 * match url with route intelligently (always get optimal result) 123 */ 124 function dispatch() { 125 var path = getFragment(), 126 fullPath = path, 127 query, 128 arg, 129 finalRoute = 0, 130 finalMatchLength = -1, 131 finalRegStr = "", 132 finalFirstCaptureGroupIndex = -1, 133 finalCallback = 0, 134 finalRouteName = "", 135 finalParam = 0; 136 137 path = fullPath.replace(queryReg, ""); 138 // user input : /xx/yy/zz 139 each(allRoutes, function (route) { 140 var routeRegs = route[ROUTER_MAP], 141 // match exactly 142 exactlyMatch = 0; 143 each(routeRegs, function (desc) { 144 var reg = desc.reg, 145 regStr = desc.regStr, 146 paramNames = desc.paramNames, 147 firstCaptureGroupIndex = -1, 148 m, 149 name = desc.name, 150 callback = desc.callback; 151 if (m = path.match(reg)) { 152 // match all result item shift out 153 m.shift(); 154 155 function genParam() { 156 if (paramNames) { 157 var params = {}; 158 each(m, function (sm, i) { 159 params[paramNames[i]] = sm; 160 }); 161 return params; 162 } else { 163 // if user gave directly reg 164 // then call callback with match result array 165 return [].concat(m); 166 } 167 } 168 169 function upToFinal() { 170 finalRegStr = regStr; 171 finalFirstCaptureGroupIndex = firstCaptureGroupIndex; 172 finalCallback = callback; 173 finalParam = genParam(); 174 finalRoute = route; 175 finalRouteName = name; 176 finalMatchLength = m.length; 177 } 178 179 // route: /xx/yy/zz 180 if (!m.length) { 181 upToFinal(); 182 exactlyMatch = 1; 183 return false; 184 } 185 else if (regStr) { 186 187 firstCaptureGroupIndex = findFirstCaptureGroupIndex(regStr); 188 189 // final route : /* 190 // now route : /xx/* 191 if (firstCaptureGroupIndex > finalFirstCaptureGroupIndex) { 192 upToFinal(); 193 } 194 195 else if ( 196 firstCaptureGroupIndex == finalFirstCaptureGroupIndex && 197 finalMatchLength >= m.length 198 ) { 199 // final route : /xx/:id/:id 200 // now route : /xx/:id/zz 201 if (m.length < finalMatchLength) { 202 upToFinal() 203 } else if (regStr.length > finalRegStr.length) { 204 upToFinal(); 205 } 206 } 207 208 // first route has priority 209 else if (!finalRoute) { 210 upToFinal(); 211 } 212 } 213 // if exists user-given reg router rule then update value directly 214 // first route has priority 215 // 用户设置的正则表达式具备高优先级 216 else { 217 upToFinal(); 218 exactlyMatch=1; 219 return false; 220 } 221 } 222 } 223 ); 224 225 if (exactlyMatch) { 226 return false; 227 } 228 }); 229 230 231 if (finalParam) { 232 query = getQuery(fullPath); 233 finalCallback.apply(finalRoute, [finalParam, query]); 234 arg = { 235 name:finalRouteName, 236 paths:finalParam, 237 query:query 238 }; 239 finalRoute.fire('route:' + finalRouteName, arg); 240 finalRoute.fire('route', arg); 241 } 242 } 243 244 /** 245 * transform route declaration to router reg 246 * @param str 247 * /search/:q 248 * /user/*path 249 */ 250 function transformRouterReg(self, str, callback) { 251 var name = str, 252 paramNames = []; 253 254 if (S.isFunction(callback)) { 255 // escape keyword from regexp 256 str = S.escapeRegExp(str); 257 258 str = str.replace(grammar, function (m, g1, g2, g3, g4) { 259 paramNames.push(g2 || g4); 260 // :name 261 if (g2) { 262 return "([^/]+)"; 263 } 264 // *name 265 else if (g4) { 266 return "(.*)"; 267 } 268 }); 269 270 return { 271 name:name, 272 paramNames:paramNames, 273 reg:new RegExp("^" + str + "$"), 274 regStr:str, 275 callback:callback 276 }; 277 } else { 278 return { 279 name:name, 280 reg:callback.reg, 281 callback:normFn(self, callback.callback) 282 }; 283 } 284 } 285 286 /** 287 * normalize function by self 288 * @param self 289 * @param callback 290 */ 291 function normFn(self, callback) { 292 if (S.isFunction(callback)) { 293 return callback; 294 } else if (S.isString(callback)) { 295 return self[callback]; 296 } 297 return callback; 298 } 299 300 function _afterRoutesChange(e) { 301 var self = this; 302 self[ROUTER_MAP] = {}; 303 self.addRoutes(e.newVal); 304 } 305 306 /** 307 * @name Router 308 * @class 309 * Router used to route url to responding action callbacks. 310 * @memberOf MVC 311 * @extends Base 312 */ 313 function Router() { 314 var self = this; 315 Router.superclass.constructor.apply(self, arguments); 316 self.on("afterRoutesChange", _afterRoutesChange, self); 317 _afterRoutesChange.call(self, {newVal:self.get("routes")}); 318 allRoutes.push(self); 319 } 320 321 Router.ATTRS = 322 /** 323 * @lends MVC.Router# 324 */ 325 { 326 /** 327 * Route and action config. 328 * @type Object 329 * @example 330 * <code> 331 * { 332 * "/search/:param":"callback" 333 * // or 334 * "search":{ 335 * reg:/xx/, 336 * callback:fn 337 * } 338 * } 339 * </code> 340 */ 341 routes:{} 342 }; 343 344 S.extend(Router, Base, 345 /** 346 * @lends MVC.Router# 347 */ 348 { 349 /** 350 * Add config to current router. 351 * @param {Object} routes Route config. 352 * @example 353 * <code> 354 * { 355 * "/search/:param":"callback" 356 * // or 357 * "search":{ 358 * reg:/xx/, 359 * callback:fn 360 * } 361 * } 362 * </code> 363 */ 364 addRoutes:function (routes) { 365 var self = this; 366 each(routes, function (callback, name) { 367 self[ROUTER_MAP][name] = transformRouterReg(self, name, normFn(self, callback)); 368 }); 369 } 370 }, 371 /** 372 * @lends MVC.Router 373 */ 374 { 375 376 /** 377 * Navigate to specified path. 378 * @param {String} path Destination path. 379 * @param {Object} [opts] Config for current navigation. 380 * @param {Boolean} opts.triggerRoute Whether to trigger responding action 381 * even current path is same as parameter 382 */ 383 navigate:function (path, opts) { 384 if (getFragment() !== path) { 385 if (Router.nativeHistory && supportNativeHistory) { 386 history['pushState']({}, "", getFullPath(path)); 387 // pushState does not fire popstate event (unlike hashchange) 388 // so popstate is not statechange 389 // fire manually 390 dispatch(); 391 } else { 392 location.hash = "!" + path; 393 } 394 } else if (opts && opts.triggerRoute) { 395 dispatch(); 396 } 397 }, 398 /** 399 * Start router (url monitor). 400 * @param {object} opts 401 * @param {Function} opts.success Callback function to be called after router is started. 402 * @param {String} opts.urlRoot Specify url root for html5 history management. 403 * @param {Boolean} opts.nativeHistory Whether enable html5 history management. 404 */ 405 start:function (opts) { 406 407 if (Router.__started) { 408 return; 409 } 410 411 opts = opts || {}; 412 413 opts.urlRoot = opts.urlRoot || ""; 414 415 var urlRoot, 416 nativeHistory = opts.nativeHistory, 417 locPath = location.pathname, 418 hash = getFragment(), 419 hashIsValid = location.hash.match(/#!.+/); 420 421 urlRoot = Router.urlRoot = opts.urlRoot; 422 Router.nativeHistory = nativeHistory; 423 424 if (nativeHistory) { 425 426 if (supportNativeHistory) { 427 // http://x.com/#!/x/y 428 // => 429 // http://x.com/x/y 430 // => 431 // process without refresh page and add history entry 432 if (hashIsValid) { 433 if (equalsIgnoreSlash(locPath, urlRoot)) { 434 // put hash to path 435 history['replaceState']({}, "", getFullPath(hash)); 436 opts.triggerRoute = 1; 437 } else { 438 S.error("location path must be same with urlRoot!"); 439 } 440 } 441 } 442 // http://x.com/x/y 443 // => 444 // http://x.com/#!/x/y 445 // => 446 // refresh page without add history entry 447 else if (!equalsIgnoreSlash(locPath, urlRoot)) { 448 location.replace(addEndSlash(urlRoot) + "#!" + hash); 449 return; 450 } 451 452 } 453 454 // prevent hashChange trigger on start 455 setTimeout(function () { 456 if (nativeHistory && supportNativeHistory) { 457 Event.on(win, 'popstate', dispatch); 458 } else { 459 Event.on(win, "hashchange", dispatch); 460 opts.triggerRoute = 1; 461 } 462 463 // check initial hash on start 464 // in case server does not render initial state correctly 465 // when monitor hashchange ,client must be responsible for dispatching and rendering. 466 if (opts.triggerRoute) { 467 dispatch(); 468 } 469 opts.success && opts.success(); 470 471 }, BREATH_INTERVAL); 472 473 Router.__started = 1; 474 } 475 }); 476 477 return Router; 478 479 }, { 480 requires:['event', 'base'] 481 }); 482 483 /** 484 * 2011-11-30 485 * - support user-given native regexp for router rule 486 * 487 * refer : 488 * http://www.w3.org/TR/html5/history.html 489 * http://documentcloud.github.com/backbone/ 490 **/