mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
Implement Router Events (#511)
* Move route loading logic to a common place. * Add router events. * Add EventEmitter's core API methods. * Add example app for loading events and docs. * Fix some typos. * Get rid of Router.ready() * Remove events package. It's already shipping with webpack. * Handling aborting properly. * Expose simple attribute based events listener API. Removed the proposed event listener API from the public API. * Remove error logged when there's an abort error. There are many other ways to capture this error. So, it doesn't look nice to print this always. * Change router events to pass only the current URL as arguments. * Add a section about Cancelled Routes to README.
This commit is contained in:
parent
6d8079a261
commit
c890dc3573
59
README.md
59
README.md
|
@ -182,13 +182,15 @@ For the initial page load, `getInitialProps` will execute on the server only. `g
|
|||
|
||||
### Routing
|
||||
|
||||
#### With `<Link>`
|
||||
|
||||
<p><details>
|
||||
<summary><b>Examples</b></summary>
|
||||
<ul><li><a href="./examples/using-routing">Basic routing</a></li></ul>
|
||||
<ul>
|
||||
<li><a href="./examples/hello-world">Hello World</a></li>
|
||||
</ul>
|
||||
</details></p>
|
||||
|
||||
#### With `<Link>`
|
||||
|
||||
Client-side transitions between routes can be enabled via a `<Link>` component. Consider these two pages:
|
||||
|
||||
```jsx
|
||||
|
@ -225,6 +227,14 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o
|
|||
|
||||
#### Imperatively
|
||||
|
||||
<p><details>
|
||||
<summary><b>Examples</b></summary>
|
||||
<ul>
|
||||
<li><a href="./examples/using-router">Basic routing</a></li>
|
||||
<li><a href="./examples/with-loading">With a page loading indicator</a></li>
|
||||
</ul>
|
||||
</details></p>
|
||||
|
||||
You can also do client-side page transitions using the `next/router`
|
||||
|
||||
```jsx
|
||||
|
@ -247,6 +257,49 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o
|
|||
|
||||
_Note: in order to programmatically change the route without triggering navigation and component-fetching, use `props.url.push` and `props.url.replace` within a component_
|
||||
|
||||
##### Router Events
|
||||
|
||||
You can also listen to different events happening inside the Router.
|
||||
Here's a list of supported events:
|
||||
|
||||
- `routeChangeStart(url)` - Fires when a route starts to change
|
||||
- `routeChangeComplete(url)` - Fires when a route changed completely
|
||||
- `routeChangeError(err, url)` - Fires when there's an error when changing routes
|
||||
|
||||
> Here `url` is the URL shown in the browser. If you call `Router.push(url, as)` (or similar), then the value of `url` will be `as`.
|
||||
|
||||
Here's how to property listen to the router event `routeChangeStart`:
|
||||
|
||||
```js
|
||||
Router.onRouteChangeStart = (url) => {
|
||||
console.log('App is changing to: ', url)
|
||||
}
|
||||
```
|
||||
|
||||
If you are no longer want to listen to that event, you can simply unset the event listener like this:
|
||||
|
||||
```js
|
||||
Router.onRouteChangeStart = null
|
||||
```
|
||||
|
||||
##### Cancelled (Abort) Routes
|
||||
|
||||
Sometimes, you might want to change a route before the current route gets completed. Current route may be downloading the page or running `getInitialProps`.
|
||||
In that case, we abort the current route and process with the new route.
|
||||
|
||||
If you need, you could capture those cancelled routes via `routeChangeError` router event. See:
|
||||
|
||||
```js
|
||||
Router.onRouteChangeError = (err, url) => {
|
||||
if (err.cancelled) {
|
||||
console.log(`Route to ${url} is cancelled!`)
|
||||
return
|
||||
}
|
||||
|
||||
// Some other error
|
||||
}
|
||||
```
|
||||
|
||||
### Prefetching Pages
|
||||
|
||||
<p><details>
|
||||
|
|
35
examples/with-loading/README.md
Normal file
35
examples/with-loading/README.md
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Example app with page loading indicator
|
||||
|
||||
## How to use
|
||||
|
||||
Download the example (or clone the repo)[https://github.com/zeit/next.js.git]:
|
||||
|
||||
```bash
|
||||
curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-loading
|
||||
cd with-loading
|
||||
```
|
||||
|
||||
Install it and run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download))
|
||||
|
||||
```bash
|
||||
now
|
||||
```
|
||||
|
||||
## The idea behind the example
|
||||
|
||||
Sometimes when switching between pages, Next.js needs to download pages(chunks) from the server before rendering the page. And it may also need to wait for the data. So while doing these tasks, browser might be non responsive.
|
||||
|
||||
We can simply fix this issue by showing a loading indicator. That's what this examples shows.
|
||||
|
||||
It features:
|
||||
|
||||
* An app with two pages which uses a common [Header](./components/Header.js) component for navigation links.
|
||||
* Using `next/router` to identify different router events
|
||||
* Uses [nprogress](https://github.com/rstacruz/nprogress) as the loading indicator.
|
30
examples/with-loading/components/Header.js
Normal file
30
examples/with-loading/components/Header.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React from 'react'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import NProgress from 'nprogress'
|
||||
import Router from 'next/router'
|
||||
|
||||
Router.onRouteChangeStart = (url) => {
|
||||
console.log(`Loading: ${url}`)
|
||||
NProgress.start()
|
||||
}
|
||||
Router.onRouteChangeComplete = () => NProgress.done()
|
||||
Router.onRouteChangeError = () => NProgress.done()
|
||||
|
||||
const linkStyle = {
|
||||
margin: '0 10px 0 0'
|
||||
}
|
||||
|
||||
export default () => (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Head>
|
||||
{/* Import CSS for nprogress */}
|
||||
<link rel='stylesheet' type='text/css' href='/static/nprogress.css' />
|
||||
</Head>
|
||||
|
||||
<Link href='/'><a style={linkStyle}>Home</a></Link>
|
||||
<Link href='/about'><a style={linkStyle}>About</a></Link>
|
||||
<Link href='/forever'><a style={linkStyle}>Forever</a></Link>
|
||||
<Link href='/non-existing'><a style={linkStyle}>Non Existing Page</a></Link>
|
||||
</div>
|
||||
)
|
17
examples/with-loading/package.json
Normal file
17
examples/with-loading/package.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "with-loading",
|
||||
"version": "1.0.0",
|
||||
"description": "This example features:",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "next",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^2.0.0-beta",
|
||||
"nprogress": "^0.2.0"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
20
examples/with-loading/pages/about.js
Normal file
20
examples/with-loading/pages/about.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React, { Component } from 'react'
|
||||
import Header from '../components/Header'
|
||||
|
||||
export default class About extends Component {
|
||||
// Add some delay
|
||||
static getInitialProps () {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<p>This is about Next!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
20
examples/with-loading/pages/forever.js
Normal file
20
examples/with-loading/pages/forever.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React, { Component } from 'react'
|
||||
import Header from '../components/Header'
|
||||
|
||||
export default class Forever extends Component {
|
||||
// Add some delay
|
||||
static getInitialProps () {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<p>This page was rendered for a while!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
9
examples/with-loading/pages/index.js
Normal file
9
examples/with-loading/pages/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import Header from '../components/Header'
|
||||
|
||||
export default () => (
|
||||
<div>
|
||||
<Header />
|
||||
<p>Hello Next!</p>
|
||||
</div>
|
||||
)
|
73
examples/with-loading/static/nprogress.css
Normal file
73
examples/with-loading/static/nprogress.css
Normal file
|
@ -0,0 +1,73 @@
|
|||
/* Make clicks pass-through */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: #29d;
|
||||
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
#nprogress .peg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
|
||||
opacity: 1.0;
|
||||
|
||||
-webkit-transform: rotate(3deg) translate(0px, -4px);
|
||||
-ms-transform: rotate(3deg) translate(0px, -4px);
|
||||
transform: rotate(3deg) translate(0px, -4px);
|
||||
}
|
||||
|
||||
/* Remove these to get rid of the spinner */
|
||||
#nprogress .spinner {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: solid 2px transparent;
|
||||
border-top-color: #29d;
|
||||
border-left-color: #29d;
|
||||
border-radius: 50%;
|
||||
|
||||
-webkit-animation: nprogress-spinner 400ms linear infinite;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .spinner,
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@-webkit-keyframes nprogress-spinner {
|
||||
0% { -webkit-transform: rotate(0deg); }
|
||||
100% { -webkit-transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
|
@ -1,13 +1,20 @@
|
|||
import _Router from './router'
|
||||
|
||||
// holds the actual router instance
|
||||
let router = null
|
||||
|
||||
const SingletonRouter = {}
|
||||
const SingletonRouter = {
|
||||
router: null, // holds the actual router instance
|
||||
readyCallbacks: [],
|
||||
ready (cb) {
|
||||
if (this.router) return cb()
|
||||
if (typeof window !== 'undefined') {
|
||||
this.readyCallbacks.push(cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create public properties and methods of the router in the SingletonRouter
|
||||
const propertyFields = ['components', 'pathname', 'route', 'query']
|
||||
const methodFields = ['push', 'replace', 'reload', 'back']
|
||||
const coreMethodFields = ['push', 'replace', 'reload', 'back']
|
||||
const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError']
|
||||
|
||||
propertyFields.forEach((field) => {
|
||||
// Here we need to use Object.defineProperty because, we need to return
|
||||
|
@ -17,20 +24,31 @@ propertyFields.forEach((field) => {
|
|||
Object.defineProperty(SingletonRouter, field, {
|
||||
get () {
|
||||
throwIfNoRouter()
|
||||
return router[field]
|
||||
return SingletonRouter.router[field]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
methodFields.forEach((field) => {
|
||||
coreMethodFields.forEach((field) => {
|
||||
SingletonRouter[field] = (...args) => {
|
||||
throwIfNoRouter()
|
||||
return router[field](...args)
|
||||
return SingletonRouter.router[field](...args)
|
||||
}
|
||||
})
|
||||
|
||||
routerEvents.forEach((event) => {
|
||||
SingletonRouter.ready(() => {
|
||||
SingletonRouter.router.on(event, (...args) => {
|
||||
const eventField = `on${event.charAt(0).toUpperCase()}${event.substring(1)}`
|
||||
if (SingletonRouter[eventField]) {
|
||||
SingletonRouter[eventField](...args)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function throwIfNoRouter () {
|
||||
if (!router) {
|
||||
if (!SingletonRouter.router) {
|
||||
const message = 'No router instance found.\n' +
|
||||
'You should only use "next/router" inside the client side of your app.\n'
|
||||
throw new Error(message)
|
||||
|
@ -48,8 +66,11 @@ export default SingletonRouter
|
|||
// This is used in client side when we are initilizing the app.
|
||||
// This should **not** use inside the server.
|
||||
export const createRouter = function (...args) {
|
||||
router = new _Router(...args)
|
||||
return router
|
||||
SingletonRouter.router = new _Router(...args)
|
||||
SingletonRouter.readyCallbacks.forEach(cb => cb())
|
||||
SingletonRouter.readyCallbacks = []
|
||||
|
||||
return SingletonRouter.router
|
||||
}
|
||||
|
||||
// Export the actual Router class, which is usually used inside the server
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { parse, format } from 'url'
|
||||
import evalScript from '../eval-script'
|
||||
import shallowEquals from '../shallow-equals'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
export default class Router {
|
||||
export default class Router extends EventEmitter {
|
||||
constructor (pathname, query, { Component, ErrorComponent, ctx } = {}) {
|
||||
super()
|
||||
// represents the current component key
|
||||
this.route = toRoute(pathname)
|
||||
|
||||
|
@ -14,6 +16,7 @@ export default class Router {
|
|||
this.pathname = pathname
|
||||
this.query = query
|
||||
this.subscriptions = new Set()
|
||||
|
||||
this.componentLoadCancel = null
|
||||
this.onPopState = this.onPopState.bind(this)
|
||||
|
||||
|
@ -26,40 +29,37 @@ export default class Router {
|
|||
}
|
||||
}
|
||||
|
||||
onPopState (e) {
|
||||
async onPopState (e) {
|
||||
this.abortComponentLoad()
|
||||
|
||||
const as = getURL()
|
||||
const { url = as } = e.state || {}
|
||||
const { url, as } = e.state
|
||||
const { pathname, query } = parse(url, true)
|
||||
|
||||
if (!this.urlIsNew(pathname, query)) return
|
||||
if (!this.urlIsNew(pathname, query)) {
|
||||
this.emit('routeChangeStart', as)
|
||||
this.emit('routeChangeComplete', as)
|
||||
return
|
||||
}
|
||||
|
||||
const route = toRoute(pathname)
|
||||
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const data = await this.fetchComponent(route)
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
const props = await this.getInitialProps(data.Component, ctx)
|
||||
this.emit('routeChangeStart', as)
|
||||
const {
|
||||
data,
|
||||
props,
|
||||
error
|
||||
} = await this.getRouteInfo(route, pathname, query)
|
||||
|
||||
this.route = route
|
||||
this.set(pathname, query, { ...data, props })
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (err.cancelled) return
|
||||
if (error) {
|
||||
this.emit('routeChangeError', error, as)
|
||||
// We don't need to throw here since the error is already logged by
|
||||
// this.getRouteInfo
|
||||
return
|
||||
}
|
||||
|
||||
const data = { Component: this.ErrorComponent, ctx: { err } }
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
const props = await this.getInitialProps(data.Component, ctx)
|
||||
|
||||
this.route = route
|
||||
this.set(pathname, query, { ...data, props })
|
||||
console.error(err)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
this.route = route
|
||||
this.set(pathname, query, { ...data, props })
|
||||
this.emit('routeChangeComplete', as)
|
||||
}
|
||||
|
||||
update (route, Component) {
|
||||
|
@ -77,29 +77,24 @@ export default class Router {
|
|||
|
||||
if (route !== this.route) return
|
||||
|
||||
const { pathname, query } = parse(window.location.href, true)
|
||||
const url = window.location.href
|
||||
const { pathname, query } = parse(url, true)
|
||||
|
||||
let data
|
||||
let props
|
||||
let _err
|
||||
try {
|
||||
data = await this.fetchComponent(route)
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
props = await this.getInitialProps(data.Component, ctx)
|
||||
} catch (err) {
|
||||
if (err.cancelled) return false
|
||||
this.emit('routeChangeStart', url)
|
||||
const {
|
||||
data,
|
||||
props,
|
||||
error
|
||||
} = await this.getRouteInfo(route, pathname, query)
|
||||
|
||||
data = { Component: this.ErrorComponent, ctx: { err } }
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
props = await this.getInitialProps(data.Component, ctx)
|
||||
|
||||
_err = err
|
||||
console.error(err)
|
||||
if (error) {
|
||||
this.emit('routeChangeError', error, url)
|
||||
throw error
|
||||
}
|
||||
|
||||
this.notify({ ...data, props })
|
||||
|
||||
if (_err) throw _err
|
||||
this.emit('routeChangeComplete', url)
|
||||
}
|
||||
|
||||
back () {
|
||||
|
@ -115,33 +110,26 @@ export default class Router {
|
|||
}
|
||||
|
||||
async change (method, url, as) {
|
||||
this.abortComponentLoad()
|
||||
const { pathname, query } = parse(url, true)
|
||||
|
||||
if (!this.urlIsNew(pathname, query)) {
|
||||
this.emit('routeChangeStart', as)
|
||||
changeState()
|
||||
this.emit('routeChangeComplete', as)
|
||||
return true
|
||||
}
|
||||
|
||||
const route = toRoute(pathname)
|
||||
|
||||
this.abortComponentLoad()
|
||||
this.emit('routeChangeStart', as)
|
||||
const {
|
||||
data, props, error
|
||||
} = await this.getRouteInfo(route, pathname, query)
|
||||
|
||||
let data
|
||||
let props
|
||||
let _err
|
||||
try {
|
||||
data = await this.fetchComponent(route)
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
props = await this.getInitialProps(data.Component, ctx)
|
||||
} catch (err) {
|
||||
if (err.cancelled) return false
|
||||
|
||||
data = { Component: this.ErrorComponent, ctx: { err } }
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
props = await this.getInitialProps(data.Component, ctx)
|
||||
|
||||
_err = err
|
||||
console.error(err)
|
||||
if (error) {
|
||||
this.emit('routeChangeError', error, as)
|
||||
throw error
|
||||
}
|
||||
|
||||
changeState()
|
||||
|
@ -149,17 +137,39 @@ export default class Router {
|
|||
this.route = route
|
||||
this.set(pathname, query, { ...data, props })
|
||||
|
||||
if (_err) throw _err
|
||||
|
||||
this.emit('routeChangeComplete', as)
|
||||
return true
|
||||
|
||||
function changeState () {
|
||||
if (method !== 'pushState' || getURL() !== as) {
|
||||
window.history[method]({ url }, null, as)
|
||||
window.history[method]({ url, as }, null, as)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getRouteInfo (route, pathname, query) {
|
||||
const routeInfo = {}
|
||||
|
||||
try {
|
||||
const data = routeInfo.data = await this.fetchComponent(route)
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
routeInfo.props = await this.getInitialProps(data.Component, ctx)
|
||||
} catch (err) {
|
||||
if (err.cancelled) {
|
||||
return { error: err }
|
||||
}
|
||||
|
||||
const data = routeInfo.data = { Component: this.ErrorComponent, ctx: { err } }
|
||||
const ctx = { ...data.ctx, pathname, query }
|
||||
routeInfo.props = await this.getInitialProps(data.Component, ctx)
|
||||
|
||||
routeInfo.error = err
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
return routeInfo
|
||||
}
|
||||
|
||||
set (pathname, query, data) {
|
||||
this.pathname = pathname
|
||||
this.query = query
|
||||
|
@ -177,7 +187,12 @@ export default class Router {
|
|||
|
||||
data = await new Promise((resolve, reject) => {
|
||||
this.componentLoadCancel = cancel = () => {
|
||||
if (xhr.abort) xhr.abort()
|
||||
if (xhr.abort) {
|
||||
xhr.abort()
|
||||
const error = new Error('Fetching componenet cancelled')
|
||||
error.cancelled = true
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/_next/pages${route}`
|
||||
|
@ -211,7 +226,7 @@ export default class Router {
|
|||
}
|
||||
|
||||
if (cancelled) {
|
||||
const err = new Error('Cancelled')
|
||||
const err = new Error('Loading initial props cancelled')
|
||||
err.cancelled = true
|
||||
throw err
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue