Skip to content
Back to blog

Frontend Rendering

Frontend rendering topics, including SPA and SSR.

Updated

SPA

How SPA Works

After listening for Router changes, the local DOM tree is patched, updating only the changed parts.

Router generally has two modes:

  • hash: mainly listens for changes in the hash part of the URL and triggers a DOM patch
  • history: listens for changes in the path part of the URL and triggers a DOM patch

hash Router

// Define the Router
class HashRouter {
  constructor() {
    this.routes = {}
    window.addEventListener('hashchange', () => this.updateView())
  }

  route(path, callback) {
    this.routes[path] = callback
  }

  push(path) {
    window.location.hash = path
  }

  updateView() {
    const path = window.location.hash.slice(1) || '/'
    this.routes[path]?.()
  }
}

// Usage example
const router = new HashRouter()
router.route('/home', () => document.getElementById('view').innerHTML = 'Home Page')
router.route('/about', () => document.getElementById('view').innerHTML = 'About Page')
router.updateView() // Initial load

history Router

// Define the Router
class HistoryRouter {
  constructor() {
    this.routes = {}
    // Bind the event listener
    window.addEventListener('popstate', () => this.updateView())
  }

  route(path, callback) {
    this.routes[path] = callback
  }

  push(path) {
    history.pushState({}, '', path)
    this.updateView()
  }

  updateView() {
    const path = window.location.pathname
    this.routes[path]?.() || console.warn('Route not found')
  }
}

// Usage example
const router = new HistoryRouter()
router.route('/home', () => document.getElementById('view').innerHTML = 'Home Page')
router.route('/about', () => document.getElementById('view').innerHTML = 'About Page')
router.updateView()

The Difference Between SPA and MPA

Single-Page Application (SPA) Multi-Page Application (MPA)
Composition One main page plus multiple page fragments Multiple main pages
Refresh style Partial refresh Full-page refresh
URL mode hash / history history
SEO Poor; SEO crawlers cannot capture site content SEO friendly
Data passing Easy Passed via URL, cookies, localStorage, and so on
Page switching Fast, good user experience Switching loads resources; slow, poor user experience
Maintenance cost Relatively easy Relatively complex

SPA Pros and Cons

Pros:

  • Has the immediacy of a desktop application and the portability and accessibility of a website
  • Good, fast user experience; content changes do not require reloading the entire page
  • Good front-end/back-end separation, with clearer division of labor

Cons:

  • Not friendly to search engine crawling
  • Relatively slow first render

SEO Optimization

  • SSR: switch to SSR rendering; when a page is visited, Node.js generates the HTML, and page switching re-renders in history mode
  • Static generation: after the build, run a second build that renders pages to HTML on the server and exports them
  • Crawler-specific handling: use Nginx rewrite to forward crawler requests to a server-side headless browser, and return the headless browser's rendered result to the crawler

SSR

Development History

No Front-End/Back-End Separation

The front end acts as a template syntax; on the back end, data is inserted directly into the template through string replacement and similar methods, rendered into complete HTML, and returned to the client.

Routing is handled by the back end, which fetches different templates and data based on the request path and renders the HTML of different pages.

---
title: User access sequence diagram without front-end/back-end separation
---
sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: Request URL
    Server-->>Browser: Return HTML

Front-End/Back-End Separation

The HTML contains only the minimal structure; the front end fetches data through Ajax and dynamically updates the page through the DOM API after getting the data.

Routing is handled by the front end, which listens for hash / history changes, requests and executes the JS of different pages, and thereby fetches data and updates DOM nodes.

---
title: User access sequence diagram with front-end/back-end separation
---
sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: Request URL
    Server-->>Browser: Return minimal HTML
    Note over Browser: Render the page DOM with JS
    Browser->>Server: Request data
    Server-->>Browser: Return data
    Note over Browser: Assemble the data and update the page

Server-Side Rendering

On top of front-end/back-end separation, a Node.js layer is added: Node.js renders the first-screen HTML, so what the client receives is no longer the minimal structure but the complete first-screen HTML structure; after the application starts, updates still follow the SPA approach.

The routing scheme is handled by Node.js; DOM updates within the same page are done by the front end, and cross-page updates are done by Node.js.

