Server Rendering - Data Prefetching and Status

Web pack from scratch

Web Pack 4 is built from scratch (1)
Construction of webpack4+React16 Project (II)
Detailed partition of Web Pack 4 function configuration (3)
Introducing Ant Design and Typescript into webpack4 (IV)
Web Pack 4 code de-duplication, information simplification and construction optimization (5)
Web Pack 4 configuration Vue version scaffolding (6)

Server Rendering Series

Server Rendering - - Vue+Koa Build Successful Output Page from Zero
Server Rendering - Data Prefetching and Status

Data prefetching and status

During server-side rendering (SSR), if the application relies on some asynchronous data, it needs to pre-fetch and parse the data before starting the rendering process.

Before mounting to the client application, you need to get exactly the same data as the server application - otherwise, the client application will fail to mix because it uses a different state from the server application.

To solve this problem, the acquired data needs to be located outside the view component, that is, in a special data store or a state container.

Server-side data prefetching

vuex/index.js

We introduce Vuex state manager to synchronize data. How to obtain asynchronous data can be used according to personal needs. As long as it is compatible with both client and server, we first use timer to simulate requests.

// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {
      items: {}
    },
    actions: {
      fetchItem ({ commit }, id) {
        // Suppose we have one that can return to Promise.
        // General API (please ignore the implementation details of this API)
        // ` store.dispatch()` will return to Promise,
        // So that we can know when the data will be updated.
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve({ name: 123 })
          }, 500)
        }).then((item) => {
          commit('setItem', { id, item })
        })
      }
    },
    mutations: {
      setItem (state, { id, item }) {
        Vue.set(state.items, id, item)
      }
    }
  })
}

src/app.js

Introducing vuex configuration file and using vuex-router-sync to synchronize routing status, we can get some routing information from store.

// app.js
import Vue from 'vue'
// Sync vue-router's current $route as part of vuex store's state.
import { sync } from 'vuex-router-sync'
import App from './App.vue'
import createRouter from './router'
import createStore from './vuex'

export default function createApp () {
  // Create router instances
  const router = createRouter()
  const store = createStore()

  // Route state to store
  sync(store, router)

  const app = new Vue({
    // Inject router to root Vue instance
    router,
    store,
    render: (h) => h(App)
  })

  // Return app and router
  return { app, router, store }
}

page/view1.vue

Because server rendering is also called first screen rendering, that is, we should only send the current page to the client, and similarly we should only get the data needed for the page, so we put the starting point of the request at the routing component level.

A custom static function asyncData is exposed in the component because it is called before the component is instantiated, so it cannot access this, but we can pass in route s and store s as parameters to get the information we need.

<template>
  <div>
    <p>Page1</p>
    <p>{{item.time}}</p>
  </div>
</template>

<script>
export default {
  asyncData({ store, route }) {
    // When action is triggered, Promise is returned
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    // Get item from the store's state object.
    item() {
      return this.$store.state.items[this.$route.params.id]
    }
  }
};
</script>

page/view2.vue

Fair and reasonable

<template>
  <div>
    <p>Page2</p>
    <p>{{item.time}}</p>
  </div>
</template>

<script>
export default {
  asyncData({ store, route }) {
    // When action is triggered, Promise is returned
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    // Get item from the store's state object.
    item() {
      return this.$store.state.items[this.$route.params.id]
    }
  }
};
</script>

entry/entry-server.js

This is where the server gets the matching routing logic, so we also call the static function to get the matching routing after we get the matching routing.

Because this is an unscheduled asynchronous operation, it is necessary to ensure that all matching components are successfully invoked through Promise.all before proceeding with the next step, remember to add the catch error operation.

This ensures that the data is pre-fetched and then filled into the store for rendering.

import createApp from '../src/app'

export default (context) => {
  // Because it may be an asynchronous routing hook function or component, we will return a Promise.
  // So that the server can wait for all the content before rendering.
  // It's ready.
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    // Setting the location of server-side router
    router.push(context.url)

    // Wait until router parses the possible asynchronous components and hook functions
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      // Unmatched routes execute reject function and return 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // Call `asyncData() for all matching routing components`
      Promise.all(
        matchedComponents.map((Component) => {
          if (Component.asyncData) {
            return Component.asyncData({
              store,
              route: router.currentRoute
            })
          }
        })
      )
        .then(() => {
          // After all preFetch hook resolves,
          // Our store is now populated with the state required by the rendering application.
          // When we attach state to context,
          // And when the `template'option is used for renderer,
          // The state is automatically serialized as `window. _INITIAL_STATE_', and HTML is injected.
          context.state = store.state

          // Promise should resolve the application instance so that it can render
          resolve(app)
        })
        .catch(reject)
    }, reject)
  })
}

CreateBundle Renderer automatically serializes the additional context data into window. _INITIAL_STATE_ and injects HTML

entry/entry-client.js

Server rendering has serialized the store and assigned it to the window. _INITIAL_STATE_ field of the page. Then we can get it before client rendering, and then call replaceState to directly cover the client's store to achieve the goal of sharing state between front and back ends.

import createApp from '../src/app'

const { app, router, store } = createApp()

// Mount data
if (window.__INITIAL_STATE__) {
  // Replace the root state of the store with state merge or time travel debugging only.
  store.replaceState(window.__INITIAL_STATE__)
}

