feat(nuxt-link): Smart prefetching and $nuxt.isOffline (#4574)

* feat(nuxt-link): Improve <n-link> and add automatic prefetch

* Update packages/vue-app/template/components/nuxt-link.js

Co-Authored-By: Atinux <seb@orion.sh>

* add missing space

* feat(nuxt-link): Split in two components for smaller bundle

* fix(vue-app): Use requestIdleCallback

* chore(vue-app): Improve nuxt prefetch strategy for nuxt links

* chore(vue-app): Add .isOnline and handle it for prefetch

* chore(vue-app): Add .isOffline and use it

* chore(vue-app): Add .isOffline

* chore(server): Check is options.modern is given in dev mode

* chore(vue-app): Add intersection-observer polyfill if router.prefetchLinks is 'polyfill'

* chore(vue-app): Remove polyfill

* chore(vue-app): Use only process.client

* chore(vue-app): Add TS typings for .isOnline and isOffline

* chore(vue-app): Update typings by @kevinmarrec

* chore(vue-app): Reorder names

* examples(nuxt-prefetch): Add Nuxt prefetching example

* chore(vue-app): Add router.linkPrefetchedClass

* lint(vue-app): Fix lint

* chore(vue-app): Use intersectionRatio, recommend by @maoberlehner

* fix(lint): Fix linting issues

* lint(vue-app): Fix again (lol)

* types(vue-app): Update TS typings

* chore(vue-app): Update Vetur tags description

* fix(vue-app): Use prefetchClass

* chore(vue-app): Disable linkPrefetchedClass by default
This commit is contained in:
Sébastien Chopin 2018-12-28 17:27:03 +01:00 committed by GitHub
parent 35151150fd
commit f319033928
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 638 additions and 33 deletions

View File

@ -0,0 +1,3 @@
# Example of Nuxt.js prefetching
Learn more at https://github.com/nuxt/nuxt.js/pull/4574

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#41b883" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@ -0,0 +1,57 @@
<template>
<div>
<div v-if="$nuxt.isOffline" class="offline">
You are offline
</div>
<div class="container">
<h1>{{ $route.name }}</h1>
<Nuxt />
</div>
</div>
</template>
<style>
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.container {
padding: 10px 20px;
padding-bottom: 40px;
}
.offline {
background: #3B8070;
color: white;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
}
a {
display: block;
padding: 10px 10px 10px 0px;
margin: 20px 0;
font-size: 20px;
border-bottom: 2px #ddd solid;
color: #3B8070;
text-decoration: none;
transition: border-bottom-color 0.3s linear;
}
a:hover,
a.nuxt-link-exact-active {
background-color: rgb(245, 245, 245);
}
a.nuxt-link-prefetched:after {
content: '';
display: inline-block;
background: url('../assets/check.svg') no-repeat;
background-size: 14px;
width: 14px;
height: 14px;
position: relative;
right: -3px;
top: 1px;
}
</style>

View File

@ -0,0 +1,12 @@
export default {
head: {
titleTemplate: '%s - NuxtJS Prefetching'
},
router: {
// To disable prefetching, uncomment the line
// prefetchLinks: false
// Activate prefetched class (default: false)
linkPrefetchedClass: 'nuxt-link-prefetched'
}
}

View File

@ -0,0 +1,11 @@
{
"name": "example-hello-world",
"dependencies": {
"nuxt": "latest"
},
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start"
}
}

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
<NuxtLink v-for="link of $store.state.links" :key="link" :to="{ name: link }">
/{{ link }}
</NuxtLink>
</div>
</template>
<script>
export default {
head() {
return {
title: this.$route.name
}
}
}
</script>

View File

@ -0,0 +1,25 @@
export const state = () => ({
links: [
'index',
'agile',
'brisk',
'hot',
'nimble',
'quick',
'rapid',
'swift',
'accelerated',
'active',
'dashing',
'electric',
'flashing',
'fleet',
'fleeting',
'flying',
'hurried',
'racing',
'ready',
'snap',
'winged'
]
})

View File

@ -6,9 +6,11 @@ export default () => ({
middleware: [], middleware: [],
linkActiveClass: 'nuxt-link-active', linkActiveClass: 'nuxt-link-active',
linkExactActiveClass: 'nuxt-link-exact-active', linkExactActiveClass: 'nuxt-link-exact-active',
linkPrefetchedClass: false,
extendRoutes: null, extendRoutes: null,
scrollBehavior: null, scrollBehavior: null,
parseQuery: false, parseQuery: false,
stringifyQuery: false, stringifyQuery: false,
fallback: false fallback: false,
prefetchLinks: true
}) })

View File

@ -17,7 +17,8 @@ export const templatesFiles = [
'components/nuxt-error.vue', 'components/nuxt-error.vue',
'components/nuxt-loading.vue', 'components/nuxt-loading.vue',
'components/nuxt-child.js', 'components/nuxt-child.js',
'components/nuxt-link.js', 'components/nuxt-link.server.js',
'components/nuxt-link.client.js',
'components/nuxt.js', 'components/nuxt.js',
'components/no-ssr.js', 'components/no-ssr.js',
'views/app.template.html', 'views/app.template.html',

View File

@ -55,6 +55,7 @@ export default {
]) ])
}, },
data: () => ({ data: () => ({
isOnline: true,
layout: null, layout: null,
layoutName: '' layoutName: ''
}), }),
@ -65,8 +66,12 @@ export default {
// Add this.$nuxt in child instances // Add this.$nuxt in child instances
Vue.prototype.<%= globals.nuxt %> = this Vue.prototype.<%= globals.nuxt %> = this
// add to window so we can listen when ready // add to window so we can listen when ready
if (typeof window !== 'undefined') { if (process.client) {
window.<%= globals.nuxt %> = <%= (globals.nuxt !== '$nuxt' ? 'window.$nuxt = ' : '') %>this window.<%= globals.nuxt %> = <%= (globals.nuxt !== '$nuxt' ? 'window.$nuxt = ' : '') %>this
this.refreshOnlineStatus()
// Setup the listeners
window.addEventListener('online', this.refreshOnlineStatus)
window.addEventListener('offline', this.refreshOnlineStatus)
} }
// Add $nuxt.error() // Add $nuxt.error()
this.error = this.nuxt.error this.error = this.nuxt.error
@ -79,7 +84,24 @@ export default {
'nuxt.err': 'errorChanged' 'nuxt.err': 'errorChanged'
}, },
<% } %> <% } %>
computed: {
isOffline() {
return !this.isOnline
}
},
methods: { methods: {
refreshOnlineStatus() {
if (process.client) {
if (typeof window.navigator.onLine === 'undefined') {
// If the browser doesn't support connection status reports
// assume that we are online because most apps' only react
// when they now that the connection has been interrupted
this.isOnline = true
} else {
this.isOnline = window.navigator.onLine
}
}
},
<% if (loading) { %> <% if (loading) { %>
errorChanged() { errorChanged() {
if (this.nuxt.err && this.$loading) { if (this.nuxt.err && this.$loading) {

View File

@ -1,5 +1,5 @@
import Vue from 'vue' import Vue from 'vue'
import middleware from './middleware' import middleware from './middleware.js'
import { import {
applyAsyncData, applyAsyncData,
sanitizeComponent, sanitizeComponent,
@ -14,8 +14,13 @@ import {
compile, compile,
getQueryDiff, getQueryDiff,
globalHandleError globalHandleError
} from './utils' } from './utils.js'
import { createApp, NuxtError } from './index' import { createApp, NuxtError } from './index.js'
import NuxtLink from './components/nuxt-link.<%= router.prefetchLinks ? "client" : "server" %>.js' // should be included after ./index.js
// Component: <NuxtLink>
Vue.component(NuxtLink.name, NuxtLink)
Vue.component('NLink', NuxtLink)
const noopData = () => { return {} } const noopData = () => { return {} }
const noopFetch = () => {} const noopFetch = () => {}

View File

@ -0,0 +1,101 @@
<%= isTest ? '// @vue/component' : '' %>
import Vue from 'vue'
const requestIdleCallback = window.requestIdleCallback ||
function (cb) {
const start = Date.now()
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50 - (Date.now() - start))
},
})
}, 1)
}
const observer = window.IntersectionObserver && new window.IntersectionObserver(entries => {
entries.forEach(({ intersectionRatio, target: link }) => {
if (intersectionRatio <= 0) {
return
}
link.__prefetch()
})
})
export default {
extends: Vue.component('RouterLink'),
name: 'NuxtLink',
props: {
noPrefetch: {
type: Boolean,
default: false
}<% if (router.linkPrefetchedClass) { %>,
prefetchedClass: {
type: String,
default: '<%= router.linkPrefetchedClass %>'
}<% } %>
},
mounted() {
if (!this.noPrefetch) {
requestIdleCallback(this.observe, { timeout: 2e3 })
}
},
beforeDestroy() {
if (this.__observed) {
observer.unobserve(this.$el)
delete this.$el.__prefetch
}
},
methods: {
observe() {
// If no IntersectionObserver, avoid prefetching
if (!observer) {
return
}
// Add to observer
if (this.shouldPrefetch()) {
this.$el.__prefetch = this.prefetch.bind(this)
observer.observe(this.$el)
this.__observed = true
}<% if (router.linkPrefetchedClass) { %> else {
this.addPrefetchedClass()
}<% } %>
},
shouldPrefetch() {
return this.getPrefetchComponents().length > 0
},
canPrefetch() {
const conn = navigator.connection
const hasBadConnection = this.$nuxt.isOffline || (conn && ((conn.effectiveType || '').includes('2g') || conn.saveData))
return !hasBadConnection
},
getPrefetchComponents() {
const ref = this.$router.resolve(this.to, this.$route, this.append)
const Components = ref.resolved.matched.map((r) => r.components.default)
return Components.filter((Component) => typeof Component === 'function' && !Component.options && !Component.__prefetched)
},
prefetch() {
if (!this.canPrefetch()) {
return
}
// Stop obersing this link (in case of internet connection changes)
observer.unobserve(this.$el)
const Components = this.getPrefetchComponents()
for (const Component of Components) {
try {
Component()
Component.__prefetched = true
} catch (e) {}
}<% if (router.linkPrefetchedClass) { %>
this.addPrefetchedClass()<% } %>
}<% if (router.linkPrefetchedClass) { %>,
addPrefetchedClass() {
if (this.prefetchedClass !== 'false') {
this.$el.className += (this.$el.className + ' ' + this.prefetchedClass).trim()
}
}<% } %>
}
}

View File

@ -1,8 +0,0 @@
<%= isTest ? '// @vue/component' : '' %>
export default {
name: 'NuxtLink',
functional: true,
render(h, { data, children }) {
return h('router-link', data, children)
}
}

View File

@ -0,0 +1,13 @@
<%= isTest ? '// @vue/component' : '' %>
import Vue from 'vue'
export default {
extends: Vue.component('RouterLink'),
name: 'NuxtLink',
props: {
noPrefetch: {
type: Boolean,
default: false
}
}
}

View File

@ -3,7 +3,6 @@ import Meta from 'vue-meta'
import { createRouter } from './router.js' import { createRouter } from './router.js'
import NoSsr from './components/no-ssr.js' import NoSsr from './components/no-ssr.js'
import NuxtChild from './components/nuxt-child.js' import NuxtChild from './components/nuxt-child.js'
import NuxtLink from './components/nuxt-link.js'
import NuxtError from '<%= components.ErrorPage ? components.ErrorPage : "./components/nuxt-error.vue" %>' import NuxtError from '<%= components.ErrorPage ? components.ErrorPage : "./components/nuxt-error.vue" %>'
import Nuxt from './components/nuxt.js' import Nuxt from './components/nuxt.js'
import App from '<%= appPath %>' import App from '<%= appPath %>'
@ -23,9 +22,7 @@ Vue.component(NoSsr.name, NoSsr)
Vue.component(NuxtChild.name, NuxtChild) Vue.component(NuxtChild.name, NuxtChild)
Vue.component('NChild', NuxtChild) Vue.component('NChild', NuxtChild)
// Component: <NuxtLink // Component NuxtLink is imported in server.js or client.js
Vue.component(NuxtLink.name, NuxtLink)
Vue.component('NLink', NuxtLink)
// Component: <Nuxt>` // Component: <Nuxt>`
Vue.component(Nuxt.name, Nuxt) Vue.component(Nuxt.name, Nuxt)

View File

@ -1,9 +1,14 @@
import { stringify } from 'querystring' import { stringify } from 'querystring'
import Vue from 'vue' import Vue from 'vue'
import omit from 'lodash/omit' import omit from 'lodash/omit'
import middleware from './middleware' import middleware from './middleware.js'
import { applyAsyncData, sanitizeComponent, getMatchedComponents, getContext, middlewareSeries, promisify, urlJoin } from './utils' import { applyAsyncData, sanitizeComponent, getMatchedComponents, getContext, middlewareSeries, promisify, urlJoin } from './utils.js'
import { createApp, NuxtError } from './index' import { createApp, NuxtError } from './index.js'
import NuxtLink from './components/nuxt-link.server.js'
// Component: <NuxtLink>
Vue.component(NuxtLink.name, NuxtLink)
Vue.component('NLink', NuxtLink)
const debug = require('debug')('nuxt:render') const debug = require('debug')('nuxt:render')
debug.color = 4 // force blue color debug.color = 4 // force blue color

View File

@ -566,4 +566,3 @@ function formatQuery(query) {
return key + '=' + val return key + '=' + val
}).filter(Boolean).join('&') }).filter(Boolean).join('&')
} }

