mirror of
https://github.com/aelve/guide.git
synced 2024-11-22 03:12:58 +03:00
Client preload rewritten (#417)
This commit is contained in:
parent
862aed5908
commit
571b67bc84
@ -2,7 +2,9 @@
|
||||
<v-app>
|
||||
<toolbar />
|
||||
<main class="app-content content-centered">
|
||||
<router-view />
|
||||
<ClientServerPrefetch>
|
||||
<RouterView />
|
||||
</ClientServerPrefetch>
|
||||
</main>
|
||||
<a-footer />
|
||||
</v-app>
|
||||
@ -14,24 +16,21 @@ import Component from 'vue-class-component'
|
||||
import { Watch } from 'vue-property-decorator'
|
||||
import AFooter from 'client/components/AFooter.vue'
|
||||
import Toolbar from 'client/components/Toolbar.vue'
|
||||
import ClientServerPrefetch from 'client/components/ClientServerPrefetch.vue'
|
||||
import * as nprogress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { getRouteDocumentTitle } from 'client/router'
|
||||
|
||||
nprogress.configure({ showSpinner: false })
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Toolbar,
|
||||
AFooter
|
||||
AFooter,
|
||||
ClientServerPrefetch
|
||||
}
|
||||
})
|
||||
export default class RootComponent extends Vue {
|
||||
beforeMount () {
|
||||
// This package can only be loaded after mounted (on client only) cause it uses "document"
|
||||
// it is used in MarkdownEditor.vue and to make it work faster in that component we preload it here
|
||||
import('easymde')
|
||||
}
|
||||
|
||||
get isPageLoading () {
|
||||
return this.$store.state.isPageLoading
|
||||
}
|
||||
@ -39,6 +38,16 @@ export default class RootComponent extends Vue {
|
||||
@Watch('isPageLoading')
|
||||
toogleLoading (isPageLoading: boolean) {
|
||||
isPageLoading ? nprogress.start() : nprogress.done()
|
||||
|
||||
if (!isPageLoading) {
|
||||
document.title = getRouteDocumentTitle(this.$route)
|
||||
}
|
||||
}
|
||||
|
||||
beforeMount () {
|
||||
// This package can only be loaded after mounted (on client only) cause it uses "document"
|
||||
// it is used in MarkdownEditor.vue and to make it work faster in that component we preload it here
|
||||
import('easymde')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
76
front/client/components/ClientServerPrefetch.vue
Normal file
76
front/client/components/ClientServerPrefetch.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import Component from 'vue-class-component'
|
||||
import { Watch } from 'vue-property-decorator'
|
||||
|
||||
function getComponentAndItsChildren (component, result?) {
|
||||
if (!result) {
|
||||
result = []
|
||||
}
|
||||
if (!result.includes(component)) {
|
||||
result.push(component)
|
||||
}
|
||||
const children = component.$children
|
||||
children.forEach(x => getComponentAndItsChildren(x, result))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* When this component slot (supports only one slot) changes it prerenders new slot in memory
|
||||
* and waits for slot (and its' children) "serverPrefetch" execution before rendering it
|
||||
*/
|
||||
@Component
|
||||
export default class ClientServerPrefetch extends Vue {
|
||||
// vNodeToRender is not set here (as reactive property) to prevent automatic "render" function call when it changes
|
||||
isServerPrefetchProcessing = false
|
||||
|
||||
@Watch('isServerPrefetchProcessing')
|
||||
onServerPrefetch (val) {
|
||||
this.$store.commit('togglePageLoading')
|
||||
}
|
||||
|
||||
onServerPrefetchFinish () {
|
||||
const previousVNode = this.vNodeToRender
|
||||
this.vNodeToRender = this.$slots.default[0]
|
||||
this.$forceUpdate()
|
||||
this.isServerPrefetchProcessing = false
|
||||
previousVNode.data.keepAlive = false
|
||||
previousVNode.componentInstance.$destroy()
|
||||
}
|
||||
|
||||
async preloadSlotComponent () {
|
||||
this.isServerPrefetchProcessing = true
|
||||
try {
|
||||
const slot = this.$slots.default[0]
|
||||
// This is vue's internal function that inits component
|
||||
this._update(slot, false)
|
||||
const componentInstance = slot.componentInstance
|
||||
const components = getComponentAndItsChildren(componentInstance)
|
||||
const getServerPrefetchFunc = x => x.$options.serverPrefetch && x.$options.serverPrefetch[0]
|
||||
const componentsWithServerPrefetch = components.filter(getServerPrefetchFunc)
|
||||
await Promise.all(componentsWithServerPrefetch.map(x => getServerPrefetchFunc(x).call(x)))
|
||||
this.onServerPrefetchFinish()
|
||||
} catch (e) {
|
||||
this.isServerPrefetchProcessing = false
|
||||
this.$router.back()
|
||||
}
|
||||
}
|
||||
|
||||
render (h) {
|
||||
const slot = this.$slots.default[0]
|
||||
// If we don't set keepAlive to true, component will be automatically destroyed when slot changes
|
||||
// and also new slot will be rendered twice: 1 - in preloadSlotComponent func, 2 - when return here
|
||||
slot.data.keepAlive = true
|
||||
|
||||
if (!this.vNodeToRender || slot === this.vNodeToRender || slot.tag === this.vNodeToRender.tag) {
|
||||
this.vNodeToRender = slot
|
||||
} else {
|
||||
// if slot was changed we dont render it immediatly but firstly execute its (and its children) 'serverPrefetch' functions
|
||||
this.preloadSlotComponent()
|
||||
}
|
||||
|
||||
return this.vNodeToRender
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-form
|
||||
class="search-bar"
|
||||
@keydown.enter.native.prevent="processSearchInput"
|
||||
@keydown.enter.native.prevent="processSearchQuery"
|
||||
>
|
||||
<v-text-field
|
||||
solo
|
||||
@ -10,7 +10,7 @@
|
||||
label="Search in all pages"
|
||||
class="search-bar__input"
|
||||
ref="input"
|
||||
v-model="searchInput"
|
||||
v-model="searchQuery"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused= false"
|
||||
>
|
||||
@ -25,7 +25,7 @@
|
||||
size="0.9rem"
|
||||
class="search-bar__input__clear-btn"
|
||||
:color="inputIconsColor"
|
||||
v-show="searchInput"
|
||||
v-show="searchQuery"
|
||||
@click="clear"
|
||||
>$vuetify.icons.times</v-icon>
|
||||
</v-text-field>
|
||||
@ -40,19 +40,17 @@ import Component from 'vue-class-component'
|
||||
export default class SearchBar extends Vue {
|
||||
|
||||
isFocused = false
|
||||
searchQuery = this.$store.state.wiki.searchQuery
|
||||
|
||||
processSearchInput () {
|
||||
this.$router.push({ name: 'SearchResults', query: { query: this.searchInput } })
|
||||
}
|
||||
|
||||
// TODO investigate is using computed necessary here
|
||||
// TODO serever rendering doesnt render value in text field
|
||||
get searchInput () {
|
||||
return this.$store.state.wiki.searchInput
|
||||
}
|
||||
|
||||
set searchInput (value) {
|
||||
this.$store.commit('wiki/setSearchInput', value)
|
||||
processSearchQuery () {
|
||||
const searchResultsRouteName = 'SearchResults'
|
||||
const isSearchCurrentRoute = this.$route.name === searchResultsRouteName
|
||||
// same commit statement called in router 'SearchResults' route's "beforeEnter", but if its same route its not called
|
||||
// searchQuery is watched used in "SearchResults" component
|
||||
if (isSearchCurrentRoute) {
|
||||
this.$store.commit('wiki/setSearchQuery', this.searchQuery)
|
||||
}
|
||||
this.$router.push({ name: searchResultsRouteName, query: { query: this.searchQuery } })
|
||||
}
|
||||
|
||||
get inputIconsColor () {
|
||||
@ -60,7 +58,7 @@ export default class SearchBar extends Vue {
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.searchInput = ''
|
||||
this.searchQuery = ''
|
||||
}
|
||||
|
||||
focus () {
|
||||
|
@ -1,5 +1,3 @@
|
||||
import _get from 'lodash/get'
|
||||
|
||||
import { createApp } from './app'
|
||||
|
||||
const { app, router, store } = createApp()
|
||||
@ -20,70 +18,5 @@ if (store.state.is404) {
|
||||
}
|
||||
|
||||
router.onReady(() => {
|
||||
registerRouterHooks()
|
||||
app.$mount('#app')
|
||||
})
|
||||
|
||||
function registerRouterHooks () {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// This case handles navigation to anchors on same page
|
||||
if (to.path === from.path) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
store.commit('tooglePageLoading')
|
||||
if (!to.matched.length) {
|
||||
store.commit('tooglePageLoading')
|
||||
next()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const propsOption = to.matched[0].props.default
|
||||
const props = propsOption
|
||||
? typeof propsOption === 'function'
|
||||
? propsOption(to)
|
||||
: typeof propsOption === 'object'
|
||||
? propsOption
|
||||
: to.params
|
||||
: {}
|
||||
const routeComponent = to.matched[0].components.default
|
||||
const matchedRootComponent = routeComponent.cid // Check if component already imported
|
||||
? routeComponent
|
||||
: (await routeComponent()).default
|
||||
const matchedComponentsAndChildren = getComponentAndItsChildren(matchedRootComponent)
|
||||
await Promise.all(matchedComponentsAndChildren.map(component => {
|
||||
const serverPrefetch = component.options.serverPrefetch && component.options.serverPrefetch[0]
|
||||
if (typeof serverPrefetch === 'function') {
|
||||
return serverPrefetch.call({
|
||||
$store: store,
|
||||
$router: router,
|
||||
...component.options.methods,
|
||||
...props
|
||||
})
|
||||
}
|
||||
}))
|
||||
next()
|
||||
} finally {
|
||||
store.commit('tooglePageLoading')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getComponentAndItsChildren (component, result?) {
|
||||
if (!result) {
|
||||
result = []
|
||||
}
|
||||
if (!component.options) {
|
||||
return result
|
||||
}
|
||||
if (!result.includes(component)) {
|
||||
result.push(component)
|
||||
}
|
||||
const children = Object.values(component.options.components)
|
||||
// Parent component is also presents in components object
|
||||
.filter(x => x !== component)
|
||||
children.forEach(x => getComponentAndItsChildren(x, result))
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import _get from 'lodash/get'
|
||||
import { createApp } from './app'
|
||||
import { getRouteDocumentTitle } from './router'
|
||||
|
||||
export default async context => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -19,6 +20,7 @@ export default async context => {
|
||||
context.rendered = () => {
|
||||
context.state = store.state
|
||||
}
|
||||
context.title = getRouteDocumentTitle(router.currentRoute)
|
||||
resolve(app)
|
||||
}, reject)
|
||||
})
|
||||
|
@ -28,18 +28,8 @@ export default class CategoryPage extends Vue {
|
||||
return this.$store.state.category.category
|
||||
}
|
||||
|
||||
beforeMount () {
|
||||
this.$watch('category', this.setDocumentTitle, { immediate: true })
|
||||
}
|
||||
|
||||
beforeDestroy () {
|
||||
this.$store.commit('category/setCategory', null)
|
||||
}
|
||||
|
||||
setDocumentTitle (category) {
|
||||
document.title = category
|
||||
? `${category.title} – Aelve Guide`
|
||||
: 'Aelve Guide'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -12,9 +12,5 @@ import Component from 'vue-class-component'
|
||||
Categories
|
||||
}
|
||||
})
|
||||
export default class Index extends Vue {
|
||||
beforeMount () {
|
||||
document.title = `Aelve Guide`
|
||||
}
|
||||
}
|
||||
export default class Index extends Vue {}
|
||||
</script>
|
||||
|
@ -20,10 +20,6 @@ export default class Page404 extends Vue {
|
||||
beforeDestroy () {
|
||||
this.$store.commit('set404', false)
|
||||
}
|
||||
|
||||
beforeMount () {
|
||||
document.title = `Error 404 – Aelve Guide`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -76,7 +76,7 @@
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import Component from 'vue-class-component'
|
||||
import { Prop, Watch } from 'vue-property-decorator'
|
||||
import { Watch } from 'vue-property-decorator'
|
||||
|
||||
const resultTagCodes = {
|
||||
category: 'Category',
|
||||
@ -85,7 +85,9 @@ const resultTagCodes = {
|
||||
|
||||
@Component
|
||||
export default class SearchResults extends Vue {
|
||||
@Prop(String) query!: string
|
||||
get query () {
|
||||
return this.$store.state.wiki.searchQuery
|
||||
}
|
||||
|
||||
get results () {
|
||||
return this.$store.state.wiki.searchResults
|
||||
@ -99,26 +101,13 @@ export default class SearchResults extends Vue {
|
||||
return this.results.filter(x => x.tag === resultTagCodes.item)
|
||||
}
|
||||
|
||||
beforeMount () {
|
||||
// This watch should be added in beforeMount (only on client) hook because setDocumentTitle function uses DOM api which is undefined on server
|
||||
this.$watch('query', this.setDocumentTitle, { immediate: true })
|
||||
}
|
||||
|
||||
mounted () {
|
||||
this.$store.commit('wiki/setSearchInput', this.query)
|
||||
}
|
||||
|
||||
async serverPrefetch () {
|
||||
await this.search()
|
||||
}
|
||||
|
||||
@Watch('query')
|
||||
async search () {
|
||||
await this.$store.dispatch('wiki/search', this.query)
|
||||
}
|
||||
|
||||
setDocumentTitle (query) {
|
||||
document.title = `${query} – Search results – Aelve Guide`
|
||||
async serverPrefetch () {
|
||||
await this.search()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Router from 'vue-router'
|
||||
import categoryPathToId from 'client/helpers/categoryPathToId'
|
||||
import _get from 'lodash/get'
|
||||
|
||||
function createRouter (store) {
|
||||
return new Router({
|
||||
@ -32,23 +33,53 @@ function createRouter (store) {
|
||||
path: '/haskell/:category',
|
||||
name: 'Category',
|
||||
component: () => import('../page/CategoryPage.vue'),
|
||||
props: (route) => ({ categoryId: categoryPathToId(route.params.category) })
|
||||
props: (route) => ({ categoryId: categoryPathToId(route.params.category) }),
|
||||
meta: {
|
||||
documentTitle: () => {
|
||||
const category = store.state.category.category
|
||||
return category
|
||||
? `${category.title} – Aelve Guide`
|
||||
: 'Aelve Guide'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/haskell/search/results/',
|
||||
name: 'SearchResults',
|
||||
component: () => import('../page/SearchResults.vue'),
|
||||
props: (route) => ({ query: route.query.query })
|
||||
beforeEnter: (to, from, next) => {
|
||||
store.commit('wiki/setSearchQuery', to.query.query)
|
||||
next()
|
||||
},
|
||||
meta: {
|
||||
documentTitle: (route) => `${route.query.query} – Search results – Aelve Guide`,
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
name: 'Page404',
|
||||
component: () => import('../page/Page404.vue')
|
||||
component: () => import('../page/Page404.vue'),
|
||||
meta: {
|
||||
documentTitle: 'Error 404 – Aelve Guide'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
createRouter
|
||||
function getRouteDocumentTitle (route) {
|
||||
const defaultTitle = 'Aelve Guide'
|
||||
const routeDocumentTitle = _get(route, 'meta.documentTitle')
|
||||
if (routeDocumentTitle) {
|
||||
return typeof routeDocumentTitle === 'function'
|
||||
? routeDocumentTitle(route)
|
||||
: routeDocumentTitle
|
||||
} else {
|
||||
return defaultTitle
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createRouter,
|
||||
getRouteDocumentTitle
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ function createStore () {
|
||||
},
|
||||
actions: {},
|
||||
mutations: {
|
||||
tooglePageLoading (state) {
|
||||
togglePageLoading (state) {
|
||||
state.isPageLoading = !state.isPageLoading
|
||||
},
|
||||
set404 (state, val) {
|
||||
|
@ -4,12 +4,12 @@ import { set } from '../helpers'
|
||||
|
||||
interface IWikiState {
|
||||
searchResults: any[],
|
||||
searchInput: string
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const state = (): IWikiState => ({
|
||||
searchResults: [],
|
||||
searchInput: ''
|
||||
searchQuery: ''
|
||||
})
|
||||
|
||||
const getters: GetterTree<IWikiState, any> = {}
|
||||
@ -23,7 +23,7 @@ const actions: ActionTree<IWikiState, any> = {
|
||||
|
||||
const mutations: MutationTree<IWikiState> = {
|
||||
setSearchResults: set('searchResults'),
|
||||
setSearchInput: set('searchInput')
|
||||
setSearchQuery: set('searchQuery')
|
||||
}
|
||||
|
||||
const wiki: Module<IWikiState, any> = {
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title> Aelve Guide </title>
|
||||
<title> {{ title }} </title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
Loading…
Reference in New Issue
Block a user