// Called when routing completes initial navigation
router.onReady(() => {
  // Mount the root element in the App.vue template
  app.$mount('#app')
})

router/index.js

Synchronize the routing configuration to get the id

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default function createRouter () {
  return new Router({
    // Remember to add the mode attribute, because the content after # will not be sent to the server, and the server does not know which route to request.
    mode: 'history',
    routes: [
      {
        // home page
        alias: '/',
        path: '/view1:id',
        component: () => import('../page/view1.vue')
      },
      {
        path: '/view2:id',
        component: () => import('../page/view2.vue')
      }
    ]
  })
}

src/App.vue

Jump routing with parameters

<template>
  <div id="app">
    <h2>Welcome to SSR Render Page</h2>
    <router-link to="/view1:1">view1</router-link>
    <router-link to="/view2:2">view2</router-link>
    <router-view></router-view>
  </div>
</template>
<script>
export default {};
</script>

Build files

Run command

yarn build
yarn start

Direct Access Address

http://localhost:3005/view1:1

You can see that the data is already available on the interface, but there's still a problem if we click view2.

Server-side acquisition has been completed, so next we need to solve the problem of client-side data acquisition.

Client Data Fetching

There are two ways

Parsing data before navigation

The disadvantage is that the user experience is not good if the process is processed for a long time. The conventional operation is to give a loading graph to ease the user's emotions. So we can:

  1. Check matching components
  2. Screening Differential Routing Components
  3. Global routing hook executes asyncData function
  4. Mount components
import createApp from '../src/app'

const { app, router, store } = createApp()

// Mount data
if (window.__INITIAL_STATE__) {
  // Replace the root state of the store with state merge or time travel debugging only.
  store.replaceState(window.__INITIAL_STATE__)
}

// Called when routing completes initial navigation
router.onReady(() => {
  // Add routing hook function to process asyncData.
  // After the initial routing resolve,
  // So that we don't double-fetch existing data.
  // Use `router.beforeResolve()', to ensure that all asynchronous components are resolve d.
  router.beforeResolve((to, from, next) => {
    // Returns the target location or an array of components matching the current route (the definition / constructor class of the array, not the instance). Usually used when pre-loading data rendered on the server side.
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // We only care about non-prerendered components
    // So let's compare them and find out the difference between the two matching lists.
    let diffed = false
    const activated = matched.filter((component, index) => {
      return diffed || (diffed = prevMatched[index] !== component)
    })

    if (!activated.length) {
      return next()
    }
    // Here, if there is a load indicator, it triggers
    Promise.all(
      activated.map((component) => {
        if (component.asyncData) {
          return component.asyncData({ store, route: to })
        }
      })
    )
      .then(() => {
        // Stop loading indicator
        next()
      })
      .catch(next)
  })

  // Mount the root element in the App.vue template
  app.$mount('#app')
})

Rerun the code and you can see that it's working properly, because when you set the timer 500 milliseconds in front of you, there's a clear sense of karton.
The final code can view the warehouse Vue-ssr-demo/demo2

Match the view and retrieve the data

Place client-side data prefetching logic in the beforeMount function of the view component. When routing navigation is triggered, views can be switched immediately, so the application has faster response speed. However, the incoming view will not have complete data available for rendering. Therefore, for each view component that uses this strategy, a conditional loading state is required.

To put it plainly, just like ordinary calls, you need to have a default state rendering view, which renders the interface again after getting the data.

We can use the beforeMount lifecycle, which has completed initializing data and el data, compiling templates, etc., but has not yet been mounted on the BOM node. Although we can not directly access the current instance of the component, we can access custom properties through this.$options.

page/view1.vue

<template>
  <div>
    <p>Page1</p>
    <p>{{item ? item.time : ''}}</p>
  </div>
</template>

<script>
export default {
  asyncData({ store, route }) {
    // When action is triggered, Promise is returned
    return store.dispatch('fetchItem', route.params.id)
  },
  beforeMount() {
    if (this.$options.asyncData) {
      // Assigning the fetch data operation to promise
      // So in the component, we can prepare the data when it's ready.
      // Perform other tasks by running `this.dataPromise.then(...)'.
      this.$options.asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  },
  computed: {
    // Get item from the store's state object.
    item() {
      return this.$store.state.items[this.$route.params.id]
    }
  }
};
</script>

page/view2.vue

<template>
  <div>
    <p>Page2</p>
    <p>{{item ? item.time : ''}}</p>
  </div>
</template>

<script>
export default {
  asyncData({ store, route }) {
    // When action is triggered, Promise is returned
    return store.dispatch('fetchItem', route.params.id)
  },
  beforeMount() {
    if (this.$options.asyncData) {
      // Assigning the fetch data operation to promise
      // So in the component, we can prepare the data when it's ready.
      // Perform other tasks by running `this.dataPromise.then(...)'.
      this.$options.asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  },
  computed: {
    // Get item from the store's state object.
    item() {
      return this.$store.state.items[this.$route.params.id]
    }
  }
};
</script>

Rerun the code to find that it's working properly, the page responds quickly, and then it delays switching data a little bit.
The final code can view the warehouse Vue-ssr-demo/demo3

Keywords: Javascript Vue TypeScript Attribute

Added by dkphp2 on Wed, 18 Sep 2019 06:01:21 +0300