View File

@ -59,7 +59,11 @@ export interface ErrorParams {
message?: string; message?: string;
} }
export interface LoadingObject { export interface NuxtApp extends Vue {
start(): void; isOffline: boolean;
finish(): void; isOnline: boolean;
$loading: {
start(): void;
finish(): void;
};
} }

View File

@ -5,7 +5,7 @@
import Vue, { ComponentOptions } from "vue"; import Vue, { ComponentOptions } from "vue";
import { Route } from "vue-router"; import { Route } from "vue-router";
import { MetaInfo } from "vue-meta"; import { MetaInfo } from "vue-meta";
import { Context, Middleware, Transition, LoadingObject } from "./index"; import { Context, Middleware, Transition, NuxtApp } from "./index";
declare module "vue/types/options" { declare module "vue/types/options" {
interface ComponentOptions<V extends Vue> { interface ComponentOptions<V extends Vue> {
@ -24,8 +24,6 @@ declare module "vue/types/options" {
declare module "vue/types/vue" { declare module "vue/types/vue" {
interface Vue { interface Vue {
$nuxt: { $nuxt: NuxtApp;
$loading: LoadingObject;
};
} }
} }

View File

@ -3,7 +3,7 @@
"attributes": [ "attributes": [
"nuxtChildKey" "nuxtChildKey"
], ],
"description": "The nuxt component." "description": "Component to render the current nuxt page."
}, },
"n-child": { "n-child": {
"description": "Component for displaying the children components in a nested route." "description": "Component for displaying the children components in a nested route."
@ -23,7 +23,7 @@
"exact-active-class", "exact-active-class",
"no-prefetch" "no-prefetch"
], ],
"description": "Component for routing. Same as <router-link> now." "description": "Component for navigating between Nuxt pages."
}, },
"nuxt-link": { "nuxt-link": {
"attributes": [ "attributes": [
@ -37,7 +37,7 @@
"exact-active-class", "exact-active-class",
"no-prefetch" "no-prefetch"
], ],
"description": "Component for routing. Same as <router-link> now." "description": "Component for navigating between Nuxt pages."
}, },
"no-ssr": { "no-ssr": {
"description": "Component for excluding a part of your app from server-side rendering." "description": "Component for excluding a part of your app from server-side rendering."

View File

@ -34,6 +34,6 @@ describe('size-limit test', () => {
const responseSizeBytes = responseSizes.reduce((bytes, responseLength) => bytes + responseLength, 0) const responseSizeBytes = responseSizes.reduce((bytes, responseLength) => bytes + responseLength, 0)
const responseSizeKilobytes = Math.ceil(responseSizeBytes / 1024) const responseSizeKilobytes = Math.ceil(responseSizeBytes / 1024)
// Without gzip! // Without gzip!
expect(responseSizeKilobytes).toBeLessThanOrEqual(171) expect(responseSizeKilobytes).toBeLessThanOrEqual(180)
}) })
}) })