---
title: User access sequence diagram with server-side rendering
---
sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: Request URL
    Note over Server: Render the first-screen page
    Server-->>Browser: Return the rendered first-screen HTML
    Note over Browser: Display the first-screen page
    Note over Browser: Activate the SPA

Use Cases

  • SEO: the first-screen HTML is returned in full, which is SEO friendly.
  • Good user experience: the first screen is already rendered when returned to the user, who can see it without waiting.

Drawbacks

  • High complexity: besides the front-end part, the project also has a Node.js part, making the overall complexity high
  • Incompatible legacy libraries: some syntax is not supported when rendering in Node.js, requiring corresponding conditional checks or a different library
  • New performance problems:
    • High rendering cost: to prevent pollution, every request needs to create an instance to render HTML
    • Low cache hit rate: conditional rendering is required for different users and permissions, so the cache cannot be fully reused
  • Increased server load: all users need rendering, caching, and other steps completed on the server side, which adds server overhead compared to SPA

Implementation

---
title: SSR implementation flow chart
---
graph LR
  subgraph Source
    A[Code] --> B[Server entry]
    A --> C[Client entry]
  end

  B --> D[Webpack build]
  C --> D
  D --> E[Server Bundle]
  D --> F[Client Bundle]

  subgraph Node Server
    E --> G[Bundle Renderer]
  end

  subgraph Browser
    F -->|Hydrate| I[HTML]
  end

  G -->|Render| I[HTML]

Advantages of the Virtual DOM

Cross-Platform

The virtual DOM is an abstraction over the real DOM. This layer of abstraction allows frameworks to achieve cross-platform rendering based on the virtual DOM, for example:

  • Web platform: render to the real DOM
  • Mini-programs: render to native mini-program components
  • Native: render to native components

Performance Optimization

Through the diff algorithm, the virtual DOM can precisely locate the DOM nodes that need updating, avoiding unnecessary DOM operations and improving performance.

Batch Updates

The virtual DOM can merge multiple DOM updates into one, reducing reflows and repaints.

On-Demand Updates

Through the diff algorithm, you can know precisely which nodes need updating and which can be reused, avoiding unnecessary DOM operations.

Development Experience

The virtual DOM provides a declarative development style: developers only need to focus on data changes without manually manipulating the DOM.

Solutions Without a Virtual DOM

Advantages

  • Smaller runtime size: no virtual-DOM-related code
  • Better performance: manipulates the DOM directly, without diff overhead
  • Simpler code: no need to deal with the complexity of the virtual DOM
  • More precise updates: only updates the nodes that truly need it

Disadvantages

  • Compile-time overhead: more compile time is needed
  • Difficult cross-platform support: manipulating the DOM directly makes cross-platform support difficult
  • Difficult debugging: the compiled code is hard to debug

Abnormal Page Rendering Caused by Duplicate Keys

Reproduction Code

<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api'

const keys = ['a', 'b', 'c', 'd', 'e', 'f']

interface Data {
  key: string
  label: string
}

const count = 10

export default defineComponent({
  setup() {
    const list = ref<Data[]>([])
    let page = 1

    function updateList() {
      const newList = Array.from({ length: count }).map((_, idx) => {
        const curIdx = (page - 1) * count + idx
        const key = keys[curIdx % keys.length]

        page++

        return {
          key,
          label: `${key}-${idx}-${curIdx}`,
        }
      })

      list.value = newList
    }
    updateList()

    return {
      list,
      updateList,
    }
  },
})
</script>

<template>
  <div>
    <button @click="updateList">
      update list
    </button>
    <div v-for="item in list" :key="item.key">
      {{ item.label }}
    </div>
  </div>
</template>

Phenomenon

Assume the interaction is paging in order from 1 - N. On page (n - 1) there are n1 VNodes with key a, and on page n there are n2 VNodes with key a.

When n1 < n2, vue reports an error saying the VNode does not exist.

Analysis

Vue assumes by default that key and VNode correspond one-to-one within a scope. In the diff algorithm of patch, once a key matches, the node is reused, and the key and VNode are removed from the scope cache to prevent the same node from being reused repeatedly.

Since key and VNode correspond one-to-one, any key should in principle be able to retrieve a VNode; so when duplicate keys occur, it reports that the VNode does not exist.