你知道吗,当你跟朋友在讨论 SSR 的时候,很有可能你们对 SSR 的认知其实是不一样的。直接举个例子,底下这几种情境,你觉得哪些算是 SSR?
- 由后端 PHP 产生画面
- 前端是 React 写成的 SPA,但后端如果侦测到搜寻引擎,就会切换另一种 template,输出专门针对搜寻引擎的模板,而非 React 渲染出的页面
- 前端是 React 写成的 SPA,但透过 Prerender 先把页面 render 成 HTML,再交给搜寻引擎(一般使用者依然是 SPA),跟上一个的差别是使用者跟搜寻引擎看到的画面基本上一致
- 前端是 React 写成的 SPA,在后端用 renderToString 把 React 渲染成字串,但是没有资料,资料会在前端拿
- 前端是 React 写成的 SPA,后端会针对每个 page 先呼叫 API 拿资料,拿完以后才呼叫 renderToString 输出 HTML,在 client 端时会做 hydration 让页面可以互动
有一种人认为只要是由后端产生出画面,就叫做 SSR,所以 1 ~ 5 全部都是 SSR。也有一种人认为前端必须先是 SPA,此时搭配的后端才能叫做 SSR,所以 2~5 都是 SSR;而另一种人则认为 SSR 的重点是 hydration,所以只有 5(或是 45)是 SSR。
下图是我自己在推特简单调查的结果,可以看见意见确实是有分歧的:
为什么会有这篇文章?
五年前的时候我就有写过一篇文章在讲 SPA 与 SSR:跟着小明一起搞懂技术名词:MVC、SPA 与 SSR,那时候的我跟现在的我想法是一致的。
「现在的我」指的是还没完全整理好想法,正在写这段前言,底下都还没写好的我,等写完以后会在结尾处再讲「之后的我」的想法。但总之呢,现在的我的想法是,「并不是所有从 Server 产生出画面的方式都『适合』称作 SSR」。
先来看一个假想情境:
A:欸,你们公司网页是用什么方式 render 啊?
B:就 SSR 啊
A:是喔,那你们是用什么框架处理 SSR?
B:就普通 PHP 而已,没有用框架,前端就 jQuery
再看一个:
A:最近在解 SSR 的问题搞到好烦,资料好难弄
B:还好吧,我们用 PHP 都用得满顺利的啊
虽然说 server-side rendering 这个词从字面上来看,就是指由 server 进行渲染,所以要说 PHP 是 SSR 从字面上看没什么问题,但我认为重点是「为什么需要 SSR 这个词」?
我的理解是在 SPA 还不流行的年代,根本没什么东西是 CSR(Client-side rendering),所以根本也不需要 SSR 这个词。那时你只会说:「我们公司用 PHP」,而不是说:「我们公司用 PHP 做 SSR」。
有点像是我问我朋友他买的便当多少钱时,他会回我:「100 块」,而不是「100 块新台币」,因为我们都预设了币值是新台币,所以不用特别多此一举。同理,那时候只有从 server render 这条路,所以根本不需要特别提什么 SSR。
但是后来 SPA 盛行,许多东西开始变成 CSR,此时就会碰到只有 CSR 才会碰到的问题如 SEO 等等,这时候为了解决这些问题,势必有些东西要让 server 去处理,在这种状况下,Server-side rendering 这个词才产生了新的意义,变成了「为了解决 CSR 的问题,产生的 server 端解决方案」
因此,将 PHP 称之为 SSR 没也不行,但却是没有意义的。
就像是如果我们把「饮料」定义为「可以喝的液体」,那你能不能说酸辣汤也是一种饮料?照定义来看没有问题,但当有人问你「最喜欢喝的饮料是什么?」的时候,你会说酸辣汤吗?应该不会,而我们也不会把酸辣汤称之为是饮料。
同理,虽然 SSR 字面上的意思是那样,PHP 这种传统 server 输出内容的方案也可以称之为 SSR,但你不会这样叫它。SSR 更适合拿来指涉的是「用来解决 SPA 问题的 server 端解决方案」。
写到这里我就开始好奇了,那是不是在 SPA 与 CSR 流行以前,SSR 这个词真的很少被使用?如果是的话,那到底从什么时候开始的?还有,我对 SSR 的认识基本上是从 React 开始,那难道更早的框架如 Angular、Ember 或什至是 backbone 等等,都没有这问题吗?如果有的话,他们的解决方案又称之为什么?
于是我开始了一段要花费很多时间,讨论的问题或许也没这么重要,但我自己很乐在其中的探索之路。
SPA 是从什么时候开始流行的?
前面有提过我的主张是:「SSR 一词在 SPA 盛行后开始跟着流行起来,专门指涉处理 CSR 与 SPA 问题的 server 端解决方案」
而我认为 SPA 的发展与整个网页前端的发展其实满有关联的,因此先带大家回顾一下历史吧!
1995 年 JavaScript 正式推出,而当时虽然 JavaScript 的功能没有这么成熟,但已经有其他的技术可以在网页上跑一个应用程式起来,就是 Java Applet。
而 Flash 在 1996 年发布,早期 JavaScript 还没这么强大时,要做比较完整的网页应用程式,应该都是透过 Java Applet 或是 Flash。
那要到什么时候,JavaScript 才成熟到真的可以独当一面,用它来写一个网页应用程式呢?这个答案会跟技术的发展有关,作为一个需要跟后端沟通的网页应用程式,最需要的是什么?
是一个现在已经跟空气和水一样存在的东西:XMLHttpRequest。
想要不换页就能独立运作并且与 server 沟通,XMLHttpRequest 是必要条件,必须先有 XMLHttpRequest 这个 API,才能不换页就能与 server 交换资料。
不过在最刚开始的时候,并不是所有的浏览器都用 XMLHttpRequest,最早有这个概念的微软用的是 ActiveXObject,从 2006 年第一版的 jQuery 原始码就能验证这件事:
// If IE is used, create a wrapper for the XMLHttpRequest object
if ( jQuery.browser.msie && typeof XMLHttpRequest == "undefined" )
XMLHttpRequest = function(){
return new ActiveXObject(
navigator.userAgent.indexOf("MSIE 5") >= 0 ?
"Microsoft.XMLHTTP" : "Msxml2.XMLHTTP"
);
};
讲到了 XMLHttpRequest 之后,理所当然就会提到 Ajax,这个词来自于 2005 年 2 月 18 日 Jesse James Garrett 发表的这篇文章:Ajax: A New Approach to Web Applications,里面描述了一种使用 HTML + CSS + DOM + XMLHttpRequest 的新型沟通模式,我认为就是 SPA 的雏型了
图片来自于上面提到的文章
另外,在文章里也有提到 XMLHttpRequest 与 Ajax 的不同之处:
Q. Is Ajax just another name for XMLHttpRequest?
A. No. XMLHttpRequest is only part of the Ajax equation. XMLHttpRequest is the technical component that makes the asynchronous server communication possible; Ajax is our name for the overall approach described in the article, which relies not only on XMLHttpRequest, but on CSS, DOM, and other technologies.
从历史的资料看起来,微软的 Outlook 似乎是最早提起并运用这些技术的产品,从 2000 年就开始了,但论起大量运用并让这个名词广为人知的话,就属 2004 ~ 2005 年左右的 Google 了。
而差不多在这个时期,JavaScript 的生态系也迎来了蓬勃的发展,出现了一堆 library 如 Prototype、Dojo Toolkit 以及 MooTools 等等,还有 2006 年诞生的 YUI(Yahoo! User Interface Library)以及至今灵压依然存在的 jQuery,都让网页前端得到了更进一步的发展,2007 年也出现了 Ext JS 这种专门拿来写网页应用的框架。
虽然说这些函式库们都让写网页变得更加容易,但 SPA 在这个时候还没有流行起来,而是要等到两位大前辈的诞生。
2010 年 10 月 13 日,Backbone.js 释出了第一个版本,而一周后的 10 月 20 日,则是 AngularJS 首次发布的日子。
而过了一年之后,别的 SPA 前端框架也出现了,分别是 2011 年 12 月 8 日发布的 Ember.js,以及 2012 年 1 月 20 出现的 Meteor.js。
一般来说一个新的框架出现以后,大概至少都要过个半年一年左右才会真正流行起来,因此我认为 2011 以及 2012 这两年是 SPA 兴起的开端,但是该用什么资料来佐证呢?
关键字搜寻趋势一定程度代表了当时某些技术名词的流行程度,从下图可以看出来,SPA 一词大概是从 2011、2012 年左右开始一路攀升,与我的推测吻合(但这个数据其实不太精确就是了,可我一时想不到更好的了):
至于 2004、2005 那个高峰是什么,我不知道,但很想知道的。或许跟一堆 Google 服务的流行有关?
之后的故事大家就比较熟悉了,2013 年 5 月 React 正式发布,2014 年 2 月则是 Vue,随着前端框架的盛行,SPA 也变得越来越流行,到了今天甚至变成了前端开发的主流。
早期的 SPA 如何解决 CSR 的问题?
从上面的发展史中可以得知开创 SPA 盛世的元老就属 Backbone.js 以及 AngularJS 了,那他们是怎么解决 CSR 的问题,例如说 SEO?
先来看 AngularJS 好了,我在 GitHub 上找到一个 2013 年的专案:angular-on-server,在 wiki 的前言中写着:
We need to pre-render pages on the server for Google to index. We don't want to have to repeat ourselves on the back end. I found a few examples of server-side rendering for Backbone applications, but none showing how to do it with AngularJS. To make this work I have modified a couple of Node modules, jsdom and xmlhttprequest. They are loaded from local subdirectories (/jsdom-with-xmlhttprequest and /xmlhttprequest).
如果他所言为真,就代表当时 AngularJS 的 SSR 解决方案并不多,大多数都是 Backbone.js 的。
从我找到的资料来看,似乎也是如此,像是这篇 2013 年的发问:AngularJS - server-side rendering,从回答中就可以看出解法确实不多。
而 AngularJS 官方正式支援 SSR,是要一直到 2015 年 6 月底的这个演讲:Angular 2 Server Rendering,在演讲结束后几天后开源了 Universal Angular 2,也就是现在的 Angular Universal 的前身。
在当时的 README 中,说明写着:
Universal (isomorphic) JavaScript support for Angular 2
看到 isomorphic 这个词,应该勾起了不少人当年的回忆,但这个我们等等再谈,先来看 Backbone.js 又是怎么解决 SPA 问题的。
我有在 GitHub 上面找到一个 2011 年的古老范例:Backbone-With-Server-Side-Rendering,README 写着:
Backbone.js is a great tool for organizing your javascript code into models, collections and views, without tying your data to the DOM elements. However, most tutorials show how to render the HTML only via Backbone (client-side), which means that none of your content is crawled by search engines. This is possibly a major problem if you're not making an app hidden behind an authentication system.
比较特别的地方在于这个专案的 SSR 是透过 Ruby on Rails 实作的,但我看了一下原始码,感觉比较像一个实验性质的专案,透过后端把 HTML 输出,接着到了前端再由 Backbone.js 接手,是一个简单的小范例,而非完整的 demo。
如果想要更完整的解决方案,就属 2013 年由 Airbnb 开源出来的 Rendr 了。
在 2013 年 1 月 30 日,Airbnb 的技术部落格发表了一篇新的文章:Our First Node.js App: Backbone on the Client and Server,里面讲到了 SPA 会有的问题,以及有许多逻辑在前后端都各有一份,想要做整合。而最后的解法就是 Rendr 这个套件,能把 Backbone.js 搬到 server 去执行。
至于 Rendr 的开源则是过了三个月以后的这篇文章宣布的:We've open sourced Rendr: Run your Backbone.js apps in the browser and Node.js,里面写说:
Many developers shared the same pain points with the traditional client-side MVC approach: poor pageload performance, lack of SEO, duplication of application logic, and context switching between languages.
可见当时有大量的开发者也都意识到了 SPA 的问题,并且想要一个比较完善的解决方案。
想要把 Backbone.js 搬到 server 去执行,有个先决条件,那就是 server 要可以执行 JavaScript。
Node.js 是在 2009 年释出的,而 Express 是在 2010 年底,NPM 则是 2011 年。2012 年中的时候 Node.js 还在 v0.8.0,是很早期的阶段。从现在回头看,Node.js 开始被大量使用,应该就差不多是 2012 ~ 2013 开始的。
总之呢,从我找到的资料来看,或许最早被广泛运用于 SSR 的 library 就是 2013 推出的 Rendr 了,它能够做到的事情是「在一开始由 server-side render,但是到了 client-side 以后由 JavaScript 接手」,如同 Airbnb 的文章中写到的:
Your great new product can run on both sides of the wire, serving up real HTML on first pageload, but then kicking off a client-side JavaScript app. In other words, the Holy Grail.
底下这张图就是所谓的 Holy Grail,取自 Airbnb 当初发表的文章:
写到这边,整理一下时间轴以及我个人的猜测。
从 2010 年底 Backbone.js 释出以后,SPA 开始变得逐渐流行起来,而大家也意识到了画面在前端渲染会碰到的问题,因此开始各自实作起不同的解决方案,也就是 server-side rendering 。
而 Backbone.js 一直到了 2013 年 Airbnb 开源了 Rendr 以后,才终于有了一个最理想的解法,那就是「首次渲染在 server side,而之后的话渲染都在 client side,并且 client 跟 server 是共用同一套程式码」
「同一行程式码既可以跑在 client 又可以跑在 server」,这个概念就是前面所提到的 isomorphic。
顺带一提,Ember.js 官方的 SSR 解法应该是要到 2014 年底的这篇:Inside FastBoot: The Road to Server-Side Rendering
再补充一件事情,根据 The History of React.js on a Timeline 这篇文章,FaxJS 是 React 的前身,而在 2011 年底开源的时候就有 server-side rendering 的 API,可以把元件渲染成 static HTML,并且在 client-side 把事件装回去:github.com/jordwalke/F…
Isomorphic JavaScript
Isomorphic JavaScript 一词来自于 Charlie Robbins 在 2011 年 10 月 18 日发表的文章:Scaling Isomorphic Javascript Code
文章中有提到了 Isomorphic 的定义:
Javascript is now an isomorphic language. By isomorphic we mean that any given line of code (with notable exceptions) can execute both on the client and the server.
而更多细节可以在 Airbnb 于 2013 年 11 月 12 日发布的这篇文章中找到:Isomorphic JavaScript: The Future of Web Apps
在文章里面还有附上了一个实际案例,很值得参考:isomorphic-tutorial。
除此之外,文章里面有提到在 Rendr 之前还有三个 Isomorphic JavaScript 的先行者,一个是 2012 年 Yahoo! 开源的 Mojito,在文章中提到了一个美好的想像:
Imagine a framework where the first page-load was always rendered server-side, and desktop browsers subsequently just made calls to API endpoints returning JSON or XML, and the client only rendered the changed portions of the page.
基本上就是现在主流前端的运作方式。
另一个则是 Meteor.js,第三个是 Asana 的 Luna,这个 Luna 挺有趣的,仔细看之后发现语法有点 React 的味道。
而 Isomorphic 这个词一直到 2015 年 Michael Jackson 的这篇文章出来以后,才渐渐被「Universal」给取代:Universal JavaScript。
这篇文章主要觉得比起 Isomorphic 这个词,Universal 更能表达原本想表达的意涵,而且听众们会更容易理解,因此提倡用 Universal JavaScript 来替代 Isomorphic JavaScript。
中场总结
写到这里,我自己回答了我之前的几个疑问:
Q: 那是不是在 SPA 与 CSR 流行以前,SSR 这个词真的很少被使用?如果是的话,那到底从什么时候开始的?
不确定,因为没有特别找更早以前的资料佐证,但如果是看 SSR 这个词的搜寻趋势的话,大概是从 2012~2013 左右开始起飞的,跟 SPA 开始流行的时间点差不多。
Q: 我对 SSR 的认识基本上是从 React 开始,那难道更早的框架如 Angular、Ember 或什至是 backbone 等等,都没有这问题吗?如果有的话,他们的解决方案又称之为什么?
他们有相同的问题,而解法一样称之为 SSR。
说实在的,讨论 SSR 这个名词的明确定义确实没什么太大意义,反倒有点太钻牛角尖了,而且也很难有个结论,或是说服别人:「这个定义才是对的」,只要在沟通的时候确保双方的认知一致即可。
在谈到 SSR 的时候,很多人都只关注到 SEO 的问题,但如果再更仔细想一点,其实需要利用 SSR 解决的,可不只有 SEO。
SSR 想解决的问题
SSR 想解决的问题,就是 CSR 会造成的问题,包括:
- SEO
- 各种社群平台的 link preview
- Performance
- 使用者体验
如果用了 CSR,由于画面都是透过 JavaScript 所产生,搜寻引擎只会爬到空白的 HTML,就算 Google 会执行 JavaScript,其他搜寻引擎也不一定会。就算所有搜寻引擎都会执行 JavaScript,你也很难保证爬出来的结果是你要的。
举例来说,你很难掌握它们执行完 JavaScript 以后,到底什么时候会结束。如果抓取资料的 API 要两秒以后才会有 response,那假设搜寻引擎执行 JavaScript 以后只等一秒就当作最终结果,那结果还是不会有资料。
社群平台的 link preview 则是另一个问题,那些 <meta> 标签在 client 产生是没有用的,通常这些社群平台的 bot 是不会去执行 JavaScript 的,只看 response,所以 CSR 的页面的 永远只能是同一个,没办法根据不同页面动态决定内容。
第三点跟第四点可以一起看,虽然现在的装置基本上都跑得很快,能够快速执行 JavaScript,但不排除在 JavaScript 很大一包而且装置比较旧的情况之下,执行 JavaScript 还是需要一段时间。
CSR 的网页要到什么时候使用者才能看到画面?要先下载完 JavaScript,下载完还要执行,执行结束更新 DOM 以后,使用者才能看到完整的画面。在等待的期间,画面就是一片空白,虽然有些网站会做个 loading,但总之使用者体验不是很好。
如果能在一开始的 response 就拿到画面,那使用者体验就会变好,效能也会增加,就算是很旧的装置,也能在一开始就看到画面,不需要等 JavaScript 执行完毕。
各种不同的 SSR
其实这篇一开始只想写这个段落的,殊不知写着写着就变成了前端历史的考古文。
因应刚刚提到的 CSR 会产生的问题,就产生出了多种解法,每一种都不太一样,而且并不一定能一次解决所有的问题。
第一种:针对搜寻引擎以及 bot 渲染另一个模板
这种解法只解了 SEO 跟 link preview 的问题,当 server 端收到的请求来自于搜寻引擎或是社群平台的 bot 时,就直接利用原本后端的 template 输出结果。
像是这样:
const express = require('express');
const app = express();
app.get('/games/:id', (req, res) => {
const userAgent = req.headers['user-agent'];
// 檢查 User Agent 是否為 Googlebot
if (userAgent.includes('Googlebot')) {
// 如果是 Googlebot,輸出 SEO 相關的 HTML 與 meta tags
const game = API.getGame(req.params.id);
res.send(`
<html>
<head>
<title>${game.title}</title>
<meta name="description" content="${game.desc}">
</head>
<body>
<h1>${game.title}</h1>
<p>${game.desc}</p>
</body>
</html>
`);
} else {
// 如果不是 Googlebot,回傳 index.html
res.sendFile(__dirname + '/public/index.html');
}
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
对于一般使用者来说,效能跟使用者体验的问题还是没有解决,这种解法只解了 SEO 跟 link preview,确保这些 bot 抓到的画面是 HTML。
我自己有在工作上实作过这种方式,优点就是简单快速,而且跟 SPA 互不干扰,缺点大概就是 Google bot 看到的页面会跟使用者看到的不一样,有可能影响到 SEO 分数,毕竟针对 Google bot 输出特殊页面是 anti-pattern,叫做 cloaking。
虽然我们的出发点是好的,但仍然是不被官方建议的行为,可以参考 Google 官方的影片:Can we serve Googlebot a different page with no ads?,里面就提到了最好是 exact same page。
但比起让 Google bot 什么都看不到,这个解法应该还是更好一些。
第二种:同样是针对搜寻引擎,但是做 pre-render
这个解法最知名的框架是 Prerender,简单来讲就是先在 server 端用 puppeteer 之类的 headless browser 去开启你的页面并且执行 JavaScript,然后把结果保存成 HTML。
当搜寻引擎来要资料的时候,就输出这个 HTML,因此使用者跟 bot 看到的画面是一样的。
我有在 local 试了一下,用 create-react-app 简单写了一个页面:
import logo from './logo.svg';
import './App.css';
import { useState, useEffect } from 'react'
function App() {
console.log('render')
const [data, setData] = useState([]);
useEffect(() => {
document.querySelector('title').textContent = 'I am new title'
fetch('https://cat-fact.herokuapp.com/facts/').then(res => res.json())
.then(a => {
setData(a);
})
}, [])
function test() {
alert('click')
}
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
{data && data.map(item => (
<div>{item.text}</div>
))}
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
Can you see me now?
</a>
<button onClick={test}>hello</button>
</header>
</div>
);
}
export default App;
主要想测的有几点:
- 页面是不是依然可以互动
- 动态修改的 title 是否会反映在结果
- 是不是会输出拿到 API response 后的结果
经过 prerender 以后,输出的 HTML 为:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="http://localhost:5555/favicon.ico">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#000000">
<meta name="description" content="Web site created using create-react-app">
<link rel="apple-touch-icon" href="http://localhost:5555/logo192.png">
<link rel="manifest" href="http://localhost:5555/manifest.json">
<title>I am new title</title>
<script defer="defer" src="http://localhost:5555/static/js/main.21981749.js"></script>
<link href="http://localhost:5555/static/css/main.f855e6bc.css" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div class="App">
<header class="App-header">
<img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="App-logo" alt="logo">
<div>When asked if her husband had any hobbies, Mary Todd Lincoln is said to have replied "cats."</div>
<div>Cats make about 100 different sounds. Dogs make only about 10.</div>
<div>Owning a cat can reduce the risk of stroke and heart attack by a third.</div>
<div>Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the
standard: clean, cool drinking water.</div>
<div>It was illegal to slay cats in ancient Egypt, in large part because they provided the great service of controlling the rat population.</div>
<a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React Can you see me now?</a>
<button>hello</button>
</header>
</div>
</div>
</body>
</html>
title 有变了,内容也是 useEffect () 的 fetch 执行完并且 render 完的结果,按了按钮以后也可以触发事件,看起来没什么问题。
如果更仔细看一下,prerender 渲染出来的页面执行流程跟正常 React app 差不多,唯一的差别在于原本的 HTML 就已经有东西了,但整个 React 还是会执行一次,并且将整个页面重新渲染。
因此会出现底下状况:
- 拿到 server response,是完整并且有资料的页面
- React 启动,进行初次渲染,此时 data 变成初始化状态,页面变成没资料的状态
- React 将结果 mount 到 DOM,触发 useEffect,再打一次 API 拿资料
- 状态更新,渲染出有资料的页面
这个解法依然是只针对搜寻引擎,跟第一种的差别在于使用者跟搜寻引擎看到的页面会更相近,但其实还是不太一样,毕竟一般使用者看到的还是什么都没有的页面。
那可以把 pre-render 的页面也拿给一般使用者看吗?
是可以,但如果有 API 的话会变得有点奇怪,如上所述,初始状态 state 是没有资料的,但是 HTML 有,因此使用者看到的页面就会是:有资料(因为 pre-render HTML) => 没资料(state 初始化) => 有资料(在 client 打 API),在体验上会不太好,所以通常也不会这样做。
这个解法的优点也是方便,不需要改到原本的 SPA,只需要在 server 那边加一个 middleware 即可,而缺点的话则是实作起来比第一种复杂,而且有满多细节要注意的,可以参考:Funliday 重磅推出新的 prerender 套件 pppr 以及在 ModernWeb 2020 分享的「pppr - 解决 JavaScript 无法被搜寻引擎正确索引的问题」。
第三种:在 server render client app
这一种就是前面一直提到的:「在 server 产生第一个画面的 HTML,而后续的操作都交给 client」,相较于前两者,这是更理想的 SSR,也是俗称的 Isomorphic/Universal 。
因为这种的做法不只解决了 SEO 的问题,也解决了使用者体验的问题。当使用者造访网站时,就可以立刻看到渲染完的结果,但此时画面因为 JavaScript 没有执行完,可能没有办法操作,需要等 JavaScript 执行完毕并且把 event handler 挂上时,才能真的跟页面互动。
另外,由于初始画面已经在 server 渲染好了,所以在 client 端通常不需要再修改一次 DOM,只需要把 event handler 挂上去,这个流程称为 hydration,中文通常翻作「水合」。
我觉得这个词用得相当有画面感,就把它想成是 SSR 输出的页面是被「脱水」过的,非常扁平干燥,就只有画面而已,没办法跟它互动。到了 client 以后,就需要把这个干燥的画面注入水,加上 event handler,让整个页面「活起来」,才能重现生机,变成可互动的页面。
然而,这种解法的缺点就是实作起来更复杂一点,需要考虑到的问题是 API,例如说如果把 API call 放在 useEffect 里面,那在 server render 时就不可能执行到,最后渲染出来的页面就是没有任何资料的状态。
因此,可能要帮每个页面都加上一个 function 去拿取资料,拿完之后放到 props 去,在 server side render 时才能正确输出有资料的页面。
也因为这个比较复杂,所以通常都交给框架来做了,像是 Next.js 就是采用我前面讲的做法(Pages Router),会在页面加上一个 的 getServerSidePropsfunction。
顺带一提,Next.js 的第一版是 2016 年 10 月 25 释出的。
第四种:在 build time 就做 render
这算是针对产品情境特化的 SSR,刚刚讲的第三种,是在每一个 request 都会做一次 render,产生出初始画面。但如果你的页面对于每一个 user 来说都长一样(例如说官方网站的公司介绍),那其实根本不用在 run time 做这件事,在 build time 就好了。
于是,有一种做法是在 build time 的时候就会把页面 render 好,速度会快上许多。
这种方法在 Next.js 里面被称之为 Static Site Generation,简称为 SSG。
该怎么命名各种不同的 SSR?
整理一下刚刚讲的四种:
- 针对搜寻引擎以及 bot 渲染另一个模板
- 同样是针对搜寻引擎,但是做 pre-render
- 在 server render client app
- 在 build time 就做 render
不同的文件对于这几种的称呼都不同,接着来看几份文件。
web.dev
第一份是 web.dev 的:Rendering on the Web,在文末有一个光谱:
第一种没特别提到,第二种比较像是「CSR with Prerendering」,但又好像不太像,第三种是:「SSR with (Re) hydration」,第四种是:「Static SSR」 。
这篇对于 SSR 的定义为:
Server-side rendering (SSR): rendering a client-side or universal app to HTML on the server.
所以像是第一种并没有在 server 端去 render client-side app,应该也不会被算作 SSR。
Next.js
第二份是 Next.js 官方的文件:nextjs.org/docs/pages/…
【第2914】从Next.js 13 认识React Server Components
有提到的就是第三种叫做 SSR,第四种叫做 SSG。而这边的定义其实又更不同了一点,它把「在 server 端产生 SPA 的 HTML」这件事情叫做 pre-render:
By default, Next.js pre-renders every page. This means that Next.js generates HTML for each page in advance, instead of having it all done by client-side JavaScript. Pre-rendering can result in better performance and SEO.
而 SSR 专门指的是「每次 request 都产生 HTML」,借此跟 SSG 做出区别。
Nuxt.js
第三份来看 Nuxt.js:nuxt.com/docs/guide/…
【第1057期】服务端与客户端同构 —— Vue.js 应用框架 Nuxt.js
文件里面把第三种称之为:「Universal Rendering」,其实我觉得取得还满不错的:
To not lose the benefits of the client-side rendering method, such as dynamic interfaces and pages transitions, the Client (browser) loads the JavaScript code that runs on the Server in the background once the HTML document has been downloaded. The browser interprets it again (hence Universal rendering) and Vue.js takes control of the document and enables interactivity.
至于对 SSR 的定义,似乎没有写得太明确,不过从底下这句看起来:
This step is similar to traditional server-side rendering performed by PHP or Ruby applications.
应该是「只要在 server render 画面」都可以叫做 SSR。
Angular
最后来看 Angular 的:angular.io/guide/ssr
它对 SSR 的定义为:
Server-side rendering (SSR) is a process that involves rendering pages on the server, resulting in initial HTML content which contains initial page state.
这定义看起来应该跟刚那种差不多,只要是「rendering pages on the server」都可以称之为 SSR。
SSR 的总结
来讲一下我写到这边以后,对于 SSR 的一些想法。
老实说我一开始好像有点把问题搞得太复杂了,SSR 就单纯是指「在 server render 画面」这件事情而已,所以确实只要符合这个前提就可以叫做 SSR。
其实这篇原本想写的只有刚刚讲的那几种不同的 SSR 解决方案,但还没写之前就突然好奇起了 SSR 的定义,才有了开头那些探索历史的段落。
更重要的应该是对于 SSR 这个议题,是否能回答出要解决的问题是什么,该怎么解决,以及每种解法的优缺点等等,并不是每个网页都需要 Next.js 才能做 SSR,要根据情境去选择合适的技术。
接着,我们来谈谈现在进行式以及未来。
榨取更多的效能,打造更快的网页
原本我们提到的第三种解法看起来已经很完美了对吧?既可以在 server 端渲染画面,解决 SEO 以及 first paint 的效能问题,又可以在 client 端做 hydration,让后续操作都有 SPA 的体验。
但其实还有能够持续改善的地方。
前面有稍微提到 hydration 的一个小问题,那就是在 hydration 完成以前,虽然看到画面了,但是这个网页是没办法互动的。例如说你在 input 打字,可能不会有反应,因为那时候 event handler 还没挂上去,或是 component 还没 render 完。
那这该怎么办呢?有另外一个名词出现了,叫做:Progressive Hydration,比起一次 hydration 整个页面,不如一个一个区块来做,还可以分优先顺序,先把比较重要的区块做完,使用者就可以马上互动,再来做比较没这么重要的区块。
除此之外,你会发现一个网页的某几个区块,可能根本就不需要做 hydration,因为是不会变的,像是 footer 好了,根本没有状态,从头到尾都长一样。此时就可以运用另一种技巧叫做 Selective Hydration,提前 render 不需要 hydration 的区块。
2019 年时,Etsy 的前端架构师 Katie Sylor-Miller 提出了 Islands Architecture,将一个网页看作是由不同的小岛组成:
上面这张图就很能体现刚刚讲的 selective hydration。当我们采用这样的架构并且搭配 selective hydration 以及其他技巧之后,就能够更快速地渲染,并且得到更好的效能。
例如说 Astro 就是使用了这样的架构,整个页面都是 static 的,只有需要互动的地方会独立成为一个小岛:
<MyReactComponent client:load />
React 目前也往这个方向在发展,server component 在这点上就满类似的,藉由把页面区分成 server 跟 client component,决定哪些需要状态哪些不需要,不需要的就直接在 server render 完再送来 client,需要的就维持以前的作法。
这种方式确实会再让网页往上加速,但同时开发也变得越来越复杂,有更多东西需要考虑,debug 也更不方便了一些,一些心得跟细节我之后再写篇文章分享吧。
总结
我自己真正接触各种前端工具的时间其实比较晚一点,撇除最开始写 FrontPage 或是 Dreamweaver 那种不谈,大概 2012 年左右开始写 jQuery,接着就是观望各种前端的发展但都没有碰过,有曾经想学过 AngularJS(那时候真的很夯)还有 Ember.js,但就是懒。
是一直到 2015 年才开始在工作上接触到 React,那时候是 React 刚在台湾要流行起来的时候。
所以早期 Backbone.js 那个年代的东西我没有参与到,在写这篇文章的时候找了不少资料,其实还满有趣的,算是帮自己补足了没有参与到的那一段历史。
在查资料的时候,也发现 Yahoo! 真的是网页前端的先行者,例如说 Atomic CSS 就是 Yahoo! 开始的,而这次也发现 2012 年时 Yahoo! 就已经在使用 Universal JavaScript 的网页框架了。
如果你对 SSR 有不同的见解,或是觉得我对历史发展脉络的诠释有点误会,可以直接写一篇新的文章与我交流,毕竟有些概念不是三言两语可以讲清楚的,写篇文章比较完整;或是也可以透过留言讨论。