mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
Introduce a simple prefetching solution (#957)
* Implement a very simple prefetching solution. * Remove next-prefetcher. * Require 'whatwg-fetch' only in the client. * Use xhr in the code. * Use a simple fetching solution. * Fix 404 and xhr status issue. * Move the prefetching implementation to next/router. * Add deprecated warnning for next/prefetch * Run only 2 parellel prefetching request at a time. * Change xhr to jsonPageRes. * Improve the prefetching logic. * Add unit tests covering the Router.prefetch() * Update examples to use the new syntax. * Update docs. * Use execOnce() to manage warn printing. * Remove prefetcher building from the flyfile.js Because, we no longer use it.
This commit is contained in:
parent
d382e4db9a
commit
14c86bef1d
27
README.md
27
README.md
|
@ -344,40 +344,37 @@ Router.onRouteChangeError = (err, url) => {
|
||||||
<ul><li><a href="./examples/with-prefetching">Prefetching</a></li></ul>
|
<ul><li><a href="./examples/with-prefetching">Prefetching</a></li></ul>
|
||||||
</details></p>
|
</details></p>
|
||||||
|
|
||||||
Next.js exposes a module that configures a `ServiceWorker` automatically to prefetch pages: `next/prefetch`.
|
Next.js has an API which allows you to prefetch pages.
|
||||||
|
|
||||||
Since Next.js server-renders your pages, this allows all the future interaction paths of your app to be instant. Effectively Next.js gives you the great initial download performance of a _website_, with the ahead-of-time download capabilities of an _app_. [Read more](https://zeit.co/blog/next#anticipation-is-the-key-to-performance).
|
Since Next.js server-renders your pages, this allows all the future interaction paths of your app to be instant. Effectively Next.js gives you the great initial download performance of a _website_, with the ahead-of-time download capabilities of an _app_. [Read more](https://zeit.co/blog/next#anticipation-is-the-key-to-performance).
|
||||||
|
|
||||||
|
> With prefetching Next.js only download JS code. When the page is getting rendered, you may need to wait for the data.
|
||||||
|
|
||||||
#### With `<Link>`
|
#### With `<Link>`
|
||||||
|
|
||||||
You can substitute your usage of `<Link>` with the default export of `next/prefetch`. For example:
|
You can add `prefetch` prop to any `<Link>` and Next.js will prefetch those pages in the background.
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import Link from 'next/prefetch'
|
import Link from 'next/link'
|
||||||
|
|
||||||
// example header component
|
// example header component
|
||||||
export default () => (
|
export default () => (
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><Link href='/'><a>Home</a></Link></li>
|
<li><Link prefetch ref='/'><a>Home</a></Link></li>
|
||||||
<li><Link href='/about'><a>About</a></Link></li>
|
<li><Link prefetch href='/about'><a>About</a></Link></li>
|
||||||
<li><Link href='/contact'><a>Contact</a></Link></li>
|
<li><Link prefetch href='/contact'><a>Contact</a></Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
When this higher-level `<Link>` component is first used, the `ServiceWorker` gets installed. To turn off prefetching on a per-`<Link>` basis, you can use the `prefetch` attribute:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<Link href='/contact' prefetch={false}><a>Home</a></Link>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Imperatively
|
#### Imperatively
|
||||||
|
|
||||||
Most needs are addressed by `<Link />`, but we also expose an imperative API for advanced usage:
|
Most prefetching needs are addressed by `<Link />`, but we also expose an imperative API for advanced usage:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { prefetch } from 'next/prefetch'
|
import Router from 'next/router'
|
||||||
export default ({ url }) => (
|
export default ({ url }) => (
|
||||||
<div>
|
<div>
|
||||||
<a onClick={ () => setTimeout(() => url.pushTo('/dynamic'), 100) }>
|
<a onClick={ () => setTimeout(() => url.pushTo('/dynamic'), 100) }>
|
||||||
|
@ -385,7 +382,7 @@ export default ({ url }) => (
|
||||||
</a>
|
</a>
|
||||||
{
|
{
|
||||||
// but we can prefetch it!
|
// but we can prefetch it!
|
||||||
prefetch('/dynamic')
|
Router.prefetch('/dynamic')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,107 +0,0 @@
|
||||||
/* global self */
|
|
||||||
|
|
||||||
const CACHE_NAME = 'next-prefetcher-v1'
|
|
||||||
const log = () => {}
|
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
|
||||||
log('Installing Next Prefetcher')
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('activate', (e) => {
|
|
||||||
log('Activated Next Prefetcher')
|
|
||||||
e.waitUntil(Promise.all([
|
|
||||||
resetCache(),
|
|
||||||
notifyClients()
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (e) => {
|
|
||||||
// bypass all requests except JSON pages.
|
|
||||||
if (!(/\/_next\/[^/]+\/pages\//.test(e.request.url))) return
|
|
||||||
|
|
||||||
e.respondWith(getResponse(e.request))
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('message', (e) => {
|
|
||||||
switch (e.data.action) {
|
|
||||||
case 'ADD_URL': {
|
|
||||||
log('CACHING ', e.data.url)
|
|
||||||
sendReply(e, cacheUrl(e.data.url))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'RESET': {
|
|
||||||
log('RESET')
|
|
||||||
sendReply(e, resetCache())
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
console.error('Unknown action: ' + e.data.action)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function sendReply (e, result) {
|
|
||||||
const payload = { action: 'REPLY', actionType: e.data.action, replyFor: e.data.id }
|
|
||||||
result
|
|
||||||
.then((result) => {
|
|
||||||
payload.result = result
|
|
||||||
e.source.postMessage(payload)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
payload.error = error.message
|
|
||||||
e.source.postMessage(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheUrl (url) {
|
|
||||||
const req = new self.Request(url, {
|
|
||||||
mode: 'no-cors',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return self.caches.open(CACHE_NAME)
|
|
||||||
.then((cache) => {
|
|
||||||
return self.fetch(req)
|
|
||||||
.then((res) => cache.put(req, res))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResponse (req) {
|
|
||||||
return self.caches.open(CACHE_NAME)
|
|
||||||
.then((cache) => cache.match(req))
|
|
||||||
.then((res) => {
|
|
||||||
if (res) {
|
|
||||||
log('CACHE HIT: ' + req.url)
|
|
||||||
return res
|
|
||||||
} else {
|
|
||||||
log('CACHE MISS: ' + req.url)
|
|
||||||
return self.fetch(req)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCache () {
|
|
||||||
let cache
|
|
||||||
|
|
||||||
return self.caches.open(CACHE_NAME)
|
|
||||||
.then((c) => {
|
|
||||||
cache = c
|
|
||||||
return cache.keys()
|
|
||||||
})
|
|
||||||
.then(function (items) {
|
|
||||||
const deleteAll = items.map((item) => cache.delete(item))
|
|
||||||
return Promise.all(deleteAll)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyClients () {
|
|
||||||
return self.clients.claim()
|
|
||||||
.then(() => self.clients.matchAll())
|
|
||||||
.then((clients) => {
|
|
||||||
const notifyAll = clients.map((client) => {
|
|
||||||
return client.postMessage({ action: 'NEXT_PREFETCHER_ACTIVATED' })
|
|
||||||
})
|
|
||||||
return Promise.all(notifyAll)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,23 +1,23 @@
|
||||||
import Link, { prefetch } from 'next/prefetch'
|
import Router from 'next/router'
|
||||||
import RegularLink from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<div>
|
<div>
|
||||||
{ /* Prefetch using the declarative API */ }
|
{ /* Prefetch using the declarative API */ }
|
||||||
<Link href='/'>
|
<Link prefetch href='/'>
|
||||||
<a>Home</a>
|
<a>Home</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href='/features'>
|
<Link prefetch href='/features'>
|
||||||
<a>Features</a>
|
<a>Features</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{ /* we imperatively prefetch on hover */ }
|
{ /* we imperatively prefetch on hover */ }
|
||||||
<RegularLink href='/about'>
|
<Link href='/about'>
|
||||||
<a onMouseEnter={() => { prefetch('/about'); console.log('prefetching /about!') }}>About</a>
|
<a onMouseEnter={() => { Router.prefetch('/about'); console.log('prefetching /about!') }}>About</a>
|
||||||
</RegularLink>
|
</Link>
|
||||||
|
|
||||||
<Link href='/contact' prefetch={false}>
|
<Link href='/contact'>
|
||||||
<a>Contact (<small>NO-PREFETCHING</small>)</a>
|
<a>Contact (<small>NO-PREFETCHING</small>)</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
15
flyfile.js
15
flyfile.js
|
@ -1,7 +1,6 @@
|
||||||
const webpack = require('webpack')
|
const webpack = require('webpack')
|
||||||
const notifier = require('node-notifier')
|
const notifier = require('node-notifier')
|
||||||
const childProcess = require('child_process')
|
const childProcess = require('child_process')
|
||||||
const webpackConfig = require('./webpack.config')
|
|
||||||
const isWindows = /^win/.test(process.platform)
|
const isWindows = /^win/.test(process.platform)
|
||||||
|
|
||||||
export async function compile(fly) {
|
export async function compile(fly) {
|
||||||
|
@ -42,15 +41,7 @@ export async function copy(fly) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function build(fly) {
|
export async function build(fly) {
|
||||||
await fly.serial(['copy', 'compile', 'prefetcher'])
|
await fly.serial(['copy', 'compile'])
|
||||||
}
|
|
||||||
|
|
||||||
const compiler = webpack(webpackConfig)
|
|
||||||
export async function prefetcher(fly) {
|
|
||||||
compiler.run((err, stats) => {
|
|
||||||
if (err) throw err
|
|
||||||
notify('Built release prefetcher')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bench(fly) {
|
export async function bench(fly) {
|
||||||
|
@ -70,8 +61,8 @@ export default async function (fly) {
|
||||||
await fly.watch('bin/*', 'bin')
|
await fly.watch('bin/*', 'bin')
|
||||||
await fly.watch('pages/**/*.js', 'copy')
|
await fly.watch('pages/**/*.js', 'copy')
|
||||||
await fly.watch('server/**/*.js', 'server')
|
await fly.watch('server/**/*.js', 'server')
|
||||||
await fly.watch('client/**/*.js', ['client', 'prefetcher'])
|
await fly.watch('client/**/*.js', ['client'])
|
||||||
await fly.watch('lib/**/*.js', ['lib', 'prefetcher'])
|
await fly.watch('lib/**/*.js', ['lib'])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function release(fly) {
|
export async function release(fly) {
|
||||||
|
|
11
lib/link.js
11
lib/link.js
|
@ -62,7 +62,7 @@ export default class Link extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let { children } = this.props
|
let { children, prefetch } = this.props
|
||||||
// Deprecated. Warning shown by propType check. If the childen provided is a string (<Link>example</Link>) we wrap it in an <a> tag
|
// Deprecated. Warning shown by propType check. If the childen provided is a string (<Link>example</Link>) we wrap it in an <a> tag
|
||||||
if (typeof children === 'string') {
|
if (typeof children === 'string') {
|
||||||
children = <a>{children}</a>
|
children = <a>{children}</a>
|
||||||
|
@ -79,11 +79,18 @@ export default class Link extends Component {
|
||||||
props.href = this.props.as || this.props.href
|
props.href = this.props.as || this.props.href
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefetch the JSON page if asked (only in the client)
|
||||||
|
if (prefetch) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Router.prefetch(props.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return React.cloneElement(child, props)
|
return React.cloneElement(child, props)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocal (href) {
|
function isLocal (href) {
|
||||||
const origin = getLocationOrigin()
|
const origin = getLocationOrigin()
|
||||||
return !/^(https?:)?\/\//.test(href) ||
|
return !/^(https?:)?\/\//.test(href) ||
|
||||||
origin === href.substr(0, origin.length)
|
origin === href.substr(0, origin.length)
|
||||||
|
|
159
lib/prefetch.js
159
lib/prefetch.js
|
@ -1,150 +1,33 @@
|
||||||
/* global __NEXT_DATA__ */
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link, { isLocal } from './link'
|
import Link from './link'
|
||||||
import { parse as urlParse } from 'url'
|
import Router from './router'
|
||||||
|
import { warn, execOnce } from './utils'
|
||||||
|
|
||||||
class Messenger {
|
const warnImperativePrefetch = execOnce(() => {
|
||||||
constructor () {
|
const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
|
||||||
this.id = 0
|
'> Use "Router.prefetch(href)" instead.'
|
||||||
this.callacks = {}
|
warn(message)
|
||||||
this.serviceWorkerReadyCallbacks = []
|
})
|
||||||
this.serviceWorkerState = null
|
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', ({ data }) => {
|
const wantLinkPrefetch = execOnce(() => {
|
||||||
if (data.action !== 'REPLY') return
|
const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
|
||||||
if (this.callacks[data.replyFor]) {
|
'> Use "<Link prefetch />" instead.'
|
||||||
this.callacks[data.replyFor](data)
|
warn(message)
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// Reset the cache always.
|
export function prefetch (href) {
|
||||||
// Sometimes, there's an already running service worker with cached requests.
|
warnImperativePrefetch()
|
||||||
// If the app doesn't use any prefetch calls, `ensureInitialized` won't get
|
return Router.prefetch(href)
|
||||||
// called and cleanup resources.
|
|
||||||
// So, that's why we do this.
|
|
||||||
this._resetCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
send (payload) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (this.serviceWorkerState === 'REGISTERED') {
|
|
||||||
this._send(payload, handleCallback)
|
|
||||||
} else {
|
|
||||||
this.serviceWorkerReadyCallbacks.push(() => {
|
|
||||||
this._send(payload, handleCallback)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCallback (err) {
|
|
||||||
if (err) return reject(err)
|
|
||||||
return resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_send (payload, cb = () => {}) {
|
|
||||||
const id = this.id ++
|
|
||||||
const newPayload = { ...payload, id }
|
|
||||||
|
|
||||||
this.callacks[id] = (data) => {
|
|
||||||
if (data.error) {
|
|
||||||
cb(data.error)
|
|
||||||
} else {
|
|
||||||
cb(null, data.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete this.callacks[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.serviceWorker.controller.postMessage(newPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
_resetCache (cb) {
|
|
||||||
const reset = () => {
|
|
||||||
this._send({ action: 'RESET' }, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigator.serviceWorker.controller) {
|
|
||||||
reset()
|
|
||||||
} else {
|
|
||||||
navigator.serviceWorker.oncontrollerchange = reset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureInitialized () {
|
|
||||||
if (this.serviceWorkerState) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serviceWorkerState = 'REGISTERING'
|
|
||||||
navigator.serviceWorker.register('/_next-prefetcher.js')
|
|
||||||
|
|
||||||
// Reset the cache after registered
|
|
||||||
// We don't need to have any old caches since service workers lives beyond
|
|
||||||
// life time of the webpage.
|
|
||||||
// With this prefetching won't work 100% if multiple pages of the same app
|
|
||||||
// loads in the same browser in same time.
|
|
||||||
// Basically, cache will only have prefetched resourses for the last loaded
|
|
||||||
// page of a given app.
|
|
||||||
// We could mitigate this, when we add a hash to a every file we fetch.
|
|
||||||
this._resetCache((err) => {
|
|
||||||
if (err) throw err
|
|
||||||
this.serviceWorkerState = 'REGISTERED'
|
|
||||||
this.serviceWorkerReadyCallbacks.forEach(cb => cb())
|
|
||||||
this.serviceWorkerReadyCallbacks = []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasServiceWorkerSupport () {
|
|
||||||
return (typeof navigator !== 'undefined' && navigator.serviceWorker)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREFETCHED_URLS = {}
|
|
||||||
let messenger
|
|
||||||
|
|
||||||
if (hasServiceWorkerSupport()) {
|
|
||||||
messenger = new Messenger()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrefetchUrl (href) {
|
|
||||||
let { pathname } = urlParse(href)
|
|
||||||
const url = `/_next/${__NEXT_DATA__.buildId}/pages${pathname}`
|
|
||||||
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prefetch (href) {
|
|
||||||
if (!hasServiceWorkerSupport()) return
|
|
||||||
if (!isLocal(href)) return
|
|
||||||
|
|
||||||
// Register the service worker if it's not.
|
|
||||||
messenger.ensureInitialized()
|
|
||||||
|
|
||||||
const url = getPrefetchUrl(href)
|
|
||||||
if (!PREFETCHED_URLS[url]) {
|
|
||||||
PREFETCHED_URLS[url] = messenger.send({ action: 'ADD_URL', url: url })
|
|
||||||
}
|
|
||||||
|
|
||||||
return PREFETCHED_URLS[url]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reloadIfPrefetched (href) {
|
|
||||||
const url = getPrefetchUrl(href)
|
|
||||||
if (!PREFETCHED_URLS[url]) return
|
|
||||||
|
|
||||||
delete PREFETCHED_URLS[url]
|
|
||||||
await prefetch(href)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class LinkPrefetch extends React.Component {
|
export default class LinkPrefetch extends React.Component {
|
||||||
render () {
|
render () {
|
||||||
const { href } = this.props
|
wantLinkPrefetch()
|
||||||
if (this.props.prefetch !== false) {
|
const props = {
|
||||||
prefetch(href)
|
...this.props,
|
||||||
|
prefetch: this.props.prefetch === false ? this.props.prefetch : true
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<Link {...this.props} />)
|
return (<Link {...props} />)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ const SingletonRouter = {
|
||||||
|
|
||||||
// Create public properties and methods of the router in the SingletonRouter
|
// Create public properties and methods of the router in the SingletonRouter
|
||||||
const propertyFields = ['components', 'pathname', 'route', 'query']
|
const propertyFields = ['components', 'pathname', 'route', 'query']
|
||||||
const coreMethodFields = ['push', 'replace', 'reload', 'back']
|
const coreMethodFields = ['push', 'replace', 'reload', 'back', 'prefetch']
|
||||||
const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError']
|
const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError']
|
||||||
|
|
||||||
propertyFields.forEach((field) => {
|
propertyFields.forEach((field) => {
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
/* global __NEXT_DATA__ */
|
/* global __NEXT_DATA__, fetch */
|
||||||
|
|
||||||
import { parse, format } from 'url'
|
import { parse, format } from 'url'
|
||||||
import evalScript from '../eval-script'
|
import evalScript from '../eval-script'
|
||||||
import shallowEquals from '../shallow-equals'
|
import shallowEquals from '../shallow-equals'
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { reloadIfPrefetched } from '../prefetch'
|
import { loadGetInitialProps, LockManager, getLocationOrigin } from '../utils'
|
||||||
import { loadGetInitialProps, getLocationOrigin } from '../utils'
|
|
||||||
|
// Add "fetch" polyfill for older browsers
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
require('whatwg-fetch')
|
||||||
|
}
|
||||||
|
|
||||||
export default class Router extends EventEmitter {
|
export default class Router extends EventEmitter {
|
||||||
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
|
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
|
||||||
|
@ -15,6 +19,10 @@ export default class Router extends EventEmitter {
|
||||||
|
|
||||||
// set up the component cache (by route keys)
|
// set up the component cache (by route keys)
|
||||||
this.components = { [this.route]: { Component, err } }
|
this.components = { [this.route]: { Component, err } }
|
||||||
|
// contain a map of response of prefetched routes
|
||||||
|
this.prefetchedRoutes = {}
|
||||||
|
this.prefetchingLockManager = new LockManager(2)
|
||||||
|
this.prefetchingRoutes = {}
|
||||||
|
|
||||||
this.ErrorComponent = ErrorComponent
|
this.ErrorComponent = ErrorComponent
|
||||||
this.pathname = pathname
|
this.pathname = pathname
|
||||||
|
@ -96,7 +104,8 @@ export default class Router extends EventEmitter {
|
||||||
|
|
||||||
async reload (route) {
|
async reload (route) {
|
||||||
delete this.components[route]
|
delete this.components[route]
|
||||||
await reloadIfPrefetched(route)
|
delete this.prefetchedRoutes[route]
|
||||||
|
this.prefetchingRoutes[route] = 'IGNORE'
|
||||||
|
|
||||||
if (route !== this.route) return
|
if (route !== this.route) return
|
||||||
|
|
||||||
|
@ -184,8 +193,8 @@ export default class Router extends EventEmitter {
|
||||||
const routeInfo = {}
|
const routeInfo = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { Component, err, xhr } = routeInfo.data = await this.fetchComponent(route)
|
const { Component, err, jsonPageRes } = routeInfo.data = await this.fetchComponent(route)
|
||||||
const ctx = { err, xhr, pathname, query }
|
const ctx = { err, pathname, query, jsonPageRes }
|
||||||
routeInfo.props = await this.getInitialProps(Component, ctx)
|
routeInfo.props = await this.getInitialProps(Component, ctx)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.cancelled) {
|
if (err.cancelled) {
|
||||||
|
@ -214,35 +223,74 @@ export default class Router extends EventEmitter {
|
||||||
return this.pathname !== pathname || !shallowEquals(query, this.query)
|
return this.pathname !== pathname || !shallowEquals(query, this.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async prefetch (url) {
|
||||||
|
const { pathname } = parse(url)
|
||||||
|
const route = toRoute(pathname)
|
||||||
|
|
||||||
|
const done = await this.prefetchingLockManager.get()
|
||||||
|
// It's possible for some other "prefetch" process
|
||||||
|
// to start prefetching the same route
|
||||||
|
// So, we should not fetch it again
|
||||||
|
if (this.prefetchingRoutes[route]) return done()
|
||||||
|
|
||||||
|
// It's possible that, this is already prefetched.
|
||||||
|
// So, we should not fetch it again
|
||||||
|
if (this.prefetchedRoutes[route]) return done()
|
||||||
|
|
||||||
|
// Mark as we are prefetching the route
|
||||||
|
this.prefetchingRoutes[route] = true
|
||||||
|
|
||||||
|
const complete = () => {
|
||||||
|
delete this.prefetchingRoutes[route]
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchUrl(route)
|
||||||
|
// Router.relaod() process may ask us to ignore the current prefetching route
|
||||||
|
// In that case, we need to discard it
|
||||||
|
if (this.prefetchingRoutes[route] !== 'IGNORE') {
|
||||||
|
this.prefetchedRoutes[route] = res
|
||||||
|
}
|
||||||
|
complete()
|
||||||
|
} catch (ex) {
|
||||||
|
complete()
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchComponent (route) {
|
async fetchComponent (route) {
|
||||||
let data = this.components[route]
|
let data = this.components[route]
|
||||||
if (!data) {
|
if (data) return data
|
||||||
let cancel
|
|
||||||
|
|
||||||
data = await new Promise((resolve, reject) => {
|
let cancelled = false
|
||||||
this.componentLoadCancel = cancel = () => {
|
const cancel = this.componentLoadCancel = function () {
|
||||||
if (xhr.abort) {
|
cancelled = true
|
||||||
xhr.abort()
|
|
||||||
const error = new Error('Fetching componenet cancelled')
|
|
||||||
error.cancelled = true
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}`
|
|
||||||
const xhr = loadComponent(url, (err, data) => {
|
|
||||||
if (err) return reject(err)
|
|
||||||
resolve({ ...data, xhr })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cancel === this.componentLoadCancel) {
|
|
||||||
this.componentLoadCancel = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.components[route] = data
|
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
let jsonPageRes = this.prefetchedRoutes[route]
|
||||||
|
if (!jsonPageRes) {
|
||||||
|
jsonPageRes = await this.fetchUrl(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = await jsonPageRes.json()
|
||||||
|
const newData = {
|
||||||
|
...loadComponent(jsonData),
|
||||||
|
jsonPageRes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
const error = new Error(`Abort fetching component for route: "${route}"`)
|
||||||
|
error.cancelled = true
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancel === this.componentLoadCancel) {
|
||||||
|
this.componentLoadCancel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.components[route] = newData
|
||||||
|
return newData
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInitialProps (Component, ctx) {
|
async getInitialProps (Component, ctx) {
|
||||||
|
@ -265,6 +313,16 @@ export default class Router extends EventEmitter {
|
||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchUrl (route) {
|
||||||
|
const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
abortComponentLoad () {
|
abortComponentLoad () {
|
||||||
if (this.componentLoadCancel) {
|
if (this.componentLoadCancel) {
|
||||||
this.componentLoadCancel()
|
this.componentLoadCancel()
|
||||||
|
@ -292,47 +350,9 @@ function toRoute (path) {
|
||||||
return path.replace(/\/$/, '') || '/'
|
return path.replace(/\/$/, '') || '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadComponent (url, fn) {
|
function loadComponent (jsonData) {
|
||||||
return loadJSON(url, (err, data) => {
|
const module = evalScript(jsonData.component)
|
||||||
if (err) return fn(err)
|
const Component = module.default || module
|
||||||
|
|
||||||
let module
|
return { Component, err: jsonData.err }
|
||||||
try {
|
|
||||||
module = evalScript(data.component)
|
|
||||||
} catch (err) {
|
|
||||||
return fn(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Component = module.default || module
|
|
||||||
fn(null, { Component, err: data.err })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadJSON (url, fn) {
|
|
||||||
const xhr = new window.XMLHttpRequest()
|
|
||||||
xhr.onload = () => {
|
|
||||||
let data
|
|
||||||
|
|
||||||
try {
|
|
||||||
data = JSON.parse(xhr.responseText)
|
|
||||||
} catch (err) {
|
|
||||||
fn(new Error('Failed to load JSON for ' + url))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fn(null, data)
|
|
||||||
}
|
|
||||||
xhr.onerror = () => {
|
|
||||||
fn(new Error('XHR failed. Status: ' + xhr.status))
|
|
||||||
}
|
|
||||||
xhr.onabort = () => {
|
|
||||||
const err = new Error('XHR aborted')
|
|
||||||
err.cancelled = true
|
|
||||||
fn(err)
|
|
||||||
}
|
|
||||||
xhr.open('GET', url)
|
|
||||||
xhr.setRequestHeader('Accept', 'application/json')
|
|
||||||
xhr.send()
|
|
||||||
|
|
||||||
return xhr
|
|
||||||
}
|
}
|
||||||
|
|
28
lib/utils.js
28
lib/utils.js
|
@ -54,6 +54,34 @@ export async function loadGetInitialProps (Component, ctx) {
|
||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LockManager {
|
||||||
|
constructor (maxLocks) {
|
||||||
|
this.maxLocks = maxLocks
|
||||||
|
this.clients = []
|
||||||
|
this.runningLocks = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get () {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.clients.push(resolve)
|
||||||
|
this._giveLocksIfPossible()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_giveLocksIfPossible () {
|
||||||
|
if (this.runningLocks < this.maxLocks) {
|
||||||
|
const client = this.clients.shift()
|
||||||
|
if (!client) return
|
||||||
|
|
||||||
|
this.runningLocks ++
|
||||||
|
client(() => {
|
||||||
|
this.runningLocks --
|
||||||
|
this._giveLocksIfPossible()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocationOrigin () {
|
export function getLocationOrigin () {
|
||||||
const { protocol, hostname, port } = window.location
|
const { protocol, hostname, port } = window.location
|
||||||
return `${protocol}//${hostname}${port ? ':' + port : ''}`
|
return `${protocol}//${hostname}${port ? ':' + port : ''}`
|
||||||
|
|
|
@ -85,6 +85,7 @@
|
||||||
"webpack": "2.2.1",
|
"webpack": "2.2.1",
|
||||||
"webpack-dev-middleware": "1.10.0",
|
"webpack-dev-middleware": "1.10.0",
|
||||||
"webpack-hot-middleware": "2.16.1",
|
"webpack-hot-middleware": "2.16.1",
|
||||||
|
"whatwg-fetch": "^2.0.2",
|
||||||
"write-file-webpack-plugin": "3.4.2"
|
"write-file-webpack-plugin": "3.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
87
test/unit/router.test.js
Normal file
87
test/unit/router.test.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/* global describe, it, expect */
|
||||||
|
import Router from '../../dist/lib/router/router'
|
||||||
|
|
||||||
|
describe('Router', () => {
|
||||||
|
describe('.prefetch()', () => {
|
||||||
|
it('should prefetch a given page', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
const res = { aa: 'res' }
|
||||||
|
const route = 'routex'
|
||||||
|
router.fetchUrl = (r) => {
|
||||||
|
expect(r).toBe(route)
|
||||||
|
return Promise.resolve(res)
|
||||||
|
}
|
||||||
|
await router.prefetch(route)
|
||||||
|
|
||||||
|
expect(router.prefetchedRoutes[route]).toBe(res)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop if it\'s prefetched already', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
const route = 'routex'
|
||||||
|
router.prefetchedRoutes[route] = { aa: 'res' }
|
||||||
|
router.fetchUrl = () => { throw new Error('Should not happen') }
|
||||||
|
await router.prefetch(route)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop if it\'s currently prefetching', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
const route = 'routex'
|
||||||
|
router.prefetchingRoutes[route] = true
|
||||||
|
router.fetchUrl = () => { throw new Error('Should not happen') }
|
||||||
|
await router.prefetch(route)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore the response if it asked to do', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
const res = { aa: 'res' }
|
||||||
|
const route = 'routex'
|
||||||
|
|
||||||
|
let called = false
|
||||||
|
router.fetchUrl = () => {
|
||||||
|
called = true
|
||||||
|
router.prefetchingRoutes[route] = 'IGNORE'
|
||||||
|
return Promise.resolve(res)
|
||||||
|
}
|
||||||
|
await router.prefetch(route)
|
||||||
|
|
||||||
|
expect(router.prefetchedRoutes[route]).toBeUndefined()
|
||||||
|
expect(called).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only run two jobs at a time', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
router.fetchUrl = () => {
|
||||||
|
count++
|
||||||
|
return new Promise((resolve) => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
router.prefetch('route1')
|
||||||
|
router.prefetch('route2')
|
||||||
|
router.prefetch('route3')
|
||||||
|
router.prefetch('route4')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(count).toBe(2)
|
||||||
|
expect(Object.keys(router.prefetchingRoutes)).toEqual(['route1', 'route2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run all the jobs', async () => {
|
||||||
|
const router = new Router('/', {})
|
||||||
|
const routes = ['route1', 'route2', 'route3', 'route4']
|
||||||
|
|
||||||
|
router.fetchUrl = () => Promise.resolve({ aa: 'res' })
|
||||||
|
|
||||||
|
await router.prefetch(routes[0])
|
||||||
|
await router.prefetch(routes[1])
|
||||||
|
await router.prefetch(routes[2])
|
||||||
|
await router.prefetch(routes[3])
|
||||||
|
|
||||||
|
expect(Object.keys(router.prefetchedRoutes)).toEqual(routes)
|
||||||
|
expect(Object.keys(router.prefetchingRoutes)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,35 +0,0 @@
|
||||||
const resolve = require('path').resolve
|
|
||||||
const webpack = require('webpack')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
entry: './client/next-prefetcher.js',
|
|
||||||
output: {
|
|
||||||
filename: 'next-prefetcher-bundle.js',
|
|
||||||
path: resolve(__dirname, 'dist/client')
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new webpack.DefinePlugin({
|
|
||||||
'process.env': {
|
|
||||||
NODE_ENV: JSON.stringify('production')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
module: {
|
|
||||||
rules: [{
|
|
||||||
test: /\.js$/,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
loader: 'babel-loader',
|
|
||||||
options: {
|
|
||||||
babelrc: false,
|
|
||||||
presets: [
|
|
||||||
['env', {
|
|
||||||
targets: {
|
|
||||||
// All browsers which supports service workers
|
|
||||||
browsers: ['chrome 49', 'firefox 49', 'opera 41']
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4835,7 +4835,7 @@ whatwg-encoding@^1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite "0.4.13"
|
iconv-lite "0.4.13"
|
||||||
|
|
||||||
whatwg-fetch@>=0.10.0:
|
whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz#fe294d1d89e36c5be8b3195057f2e4bc74fc980e"
|
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz#fe294d1d89e36c5be8b3195057f2e4bc74fc980e"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue