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 thehashpart of the URL and triggers a DOMpatchhistory: listens for changes in thepathpart of the URL and triggers a DOMpatch
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 inhistorymode- 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.