1
0
Fork 0
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:
Arunoda Susiripala 2017-02-15 14:22:22 +05:30 committed by GitHub
parent d382e4db9a
commit 14c86bef1d
13 changed files with 264 additions and 392 deletions

View file

@ -344,40 +344,37 @@ Router.onRouteChangeError = (err, url) => {
<ul><li><a href="./examples/with-prefetching">Prefetching</a></li></ul>
</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).
> With prefetching Next.js only download JS code. When the page is getting rendered, you may need to wait for the data.
#### 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
import Link from 'next/prefetch'
import Link from 'next/link'
// example header component
export default () => (
<nav>
<ul>
<li><Link href='/'><a>Home</a></Link></li>
<li><Link href='/about'><a>About</a></Link></li>
<li><Link href='/contact'><a>Contact</a></Link></li>
<li><Link prefetch ref='/'><a>Home</a></Link></li>
<li><Link prefetch href='/about'><a>About</a></Link></li>
<li><Link prefetch href='/contact'><a>Contact</a></Link></li>
</ul>
</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
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
import { prefetch } from 'next/prefetch'
import Router from 'next/router'
export default ({ url }) => (
<div>
<a onClick={ () => setTimeout(() => url.pushTo('/dynamic'), 100) }>
@ -385,7 +382,7 @@ export default ({ url }) => (
</a>
{
// but we can prefetch it!
prefetch('/dynamic')
Router.prefetch('/dynamic')
}
</div>
)

View file

@ -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)
})
}

View file

