1
0
Fork 0
mirror of https://github.com/terribleplan/next.js.git synced 2024-01-19 02:48:18 +00:00

Prefetch pages with Service Workers (#375)

* Register the service worker.

* Update prefetcher code to do prefetching.

* Implement the core prefetching API.
support "import <Link>, { prefetch } from 'next/prefetch'"

* Implement a better communication system with the service worker.

* Add a separate example for prefetching

* Fix some typos.

* Initiate service worker support even prefetching is not used.
This is pretty important since initiating will reset the cache.
If we don't do this, it's possible to have old cached resources
after the user decided to remove all of the prefetching logic.
In this case, even the page didn't prefetch it'll use the
previously cached pages. That because of there might be a already running
service worker.

* Use url module to get pathname.

* Move prefetcher code to the client from pages
Now we also do a webpack build for the prefetcher code.

* Add prefetching docs to the README.md

* Fix some typo.

* Register service worker only if asked to prefetch
We also clean the cache always, even we initialize
the service worker or not.
This commit is contained in:
Arunoda Susiripala 2016-12-16 00:43:40 +05:30 committed by Guillermo Rauch
parent 422c631832
commit 36abdc77c5
17 changed files with 431 additions and 2 deletions

View file

@ -182,6 +182,58 @@ Each top-level component receives a `url` property with the following API:
- `pushTo(url)` - performs a `pushState` call that renders the new `url`. This is equivalent to following a `<Link>`
- `replaceTo(url)` - performs a `replaceState` call that renders the new `url`
### Prefetching Pages
When you are switching between pages, Next.js will download new pages from the server and render them for you. So, it'll take some time to download. Because of that, when you click on a page, it might wait few milliseconds (depending on the network speed) before it render the page.
> Once the Next.js has download the page, it'll reuse it in the next time when you navigate to that same page.
This is a problem specially in UX wise. "Prefetching Pages" is one of our solutions for this problem. With this, Next.js will prefetch pages behind the scene using the support of [Service Workers](https://developers.google.com/web/fundamentals/getting-started/primers/service-workers).
#### Declarative API
You can simply ask Next.js to prefetch pages using `next/prefetch`. See:
```jsx
import Link from 'next/prefetch'
// This is the header component
export default () => (
<div>
<Link href='/'>Home</Link>
<Link href='/about'>Home</Link>
<Link href='/contact'>Home</Link>
</div>
)
```
Here you are using `<Link>` from `next/prefetch` instead of `next/link`. It's an extended version of `next/link` with prefetching support.
Then Next.js will start to prefetch all the pages behind the scene. So, when you click on any of the link it won't need to do a network hit to fetch the page.
If you need, you could stop prefetching like this:
```jsx
<Link href='/contact' prefetch={false}>Home</Link>
```
#### Imperative API
You can get started with prefetching using `<Link>` pretty quickly. But you may want to prefetch based on your own logic. (You may need to write a custom prefetching `<Link>` based on [premonish](https://github.com/mathisonian/premonish).)
Then you can use the imperative API like this:
```jsx
import { prefetch } from 'next/prefetch'
prefetch('/')
prefetch('/features')
```
When you simply run `prefetch('/page_url')` we'll start prefetching that page.
> We can only do this, if `prefetch` is called when loading the current page. So in general, make sure to run `prefetch` calls in a common module all of your pages import.
### Error handling
404 or 500 errors are handled both client and server side by a default component `error.js`. If you wish to override it, define a `_error.js`:

100
client/next-prefetcher.js Normal file
View file

@ -0,0 +1,100 @@
/* global self */
const CACHE_NAME = 'next-prefetcher-v1'
self.addEventListener('install', () => {
console.log('Installing Next Prefetcher')
})
self.addEventListener('activate', (e) => {
console.log('Activated Next Prefetcher')
e.waitUntil(Promise.all([
resetCache(),
notifyClients()
]))
})
self.addEventListener('fetch', (e) => {
e.respondWith(getResponse(e.request))
})
self.addEventListener('message', (e) => {
switch (e.data.action) {
case 'ADD_URL': {
console.log('CACHING ', e.data.url)
sendReply(e, cacheUrl(e.data.url))
break
}
case 'RESET': {
console.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'
})
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) {
console.log('CACHE HIT: ' + req.url)
return res
} else {
console.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

@ -0,0 +1,13 @@
# Example app with prefetching pages
This example features:
* An app with four simple pages
* It will prefetch all the pages in the background except the "contact" page
## How to run it
```sh
npm install
npm run dev
```

View file

@ -0,0 +1,32 @@
import React from 'react'
import Link, { prefetch } from 'next/prefetch'
// Prefetch using the imperative API
prefetch('/')
const styles = {
a: {
marginRight: 10
}
}
export default () => (
<div>
{ /* Prefetch using the declarative API */ }
<Link href='/'>
<a style={styles.a} >Home</a>
</Link>
<Link href='/features'>
<a style={styles.a} >Features</a>
</Link>
<Link href='/about'>
<a style={styles.a} >About</a>
</Link>
<Link href='/contact' prefetch={false}>
<a style={styles.a} >Contact (<small>NO-PREFETCHING</small>)</a>
</Link>
</div>
)

View file

@ -0,0 +1,14 @@
{
"name": "with-prefetching",
"version": "1.0.0",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "*"
},
"author": "",
"license": "ISC"
}

View file

@ -0,0 +1,9 @@
import React from 'react'
import Header from '../components/Header'
export default () => (
<div>
<Header />
<p>This is the ABOUT page.</p>
</div>
)

View file

@ -0,0 +1,9 @@
import React from 'react'
import Header from '../components/Header'
export default () => (
<div>
<Header />
<p>This is the CONTACT page.</p>
</div>
)

View file

@ -0,0 +1,9 @@
import React from 'react'
import Header from '../components/Header'
export default () => (
<div>
<Header />
<p>This is the FEATURES page.</p>
</div>
)

View file

@ -0,0 +1,9 @@
import React from 'react'
import Header from '../components/Header'
export default () => (
<div>
<Header />
<p>This is the HOME page</p>
</div>
)

View file

@ -72,7 +72,8 @@ gulp.task('copy-bench-fixtures', () => {
gulp.task('build', [
'build-dev-client',
'build-client'
'build-client',
'build-prefetcher'
])
gulp.task('build-dev-client', ['compile-lib', 'compile-client'], () => {
@ -133,6 +134,44 @@ gulp.task('build-client', ['compile-lib', 'compile-client'], () => {
.pipe(notify('Built release client'))
})
gulp.task('build-prefetcher', ['compile-lib', 'compile-client'], () => {
return gulp
.src('client/next-prefetcher.js')
.pipe(webpack({
quiet: true,
output: { filename: 'next-prefetcher-bundle.js' },
plugins: [
new webpack.webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
}
})
],
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel',
query: {
'babelrc': false,
'presets': [
['env', {
'targets': {
// All browsers which supports service workers
'browsers': ['chrome 49', 'firefox 49', 'opera 41']
}
}]
]
}
}
]
}
}))
.pipe(gulp.dest('dist/client'))
.pipe(notify('Built release prefetcher'))
})
gulp.task('test', () => {
return gulp.src('./test')
.pipe(jest.default({

View file

@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import App from '../lib/app'
import Link from '../lib/link'
import * as Prefetch from '../lib/prefetch'
import * as Css from '../lib/css'
import Head from '../lib/head'
@ -10,6 +11,7 @@ const modules = new Map([
['react-dom', ReactDOM],
['next/app', App],
['next/link', Link],
['next/prefetch', Prefetch],
['next/css', Css],
['next/head', Head]
])

View file

@ -62,7 +62,7 @@ export default class Link extends Component {
}
}
function isLocal (href) {
export function isLocal (href) {
const origin = window.location.origin
return !/^https?:\/\//.test(href) ||
origin === href.substr(0, origin.length)

132
lib/prefetch.js Normal file
View file

@ -0,0 +1,132 @@
import React from 'react'
import Link, { isLocal } from './link'
import { parse as urlParse } from 'url'
class Messenger {
constructor () {
this.id = 0
this.callacks = {}
this.serviceWorkerReadyCallbacks = []
this.serviceWorkerState = null
navigator.serviceWorker.addEventListener('message', ({ data }) => {
if (data.action !== 'REPLY') return
if (this.callacks[data.replyFor]) {
this.callacks[data.replyFor](data)
}
})
// 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, cb) {
if (this.serviceWorkerState === 'REGISTERED') {
this._send(payload, cb)
} else {
this.serviceWorkerReadyCallbacks.push(() => {
this._send(payload, cb)
})
}
}
_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 = []
console.log('Next Prefetcher registered')
})
}
}
function hasServiceWorkerSupport () {
return (typeof navigator !== 'undefined' && navigator.serviceWorker)
}
const PREFETCHED_URLS = {}
let messenger
if (hasServiceWorkerSupport()) {
messenger = new Messenger()
}
export function prefetch (href) {
if (!hasServiceWorkerSupport()) return
if (!isLocal(href)) return
// Register the service worker if it's not.
messenger.ensureInitialized()
let { pathname } = urlParse(href)
// Add support for the index page
if (pathname === '/') {
pathname = '/index'
}
const url = `${pathname}.json`
if (PREFETCHED_URLS[url]) return
messenger.send({ action: 'ADD_URL', url: url })
PREFETCHED_URLS[url] = true
}
export default class LinkPrefetch extends React.Component {
render () {
const { href } = this.props
if (this.props.prefetch !== false) {
prefetch(href)
}
return (<Link {...this.props} />)
}
}

View file

@ -72,6 +72,7 @@
"devDependencies": {
"babel-eslint": "7.1.1",
"babel-plugin-transform-remove-strict-mode": "0.0.2",
"babel-preset-env": "1.0.2",
"benchmark": "2.1.2",
"coveralls": "2.11.15",
"gulp": "3.9.1",

1
prefetch.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('./dist/lib/prefetch')

View file

@ -144,6 +144,7 @@ export default async function createCompiler (dir, { hotReload = false, dev = fa
'babel-runtime': babelRuntimePath,
react: require.resolve('react'),
'next/link': require.resolve('../../lib/link'),
'next/prefetch': require.resolve('../../lib/prefetch'),
'next/css': require.resolve('../../lib/css'),
'next/head': require.resolve('../../lib/head')
}
@ -181,6 +182,7 @@ export default async function createCompiler (dir, { hotReload = false, dev = fa
{
[require.resolve('react')]: 'react',
[require.resolve('../../lib/link')]: 'next/link',
[require.resolve('../../lib/prefetch')]: 'next/prefetch',
[require.resolve('../../lib/css')]: 'next/css',
[require.resolve('../../lib/head')]: 'next/head'
}

View file

@ -40,6 +40,11 @@ export default class Server {
}
defineRoutes () {
this.router.get('/_next-prefetcher.js', async (req, res, params) => {
const p = join(__dirname, '../client/next-prefetcher-bundle.js')
await this.serveStatic(req, res, p)
})
this.router.get('/_next/commons.js', async (req, res, params) => {
const p = join(this.dir, '.next/commons.js')
await this.serveStatic(req, res, p)