Kalan's Blog

Software Engineer / Taiwanese / Life in Fukuoka

Current Theme light

In the early stages of frontend development, when interactivity was not as demanding, page rendering was usually done using template engines provided by backend programming languages (such as popular ones like ejs, pug, erb, thymeleaf, etc.). The server would render HTML using these template engines and send it back to the browser, where JavaScript would handle the interactivity.

Although it may seem obvious, there are some drawbacks that have emerged as browsers and technology have evolved:

  • Each time a different page is clicked, a new request needs to be sent, which means HTML, JavaScript, CSS, and the current state all need to be reloaded and executed again (assuming no caching is involved).
  • The flexibility of the view is often limited by the syntax provided by the template engine.
  • Sometimes it is desirable to tightly bind the view and interactivity together, which becomes inconvenient when they are separated.
  • As interactivity becomes more complex and nuanced, a reactive mechanism is often needed.

The evolution of Single-Page Applications (SPAs) allows us to move all interactivity and display on the page to JavaScript, making it easier to maintain code through componentization and reactive mechanisms. However, relying solely on JavaScript cannot achieve certain things that traditional server rendering can, such as:

  • Assisting with SEO and search engine crawling of the page.
  • Generating Open Graph content.
  • Optimizing user experience.

In the following sections, each point will be explained in detail. Before diving into this article, if readers are not familiar with the concepts of SPA and SSR, it is recommended to refer to Huli's article, Understanding Technical Terms: MVC, SPA, and SSR. This article mainly focuses on the benefits and practical scenarios brought by SSR itself (using React as an example).

Assisting with SEO and Search Engine Crawling

When web crawlers crawl web pages in browsers, they scrape the HTML content of the page and generate content. They also cache this content in a database and update it regularly. In other words, if SSR is not implemented, the HTML file itself would be blank, and the actual page can only be seen after the main.js is parsed and executed.

For example:

<html>
    <head>
        
    </head>
    <body>
        <div id="app"></div>
        <script src="main.js"></script>
    </body>
</html>

Although Google claims to be able to parse and execute JavaScript, the effect is still limited, and it cannot achieve effects like those requiring requests, such as fetch.

Taking 17 Live as an example, when searching for 17 Live on Google:

Screenshot from 2020-11-23 21-40-18

Under the search results, search engines usually display <title> and <meta name="description" content="xxx"/>. These parts can be generated directly on the server or hardcoded in the HTML file. However, if you click on the cached page, you will find that it is blank:

After further inspecting the source code, you will find an HTML file with an empty body:

<!DOCTYPE html>
<html>

<head>
  <title>17LIVE - Live Streaming 直播互動娛樂平台</title>
  <meta charset="utf-8">
  <meta name="description" content="17LIVE 直播互動零距離。各式特色才藝直播主分享生活每一刻;多元節目內容免費線上看!" />
  ...
</head>

<body></body>

</html>

Generating Open Graph Content / Managing <head>

Platforms like Facebook, Twitter, LINE, etc., when sharing a link in a post, send a request to the link URL and parse the <meta> tags to determine how to display the preview (for more details, refer to the Open Graph Protocol). This data must be returned by the server. Otherwise, the crawler will still see a blank content.

In React, managing content placed in the <head> tag is usually achieved in several ways:

  • react-helmet: It allows managing the content inside the <head> tag using components and also provides server-side rendering.
  • next/head: next.js provides built-in ways to manage the <head> tag.

Optimizing User Experience

When parsing a web page, the browser needs to receive and parse the HTML before executing JavaScript. The content cannot be seen until JavaScript has finished executing. Although there is not much difference in performance on mainstream devices (with CPU i5 or above), here are a few considerations:

  • Users may not browse the web using computers or mobile devices. They might use IoT devices, e-readers (such as Kindle), PS4, TVs, etc. These devices often cannot parse JavaScript and have limited performance.
  • Not every user enables JavaScript (although they are a minority). Users may leave the page directly if they see a blank page, resulting in potential user loss.
  • With the help of SSR, the framework can match the content during JavaScript execution, saving the performance of calling DOM APIs like document.appendChild.

Differences from Traditional Template Engines

SSR

When using frontend frameworks like React and Vue for SSR, there are several differences compared to traditional template engines:

  1. Traditional template engines render pure static HTML strings, and variables are injected by the server. In frontend frameworks, in addition to rendering HTML strings on the server, the client-side dynamically calls DOM APIs on the HTML. After SSR rendering, event listeners (click, change, etc.) are injected and corresponding lifecycle methods are executed.
  2. Traditional template engines do not have the concept of reactivity, and the DOM does not automatically update when variables change.
  3. Because the rendered HTML needs to match the frontend code, frontend and backend code need to be shared (when rendering HTML). SSR with frontend frameworks needs to be used together with Node.js, while traditional template engines do not necessarily require it and can be determined based on the backend programming language.

How Do Frontend Frameworks Achieve SSR?

This section will not mention specific framework implementations but will provide an overview of how mainstream frontend frameworks achieve SSR.

When achieving SSR with frontend frameworks, the first consideration is the state. Given the same state, the rendered result should be the same. To achieve the same initial rendering between frontend and backend (the initial view), we need to ensure that the frontend and backend reach the same state during HTML rendering.

Typically, when doing server-side rendering, we prepare a global store:

route.get('/', (req, res) => {
  const store = {
    posts: [],
    user: {
      name: 'kalan',
      ...
    },
  };
      
  const html = ReactDOMServer.renderToString(<App />);
  res.render('index', {
    html: html,
    store: JSON.stringify(store);
  })
});

Then, we place the store data in a global variable:

<div id="app">
 <%- html %>
</div>
<script>
  window.GLOBAL_STORE = store;
</script>

Finally, in app.js, we write:

ReactDOM.hydrate(<App store={window.GLOBAL_STORE} />, document.getElementById('app'));

Here, we use the hydrate API to inform React that we have pre-rendered the content on the server. React will skip the process of DOM API calls and start adding event listeners and executing corresponding lifecycle events and useEffect. It is important to note that SSR only performs the initial rendering (the HTML received after the request).

As the App becomes more complex, preparing a global variable may not be a good idea. At this point, we can consider using frameworks like next.js to simplify the complexities of SSR.

Common Misconception: Dynamic Import and Ajax

As the scale of the App grows, the bundle size tends to reach a critical point, which begins to affect the performance of initial loading. Dynamic import mechanisms can be used to split less important components or pages into separate files, which are then requested when needed.

Currently, React's React.lazy does not support SSR. You can refer to the official SSR guide (using loadable-components).

The approach taken by loadable-components is to collect the locations where dynamic imports are used in the current <App/> component, generate a manifest file, and then load these files using <script src="xxx.js">. If the entire page is rendered using dynamically imported components, the initial rendering may still be blank (as the files still need to be loaded using <script>).

Additionally, if data is fetched in the frontend using Ajax, and no data is fetched on the server, the initial rendering will also be blank or show a loading screen. Assuming there is a simple component like this:

const App = () => {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetch('/api/data').then(res => res.json())
      	.then(data => setData(data));
  }, [])
  
  if (data.length === 0) {
      return <span>loading</span>
  }
    
  return <div>
    {renderData()}
  </div>
}

When doing SSR, React does not execute the code inside useEffect, but rather simply injects the server's data into the component and renders the markup. The actual request is sent and executed by the browser after rendering the page. Therefore, the rendered code would look like this:

<span data-reactroot="">loading</span>

For SSR, this kind of page can reduce the initial rendering performance burden to some extent. However, it is not good for SEO and user experience. After all, one of the purposes of implementing SSR is to improve user experience (avoiding the need to wait for a loading page) and SEO (the browser seeing a loading mess). A better approach is to send the server's data to the frontend using a global store or utilize mechanisms like getStaticProps() in next.js.

Is SSR Really Necessary? Does It Make a Big Difference?

This depends on how you view SSR and the specific use case. It should be decided based on the user's requirements. Instead of debating the need for SSR, it is better to start the discussion from the perspective of user needs. After all, the most important aspect of any technology and product is to serve humans.

For example:

  • Users may access our website using e-readers, IoT devices, or devices with lower performance. It is important to minimize unnecessary performance waste.
  • Users may use the service to accomplish a certain goal. In this case, optimization could be related to the workflow or design, and enhancing the experience using browsers and JavaScript as much as possible.

Other Solutions and Considerations

Let's not focus on the so-called best practices here, but instead explore what can be done based on actual scenarios and limitations.

  • If the current development situation does not justify the cost-effectiveness of introducing SSR, you can let the backend provide a simplified version of the view for crawlers. The JavaScript rendering mechanism can be used when users browse the website. Although it may require maintaining two different views, sometimes it is simpler.
  • If the content is the same every time, you can generate a pure static HTML.
  • If the conversion cost is high, you can use headless Chrome tools like puppeteer to directly browse the web page and cache the rendered HTML on the server. This can be updated regularly through scheduling.
  • Using an SPA often means that the complexity of frontend JavaScript and bundle size inevitably increase. Sometimes, choosing a traditional rendering approach with prefetching mechanisms may result in better performance and experience.

Prev

Vue ref syntax sugar with Svelte

Next

Data Visualization — Taiwan Sexual Assault Statistics

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

Hi, I'm Kai. I'm Taiwanese and moved to Japan in 2019 for work. Currently settled in Fukuoka. In addition to being familiar with frontend development, I also have experience in IoT, app development, backend, and electronics. Recently, I started playing electric guitar! Feel free to contact me via email for consultations or collaborations or music! I hope to connect with more people through this blog.