@ -1,23 +1,23 @@
import Link, { prefetch } from 'next/prefetch'
import RegularLink from 'next/link'
import Router from 'next/router'
import Link from 'next/link'
export default () => (
<div>
{ /* Prefetch using the declarative API */ }
<Link href='/'>
<Link prefetch href='/'>
<a>Home</a>
</Link>
<Link href='/features'>
<Link prefetch href='/features'>
<a>Features</a>
</Link>
{ /* we imperatively prefetch on hover */ }
<RegularLink href='/about'>
<a onMouseEnter={() => { prefetch('/about'); console.log('prefetching /about!') }}>About</a>
</RegularLink>
<Link href='/about'>
<a onMouseEnter={() => { Router.prefetch('/about'); console.log('prefetching /about!') }}>About</a>
</Link>
<Link href='/contact' prefetch={false}>
<Link href='/contact'>
<a>Contact (<small>NO-PREFETCHING</small>)</a>
</Link>

View file

@ -1,7 +1,6 @@
const webpack = require('webpack')
const notifier = require('node-notifier')
const childProcess = require('child_process')
const webpackConfig = require('./webpack.config')
const isWindows = /^win/.test(process.platform)
export async function compile(fly) {
@ -42,15 +41,7 @@ export async function copy(fly) {
}
export async function build(fly) {
await fly.serial(['copy', 'compile', 'prefetcher'])
}
const compiler = webpack(webpackConfig)
export async function prefetcher(fly) {
compiler.run((err, stats) => {
if (err) throw err
notify('Built release prefetcher')
})
await fly.serial(['copy', 'compile'])
}
export async function bench(fly) {
@ -70,8 +61,8 @@ export default async function (fly) {
await fly.watch('bin/*', 'bin')
await fly.watch('pages/**/*.js', 'copy')
await fly.watch('server/**/*.js', 'server')
await fly.watch('client/**/*.js', ['client', 'prefetcher'])
await fly.watch('lib/**/*.js', ['lib', 'prefetcher'])
await fly.watch('client/**/*.js', ['client'])
await fly.watch('lib/**/*.js', ['lib'])
}
export async function release(fly) {

View file

@ -62,7 +62,7 @@ export default class Link extends Component {
}
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
if (typeof children === 'string') {
children = <a>{children}</a>
@ -79,11 +79,18 @@ export default class Link extends Component {
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)
}
}
export function isLocal (href) {
function isLocal (href) {
const origin = getLocationOrigin()
return !/^(https?:)?\/\//.test(href) ||
origin === href.substr(0, origin.length)

View file

@ -1,150 +1,33 @@
/* global __NEXT_DATA__ */
import React from 'react'
import Link, { isLocal } from './link'
import { parse as urlParse } from 'url'
import Link from './link'
import Router from './router'
import { warn, execOnce } from './utils'
class Messenger {
constructor () {
this.id = 0
this.callacks = {}
this.serviceWorkerReadyCallbacks = []
this.serviceWorkerState = null
const warnImperativePrefetch = execOnce(() => {
const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
'> Use "Router.prefetch(href)" instead.'
warn(message)
})
navigator.serviceWorker.addEventListener('message', ({ data }) => {
if (data.action !== 'REPLY') return
if (this.callacks[data.replyFor]) {
this.callacks[data.replyFor](data)
}
})
const wantLinkPrefetch = execOnce(() => {
const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
'> Use "<Link prefetch />" instead.'
warn(message)
})
// Reset the cache always.
// Sometimes, there's an already running service worker with cached requests.
// If the app doesn't use any prefetch calls, `ensureInitialized` won't get
// 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 function prefetch (href) {
warnImperativePrefetch()
return Router.prefetch(href)
}
export default class LinkPrefetch extends React.Component {
render () {
const { href } = this.props
if (this.props.prefetch !== false) {
prefetch(href)
wantLinkPrefetch()
const props = {
...this.props,
prefetch: this.props.prefetch === false ? this.props.prefetch : true
}
return (<Link {...this.props} />)
return (<Link {...props} />)
}
}

View file

@ -13,7 +13,7 @@ const SingletonRouter = {
// Create public properties and methods of the router in the SingletonRouter
const propertyFields = ['components', 'pathname', 'route', 'query']
const coreMethodFields = ['push', 'replace', 'reload', 'back']
const coreMethodFields = ['push', 'replace', 'reload', 'back', 'prefetch']
const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError']
propertyFields.forEach((field) => {

View file

@ -1,11 +1,15 @@
/* global __NEXT_DATA__ */
/* global __NEXT_DATA__, fetch */
import { parse, format } from 'url'
import evalScript from '../eval-script'
import shallowEquals from '../shallow-equals'
import { EventEmitter } from 'events'
import { reloadIfPrefetched } from '../prefetch'
import { loadGetInitialProps, getLocationOrigin } from '../utils'
import { loadGetInitialProps, LockManager, getLocationOrigin } from '../utils'
// Add "fetch" polyfill for older browsers
if (typeof window !== 'undefined') {
require('whatwg-fetch')
}
export default class Router extends EventEmitter {
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
@ -15,6 +19,10 @@ export default class Router extends EventEmitter {
// set up the component cache (by route keys)
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.pathname = pathname
@ -96,7 +104,8 @@ export default class Router extends EventEmitter {
async reload (route) {
delete this.components[route]
await reloadIfPrefetched(route)
delete this.prefetchedRoutes[route]
this.prefetchingRoutes[route] = 'IGNORE'
if (route !== this.route) return
@ -184,8 +193,8 @@ export default class Router extends EventEmitter {
const routeInfo = {}
try {
const { Component, err, xhr } = routeInfo.data = await this.fetchComponent(route)
const ctx = { err, xhr, pathname, query }
const { Component, err, jsonPageRes } = routeInfo.data = await this.fetchComponent(route)
const ctx = { err, pathname, query, jsonPageRes }
routeInfo.props = await this.getInitialProps(Component, ctx)
} catch (err) {
if (err.cancelled) {
@ -214,35 +223,74 @@ export default class Router extends EventEmitter {
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) {
let data = this.components[route]
if (!data) {
let cancel
if (data) return data
data = await new Promise((resolve, reject) => {
this.componentLoadCancel = cancel = () => {
if (xhr.abort) {
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
let cancelled = false
const cancel = this.componentLoadCancel = function () {
cancelled = true
}
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) {
@ -265,6 +313,16 @@ export default class Router extends EventEmitter {
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 () {
if (this.componentLoadCancel) {
this.componentLoadCancel()
@ -292,47 +350,9 @@ function toRoute (path) {
return path.replace(/\/$/, '') || '/'
}
function loadComponent (url, fn) {
return loadJSON(url, (err, data) => {
if (err) return fn(err)
function loadComponent (jsonData) {
const module = evalScript(jsonData.component)
const Component = module.default || module
let module
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
return { Component, err: jsonData.err }
}

View file

@ -54,6 +54,34 @@ export async function loadGetInitialProps (Component, ctx) {
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 () {
const { protocol, hostname, port } = window.location
return `${protocol}//${hostname}${port ? ':' + port : ''}`

View file

@ -85,6 +85,7 @@
"webpack": "2.2.1",
"webpack-dev-middleware": "1.10.0",
"webpack-hot-middleware": "2.16.1",
"whatwg-fetch": "^2.0.2",
"write-file-webpack-plugin": "3.4.2"
},
"devDependencies": {

87
test/unit/router.test.js Normal file
View 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([])
})
})
})

View file

@ -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']
}
}]
]
}
}]
}
}

View file

@ -4835,7 +4835,7 @@ whatwg-encoding@^1.0.1:
dependencies:
iconv-lite "0.4.13"
whatwg-fetch@>=0.10.0:
whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz#fe294d1d89e36c5be8b3195057f2e4bc74fc980e"