-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrouter.js
427 lines (376 loc) · 11.3 KB
/
router.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
import { pick, clone, extend, isEqual, isString } from './dash'
import functionDsl from './function-dsl'
import arrayDsl from './array-dsl'
import { withQuery, withoutQuery, injectParams, extractParams, extractParamNames, extractQuery } from './path'
import invariant from './invariant'
import BrowserLocation from './locations/browser'
import MemoryLocation from './locations/memory'
import transition from './transition'
import { intercept } from './links'
import defineLogger from './logger'
import qs from './qs'
import { TRANSITION_CANCELLED, TRANSITION_REDIRECTED } from './constants'
class Router {
constructor (options = {}) {
this.nextId = 1
this.state = {}
this.middleware = []
this.options = extend({
location: 'browser',
interceptLinks: true,
logError: true,
qs
}, options)
defineLogger(this, 'log', this.options.log)
defineLogger(this, 'logError', this.options.logError)
if (options.routes) {
this.map(options.routes)
}
}
/**
* Add a middleware
* @param {Function} middleware
* @return {Object} router
* @api public
*/
use (middleware) {
const m = typeof middleware === 'function' ? { next: middleware } : middleware
this.middleware.push(m)
return this
}
/**
* Add the route map
* @param {Function} routes
* @return {Object} router
* @api public
*/
map (routes) {
// create the route tree
this.routes = Array.isArray(routes) ? arrayDsl(routes) : functionDsl(routes)
// create the matcher list, which is like a flattened
// list of routes = a list of all branches of the route tree
const matchers = this.matchers = []
// keep track of whether duplicate paths have been created,
// in which case we'll warn the dev
const dupes = {}
// keep track of abstract routes to build index route forwarding
const abstracts = {}
eachBranch({ routes: this.routes }, [], routes => {
// concatenate the paths of the list of routes
let path = routes.reduce((memo, r) => // reset if there's a leading slash, otherwise concat
// and keep resetting the trailing slash
(r.path[0] === '/' ? r.path : `${memo}/${r.path}`).replace(/\/$/, ''), '')
// ensure we have a leading slash
if (path === '') {
path = '/'
}
const lastRoute = routes[routes.length - 1]
if (lastRoute.options.abstract) {
abstracts[path] = lastRoute.name
return
}
// register routes
matchers.push({
routes,
name: lastRoute.name,
path
})
// dupe detection
if (dupes[path]) {
throw new Error(`Routes ${dupes[path]} and ${lastRoute.name} have the same url path '${path}'`)
}
dupes[path] = lastRoute.name
})
// check if there is an index route for each abstract route
Object.keys(abstracts).forEach(path => {
let matcher
if (!dupes[path]) return
matchers.some(m => {
if (m.path === path) {
matcher = m
return true
}
})
matchers.push({
routes: matcher.routes,
name: abstracts[path],
path
})
})
function eachBranch (node, memo, fn) {
node.routes.forEach(route => {
fn(memo.concat(route))
if (route.routes.length) {
eachBranch(route, memo.concat(route), fn)
}
})
}
return this
}
/**
* Starts listening to the location changes.
* @param {Object} location (optional)
* @return {Promise} initial transition
*
* @api public
*/
listen (path) {
const location = this.location = this.createLocation(path || '')
// setup the location onChange handler
location.onChange((url) => {
const previousUrl = this.state.path
this.dispatch(url).catch((err) => {
if (err && err.type === TRANSITION_CANCELLED) {
// reset the URL in case the transition has been cancelled
this.location.replaceURL(previousUrl, { trigger: false })
}
return err
})
})
// start intercepting links
if (this.options.interceptLinks && location.usesPushState()) {
this.interceptLinks()
}
// and also kick off the initial transition
return this.dispatch(location.getURL())
}
/**
* Transition to a different route. Passe in url or a route name followed by params and query
* @param {String} url url or route name
* @param {Object} params Optional
* @param {Object} query Optional
* @return {Object} transition
*
* @api public
*/
transitionTo (...args) {
if (this.state.activeTransition) {
return this.replaceWith.apply(this, args)
}
return this.doTransition('setURL', args)
}
/**
* Like transitionTo, but doesn't leave an entry in the browser's history,
* so clicking back will skip this route
* @param {String} url url or route name followed by params and query
* @param {Object} params Optional
* @param {Object} query Optional
* @return {Object} transition
*
* @api public
*/
replaceWith (...args) {
return this.doTransition('replaceURL', args)
}
/**
* Create an href
* @param {String} name target route name
* @param {Object} params
* @param {Object} query
* @return {String} href
*
* @api public
*/
generate (name, params, query) {
invariant(this.location, 'call .listen() before using .generate()')
let matcher
query = query || {}
this.matchers.forEach(m => {
if (m.name === name) {
matcher = m
}
})
if (!matcher) {
throw new Error(`No route is named ${name}`)
}
const url = withQuery(this.options.qs, injectParams(matcher.path, params), query)
return this.location.formatURL(url)
}
/**
* Stop listening to URL changes
* @api public
*/
destroy () {
if (this.location && this.location.destroy) {
this.location.destroy()
}
if (this.disposeIntercept) {
this.disposeIntercept()
}
if (this.state.activeTransition) {
this.state.activeTransition.cancel()
}
this.state = {}
}
/**
* Check if the given route/params/query combo is active
* @param {String} name target route name
* @param {Object} params
* @param {Object} query
* @return {Boolean}
*
* @api public
*/
isActive (name, params, query) {
params = params || {}
query = query || {}
const activeRoutes = this.state.routes || []
const activeParams = this.state.params || {}
const activeQuery = this.state.query || {}
let isActive = activeRoutes.some(route => route.name === name)
isActive = isActive && Object.keys(params).every(key => activeParams[key] === params[key])
isActive = isActive && Object.keys(query).every(key => activeQuery[key] === query[key])
return isActive
}
/**
* @api private
*/
doTransition (method, params) {
const previousUrl = this.location.getURL()
let url = params[0]
if (url[0] !== '/') {
url = this.generate.apply(this, params)
url = url.replace(/^#/, '/')
}
if (this.options.pushState) {
url = this.location.removeRoot(url)
}
const transition = this.dispatch(url)
transition.catch((err) => {
if (err && err.type === TRANSITION_CANCELLED) {
// reset the URL in case the transition has been cancelled
this.location.replaceURL(previousUrl, { trigger: false })
}
return err
})
this.location[method](url, { trigger: false })
return transition
}
/**
* Match the path against the routes
* @param {String} path
* @return {Object} the list of matching routes and params
*
* @api private
*/
match (path) {
path = (path || '').replace(/\/$/, '') || '/'
let params
let routes = []
const pathWithoutQuery = withoutQuery(path)
const qs = this.options.qs
this.matchers.some(matcher => {
params = extractParams(matcher.path, pathWithoutQuery)
if (params) {
routes = matcher.routes
return true
}
})
return {
routes: routes.map(descriptor),
params: params || {},
pathname: pathWithoutQuery,
query: extractQuery(qs, path) || {}
}
// clone the data (only a shallow clone of options)
// to make sure the internal route store is not mutated
// by the middleware. The middleware can mutate data
// before it gets passed into the next middleware, but
// only within the same transition. New transitions
// will get to use pristine data.
function descriptor (route) {
return {
name: route.name,
path: route.path,
params: pick(params, extractParamNames(route.path)),
options: clone(route.options)
}
}
}
dispatch (path) {
const match = this.match(path)
const query = match.query
const pathname = match.pathname
const activeTransition = this.state.activeTransition
// if we already have an active transition with all the same
// params - return that and don't do anything else
if (activeTransition &&
activeTransition.pathname === pathname &&
isEqual(activeTransition.query, query)) {
return activeTransition
}
// otherwise, cancel the active transition since we're
// redirecting (or initiating a brand new transition)
if (activeTransition) {
const err = new Error(TRANSITION_REDIRECTED)
err.type = TRANSITION_REDIRECTED
err.nextPath = path
activeTransition.cancel(err)
}
// if there is no active transition, check if
// this is a noop transition, in which case, return
// a transition to respect the function signature,
// but don't actually run any of the middleware
if (!activeTransition) {
if (this.state.pathname === pathname &&
isEqual(this.state.query, query)) {
return transition({
id: this.nextId++,
path,
match,
noop: true,
router: this
})
}
}
const t = transition({
id: this.nextId++,
path,
match,
router: this
})
this.state.activeTransition = t
return t
}
/**
* Create the default location.
* This is used when no custom location is passed to
* the listen call.
* @return {Object} location
*
* @api private
*/
createLocation (path) {
const location = this.options.location
if (!isString(location)) {
return location
}
if (location === 'browser') {
return new BrowserLocation(pick(this.options, ['pushState', 'root']))
} else if (location === 'memory') {
return new MemoryLocation({ path })
} else {
throw new Error('Location can be `browser`, `memory` or a custom implementation')
}
}
log (...args) {
console.info.apply(console, args)
}
logError (...args) {
console.error.apply(console, args)
}
}
/**
* When using pushState, it's possible to setup link interception
* because all link clicks should be handled via the router instead of
* browser reloading the page
*/
function interceptLinks (router, clickHandler = defaultClickHandler) {
const disposeIntercept = intercept((event, link) => clickHandler(event, link, this))
function defaultClickHandler (event, link, router) {
event.preventDefault()
router.transitionTo(router.location.removeRoot(link.getAttribute('href')))
}
return disposeIn
}
export { Router, inter }